From deac9e116c0d5ff3e3fbde035b9218bfd11a716c Mon Sep 17 00:00:00 2001 From: Hannes Janott Date: Mon, 25 Nov 2024 16:04:14 +0100 Subject: [PATCH] new saml meeting mapping (#2722) remove old direct meeting mapping --------- Co-authored-by: rrenkert --- docs/actions/user.save_saml_account.md | 58 +- global/data/example-data.json | 3 +- global/data/initial-data.json | 3 +- .../action/actions/meeting_user/create.py | 58 +- .../action/actions/organization/update.py | 23 +- .../action/actions/speaker/create.py | 2 +- .../action/actions/user/save_saml_account.py | 447 +++++++++++--- .../system/action/organization/test_update.py | 73 ++- tests/system/action/speaker/test_create.py | 2 +- tests/system/action/user/test_create.py | 2 +- .../action/user/test_save_saml_account.py | 584 ++++++++++++++++-- tests/system/action/user/test_update.py | 8 +- 12 files changed, 1091 insertions(+), 172 deletions(-) diff --git a/docs/actions/user.save_saml_account.md b/docs/actions/user.save_saml_account.md index 523507efc..15e2891bd 100644 --- a/docs/actions/user.save_saml_account.md +++ b/docs/actions/user.save_saml_account.md @@ -11,6 +11,8 @@ pronoun: string, is_active: boolean, is_physical_person: boolean, + member_number: string, + // Additional meeting related data can be given. See below explanation on meeting mappers. } ``` @@ -31,7 +33,61 @@ Extras to do on creation: As you can see there is no password for local login and the user can't change it. -- Add user to the meeting by adding him to the group given in the organization-wide field-mapping as `"meeting": { "external_id": "xyz", "external_group_id": "delegates"}` if a `meeting`-entry is given. If it fails for any reason, a log entry is written, but no exception thrown. Add the user always to the group, if it fails try to add him to the default group. +### Meeting Mappers +- The saml attribute mapping can have a list of 'meeting_mappers' that can be used to assign users meeting related data. (See example below.) + - A mapper can be given a 'name' for debugging purposes. + - The 'external_id' maps to the meeting and is required (logged as warning if meeting does not exist). Multiple mappers can map to the same meeting. + - If 'allow_update' is set to false, the mapper is only used if the user does not already exist. If it is not given it defaults to true. + - Mappers are only used if every condition in the list of 'conditions' resolves to true. For this the 'attribute' in the payload data needs to match the string or regex given in 'condition'. If no condition is given this defaults to true. + - The actual mappings are objects or lists of objects of attribute-default pairs (exception: number, which only has the option of an attribute). + - The attribute refers to the payloads data. + - A default value can be given in case the payloads attribute does not exist or contains no data. (Logged as debug) + - Groups and structure levels are given as a list of attribute-default pairs. +- On conflict of multiple mappers mappings on a same meetings field the last given mappers data for that field is used. Exception to this are groups and structure levels. Their data is combined. +- Values for groups and structure levels can additionally be given in comma separated lists composed as a single string. +- Values for groups are interpreted as their external ID and structure levels as their name within that meeting. +- If no group exists for a meeting and no default is given, the meetings default group is used. (Logged as warning) +- If a structure level does not exist, it is created. +- Vote weights need to be given as 6 digit decimal strings. + +``` +"meeting_mappers": [{ + "name": "Mapper-Name", + "external_id": "M2025", + "allow_update": "false", + "conditions": [{ + "attribute": "membernumber", + "condition": "1426\d{4,6}$" + }, { + "attribute": "function", + "condition": "board" + }], + "mappings": { + "groups": [{ + "attribute": "membership", + "default": "admin, standard" + }], + "structure_levels": [{ + "attribute": "ovname", + "default": "struct1, struct2" + }], + "number": {"attribute": "p_number"}, + "comment": { + "attribute": "idp_comment", + "default": "Group set via SSO" + }, + "vote_weight": { + "attribute": "vote", + "default":"1.000000" + }, + "present": { + "attribute": "present_key", + "default":"True" + } + } +}] +``` +If you are using Keycloak as your SAML-server, make sure to fill the attributes of all users. Then you also need to configure for each attribute in 'Clients' a mapping for your Openslides services 'Client Scopes'. Choose 'User Attribute' and assign the 'User Attribute' as in the step before and the 'SAML Attribut Name' as defined in Openslides 'meeting_mappers'. ## Return Value diff --git a/global/data/example-data.json b/global/data/example-data.json index d842581ee..f0a1400f6 100644 --- a/global/data/example-data.json +++ b/global/data/example-data.json @@ -72,7 +72,8 @@ "gender": "gender", "pronoun": "pronoun", "is_active": "is_active", - "is_physical_person": "is_person" + "is_physical_person": "is_person", + "member_number": "member_number" } } }, diff --git a/global/data/initial-data.json b/global/data/initial-data.json index ba6880235..a219af8dd 100644 --- a/global/data/initial-data.json +++ b/global/data/initial-data.json @@ -58,7 +58,8 @@ "gender": "gender", "pronoun": "pronoun", "is_active": "is_active", - "is_physical_person": "is_person" + "is_physical_person": "is_person", + "member_number": "member_number" } } }, diff --git a/openslides_backend/action/actions/meeting_user/create.py b/openslides_backend/action/actions/meeting_user/create.py index 929cfc408..5b08281db 100644 --- a/openslides_backend/action/actions/meeting_user/create.py +++ b/openslides_backend/action/actions/meeting_user/create.py @@ -54,24 +54,22 @@ def get_history_information(self) -> HistoryInformation | None: information = {} for instance in self.instances: instance_information = [] - if "group_ids" in instance: - if len(instance["group_ids"]) == 1: - instance_information.extend( - [ - "Participant added to group {} in meeting {}", - fqid_from_collection_and_id( - "group", instance["group_ids"][0] - ), - ] + fqids_per_collection = { + collection_name: [ + fqid_from_collection_and_id( + collection_name, + _id, ) - else: - instance_information.append( - "Participant added to multiple groups in meeting {}", - ) - else: - instance_information.append( - "Participant added to meeting {}", - ) + for _id in ids + ] + for collection_name in ["group", "structure_level"] + if (ids := instance.get(f"{collection_name}_ids")) + } + instance_information.append( + self.compose_history_string(list(fqids_per_collection.items())) + ) + for collection_name, fqids in fqids_per_collection.items(): + instance_information.extend(fqids) instance_information.append( fqid_from_collection_and_id("meeting", instance["meeting_id"]), ) @@ -79,3 +77,29 @@ def get_history_information(self) -> HistoryInformation | None: instance_information ) return information + + def compose_history_string( + self, fqids_per_collection: list[tuple[str, list[str]]] + ) -> str: + """ + Composes a string of the shape: + Participant added to groups {}, {} and structure levels {} in meeting {}. + """ + middle_sentence_parts = [ + " ".join( + [ # prefix and to collection name if it's not the first in list + ("and " if collection_name != fqids_per_collection[0][0] else "") + + collection_name.replace("_", " ") # replace for human readablity + + ("s" if len(fqids) != 1 else ""), # plural s + ", ".join(["{}" for _ in range(len(fqids))]), + ] + ) + for collection_name, fqids in fqids_per_collection + ] + return " ".join( + [ + "Participant added to", + *middle_sentence_parts, + ("in " if fqids_per_collection else "") + "meeting {}.", + ] + ) diff --git a/openslides_backend/action/actions/organization/update.py b/openslides_backend/action/actions/organization/update.py index c82a1c90f..95ff49601 100644 --- a/openslides_backend/action/actions/organization/update.py +++ b/openslides_backend/action/actions/organization/update.py @@ -60,13 +60,24 @@ class OrganizationUpdate( field: {**optional_str_schema, "max_length": 256} for field in allowed_user_fields } - saml_props["meeting"] = { - "type": ["object", "null"], - "properties": { - field: {**optional_str_schema, "max_length": 256} - for field in ("external_id", "external_group_id") + saml_props["meeting_mappers"] = { + "type": ["array", "null"], + "items": { + "type": "object", + "properties": { + **{ + field: {**optional_str_schema, "max_length": 256} + for field in ("external_id", "name", "allow_update") + }, + "conditions": { + "type": ["array", "null"], + "max_length": 256, + }, # , "items": {"object"} + "mappings": {"type": ["object", "array"], "max_length": 256}, + }, + "required": ["external_id"], + "additionalProperties": False, }, - "additionalProperties": False, } schema = DefaultSchema(Organization()).get_update_schema( optional_properties=group_A_fields + group_B_fields, diff --git a/openslides_backend/action/actions/speaker/create.py b/openslides_backend/action/actions/speaker/create.py index cf9aaed91..e0eff2684 100644 --- a/openslides_backend/action/actions/speaker/create.py +++ b/openslides_backend/action/actions/speaker/create.py @@ -294,7 +294,7 @@ def validate_fields(self, instance: dict[str, Any]) -> dict[str, Any]: user = self.datastore.get(user_fqid, ["is_present_in_meeting_ids"]) if meeting_id not in user.get("is_present_in_meeting_ids", ()): raise ActionException( - "Only present users can be on the lists of speakers." + "Only present users can be on the list of speakers." ) if not meeting.get("list_of_speakers_allow_multiple_speakers"): diff --git a/openslides_backend/action/actions/user/save_saml_account.py b/openslides_backend/action/actions/user/save_saml_account.py index 1989cde14..35648b3fb 100644 --- a/openslides_backend/action/actions/user/save_saml_account.py +++ b/openslides_backend/action/actions/user/save_saml_account.py @@ -1,4 +1,6 @@ -from collections.abc import Iterable +import re +from collections import defaultdict +from collections.abc import Generator, Iterable from typing import Any, cast import fastjsonschema @@ -7,7 +9,7 @@ from ....models.models import User from ....shared.exceptions import ActionException -from ....shared.filters import And, FilterOperator +from ....shared.filters import And, FilterOperator, Or from ....shared.interfaces.event import Event from ....shared.schema import schema_version from ....shared.typing import Schema @@ -18,6 +20,7 @@ from ...util.register import register_action from ...util.typing import ActionData, ActionResultElement from ..gender.create import GenderCreate +from ..structure_level.create import StructureLevelCreateAction from .create import UserCreate from .update import UserUpdate from .user_mixins import UsernameMixin @@ -32,6 +35,16 @@ "pronoun", "is_active", "is_physical_person", + "member_number", +] + +allowed_meeting_user_fields = [ + "groups", + "structure_levels", + "number", + "comment", + "vote_weight", + "present", ] @@ -63,7 +76,9 @@ def validate_instance(self, instance: dict[str, Any]) -> None: raise ActionException( "SingleSignOn is not enabled in OpenSlides configuration" ) - self.saml_attr_mapping = organization.get("saml_attr_mapping", {}) + self.saml_attr_mapping: dict[str, Any] = organization.get( + "saml_attr_mapping", dict() + ) if not self.saml_attr_mapping or not isinstance(self.saml_attr_mapping, dict): raise ActionException( "SingleSignOn field attributes are not configured in OpenSlides" @@ -111,7 +126,10 @@ def validate_instance(self, instance: dict[str, Any]) -> None: def validate_fields(self, instance_old: dict[str, Any]) -> dict[str, Any]: """ - Transforms the payload fields into model fields, removes the possible array-wrapped format + Transforms the payload fields into model fields, removes the possible array-wrapped format. + Mapper data is comprised on a per meeting basis. On conflicts the last statement is used. + Groups and structure levels are combined, however. + Meeting related data will be transformed via the idp attributes to the actual model data. """ instance: dict[str, Any] = dict() for model_field, payload_field in self.saml_attr_mapping.items(): @@ -120,14 +138,14 @@ def validate_fields(self, instance_old: dict[str, Any]) -> dict[str, Any]: and payload_field in instance_old and model_field in allowed_user_fields ): - value = ( + idp_attribute = ( tx[0] if isinstance((tx := instance_old[payload_field]), list) and len(tx) else tx ) - if value not in (None, []): - instance[model_field] = value - + if idp_attribute not in (None, []): + instance[model_field] = idp_attribute + self.apply_meeting_mapping(instance, instance_old) return super().validate_fields(instance) def prepare_action_data(self, action_data: ActionData) -> ActionData: @@ -138,49 +156,49 @@ def check_permissions(self, instance: dict[str, Any]) -> None: pass def base_update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - meeting_id, group_id = self.check_for_group_add() users = self.datastore.filter( "user", FilterOperator("saml_id", "=", instance["saml_id"]), - ["id", "gender_id", *allowed_user_fields], + [ + "id", + "meeting_user_ids", + "is_present_in_meeting_ids", + "gender_id", + *allowed_user_fields, + ], ) - - if gender := instance.get("gender"): - if gender == "": - instance["gender_id"] = None + if gender := instance.pop("gender", None): + gender_dict = self.datastore.filter( + "gender", + FilterOperator("name", "=", gender), + ["id"], + ) + gender_id = None + if gender_dict: + gender_id = next(iter(gender_dict.keys())) else: - gender_dict = self.datastore.filter( - "gender", - FilterOperator("name", "=", gender), - ["id"], + action_result = self.execute_other_action( + GenderCreate, [{"name": gender}] ) - if gender_dict: - gender_id = next(iter(gender_dict.keys())) - else: - action_result = self.execute_other_action( - GenderCreate, [{"name": gender}] - ) - gender_id = action_result[0].get("id", 0) # type: ignore + if action_result and action_result[0]: + gender_id = action_result[0].get("id", 0) + if gender_id: instance["gender_id"] = gender_id - del instance["gender"] + else: + self.logger.warning( + f"save_saml_account could neither find nor create {gender}. Not handling gender." + ) + # Empty string: remove gender_id elif gender == "": instance["gender_id"] = None - del instance["gender"] + meeting_users: dict[int, dict[str, Any]] | None = dict() + user_id = None if len(users) == 1: self.user = next(iter(users.values())) instance["id"] = (user_id := cast(int, self.user["id"])) - if meeting_id and group_id: - meeting_user = get_meeting_user( - self.datastore, meeting_id, user_id, ["id", "group_ids"] - ) - if meeting_user: - old_group_ids = meeting_user["group_ids"] - if group_id not in old_group_ids: - instance["meeting_id"] = meeting_id - instance["group_ids"] = old_group_ids + [group_id] - else: - instance["meeting_id"] = meeting_id - instance["group_ids"] = [group_id] + meeting_users = self.apply_meeting_user_data(instance, user_id, True) + if meeting_users: + self.update_meeting_users_from_db(meeting_users, user_id) instance = { k: v for k, v in instance.items() if k == "id" or v != self.user.get(k) } @@ -188,19 +206,24 @@ def base_update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: self.execute_other_action(UserUpdate, [instance]) elif len(users) == 0: instance = self.set_defaults(instance) - if group_id: - instance["meeting_id"] = meeting_id - instance["group_ids"] = [group_id] - self.execute_other_action(UserCreate, [instance]) + meeting_users = instance.pop("meeting_user_data", None) + response = self.execute_other_action(UserCreate, [instance]) + if response and response[0]: + user_id = response[0].get("id") + instance["meeting_user_data"] = meeting_users + if user_id: + meeting_users = self.apply_meeting_user_data(instance, user_id, False) else: ActionException( f"More than one existing user found in database with saml_id {instance['saml_id']}" ) + if meeting_users: + self.execute_other_action(UserUpdate, [mu for mu in meeting_users.values()]) return instance def create_events(self, instance: dict[str, Any]) -> Iterable[Event]: """ - delegated to execute_other_actions + delegated to execute_other_action """ return [] @@ -218,46 +241,312 @@ def set_defaults(self, instance: dict[str, Any]) -> dict[str, Any]: instance["username"] = self.generate_usernames([instance.get("saml_id", "")])[0] return instance - def check_for_group_add(self) -> tuple[int, int] | tuple[None, None]: - NoneResult = (None, None) - if not ( - meeting_info := cast(dict, self.saml_attr_mapping.get("meeting")) - ) or not (external_id := meeting_info.get("external_id")): - return NoneResult - - meetings = self.datastore.filter( - collection="meeting", - filter=FilterOperator("external_id", "=", external_id), - mapped_fields=["id", "default_group_id"], + def validate_meeting_mapper( + self, instance: dict[str, Any], meeting_mapper: dict[str, Any] + ) -> bool: + """ + Validates the meeting mapper to be complete. Returns False if not. + Returns True if the mapper matches its criteria on instances values or no conditions were given. + Instances values can not be None or empty string. + """ + if not meeting_mapper.get("external_id"): + return False + if not (mapper_conditions := meeting_mapper.get("conditions")): + return True + return all( + ( + (instance_value := instance.get(mapper_condition.get("attribute"))) + and regex_condition.search(instance_value) + ) + for mapper_condition in mapper_conditions + if (regex_condition := re.compile(mapper_condition.get("condition"))) ) - if len(meetings) == 1: - meeting = next(iter(meetings.values())) - group_id = meeting["default_group_id"] - else: + + def apply_meeting_mapping( + self, instance: dict[str, Any], instance_old: dict[str, Any] + ) -> None: + if meeting_mappers := cast( + list[dict[str, Any]], + self.saml_attr_mapping.get("meeting_mappers", []), + ): + meeting_user_data: dict[str, Any] = defaultdict(dict) + for meeting_mapper in meeting_mappers: + if self.validate_meeting_mapper(instance_old, meeting_mapper): + meeting_external_id = cast(str, meeting_mapper["external_id"]) + mapping_results = meeting_user_data[meeting_external_id] + allow_update: str | bool + if isinstance( + allow_update := cast( + str, meeting_mapper.get("allow_update", "True") + ), + str, + ): + allow_update = allow_update.casefold() != "False".casefold() + result = { + **{ + key: value + for key, value in self.get_field_data( + instance_old, + mapping_results.get("for_create", dict()), + meeting_mapper, + ) + }, + } + if allow_update: + mapping_results["for_create"] = result + mapping_results["for_update"] = { + **{ + key: value + for key, value in self.get_field_data( + instance_old, + mapping_results.get("for_update", dict()), + meeting_mapper, + ) + }, + } + else: + mapping_results["for_create"] = result + if meeting_user_data: + instance["meeting_user_data"] = meeting_user_data + else: + self.logger.warning( + "save_saml_account found no matching meeting mappers." + ) + + def apply_meeting_user_data( + self, instance: dict[str, Any], user_id: int, is_update: bool + ) -> dict[int, dict[str, Any]] | None: + if not (meeting_user_data := instance.pop("meeting_user_data", None)) or not ( + external_meeting_ids := sorted( + [ext_id for ext_id in meeting_user_data.keys()] + ) + ): + return None + meetings = { + meeting_id: meeting + for meeting_id, meeting in sorted( + self.datastore.filter( + "meeting", + Or( + FilterOperator("external_id", "=", external_meeting_id) + for external_meeting_id in external_meeting_ids + ), + ["id", "default_group_id", "external_id"], + ).items() + ) + } + missing_meetings = [ + external_meeting_id + for external_meeting_id in external_meeting_ids + if external_meeting_id + not in {meeting.get("external_id") for meeting in meetings.values()} + ] + if missing_meetings: self.logger.warning( - f"save_saml_account found {len(meetings)} meetings with external_id '{external_id}'" + f"save_saml_account found no meetings for {len(missing_meetings)} meetings with external_ids {missing_meetings}" + ) + # declare and half way through initialize mu data + result: dict[int, dict[str, Any]] = dict() + for ( + meeting_id, + meeting, + ) in meetings.items(): + if not ( + instance_meeting_user_data := meeting_user_data.get( + meeting["external_id"] + ) + ): + continue + if is_update: + instance_meeting_user = instance_meeting_user_data.get("for_update") + else: + instance_meeting_user = instance_meeting_user_data.get("for_create") + if instance_meeting_user is not None: + instance_meeting_user["id"] = user_id + instance_meeting_user["meeting_id"] = meeting_id + for saml_meeting_user_field in ["groups", "structure_levels"]: + names = sorted( + instance_meeting_user.pop(saml_meeting_user_field, []) + ) + if saml_meeting_user_field == "groups": + ids = self.get_group_ids(names, meeting) + elif saml_meeting_user_field == "structure_levels": + ids = self.get_structure_level_ids(names, meeting) + if ids: + instance_meeting_user[ + f"{saml_meeting_user_field.rstrip('s')}_ids" + ] = ids + if instance_meeting_user.pop("present", ""): + present_in_meeting_ids = instance.get( + "is_present_in_meeting_ids", [] + ) + if meeting_id not in present_in_meeting_ids: + present_in_meeting_ids.append(meeting_id) + instance["is_present_in_meeting_ids"] = present_in_meeting_ids + result[meeting_id] = instance_meeting_user + return result + + def update_meeting_users_from_db( + self, meeting_users: dict[int, dict[str, Any]], user_id: int + ) -> None: + """updates meeting users with groups and structure level relations from database""" + for meeting_id, meeting_user in meeting_users.items(): + if meeting_user_db := get_meeting_user( + self.datastore, + meeting_id, + user_id, + ["id", "group_ids", "structure_level_ids"], + ): + for field_name in ["group_ids", "structure_level_ids"]: + if old_ids := meeting_user_db.get(field_name): + ids = meeting_user.get(field_name, []) + for _id in ids: + if _id not in old_ids: + meeting_user[field_name] = old_ids + [_id] + + def get_field_data( + self, + instance: dict[str, Any], + meeting_user: dict[str, Any], + meeting_mapper: dict[str, dict[str, Any]], + ) -> Generator[tuple[str, Any]]: + """ + returns the field data for the given idp mapping field. Groups the groups and structure levels for each meeting. + Uses mappers for generating default values. + """ + missing_attributes = [] + for saml_meeting_user_field in allowed_meeting_user_fields: + result: set[str] | str | bool = "" + meeting_mapping = meeting_mapper.get("mappings", dict()) + result = meeting_user.get(saml_meeting_user_field, "") + if saml_meeting_user_field in ["groups", "structure_levels"]: + attr_default_list = meeting_mapping.get(saml_meeting_user_field, []) + else: + attr_default_list = [ + meeting_mapping.get(saml_meeting_user_field, dict()) + ] + for attr_default in attr_default_list: + idp_attribute = attr_default.get("attribute", "") + if saml_meeting_user_field == "number": + # Number cannot have a default. + if value := instance.get(idp_attribute): + result = cast(str, value) + else: + missing_attributes.append(idp_attribute) + elif not (value := instance.get(idp_attribute)): + missing_attributes.append(idp_attribute) + value = attr_default.get("default") + if value: + if saml_meeting_user_field in ["groups", "structure_levels"]: + # Need to append to group and structure_level for same meeting. + if not result: + result = set() + cast(set, result).update(value.split(", ")) + elif saml_meeting_user_field == "comment": + # Want comments from all matching mappers. + if result: + result = cast(str, result) + " " + value + else: + result = value + elif saml_meeting_user_field == "present": + # Result is int or bool. int will later be interpreted as bool. + result = ( + value + if not isinstance(value, str) + else ( + False + if value.casefold() == "false".casefold() + else True + ) + ) + else: + result = value + if result: + yield saml_meeting_user_field, result + if fields := ",".join(missing_attributes): + mapper_name = meeting_mapper.get("name", "unnamed") + self.logger.debug( + f"Meeting mapper: {mapper_name} could not find value in idp data for fields: {fields}. Using default if available." ) - return NoneResult - if external_group_id := meeting_info.get("external_group_id"): + + def get_group_ids(self, group_names: list[str], meeting: dict) -> list[int]: + """ + Gets the group ids from given group names in that meeting. + If none of the groups exists in the meeting, the meetings default group is returned. + """ + if group_names: groups = self.datastore.filter( - collection="group", - filter=And( - [ - FilterOperator("external_id", "=", external_group_id), - FilterOperator("meeting_id", "=", meeting.get("id")), - ] + "group", + And( + FilterOperator("meeting_id", "=", meeting["id"]), + Or( + FilterOperator("external_id", "=", group_name) + for group_name in group_names + ), ), - mapped_fields=["id"], + ["meeting_user_ids"], ) - if len(groups) == 1: - group_id = next(iter(groups.keys())) - else: - self.logger.warning( - f"save_saml_account found no group in meeting '{external_id}' for '{external_group_id}', but use default_group of meeting" - ) - if not group_id: + if len(groups) > 0: + return sorted(groups) + if default_group_id := meeting["default_group_id"]: + external_meeting_id = meeting["external_id"] self.logger.warning( - f"save_saml_account found no group in meeting '{external_id}' for '{external_group_id}'" + f"save_saml_account found no group in meeting '{external_meeting_id}' for {group_names}, but used default_group of meeting" ) - return NoneResult - return meeting.get("id"), group_id + return [default_group_id] + else: + assert False + + def get_structure_level_ids( + self, structure_level_names: list[str], meeting: dict[str, Any] + ) -> list[int]: + """ + Gets the structure level ids from given structure level names in that meeting. + For this also creates new structure levels not already existing in the meeting. + """ + if structure_level_names: + meeting_id = meeting["id"] + found_structure_levels = self.datastore.filter( + "structure_level", + And( + FilterOperator("meeting_id", "=", meeting_id), + Or( + FilterOperator("name", "=", structure_level_name) + for structure_level_name in structure_level_names + if structure_level_name + ), + ), + ["meeting_user_ids"], + ) + found_structure_level_ids = list(found_structure_levels.keys()) + if len(found_structure_levels) == len(structure_level_names): + return found_structure_level_ids + else: + found_structure_level_names = [ + structure_level.get("name") + for structure_level in found_structure_levels.values() + ] + to_be_created_structure_levels = [ + sl_name + for sl_name in structure_level_names + if sl_name and sl_name not in found_structure_level_names + ] + # meeting_user_ids are only known during UserUpdate. Hence we cannot do batch create for all meeting users + if structure_levels_result := ( + self.execute_other_action( + StructureLevelCreateAction, + [ + {"name": structure_level_name, "meeting_id": meeting_id} + for structure_level_name in to_be_created_structure_levels + ], + ) + ): + return sorted( + [ + structure_level["id"] + for structure_level in structure_levels_result + if structure_level + ] + + found_structure_level_ids + ) + return [] diff --git a/tests/system/action/organization/test_update.py b/tests/system/action/organization/test_update.py index 182db03a6..d13555d9a 100644 --- a/tests/system/action/organization/test_update.py +++ b/tests/system/action/organization/test_update.py @@ -6,7 +6,11 @@ class OrganizationUpdateActionTest(BaseActionTestCase): - saml_attr_mapping: dict[str, str | dict[str, str]] = { + ListOfDicts = list[dict[str, str]] + MeetingMappers = list[ + dict[str, str | ListOfDicts | dict[str, str | dict[str, str] | ListOfDicts]] + ] + saml_attr_mapping: dict[str, str | MeetingMappers] = { "saml_id": "username", "title": "title", "first_name": "firstName", @@ -16,6 +20,7 @@ class OrganizationUpdateActionTest(BaseActionTestCase): "pronoun": "pronoun", "is_active": "is_active", "is_physical_person": "is_person", + "member_number": "member_number", } def setUp(self) -> None: @@ -64,7 +69,46 @@ def test_update(self) -> None: def test_update_with_meeting(self) -> None: self.saml_attr_mapping.update( - {"meeting": {"external_id": "Landtag", "external_group_id": "Delegated"}} + { + "meeting_mappers": [ + { + "name": "Mapper-Name", + "external_id": "Landtag", + "allow_update": "false", + "conditions": [ + {"attribute": "membernumber", "condition": r"1426\d{4,6}$"}, + {"attribute": "function", "condition": "board"}, + ], + "mappings": { + "groups": [ + { + "attribute": "membership", + "default": "admin, standard", + } + ], + "structure_levels": [ + { + "attribute": "ovname", + "default": "struct1, struct2", + } + ], + "number": {"attribute": "p_number"}, + "comment": { + "attribute": "idp_comment", + "default": "Group set via SSO", + }, + "vote_weight": { + "attribute": "vote", + "default": "1.000000", + }, + "present": { + "attribute": "present_key", + "default": "True", + }, + }, + } + ] + } ), response = self.request( "organization.update", @@ -85,9 +129,28 @@ def test_update_with_meeting(self) -> None: }, ) - def test_update_with_meeting_error(self) -> None: + def test_update_with_meeting_missing_ext_id(self) -> None: + self.saml_attr_mapping.update( + {"meeting_mappers": [{"external_idx": "Landtag"}]} + ), + response = self.request( + "organization.update", + { + "id": 1, + "name": "testtest", + "description": "blablabla", + "saml_attr_mapping": self.saml_attr_mapping, + }, + ) + self.assert_status_code(response, 400) + assert ( + "data.saml_attr_mapping.meeting_mappers[0] must contain ['external_id'] properties" + in response.json["message"] + ) + + def test_update_with_meeting_wrong_attr(self) -> None: self.saml_attr_mapping.update( - {"meeting": {"external_idx": "Landtag", "external_group_id": "Delegated"}} + {"meeting_mappers": [{"external_id": "Landtag", "unkown_field": " "}]} ), response = self.request( "organization.update", @@ -100,7 +163,7 @@ def test_update_with_meeting_error(self) -> None: ) self.assert_status_code(response, 400) assert ( - "data.saml_attr_mapping.meeting must not contain {'external_idx'} properties" + "data.saml_attr_mapping.meeting_mappers[0] must not contain {'unkown_field'} properties" in response.json["message"] ) diff --git a/tests/system/action/speaker/test_create.py b/tests/system/action/speaker/test_create.py index 93000e44d..c5a04b445 100644 --- a/tests/system/action/speaker/test_create.py +++ b/tests/system/action/speaker/test_create.py @@ -353,7 +353,7 @@ def test_create_user_not_present(self) -> None: self.assert_status_code(response, 400) self.assert_model_not_exists("speaker/1") self.assertIn( - "Only present users can be on the lists of speakers.", + "Only present users can be on the list of speakers.", response.json["message"], ) diff --git a/tests/system/action/user/test_create.py b/tests/system/action/user/test_create.py index 96e76bf4d..dfcc537b8 100644 --- a/tests/system/action/user/test_create.py +++ b/tests/system/action/user/test_create.py @@ -140,7 +140,7 @@ def test_create_some_more_fields(self) -> None: ) self.assert_history_information( "user/2", - ["Account created", "Participant added to meeting {}", "meeting/111"], + ["Account created", "Participant added to meeting {}.", "meeting/111"], ) def test_create_comment(self) -> None: diff --git a/tests/system/action/user/test_save_saml_account.py b/tests/system/action/user/test_save_saml_account.py index 795f94881..4374924d0 100644 --- a/tests/system/action/user/test_save_saml_account.py +++ b/tests/system/action/user/test_save_saml_account.py @@ -500,6 +500,7 @@ def setUp(self) -> None: self.organization = { "saml_enabled": True, "saml_attr_mapping": { + "member_number": "member_number", "saml_id": "username", "title": "title", "first_name": "firstName", @@ -509,130 +510,575 @@ def setUp(self) -> None: "pronoun": "pronoun", "is_active": "is_active", "is_physical_person": "is_person", - "meeting": { - "external_id": "Landtag", - "external_group_id": "Delegates", - }, }, } + self.meeting_mappers = [ + { + "name": "works", + "external_id": "Landtag", + "conditions": [ + {"attribute": "member_number", "condition": "LV_.*"}, + { + "attribute": "email", + "condition": "[\\w\\.]+@([\\w-]+\\.)+[\\w]{2,4}", + }, + ], + "mappings": { + "comment": { + "attribute": "idp_commentary", + "default": "Vote weight, groups and structure levels set via SSO.", + }, + "number": {"attribute": "participant_number"}, + "structure_levels": [ + { + "attribute": "structure", + "default": "structure1", + } + ], + "groups": [ + { + "attribute": "idp_group_attribute", + "default": "not_a_group", + } + ], + "vote_weight": {"attribute": "vw", "default": "1.000000"}, + "present": {"attribute": "presence", "default": "True"}, + }, + }, + { + "name": "works_too", + "external_id": "Kreistag", + "conditions": [{"attribute": "kv_member_number", "condition": "KV_.*"}], + "mappings": { + "comment": { + "attribute": "idp_commentary", + "default": "Vote weight, groups and structure levels set via SSO.", + }, + "number": {"attribute": "participant_kv_number"}, + "structure_levels": [ + { + "attribute": "kv_structure", + "default": "structure1", + } + ], + "groups": [ + { + "attribute": "kv_group_attribute", + "default": "not_a_group", + } + ], + "vote_weight": { + "attribute": "kv_vw", + "default": "1.000000", + }, + "present": {"attribute": "kv_presence", "default": "True"}, + }, + }, + ] + self.organization["saml_attr_mapping"]["meeting_mappers"] = self.meeting_mappers # type: ignore self.create_meeting() + self.create_meeting(4) self.set_models( { "organization/1": self.organization, "group/1": {"external_id": "Default"}, "group/2": {"external_id": "Delegates"}, "group/3": {"external_id": "Admin"}, + "group/4": {"external_id": "Default"}, + "group/5": {"external_id": "Delegates"}, + "group/6": {"external_id": "Admin"}, "meeting/1": {"external_id": "Landtag", "default_group_id": 1}, + "meeting/4": {"external_id": "Kreistag", "default_group_id": 4}, "user/1": {"saml_id": "admin_saml"}, } ) - def test_create_user_with_membership(self) -> None: - response = self.request("user.save_saml_account", {"username": ["111"]}) + def test_create_user_with_multi_membership(self) -> None: + """ + Shows: + * generally works for multiple matching mappers on different meetings + * if default for 'groups' doesn't resolve, default group of that meeting is used + """ + response = self.request( + "user.save_saml_account", + { + "username": ["111"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates", + "kv_member_number": "KV_Könighols", + "kv_email": "hols@holz.de", + "participant_kv_number": "MG_1254", + "idp_kv_group_attribute": "Delegates", + "kv_structure": "structure2", + "idp_commentary": "normal data used", + }, + ) self.assert_status_code(response, 200) + self.app.logger.warning.assert_called_with( # type: ignore + "save_saml_account found no group in meeting 'Kreistag' for ['not_a_group'], but used default_group of meeting" + ) self.assert_model_exists( "user/2", { "saml_id": "111", "username": "111", - "meeting_user_ids": [1], - "meeting_ids": [1], + "email": "holzi@holz.de", + "meeting_user_ids": [1, 2], + "meeting_ids": [1, 4], + "is_present_in_meeting_ids": [1, 4], }, ) self.assert_model_exists( - "meeting_user/1", {"user_id": 2, "group_ids": [2], "meeting_id": 1} + "meeting_user/1", + { + "user_id": 2, + "meeting_id": 1, + "group_ids": [2], + "structure_level_ids": [1], + "vote_weight": "1.000000", + "number": "MG_1254", + "comment": "normal data used", + }, + ) + self.assert_model_exists( + "meeting_user/2", + { + "user_id": 2, + "group_ids": [4], + "meeting_id": 4, + "comment": "normal data used", + "structure_level_ids": [2], + "vote_weight": "1.000000", + "number": "MG_1254", + }, ) self.assert_model_exists( "group/2", {"meeting_user_ids": [1], "external_id": "Delegates"} ) + self.assert_model_exists( + "structure_level/1", + {"meeting_user_ids": [1], "name": "structure1"}, + ) + self.assert_model_exists( + "structure_level/2", + {"meeting_user_ids": [2], "name": "structure2"}, + ) - def test_update_user_with_membership(self) -> None: - response = self.request("user.save_saml_account", {"username": ["admin_saml"]}) + def test_create_user_with_multi_membership_multi(self) -> None: + """ + Shows: + * group and structure levels can be multiple values in concatenated, comma separated list string for: + * default values + * saml datas values + * multiple entries in structure level and group lists are respected. + * multiple values can be repeated + * mappers can share idp data fields + """ + self.meeting_mappers[1]["mappings"]["groups"] = [ # type: ignore + { + "attribute": "use_default", + "default": "Default, Delegates, Delegates", + }, + {"attribute": "idp_group_attribute", "default": ""}, + ] + self.set_models({"organization/1": self.organization}) + response = self.request( + "user.save_saml_account", + { + "username": ["111"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates, Admin, Delegates", + "kv_member_number": "KV_Könighols", + "participant_kv_number": "MG_1254", + "idp_kv_group_attribute": "Delegates", + "kv_structure": "structure2", + }, + ) self.assert_status_code(response, 200) self.assert_model_exists( - "user/1", + "user/2", { - "saml_id": "admin_saml", - "username": "admin", + "saml_id": "111", + "username": "111", + "meeting_user_ids": [1, 2], + "meeting_ids": [1, 4], + }, + ) + self.assert_model_exists( + "meeting_user/1", {"user_id": 2, "group_ids": [2, 3], "meeting_id": 1} + ) + self.assert_model_exists( + "meeting_user/2", {"user_id": 2, "group_ids": [4, 5, 6], "meeting_id": 4} + ) + self.assert_model_exists( + "group/2", {"meeting_user_ids": [1], "external_id": "Delegates"} + ) + self.assert_model_exists( + "structure_level/1", + {"meeting_user_ids": [1], "name": "structure1"}, + ) + self.assert_model_exists( + "structure_level/2", + {"meeting_user_ids": [2], "name": "structure2"}, + ) + + def test_create_user_with_multi_membership_not_matching(self) -> None: + """ + shows: + * matching only one mapper -> creating only one meeting user + """ + response = self.request( + "user.save_saml_account", + { + "username": ["111"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates", + "kv_member_number": "LV_Königholz", + "kv_email": "hols@holz.de", + "participant_kv_number": "MG_1254", + "idp_kv_group_attribute": "Delegates", + "kv_structure": "structure2", + }, + ) + self.assert_status_code(response, 200) + self.assert_model_exists( + "user/2", + { + "saml_id": "111", + "username": "111", "meeting_user_ids": [1], "meeting_ids": [1], }, ) self.assert_model_exists( - "meeting_user/1", {"user_id": 1, "group_ids": [2], "meeting_id": 1} + "meeting_user/1", {"user_id": 2, "group_ids": [2], "meeting_id": 1} ) self.assert_model_exists( "group/2", {"meeting_user_ids": [1], "external_id": "Delegates"} ) + self.assert_model_exists( + "structure_level/1", + {"meeting_user_ids": [1], "name": "structure1"}, + ) + self.assert_model_not_exists("structure_level/2") - def test_create_user_invalid_meeting(self) -> None: - """silent fail, user created and logged in""" - self.organization["saml_attr_mapping"]["meeting"]["external_id"] = "Kreistag" # type: ignore + def test_create_user_mapping_no_mapper(self) -> None: + del self.organization["saml_attr_mapping"]["meeting_mappers"] # type: ignore self.set_models({"organization/1": self.organization}) - response = self.request("user.save_saml_account", {"username": ["111"]}) - self.assert_status_code(response, 200) - self.app.logger.warning.assert_called_with( # type: ignore - "save_saml_account found 0 meetings with external_id 'Kreistag'" + response = self.request( + "user.save_saml_account", + { + "username": ["111"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates", + "kv_member_number": "KV_Könighols", + "kv_email": "hols@holz.de", + "participant_kv_number": "MG_1254", + "idp_kv_group_attribute": "Delegates", + "kv_structure": "structure2", + "kv_presence": "True", + }, ) + self.assert_status_code(response, 200) self.assert_model_exists( - "user/2", {"saml_id": "111", "username": "111", "meeting_user_ids": None} + "user/2", + { + "saml_id": "111", + "username": "111", + }, ) self.assert_model_not_exists("meeting_user/1") + self.assert_model_not_exists("structure_level/1") + + def test_create_user_mapping_no_mapping(self) -> None: + del self.meeting_mappers[0]["mappings"] + del self.meeting_mappers[1] + self.set_models({"organization/1": self.organization}) + response = self.request( + "user.save_saml_account", + { + "username": ["111"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates", + "kv_member_number": "KV_Könighols", + "kv_email": "hols@holz.de", + "participant_kv_number": "MG_1254", + "idp_kv_group_attribute": "Delegates", + "kv_structure": "structure2", + "kv_presence": "True", + }, + ) + self.assert_status_code(response, 200) + self.assert_model_exists( + "user/2", + { + "saml_id": "111", + "username": "111", + }, + ) self.assert_model_exists( - "group/2", {"meeting_user_ids": None, "external_id": "Delegates"} + "meeting_user/1", {"user_id": 2, "group_ids": [1], "meeting_id": 1} ) + self.assert_model_exists( + "group/1", {"meeting_user_ids": [1], "external_id": "Default"} + ) + self.assert_model_not_exists("structure_level/1") - def test_create_user_invalid_group_but_default(self) -> None: - """silent fail, but added to default group and logged in""" - self.organization["saml_attr_mapping"]["meeting"][ # type: ignore - "external_group_id" - ] = "Developers" + def test_create_user_mapping_empty_mappings(self) -> None: + self.meeting_mappers[0]["mappings"] = dict() + del self.meeting_mappers[1] self.set_models({"organization/1": self.organization}) - response = self.request("user.save_saml_account", {"username": ["111"]}) - self.assert_status_code(response, 200) - self.app.logger.warning.assert_called_with( # type: ignore - "save_saml_account found no group in meeting 'Landtag' for 'Developers', but use default_group of meeting" + response = self.request( + "user.save_saml_account", + { + "username": ["111"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates", + "kv_member_number": "KV_Könighols", + "kv_email": "hols@holz.de", + "participant_kv_number": "MG_1254", + "idp_kv_group_attribute": "Delegates", + "kv_structure": "structure2", + "kv_presence": "True", + }, ) + self.assert_status_code(response, 200) self.assert_model_exists( - "user/2", {"saml_id": "111", "meeting_user_ids": [1], "meeting_ids": [1]} + "user/2", + { + "saml_id": "111", + "username": "111", + }, ) self.assert_model_exists( "meeting_user/1", {"user_id": 2, "group_ids": [1], "meeting_id": 1} ) self.assert_model_exists( - "group/1", + "group/1", {"meeting_user_ids": [1], "external_id": "Default"} + ) + self.assert_model_not_exists("structure_level/1") + + def test_create_user_meeting_not_exists(self) -> None: + """ + Shows: if meeting does not exist error is logged. + """ + self.meeting_mappers[0]["external_id"] = "Bundestag" + del self.meeting_mappers[1] + self.set_models({"organization/1": self.organization}) + response = self.request( + "user.save_saml_account", { - "meeting_user_ids": [1], - "external_id": "Default", - "default_group_for_meeting_id": 1, + "username": ["111"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates", + "kv_member_number": "KV_Könighols", + "kv_email": "hols@holz.de", + "participant_kv_number": "MG_1254", + "kv_group_attribute": "Delegates", + "kv_structure": "structure2", + }, + ) + self.assert_status_code(response, 200) + self.app.logger.warning.assert_called_with( # type: ignore + "save_saml_account found no meetings for 1 meetings with external_ids ['Bundestag']" + ) + self.assert_model_exists( + "user/2", + { + "saml_id": "111", + "username": "111", + "meeting_user_ids": None, + "meeting_ids": None, }, ) + self.assert_model_not_exists("meeting_user/1") + self.assert_model_not_exists("structure_level/1") - def test_create_user_only_meeting_given(self) -> None: - """silent fail, but added to default group and logged in""" - del self.organization["saml_attr_mapping"]["meeting"]["external_group_id"] # type: ignore - self.set_models({"organization/1": self.organization}) - response = self.request("user.save_saml_account", {"username": ["111"]}) + def test_create_user_only_one_sl_exists(self) -> None: + """Shows: no errors if one structure level exists and the other doesn't. Latter being created.""" + self.create_model("structure_level/1", {"name": "structure1", "meeting_id": 1}) + response = self.request( + "user.save_saml_account", + { + "username": ["111"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates", + "kv_member_number": "KV_Könighols", + "kv_email": "hols@holz.de", + "participant_kv_number": "MG_1254", + "idp_kv_group_attribute": "Delegates", + "kv_structure": "structure2", + }, + ) self.assert_status_code(response, 200) self.assert_model_exists( - "user/2", {"saml_id": "111", "meeting_user_ids": [1], "meeting_ids": [1]} + "user/2", + { + "saml_id": "111", + "username": "111", + "meeting_user_ids": [1, 2], + "meeting_ids": [1, 4], + }, + ) + self.assert_model_exists( + "meeting_user/1", {"user_id": 2, "group_ids": [2], "meeting_id": 1} ) self.assert_model_exists( - "meeting_user/1", {"user_id": 2, "group_ids": [1], "meeting_id": 1} + "group/2", {"meeting_user_ids": [1], "external_id": "Delegates"} + ) + self.assert_model_exists( + "structure_level/1", + {"meeting_user_ids": [1], "name": "structure1"}, + ) + self.assert_model_exists( + "structure_level/2", + {"meeting_user_ids": [2], "name": "structure2"}, + ) + + def test_create_user_mapping_one_meeting_twice(self) -> None: + self.meeting_mappers[1]["external_id"] = "Landtag" + self.meeting_mappers[1]["mappings"]["groups"] = [ # type: ignore + { + "attribute": "use_default", + "default": "Default", + } + ] + self.set_models({"organization/1": self.organization}) + response = self.request( + "user.save_saml_account", + { + "username": ["111"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates", + "kv_member_number": "KV_Könighols", + "kv_email": "hols@holz.de", + "participant_kv_number": "MG_1254", + "idp_kv_group_attribute": "Delegates", + "kv_structure": "structure2", + "kv_presence": "True", + }, ) + self.assert_status_code(response, 200) self.assert_model_exists( - "group/1", + "user/2", { + "saml_id": "111", + "username": "111", "meeting_user_ids": [1], - "external_id": "Default", - "default_group_for_meeting_id": 1, + "meeting_ids": [1], }, ) + self.assert_model_exists( + "meeting_user/1", {"user_id": 2, "group_ids": [1, 2], "meeting_id": 1} + ) + self.assert_model_exists( + "group/1", {"meeting_user_ids": [1], "external_id": "Default"} + ) + self.assert_model_exists( + "group/2", {"meeting_user_ids": [1], "external_id": "Delegates"} + ) + self.assert_model_exists( + "structure_level/1", + {"meeting_user_ids": [1], "name": "structure1"}, + ) + self.assert_model_exists( + "structure_level/2", + {"meeting_user_ids": [1], "name": "structure2"}, + ) - def test_update_user_existing_member_in_group(self) -> None: - """user created and logged in""" + def test_update_user_with_default_membership(self) -> None: + """ + Shows: + * deleting all conditions of a single mapper defaults this mapper to true + * updating without any group data in saml payload and not existing group in default inserts user in meetings default group + * updating without group data in saml payload but existing group in default works + """ + del self.meeting_mappers[0]["conditions"] + self.meeting_mappers[1]["conditions"] = [ + {"attribute": "yes", "condition": ".*"} + ] + self.meeting_mappers[1]["mappings"]["groups"][0]["default"] = "Delegates" # type: ignore + self.set_models({"organization/1": self.organization}) + response = self.request( + "user.save_saml_account", {"username": ["admin_saml"], "yes": "to_all"} + ) + self.assert_status_code(response, 200) + self.assert_model_exists( + "user/1", + { + "saml_id": "admin_saml", + "username": "admin", + "meeting_user_ids": [1, 2], + "meeting_ids": [1, 4], + }, + ) + self.assert_model_exists( + "meeting_user/1", {"user_id": 1, "group_ids": [1], "meeting_id": 1} + ) + self.assert_model_exists( + "meeting_user/2", {"user_id": 1, "group_ids": [5], "meeting_id": 4} + ) + self.assert_model_exists( + "group/1", {"meeting_user_ids": [1], "external_id": "Default"} + ) + self.assert_model_exists( + "group/5", {"meeting_user_ids": [2], "external_id": "Delegates"} + ) + + def test_update_user_participant_already_in_group(self) -> None: + """ + Shows: + * user stays in group 2 + * structure_level/1 left untouched structure_level/2 added + * second mapper is ignored due to being an update and allow_update being false + """ + self.meeting_mappers[1]["allow_update"] = "False" + self.set_models({"organization/1": self.organization}) self.set_user_groups(1, [2]) - response = self.request("user.save_saml_account", {"username": ["admin_saml"]}) + self.set_models( + { + "structure_level/1": { + "name": "structure1", + "meeting_user_ids": [1], + "meeting_id": 1, + } + } + ) + self.update_model("meeting_user/1", {"structure_level_ids": [1]}) + response = self.request( + "user.save_saml_account", + { + "username": ["admin_saml"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "participant_number": "MG_1254", + "idp_group_attribute": "Delegates", + "kv_member_number": "KV_Könighols", + "kv_email": "hols@holz.de", + "participant_kv_number": "MG_1254", + "kv_group_attribute": "Delegates", + "kv_structure": "structure2", + "structure": "structure2", + }, + ) self.assert_status_code(response, 200) self.assert_model_exists( "user/1", @@ -644,29 +1090,57 @@ def test_update_user_existing_member_in_group(self) -> None: }, ) self.assert_model_exists( - "meeting_user/1", {"user_id": 1, "group_ids": [2], "meeting_id": 1} + "meeting_user/1", + { + "user_id": 1, + "group_ids": [2], + "meeting_id": 1, + "structure_level_ids": [1, 2], + }, ) + self.assert_model_not_exists("meeting_user/2") self.assert_model_exists( "group/2", {"meeting_user_ids": [1], "external_id": "Delegates"} ) + self.assert_model_exists( + "structure_level/1", {"name": "structure1", "meeting_user_ids": [1]} + ) + self.assert_model_exists( + "structure_level/2", {"name": "structure2", "meeting_user_ids": [1]} + ) + self.assert_model_not_exists("structure_level/3") def test_update_user_add_group_to_existing_groups(self) -> None: """group added, user created and logged in""" self.set_user_groups(1, [1, 3]) - response = self.request("user.save_saml_account", {"username": ["admin_saml"]}) + response = self.request( + "user.save_saml_account", + { + "username": ["admin_saml"], + "member_number": "LV_Königholz", + "email": "holzi@holz.de", + "idp_group_attribute": "Delegates", + "kv_member_number": "KV_Könighols", + "kv_group_attribute": "Delegates", + "kv_structure": "structure2", + }, + ) self.assert_status_code(response, 200) self.assert_model_exists( "user/1", { "saml_id": "admin_saml", "username": "admin", - "meeting_user_ids": [1], - "meeting_ids": [1], + "meeting_user_ids": [1, 2], + "meeting_ids": [1, 4], }, ) self.assert_model_exists( "meeting_user/1", {"user_id": 1, "group_ids": [1, 3, 2], "meeting_id": 1} ) + self.assert_model_exists( + "meeting_user/2", {"user_id": 1, "group_ids": [5], "meeting_id": 4} + ) self.assert_model_exists( "group/2", {"meeting_user_ids": [1], "external_id": "Delegates"} ) diff --git a/tests/system/action/user/test_update.py b/tests/system/action/user/test_update.py index 2e3ace2e9..0b1b33e3a 100644 --- a/tests/system/action/user/test_update.py +++ b/tests/system/action/user/test_update.py @@ -153,7 +153,7 @@ def test_update_with_meeting_user_fields(self) -> None: self.assert_history_information( "user/22", [ - "Participant added to meeting {}", + "Participant added to meeting {}.", "meeting/1", "Committee management changed", ], @@ -2354,7 +2354,7 @@ def test_update_participant_data_with_existing_meetings(self) -> None: self.assert_history_information( "user/222", [ - "Participant added to meeting {}", + "Participant added to meeting {}.", "meeting/2", ], ) @@ -2395,9 +2395,9 @@ def test_update_participant_data_in_multiple_meetings_with_existing_meetings( self.assert_history_information( "user/222", [ - "Participant added to meeting {}", + "Participant added to meeting {}.", "meeting/2", - "Participant added to meeting {}", + "Participant added to meeting {}.", "meeting/3", ], )