diff --git a/docs/actions/motion.create_forwarded.md b/docs/actions/motion.create_forwarded.md index 7a5e5680b..5dba5949d 100644 --- a/docs/actions/motion.create_forwarded.md +++ b/docs/actions/motion.create_forwarded.md @@ -9,6 +9,9 @@ // Optional reason: HTML; + use_original_submitter: boolean; + use_original_number: boolean; + with_amendments: boolean; } ``` @@ -25,19 +28,34 @@ The original motion must be updated as well (this is done by the automatic relat * The unique `id` of the newly created motion has to be linked to the _origin motion_s `derived_motion_ids` field. * Deleting the newly created motion has to ensure that the corresponding entry was removed from the _origin motion_s `derived_motion_ids` field +The optional flags `use_original_submitter` and `use_original_number` will cause the original submitters and original numbers to be used in the new motion respectively. In case of the submitters, the action will generate the full name of the submitters and write the entire list of them and the value of the origin motions `additional_submitter` comma separated into the new motions `additional_submitter` field. If `use_original_submitter` is false the name of the origin motions committee will be written into the `additional_submitter` field instead + +If `with_amendments` is set to True, all amendments of the motion, that have a state that can forward, will also be forwarded to the target meeting and connected to the newly forwarded lead motion. +The three boolean flags for extra rules will be applied to the amendments as well. + +If the forwarded amendments have amendments themselves, those will also be treated the same way + ### Forwarding tree fields * `all_origin_ids` of the newly created motion must be set to `all_origin_ids` of the origin motion plus the given `origin_id`. It is important that the id is appended at the end of the list, since the order of this field represents the order of the tree in case a motion of the tree is deleted. * The id of the newly created motion must be added to the `all_derived_motion_ids` field of all motions in the `all_origin_ids` field of this motion. Order is not important here. -### New user in receiving meeting - -* A new user on committee level will be generated automatically _inactive_ with meeting standard group and committee's name. This user is stored in the committee as `forwarding_user` and used in further forwardings, if necessary with new membership in standard group of new meetings. - ### State needs to allow forwarding * The origin state must allow forwarding (`allow_motion_forwarding` must be set to True). +## Result + +The result object for each instance has the format +``` +{ + id: Id, + sequential_number: int, + non_forwarded_amendment_amount: int, // Number of amendments that couldn't be returned because of forwarding being not allowed in the state + amendment_result_data: [...], // List of result data objects in the same format, for all newly created amendments for the newly created motion +} +``` + ## Permissions The request user needs `motion.can_forward` in the source meeting. `motion.can_manage` is not explicitly needed for the request user, because it is included. There are no rights needed in the receiving meeting. diff --git a/docs/actions/motion.create_forwarded_amendment.md b/docs/actions/motion.create_forwarded_amendment.md new file mode 100644 index 000000000..53338ec57 --- /dev/null +++ b/docs/actions/motion.create_forwarded_amendment.md @@ -0,0 +1,22 @@ +## Payload +``` +{ +// Required + meeting_id: Id; + title: string; + lead_motion_id: Id; + origin_id: Id; + +// Optional + text: HTML; + reason: HTML; + amendment_paragraphs: JSON + use_original_submitter: boolean; + use_original_number: boolean; +} +``` + +## Internal action +Forwards an amendment in a manner that is to what is done with normal motions in [motion.create_forwarded](motion.create_forwarded.md) + +The only change is that the `with_amendments` flag is not in the payload, because it is assumed to be true. \ No newline at end of file diff --git a/global/data/example-data.json b/global/data/example-data.json index 172a84d10..3078ea09c 100644 --- a/global/data/example-data.json +++ b/global/data/example-data.json @@ -1,5 +1,5 @@ { - "_migration_index": 54, + "_migration_index": 55, "organization": { "1": { "id": 1, diff --git a/global/data/initial-data.json b/global/data/initial-data.json index e69b34855..39fbc8f3c 100644 --- a/global/data/initial-data.json +++ b/global/data/initial-data.json @@ -1,5 +1,5 @@ { - "_migration_index": 54, + "_migration_index": 55, "organization": { "1": { "id": 1, diff --git a/global/meta b/global/meta index b281df417..ab5274633 160000 --- a/global/meta +++ b/global/meta @@ -1 +1 @@ -Subproject commit b281df4171329cf74dfa0329deab1a1fb72f1bff +Subproject commit ab527463324866435ea932305b77dc1334c1738d diff --git a/openslides_backend/action/actions/meeting/import_.py b/openslides_backend/action/actions/meeting/import_.py index f7840d384..6c44fc8e6 100644 --- a/openslides_backend/action/actions/meeting/import_.py +++ b/openslides_backend/action/actions/meeting/import_.py @@ -167,7 +167,6 @@ def remove_not_allowed_fields(self, instance: dict[str, Any]) -> None: user.pop("organization_management_level", None) user.pop("committee_ids", None) user.pop("committee_management_ids", None) - user.pop("forwarding_committee_ids", None) self.get_meeting_from_json(json_data).pop("organization_tag_ids", None) json_data.pop("action_worker", None) json_data.pop("import_preview", None) diff --git a/openslides_backend/action/actions/motion/base_create_forwarded.py b/openslides_backend/action/actions/motion/base_create_forwarded.py new file mode 100644 index 000000000..c6b31f105 --- /dev/null +++ b/openslides_backend/action/actions/motion/base_create_forwarded.py @@ -0,0 +1,394 @@ +import time +from collections import defaultdict +from typing import Any + +from openslides_backend.action.actions.motion.mixins import TextHashMixin +from openslides_backend.shared.typing import HistoryInformation + +from ....permissions.permission_helper import has_perm +from ....permissions.permissions import Permissions +from ....services.datastore.commands import GetManyRequest +from ....shared.exceptions import ActionException, PermissionDenied +from ....shared.filters import FilterOperator +from ....shared.interfaces.write_request import WriteRequest +from ....shared.patterns import fqid_from_collection_and_id +from ...util.typing import ActionData, ActionResultElement, ActionResults +from .create_base import MotionCreateBase + + +class BaseMotionCreateForwarded(TextHashMixin, MotionCreateBase): + """ + Base create action for forwarded motions. + """ + + def prefetch(self, action_data: ActionData) -> None: + self.datastore.get_many( + [ + GetManyRequest( + "meeting", + list( + { + meeting_id + for instance in action_data + if (meeting_id := instance.get("meeting_id")) + } + ), + [ + "id", + "is_active_in_organization_id", + "name", + "motions_default_workflow_id", + "motions_default_amendment_workflow_id", + "committee_id", + "default_group_id", + "motion_submitter_ids", + "motions_number_type", + "motions_number_min_digits", + "agenda_item_creation", + "list_of_speakers_initially_closed", + "list_of_speakers_ids", + "motion_ids", + ], + ), + GetManyRequest( + "motion", + list( + { + origin_id + for instance in action_data + if (origin_id := instance.get("origin_id")) + } + ), + [ + "meeting_id", + "lead_motion_id", + "statute_paragraph_id", + "state_id", + "all_origin_ids", + "derived_motion_ids", + "all_derived_motion_ids", + "amendment_ids", + ], + ), + ], + lock_result=False, + ) + + def get_user_verbose_names(self, meeting_user_ids: list[int]) -> str | None: + meeting_users = self.datastore.get_many( + [ + GetManyRequest( + "meeting_user", meeting_user_ids, ["user_id", "structure_level_ids"] + ) + ], + lock_result=False, + )["meeting_user"] + user_ids = [ + user_id + for meeting_user in meeting_users.values() + if (user_id := meeting_user.get("user_id")) + ] + if not len(user_ids): + return None + requests = [ + GetManyRequest( + "user", user_ids, ["id", "first_name", "last_name", "title", "pronoun"] + ) + ] + if structure_level_ids := list( + { + structure_level_id + for meeting_user in meeting_users.values() + for structure_level_id in meeting_user.get("structure_level_ids", []) + } + ): + requests.append( + GetManyRequest("structure_level", structure_level_ids, ["name"]) + ) + user_data = self.datastore.get_many(requests, lock_result=False) + users = user_data["user"] + structure_levels = user_data["structure_level"] + names = [] + for meeting_user_id in meeting_user_ids: + meeting_user = meeting_users[meeting_user_id] + user = users.get(meeting_user.get("user_id", 0)) + if user: + additional_info: list[str] = [] + if pronoun := user.get("pronoun"): + additional_info = [pronoun] + if sl_ids := meeting_user.get("structure_level_ids"): + if slnames := ", ".join( + name + for structure_level_id in sl_ids + if ( + name := structure_levels.get(structure_level_id, {}).get( + "name" + ) + ) + ): + additional_info.append(slnames) + suffix = " · ".join(additional_info) + if suffix: + suffix = f"({suffix})" + if not any(user.get(field) for field in ["first_name", "last_name"]): + short_name = f"User {user['id']}" + else: + short_name = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip() + long_name = f"{user.get('title', '')} {short_name} {suffix}".strip() + names.append(long_name) + return ", ".join(names) + + def perform( + self, action_data: ActionData, user_id: int, internal: bool = False + ) -> tuple[WriteRequest | None, ActionResults | None]: + self.id_to_result_extra_data: dict[int, dict[str, Any]] = {} + return super().perform(action_data, user_id, internal) + + def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: + meeting = self.datastore.get( + fqid_from_collection_and_id("meeting", instance["meeting_id"]), + ["motions_default_workflow_id", "motions_default_amendment_workflow_id"], + lock_result=False, + ) + self.set_state_from_workflow(instance, meeting) + committee = self.check_for_origin_id(instance) + self.check_state_allow_forwarding(instance) + use_original_number = instance.get("use_original_number", False) + + if use_original_submitter := instance.pop("use_original_submitter", False): + submitters = list( + self.datastore.filter( + "motion_submitter", + FilterOperator("motion_id", "=", instance["origin_id"]), + ["meeting_user_id"], + lock_result=False, + ).values() + ) + submitters = sorted(submitters, key=lambda x: x.get("weight", 10000)) + meeting_user_ids = [ + meeting_user_id + for submitter in submitters + if (meeting_user_id := submitter.get("meeting_user_id")) + ] + if len(meeting_user_ids): + instance["additional_submitter"] = self.get_user_verbose_names( + meeting_user_ids + ) + text_submitter = self.datastore.get( + fqid_from_collection_and_id("motion", instance["origin_id"]), + ["additional_submitter"], + lock_result=False, + ).get("additional_submitter") + if text_submitter: + if instance.get("additional_submitter"): + instance["additional_submitter"] += ", " + text_submitter + else: + instance["additional_submitter"] = text_submitter + else: + name = committee.get("name", f"Committee {committee['id']}") + instance["additional_submitter"] = name + + self.set_sequential_number(instance) + self.handle_number(instance) + self.set_origin_ids(instance) + self.set_text_hash(instance) + instance["forwarded"] = round(time.time()) + self.datastore.apply_changed_model( + fqid_from_collection_and_id("motion", instance["id"]), instance + ) + amendment_ids = self.datastore.get( + fqid_from_collection_and_id("motion", instance["origin_id"]), + ["amendment_ids"], + lock_result=False, + ).get("amendment_ids", []) + if self.should_forward_amendments(instance): + new_amendments = self.datastore.get_many( + [ + GetManyRequest( + "motion", + amendment_ids, + [ + "title", + "text", + "amendment_paragraphs", + "reason", + "id", + "state_id", + ], + ) + ] + )["motion"] + total = len(new_amendments) + state_ids = { + state_id + for amendment in new_amendments.values() + if (state_id := amendment.get("state_id")) + } + if len(state_ids): + states = self.datastore.get_many( + [ + GetManyRequest( + "motion_state", list(state_ids), ["allow_motion_forwarding"] + ) + ], + lock_result=False, + )["motion_state"] + else: + states = {} + states = { + id_: state + for id_, state in states.items() + if state.get("allow_motion_forwarding") + } + for amendment in list(new_amendments.values()): + if not ( + (state_id := amendment.pop("state_id", None)) and state_id in states + ): + new_amendments.pop(amendment["id"]) + amendment_data = new_amendments.values() + for amendment in amendment_data: + amendment.update( + { + "lead_motion_id": instance["id"], + "origin_id": amendment["id"], + "meeting_id": instance["meeting_id"], + "use_original_submitter": use_original_submitter, + "use_original_number": use_original_number, + } + ) + amendment.pop("meta_position", 0) + amendment.pop("id") + amendment_results = self.create_amendments(list(amendment_data)) or [] + self.id_to_result_extra_data[instance["id"]] = { + "non_forwarded_amendment_amount": total - len(amendment_results), + "amendment_result_data": amendment_results, + } + else: + self.id_to_result_extra_data[instance["id"]] = { + "non_forwarded_amendment_amount": len(amendment_ids), + "amendment_result_data": [], + } + return instance + + def create_amendments(self, amendment_data: ActionData) -> ActionResults | None: + raise ActionException("Not implemented") + + def create_action_result_element( + self, instance: dict[str, Any] + ) -> ActionResultElement | None: + result = super().create_action_result_element(instance) or {} + result.update(self.id_to_result_extra_data.get(result["id"], {})) + return result + + def handle_number(self, instance: dict[str, Any]) -> dict[str, Any]: + origin = self.datastore.get( + fqid_from_collection_and_id("motion", instance["origin_id"]), + ["number"], + lock_result=False, + ) + if instance.pop("use_original_number", None) and (num := origin.get("number")): + number = self.get_clean_number(num, instance["meeting_id"]) + self.set_created_last_modified(instance) + instance["number"] = number + else: + self.set_created_last_modified_and_number(instance) + return instance + + def get_clean_number(self, number: str, meeting_id: int) -> str: + new_number = number + next_identifier = 1 + while not self._check_if_unique(new_number, meeting_id, None): + new_number = f"{number}-{next_identifier}" + next_identifier += 1 + return new_number + + def check_for_origin_id(self, instance: dict[str, Any]) -> dict[str, Any]: + meeting = self.datastore.get( + fqid_from_collection_and_id("meeting", instance["meeting_id"]), + ["committee_id"], + lock_result=False, + ) + forwarded_from = self.datastore.get( + fqid_from_collection_and_id("motion", instance["origin_id"]), + ["meeting_id"], + lock_result=False, + ) + forwarded_from_meeting = self.datastore.get( + fqid_from_collection_and_id("meeting", forwarded_from["meeting_id"]), + ["committee_id"], + lock_result=False, + ) + # use the forwarding user id and id later in the handle forwarding user + # code. + committee = self.datastore.get( + fqid_from_collection_and_id( + "committee", forwarded_from_meeting["committee_id"] + ), + ["id", "name", "forward_to_committee_ids"], + lock_result=False, + ) + if meeting["committee_id"] not in committee.get("forward_to_committee_ids", []): + raise ActionException( + f"Committee id {meeting['committee_id']} not in {committee.get('forward_to_committee_ids', [])}" + ) + return committee + + def should_forward_amendments(self, instance: dict[str, Any]) -> bool: + raise ActionException("Not implemented") + + def check_permissions(self, instance: dict[str, Any]) -> None: + origin = self.datastore.get( + fqid_from_collection_and_id(self.model.collection, instance["origin_id"]), + ["meeting_id"], + lock_result=False, + ) + perm_origin = Permissions.Motion.CAN_FORWARD + if not has_perm( + self.datastore, self.user_id, perm_origin, origin["meeting_id"] + ): + msg = f"You are not allowed to perform action {self.name}." + msg += f" Missing permission: {perm_origin}" + raise PermissionDenied(msg) + + def set_origin_ids(self, instance: dict[str, Any]) -> None: + if instance.get("origin_id"): + origin = self.datastore.get( + fqid_from_collection_and_id("motion", instance["origin_id"]), + ["all_origin_ids", "meeting_id"], + lock_result=False, + ) + instance["origin_meeting_id"] = origin["meeting_id"] + instance["all_origin_ids"] = origin.get("all_origin_ids", []) + instance["all_origin_ids"].append(instance["origin_id"]) + + def check_state_allow_forwarding(self, instance: dict[str, Any]) -> None: + origin = self.datastore.get( + fqid_from_collection_and_id(self.model.collection, instance["origin_id"]), + ["state_id"], + lock_result=False, + ) + state = self.datastore.get( + fqid_from_collection_and_id("motion_state", origin["state_id"]), + ["allow_motion_forwarding"], + lock_result=False, + ) + if not state.get("allow_motion_forwarding"): + raise ActionException("State doesn't allow to forward motion.") + + def get_history_information(self) -> HistoryInformation | None: + forwarded_entries = defaultdict(list) + for instance in self.instances: + forwarded_entries[ + fqid_from_collection_and_id("motion", instance["origin_id"]) + ].extend( + [ + "Forwarded to {}", + fqid_from_collection_and_id("meeting", instance["meeting_id"]), + ] + ) + return forwarded_entries | { + fqid_from_collection_and_id("motion", instance["id"]): [ + "Motion created (forwarded)" + ] + for instance in self.instances + } diff --git a/openslides_backend/action/actions/motion/create_base.py b/openslides_backend/action/actions/motion/create_base.py index 31bdbf2f8..319158e53 100644 --- a/openslides_backend/action/actions/motion/create_base.py +++ b/openslides_backend/action/actions/motion/create_base.py @@ -79,10 +79,7 @@ def set_sequential_number(self, instance: dict[str, Any]) -> None: ) def set_created_last_modified_and_number(self, instance: dict[str, Any]) -> None: - timestamp = round(time.time()) - set_workflow_timestamp_helper(self.datastore, instance, timestamp) - instance["last_modified"] = timestamp - instance["created"] = timestamp + self.set_created_last_modified(instance) self.set_number( instance, instance["meeting_id"], @@ -90,3 +87,9 @@ def set_created_last_modified_and_number(self, instance: dict[str, Any]) -> None instance.get("lead_motion_id"), instance.get("category_id"), ) + + def set_created_last_modified(self, instance: dict[str, Any]) -> None: + timestamp = round(time.time()) + set_workflow_timestamp_helper(self.datastore, instance, timestamp) + instance["last_modified"] = timestamp + instance["created"] = timestamp diff --git a/openslides_backend/action/actions/motion/create_forwarded.py b/openslides_backend/action/actions/motion/create_forwarded.py index 247bb1b91..23339127f 100644 --- a/openslides_backend/action/actions/motion/create_forwarded.py +++ b/openslides_backend/action/actions/motion/create_forwarded.py @@ -1,209 +1,34 @@ -import time -from collections import defaultdict from typing import Any -from openslides_backend.action.actions.motion.mixins import TextHashMixin -from openslides_backend.shared.typing import HistoryInformation - from ....models.models import Motion -from ....permissions.permission_helper import has_perm -from ....permissions.permissions import Permissions -from ....services.datastore.commands import GetManyRequest -from ....shared.exceptions import ActionException, PermissionDenied +from ....shared.exceptions import PermissionDenied from ....shared.patterns import fqid_from_collection_and_id from ...util.default_schema import DefaultSchema from ...util.register import register_action -from ...util.typing import ActionData -from ..meeting_user.create import MeetingUserCreate -from ..meeting_user.update import MeetingUserUpdate -from ..user.create import UserCreate -from .create_base import MotionCreateBase +from ...util.typing import ActionData, ActionResults +from .base_create_forwarded import BaseMotionCreateForwarded +from .create_forwarded_amendment import MotionCreateForwardedAmendment @register_action("motion.create_forwarded") -class MotionCreateForwarded(TextHashMixin, MotionCreateBase): +class MotionCreateForwarded(BaseMotionCreateForwarded): """ - Create action for forwarded motions. + Create action for forwarded amendments. + Result amendment will not have a lead_motion_id yet, that will have to be set via the calling action. """ schema = DefaultSchema(Motion()).get_create_schema( required_properties=["meeting_id", "title", "text", "origin_id"], optional_properties=["reason"], + additional_optional_fields={ + "use_original_submitter": {"type": "boolean"}, + "use_original_number": {"type": "boolean"}, + "with_amendments": {"type": "boolean"}, + }, ) - def prefetch(self, action_data: ActionData) -> None: - self.datastore.get_many( - [ - GetManyRequest( - "meeting", - list( - { - meeting_id - for instance in action_data - if (meeting_id := instance.get("meeting_id")) - } - ), - [ - "id", - "is_active_in_organization_id", - "name", - "motions_default_workflow_id", - "committee_id", - "default_group_id", - "motion_submitter_ids", - "motions_number_type", - "motions_number_min_digits", - "agenda_item_creation", - "list_of_speakers_initially_closed", - "list_of_speakers_ids", - "motion_ids", - ], - ), - GetManyRequest( - "motion", - list( - { - origin_id - for instance in action_data - if (origin_id := instance.get("origin_id")) - } - ), - [ - "meeting_id", - "lead_motion_id", - "statute_paragraph_id", - "state_id", - "all_origin_ids", - "derived_motion_ids", - "all_derived_motion_ids", - ], - ), - ] - ) - - def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: - meeting = self.datastore.get( - fqid_from_collection_and_id("meeting", instance["meeting_id"]), - [ - "motions_default_workflow_id", - ], - ) - self.set_state_from_workflow(instance, meeting) - committee = self.check_for_origin_id(instance) - self.check_state_allow_forwarding(instance) - - # handle forwarding user - target_meeting = self.datastore.get( - fqid_from_collection_and_id("meeting", instance["meeting_id"]), - ["id", "default_group_id"], - ) - if committee.get("forwarding_user_id"): - forwarding_user_id = committee["forwarding_user_id"] - meeting_id = instance["meeting_id"] - forwarding_user_groups = self.get_groups_from_meeting_user( - meeting_id, forwarding_user_id - ) - if target_meeting["default_group_id"] not in forwarding_user_groups: - meeting_user = self.get_meeting_user( - meeting_id, forwarding_user_id, ["id", "group_ids"] - ) - if not meeting_user: - self.execute_other_action( - MeetingUserCreate, - [ - { - "meeting_id": meeting_id, - "user_id": forwarding_user_id, - "group_ids": [target_meeting["default_group_id"]], - } - ], - ) - else: - self.execute_other_action( - MeetingUserUpdate, - [ - { - "id": meeting_user["id"], - "group_ids": (meeting_user.get("group_ids") or []) - + [target_meeting["default_group_id"]], - } - ], - ) - - else: - username = committee.get("name", "Committee User") - meeting_id = instance["meeting_id"] - committee_user_create_payload = { - "last_name": username, - "is_physical_person": False, - "is_active": False, - "forwarding_committee_ids": [committee["id"]], - } - action_result = self.execute_other_action( - UserCreate, [committee_user_create_payload], skip_history=True - ) - assert action_result and action_result[0] - forwarding_user_id = action_result[0]["id"] - self.execute_other_action( - MeetingUserCreate, - [ - { - "user_id": forwarding_user_id, - "meeting_id": meeting_id, - "group_ids": [target_meeting["default_group_id"]], - } - ], - ) - instance["submitter_ids"] = [forwarding_user_id] - - self.create_submitters(instance) - self.set_sequential_number(instance) - self.set_created_last_modified_and_number(instance) - self.set_origin_ids(instance) - self.set_text_hash(instance) - instance["forwarded"] = round(time.time()) - return instance - - def check_for_origin_id(self, instance: dict[str, Any]) -> dict[str, Any]: - meeting = self.datastore.get( - fqid_from_collection_and_id("meeting", instance["meeting_id"]), - ["committee_id"], - ) - forwarded_from = self.datastore.get( - fqid_from_collection_and_id("motion", instance["origin_id"]), - ["meeting_id"], - ) - forwarded_from_meeting = self.datastore.get( - fqid_from_collection_and_id("meeting", forwarded_from["meeting_id"]), - ["committee_id"], - ) - # use the forwarding user id and id later in the handle forwarding user - # code. - committee = self.datastore.get( - fqid_from_collection_and_id( - "committee", forwarded_from_meeting["committee_id"] - ), - ["id", "name", "forward_to_committee_ids", "forwarding_user_id"], - ) - if meeting["committee_id"] not in committee.get("forward_to_committee_ids", []): - raise ActionException( - f"Committee id {meeting['committee_id']} not in {committee.get('forward_to_committee_ids', [])}" - ) - return committee - def check_permissions(self, instance: dict[str, Any]) -> None: - origin = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["origin_id"]), - ["meeting_id"], - lock_result=False, - ) - perm_origin = Permissions.Motion.CAN_FORWARD - if not has_perm( - self.datastore, self.user_id, perm_origin, origin["meeting_id"] - ): - msg = f"You are not allowed to perform action {self.name}." - msg += f" Missing permission: {perm_origin}" - raise PermissionDenied(msg) + super().check_permissions(instance) # check if origin motion is amendment or statute_amendment origin = self.datastore.get( @@ -215,42 +40,13 @@ def check_permissions(self, instance: dict[str, Any]) -> None: msg = "Amendments cannot be forwarded." raise PermissionDenied(msg) - def set_origin_ids(self, instance: dict[str, Any]) -> None: - if instance.get("origin_id"): - origin = self.datastore.get( - fqid_from_collection_and_id("motion", instance["origin_id"]), - ["all_origin_ids", "meeting_id"], - ) - instance["origin_meeting_id"] = origin["meeting_id"] - instance["all_origin_ids"] = origin.get("all_origin_ids", []) - instance["all_origin_ids"].append(instance["origin_id"]) + def create_amendments(self, amendment_data: ActionData) -> ActionResults | None: + return self.execute_other_action(MotionCreateForwardedAmendment, amendment_data) - def check_state_allow_forwarding(self, instance: dict[str, Any]) -> None: - origin = self.datastore.get( - fqid_from_collection_and_id(self.model.collection, instance["origin_id"]), - ["state_id"], - ) - state = self.datastore.get( - fqid_from_collection_and_id("motion_state", origin["state_id"]), - ["allow_motion_forwarding"], - ) - if not state.get("allow_motion_forwarding"): - raise ActionException("State doesn't allow to forward motion.") + def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: + self.with_amendments = instance.pop("with_amendments", False) + super().update_instance(instance) + return instance - def get_history_information(self) -> HistoryInformation | None: - forwarded_entries = defaultdict(list) - for instance in self.instances: - forwarded_entries[ - fqid_from_collection_and_id("motion", instance["origin_id"]) - ].extend( - [ - "Forwarded to {}", - fqid_from_collection_and_id("meeting", instance["meeting_id"]), - ] - ) - return forwarded_entries | { - fqid_from_collection_and_id("motion", instance["id"]): [ - "Motion created (forwarded)" - ] - for instance in self.instances - } + def should_forward_amendments(self, instance: dict[str, Any]) -> bool: + return self.with_amendments diff --git a/openslides_backend/action/actions/motion/create_forwarded_amendment.py b/openslides_backend/action/actions/motion/create_forwarded_amendment.py new file mode 100644 index 000000000..82b84d30a --- /dev/null +++ b/openslides_backend/action/actions/motion/create_forwarded_amendment.py @@ -0,0 +1,48 @@ +from typing import Any + +from ....models.models import Motion +from ....shared.exceptions import PermissionDenied +from ....shared.patterns import fqid_from_collection_and_id +from ...util.action_type import ActionType +from ...util.default_schema import DefaultSchema +from ...util.register import register_action +from ...util.typing import ActionData, ActionResults +from .base_create_forwarded import BaseMotionCreateForwarded + + +@register_action( + "motion.create_forwarded_amendment", action_type=ActionType.BACKEND_INTERNAL +) +class MotionCreateForwardedAmendment(BaseMotionCreateForwarded): + """ + Internal create action for forwarded motion amendments. + Should only be called by motion.create_forwarded + """ + + schema = DefaultSchema(Motion()).get_create_schema( + required_properties=["meeting_id", "title", "origin_id", "lead_motion_id"], + optional_properties=["reason", "text", "amendment_paragraphs"], + additional_optional_fields={ + "use_original_submitter": {"type": "boolean"}, + "use_original_number": {"type": "boolean"}, + }, + ) + + def check_permissions(self, instance: dict[str, Any]) -> None: + super().check_permissions(instance) + + # check if origin motion is normal or statute_amendment + origin = self.datastore.get( + fqid_from_collection_and_id(self.model.collection, instance["origin_id"]), + ["lead_motion_id", "statute_paragraph_id"], + lock_result=False, + ) + if not origin.get("lead_motion_id") or origin.get("statute_paragraph_id"): + msg = "Can only forward amendments in internal forward." + raise PermissionDenied(msg) + + def create_amendments(self, amendment_data: ActionData) -> ActionResults | None: + return self.execute_other_action(MotionCreateForwardedAmendment, amendment_data) + + def should_forward_amendments(self, instance: dict[str, Any]) -> bool: + return True diff --git a/openslides_backend/action/actions/motion/update.py b/openslides_backend/action/actions/motion/update.py index 2d906ce0e..7dd5c1061 100644 --- a/openslides_backend/action/actions/motion/update.py +++ b/openslides_backend/action/actions/motion/update.py @@ -27,7 +27,6 @@ set_workflow_timestamp_helper, ) from .payload_validation_mixin import MotionUpdatePayloadValidationMixin -from .set_number_mixin import SetNumberMixin @register_action("motion.update") @@ -35,7 +34,6 @@ class MotionUpdate( MotionUpdatePayloadValidationMixin, AmendmentParagraphHelper, PermissionHelperMixin, - SetNumberMixin, TextHashMixin, UpdateAction, ): diff --git a/openslides_backend/action/actions/motion_submitter/create.py b/openslides_backend/action/actions/motion_submitter/create.py index 5758cf250..b289cad48 100644 --- a/openslides_backend/action/actions/motion_submitter/create.py +++ b/openslides_backend/action/actions/motion_submitter/create.py @@ -2,7 +2,7 @@ from ...mixins.motion_meeting_user_create import build_motion_meeting_user_create_action from ...util.register import register_action -BaseClass: type = build_motion_meeting_user_create_action(MotionSubmitter) +BaseClass: type = build_motion_meeting_user_create_action(MotionSubmitter, True) @register_action("motion_submitter.create") diff --git a/openslides_backend/action/actions/user/create.py b/openslides_backend/action/actions/user/create.py index 2dfaf6819..ffa29be4c 100644 --- a/openslides_backend/action/actions/user/create.py +++ b/openslides_backend/action/actions/user/create.py @@ -51,7 +51,6 @@ class UserCreate( "is_present_in_meeting_ids", "committee_management_ids", "is_demo_user", - "forwarding_committee_ids", "saml_id", "member_number", ], diff --git a/openslides_backend/action/actions/user/create_update_permissions_mixin.py b/openslides_backend/action/actions/user/create_update_permissions_mixin.py index 2ca55034e..e9c801807 100644 --- a/openslides_backend/action/actions/user/create_update_permissions_mixin.py +++ b/openslides_backend/action/actions/user/create_update_permissions_mixin.py @@ -213,9 +213,6 @@ def check_permissions(self, instance: dict[str, Any]) -> None: """ self.assert_not_anonymous() - if "forwarding_committee_ids" in instance: - raise PermissionDenied("forwarding_committee_ids is not allowed.") - if not hasattr(self, "permstore"): self.permstore = PermissionVarStore( self.datastore, self.user_id, self.permission diff --git a/openslides_backend/action/actions/user/merge_together.py b/openslides_backend/action/actions/user/merge_together.py index 09bb08bcd..6a52a65c5 100644 --- a/openslides_backend/action/actions/user/merge_together.py +++ b/openslides_backend/action/actions/user/merge_together.py @@ -99,7 +99,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: ], "error": [ "is_demo_user", - "forwarding_committee_ids", ], "merge": [ "committee_ids", diff --git a/openslides_backend/action/mixins/motion_meeting_user_create.py b/openslides_backend/action/mixins/motion_meeting_user_create.py index f71e23f25..9edf3ccd1 100644 --- a/openslides_backend/action/mixins/motion_meeting_user_create.py +++ b/openslides_backend/action/mixins/motion_meeting_user_create.py @@ -18,7 +18,7 @@ def build_motion_meeting_user_create_action( - ModelClass: type[Model], + ModelClass: type[Model], ignore_meeting_if_internal: bool = False ) -> type[CreateAction]: class BaseMotionMeetingUserCreateAction( WeightMixin, CreateActionWithInferredMeetingMixin, CreateAction @@ -45,7 +45,9 @@ def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: ), ["user_id"], ) - if not has_organization_management_level( + if not ( + ignore_meeting_if_internal and self.internal + ) and not has_organization_management_level( self.datastore, meeting_user["user_id"], OrganizationManagementLevel.SUPERADMIN, diff --git a/openslides_backend/migrations/migrations/0054_remove_forwarding_user.py b/openslides_backend/migrations/migrations/0054_remove_forwarding_user.py new file mode 100644 index 000000000..b37176aa2 --- /dev/null +++ b/openslides_backend/migrations/migrations/0054_remove_forwarding_user.py @@ -0,0 +1,28 @@ +from datastore.migrations import BaseModelMigration +from datastore.shared.util import fqid_from_collection_and_id +from datastore.writer.core import BaseRequestEvent, RequestUpdateEvent + + +class Migration(BaseModelMigration): + """ + This migration removes the forwarding user relation + """ + + target_migration_index = 55 + + def migrate_models(self) -> list[BaseRequestEvent] | None: + events: list[BaseRequestEvent] = [] + for collection, field in [ + ("user", "forwarding_committee_ids"), + ("committee", "forwarding_user_id"), + ]: + data = self.reader.get_all(collection, ["id", field]) + for id_, model in data.items(): + if field in model: + events.append( + RequestUpdateEvent( + fqid_from_collection_and_id(collection, id_), + {field: None}, + ) + ) + return events diff --git a/openslides_backend/models/models.py b/openslides_backend/models/models.py index cdec8a7e2..e357d9999 100644 --- a/openslides_backend/models/models.py +++ b/openslides_backend/models/models.py @@ -118,9 +118,6 @@ class User(Model): constraints={"description": "Calculated field."}, ) committee_management_ids = fields.RelationListField(to={"committee": "manager_ids"}) - forwarding_committee_ids = fields.RelationListField( - to={"committee": "forwarding_user_id"} - ) meeting_user_ids = fields.RelationListField( to={"meeting_user": "user_id"}, on_delete=fields.OnDelete.CASCADE ) @@ -297,7 +294,6 @@ class Committee(Model): receive_forwardings_from_committee_ids = fields.RelationListField( to={"committee": "forward_to_committee_ids"} ) - forwarding_user_id = fields.RelationField(to={"user": "forwarding_committee_ids"}) organization_tag_ids = fields.RelationListField( to={"organization_tag": "tagged_ids"} ) diff --git a/tests/system/action/meeting/test_import.py b/tests/system/action/meeting/test_import.py index 71992c1e6..f6c0adabf 100644 --- a/tests/system/action/meeting/test_import.py +++ b/tests/system/action/meeting/test_import.py @@ -823,35 +823,6 @@ def test_check_usernames_new_and_twice(self) -> None: ) self.assert_model_not_exists("user/3") - def test_with_forwarding_committees(self) -> None: - request_data = self.create_request_data( - { - "user": { - "2": self.get_user_data( - 2, - { - "username": "user2", - "last_name": "new user", - "email": "tesT@email.de", - "forwarding_committee_ids": [4], - }, - ) - } - } - ) - - response = self.request("meeting.import", request_data) - self.assert_status_code(response, 200) - user2 = self.assert_model_exists( - "user/3", - { - "username": "user2", - "last_name": "new user", - "email": "tesT@email.de", - }, - ) - assert not user2.get("forwarding_committee_ids") - def test_check_negative_default_vote_weight(self) -> None: request_data = self.create_request_data({}) request_data["meeting"]["user"]["1"] = self.get_user_data( diff --git a/tests/system/action/motion/test_create_forwarded.py b/tests/system/action/motion/test_create_forwarded.py index 15b50fbd9..18784a532 100644 --- a/tests/system/action/motion/test_create_forwarded.py +++ b/tests/system/action/motion/test_create_forwarded.py @@ -17,10 +17,12 @@ def setUp(self) -> None: "motion_ids": [12], "meeting_user_ids": [1], "user_ids": [1], + "structure_level_ids": [1, 2, 3], }, "meeting/2": { "name": "name_SNLGsvIV", "motions_default_workflow_id": 12, + "motions_default_amendment_workflow_id": 12, "committee_id": 52, "is_active_in_organization_id": 1, "default_group_id": 112, @@ -30,7 +32,14 @@ def setUp(self) -> None: 1, ], }, - "user/1": {"meeting_ids": [1, 2]}, + "user/1": { + "meeting_ids": [1, 2], + "first_name": "the", + "last_name": "administrator", + "title": "Worship", + "pronoun": "he", + "meeting_user_ids": [1, 2], + }, "motion_workflow/12": { "name": "name_workflow1", "first_state_id": 34, @@ -74,6 +83,22 @@ def setUp(self) -> None: "user_id": 1, "meeting_id": 1, "group_ids": [111], + "structure_level_ids": [1, 2, 3], + }, + "structure_level/1": { + "meeting_user_ids": [1], + "meeting_id": 1, + "name": "is", + }, + "structure_level/2": { + "meeting_user_ids": [1], + "meeting_id": 1, + "name": "very", + }, + "structure_level/3": { + "meeting_user_ids": [1], + "meeting_id": 1, + "name": "good", }, "meeting_user/2": { "id": 2, @@ -106,40 +131,15 @@ def test_correct_origin_id_set(self) -> None: "all_derived_motion_ids": None, "all_origin_ids": [12], "reason": "reason_jLvcgAMx", - "submitter_ids": [1], + "submitter_ids": None, + "additional_submitter": "committee_forwarder", "state_id": 34, }, ) assert model.get("forwarded") - self.assert_model_exists( - "motion_submitter/1", - { - "meeting_user_id": 3, - "motion_id": 13, - }, - ) - self.assert_model_exists( - "user/2", - { - "username": "committee_forwarder", - "last_name": "committee_forwarder", - "is_physical_person": False, - "is_active": False, - "meeting_user_ids": [3], - "forwarding_committee_ids": [53], - }, - ) - self.assert_model_exists( - "meeting_user/3", - { - "meeting_id": 2, - "user_id": 2, - "motion_submitter_ids": [1], - "group_ids": [112], - }, - ) - self.assert_model_exists("group/112", {"meeting_user_ids": [2, 3]}) - self.assert_model_exists("committee/53", {"forwarding_user_id": 2}) + self.assert_model_not_exists("motion_submitter/1") + self.assert_model_not_exists("user/2") + self.assert_model_not_exists("meeting_user/3") self.assert_model_exists( "motion/12", {"derived_motion_ids": [13], "all_derived_motion_ids": [13]} ) @@ -175,139 +175,6 @@ def test_no_meeting_id(self) -> None: self.assert_status_code(response, 400) assert response.json["message"] == "data must contain ['origin_id'] properties" - def test_correct_existing_registered_forward_user(self) -> None: - self.set_models(self.test_model) - self.set_models( - { - "user/2": { - "username": "committee_forwarder53", - "is_physical_person": False, - "is_active": False, - "forwarding_committee_ids": [53], - }, - "group/113": {"name": "HPMHcWhk", "meeting_id": 2}, - "meeting/2": {"group_ids": [112, 113]}, - "committee/53": {"forwarding_user_id": 2}, - } - ) - self.set_user_groups( - 2, - [ - 113, - ], - ) - response = self.request( - "motion.create_forwarded", - { - "title": "test_Xcdfgee", - "meeting_id": 2, - "origin_id": 12, - "text": "test", - "reason": "reason_jLvcgAMx", - }, - ) - self.assert_status_code(response, 200) - self.assert_model_exists( - "committee/52", - { - "name": "committee_receiver", - "user_ids": [1, 2], - "meeting_ids": [2], - "receive_forwardings_from_committee_ids": [53], - }, - ) - self.assert_model_exists( - "committee/53", - { - "name": "committee_forwarder", - "user_ids": [1], - "forwarding_user_id": 2, - "forward_to_committee_ids": [52], - }, - ) - self.assert_model_exists( - "meeting/1", - { - "committee_id": 53, - "user_ids": [1], - "motion_ids": [ - 12, - ], - "forwarded_motion_ids": [13], - "group_ids": [111], - "meeting_user_ids": [1], - }, - ) - self.assert_model_exists( - "meeting/2", - { - "committee_id": 52, - "user_ids": [1, 2], - "meeting_user_ids": [2, 3], - "motion_ids": [13], - "motion_submitter_ids": [1], - "group_ids": [112, 113], - "list_of_speakers_ids": [1], - }, - ) - - self.assert_model_exists( - "user/2", - { - "username": "committee_forwarder53", - "is_physical_person": False, - "is_active": False, - "meeting_ids": [2], - "committee_ids": [52], - "meeting_user_ids": [3], - "forwarding_committee_ids": [53], - }, - ) - self.assert_model_exists( - "meeting_user/3", - { - "user_id": 2, - "meeting_id": 2, - "group_ids": [113, 112], - "motion_submitter_ids": [1], - }, - ) - self.assert_model_exists( - "group/112", {"meeting_id": 2, "meeting_user_ids": [2, 3]} - ) - self.assert_model_exists( - "group/113", {"meeting_id": 2, "meeting_user_ids": [3]} - ) - self.assert_model_exists( - "motion/12", - { - "title": "title_FcnPUXJB", - "meeting_id": 1, - "origin_id": None, - "all_origin_ids": None, - "derived_motion_ids": [13], - "all_derived_motion_ids": [13], - }, - ) - motion13 = self.assert_model_exists( - "motion/13", - { - "title": "test_Xcdfgee", - "text": "test", - "meeting_id": 2, - "origin_id": 12, - "all_derived_motion_ids": None, - "all_origin_ids": [12], - "reason": "reason_jLvcgAMx", - "submitter_ids": [1], - "list_of_speakers_id": 1, - }, - ) - assert motion13.get("forwarded") - self.assert_model_exists( - "motion_submitter/1", {"motion_id": 13, "meeting_user_id": 3} - ) - def test_correct_existing_unregistered_forward_user(self) -> None: self.set_models(self.test_model) self.set_models( @@ -337,41 +204,14 @@ def test_correct_existing_unregistered_forward_user(self) -> None: "origin_id": 12, "all_derived_motion_ids": None, "all_origin_ids": [12], - "submitter_ids": [1], + "submitter_ids": None, + "additional_submitter": "committee_forwarder", }, ) assert model.get("forwarded") - self.assert_model_exists( - "user/3", - { - "username": "committee_forwarder1", - "last_name": "committee_forwarder", - "is_physical_person": False, - "is_active": False, - "meeting_user_ids": [3], - "forwarding_committee_ids": [53], - "committee_ids": [52], - "meeting_ids": [2], - }, - ) - self.assert_model_exists( - "meeting_user/3", - { - "meeting_id": 2, - "user_id": 3, - "group_ids": [112], - "motion_submitter_ids": [1], - }, - ) - self.assert_model_exists("group/112", {"meeting_user_ids": [2, 3]}) - self.assert_model_exists("committee/53", {"forwarding_user_id": 3}) self.assert_model_exists( "motion/12", {"derived_motion_ids": [13], "all_derived_motion_ids": [13]} ) - self.assert_model_exists( - "motion_submitter/1", - {"meeting_user_id": 3, "motion_id": 13, "meeting_id": 2}, - ) def test_correct_origin_id_wrong_1(self) -> None: self.test_model["committee/53"]["forward_to_committee_ids"] = [] @@ -580,7 +420,7 @@ def test_forward_with_deleted_motion_in_all_origin_ids(self) -> None: ) self.assert_status_code(response, 200) - def test_not_allowed_to_forward_amendments(self) -> None: + def test_not_allowed_to_forward_amendments_directly(self) -> None: self.set_models( { "meeting/1": { @@ -596,6 +436,7 @@ def test_not_allowed_to_forward_amendments(self) -> None: "derived_motion_ids": [], "all_origin_ids": [], "all_derived_motion_ids": [], + "amendment_ids": [11], }, "motion/11": { "title": "test11 layer 2", @@ -630,6 +471,308 @@ def test_not_allowed_to_forward_amendments(self) -> None: self.assert_status_code(response, 403) assert "Amendments cannot be forwarded." in response.json["message"] + def test_allowed_to_forward_amendments_indirectly(self) -> None: + self.set_models(self.test_model) + self.set_models( + { + "meeting/1": { + "name": "name_XDAddEAW", + "committee_id": 53, + "is_active_in_organization_id": 1, + "motion_ids": [12, 13], + }, + "user/1": {"meeting_ids": [1, 2]}, + "motion/12": { + "title": "title_FcnPUXJB layer 1", + "meeting_id": 1, + "derived_motion_ids": [], + "all_origin_ids": [], + "all_derived_motion_ids": [], + "amendment_ids": [13], + }, + "motion/13": { + "title": "amendment", + "meeting_id": 1, + "derived_motion_ids": [], + "all_origin_ids": [], + "all_derived_motion_ids": [], + "lead_motion_id": 12, + "state_id": 30, + "amendment_paragraphs": {"0": "texts"}, + }, + } + ) + response = self.request( + "motion.create_forwarded", + { + "title": "test_foo", + "meeting_id": 2, + "origin_id": 12, + "text": "test", + "with_amendments": True, + }, + ) + self.assert_status_code(response, 200) + assert response.json["results"][0] == [ + { + "id": 14, + "non_forwarded_amendment_amount": 0, + "sequential_number": 1, + "amendment_result_data": [ + { + "amendment_result_data": [], + "id": 15, + "non_forwarded_amendment_amount": 0, + "sequential_number": 2, + } + ], + } + ] + self.assert_model_exists( + "motion/14", + { + "origin_id": 12, + "title": "test_foo", + "meeting_id": 2, + "text": "test", + "amendment_ids": [15], + "state_id": 34, + "additional_submitter": "committee_forwarder", + }, + ) + self.assert_model_exists( + "motion/15", + { + "lead_motion_id": 14, + "origin_id": 13, + "title": "amendment", + "meeting_id": 2, + "state_id": 34, + "amendment_paragraphs": {"0": "texts"}, + "additional_submitter": "committee_forwarder", + }, + ) + + def test_allowed_to_forward_amendments_indirectly_complex(self) -> None: + self.set_models(self.test_model) + user1 = self.create_user("first_submitter", [111]) + user2 = self.create_user("second_submitter", [111]) + self.set_models( + { + f"user/{user1}": {"first_name": "A", "last_name": "man"}, + f"user/{user2}": { + "title": "A", + "first_name": "hairy", + "last_name": "woman", + }, + "meeting/1": { + "name": "name_XDAddEAW", + "committee_id": 53, + "is_active_in_organization_id": 1, + "motion_ids": [12, 13, 14, 15], + }, + "user/1": {"meeting_ids": [1, 2]}, + "motion/12": { + "number": "MAIN", + "title": "title_FcnPUXJB layer 1", + "meeting_id": 1, + "derived_motion_ids": [], + "all_origin_ids": [], + "all_derived_motion_ids": [], + "amendment_ids": [13, 14, 15], + "submitter_ids": [1, 2], + }, + "motion_submitter/1": { + "motion_id": 12, + "meeting_user_id": 3, + "weight": 1, + }, + "motion_submitter/2": { + "motion_id": 12, + "meeting_user_id": 4, + "weight": 2, + }, + "motion/13": { + "number": "AMNDMNT1", + "title": "amendment1", + "meeting_id": 1, + "derived_motion_ids": [], + "all_origin_ids": [], + "all_derived_motion_ids": [], + "lead_motion_id": 12, + "state_id": 30, + "amendment_paragraphs": {"0": "texts"}, + "submitter_ids": [3], + }, + "motion_submitter/3": { + "motion_id": 13, + "meeting_user_id": 3, + "weight": 1, + }, + "motion/14": { + "number": "AMNDMNT2", + "title": "amendment2", + "meeting_id": 1, + "derived_motion_ids": [], + "all_origin_ids": [], + "all_derived_motion_ids": [], + "lead_motion_id": 12, + "state_id": 31, + "amendment_paragraphs": {"0": "NO!!!"}, + }, + "motion/15": { + "number": "AMNDMNT3", + "title": "amendment3", + "meeting_id": 1, + "derived_motion_ids": [], + "all_origin_ids": [], + "all_derived_motion_ids": [], + "lead_motion_id": 12, + "state_id": 30, + "amendment_paragraphs": {"0": "tests"}, + "amendment_ids": [16, 17], + }, + "motion/16": { + "number": "AMNDMNT4", + "title": "amendment4", + "meeting_id": 1, + "derived_motion_ids": [], + "all_origin_ids": [], + "all_derived_motion_ids": [], + "lead_motion_id": 15, + "state_id": 30, + "amendment_paragraphs": {"0": "testssss"}, + }, + "motion/17": { + "number": "AMNDMNT5", + "title": "amendment5", + "meeting_id": 1, + "derived_motion_ids": [], + "all_origin_ids": [], + "all_derived_motion_ids": [], + "lead_motion_id": 15, + "state_id": 31, + "amendment_paragraphs": {"0": "test"}, + }, + "meeting/2": { + "motions_default_workflow_id": 12, + "motions_default_amendment_workflow_id": 13, + }, + "motion_state/31": { + "name": "No forward state", + "meeting_id": 1, + }, + "motion_workflow/13": { + "name": "name_workflow2", + "first_state_id": 35, + "state_ids": [35], + "meeting_id": 2, + }, + "motion_state/35": { + "name": "name_state35", + "meeting_id": 2, + }, + } + ) + response = self.request( + "motion.create_forwarded", + { + "title": "test_foo", + "meeting_id": 2, + "origin_id": 12, + "text": "test", + "with_amendments": True, + "use_original_submitter": True, + "use_original_number": True, + }, + ) + self.assert_status_code(response, 200) + assert response.json["results"][0] == [ + { + "id": 18, + "non_forwarded_amendment_amount": 1, + "sequential_number": 1, + "amendment_result_data": [ + { + "id": 19, + "non_forwarded_amendment_amount": 0, + "sequential_number": 2, + "amendment_result_data": [], + }, + { + "id": 20, + "non_forwarded_amendment_amount": 1, + "sequential_number": 3, + "amendment_result_data": [ + { + "id": 21, + "non_forwarded_amendment_amount": 0, + "sequential_number": 4, + "amendment_result_data": [], + }, + ], + }, + ], + } + ] + self.assert_model_exists( + "motion/18", + { + "number": "MAIN", + "origin_id": 12, + "title": "test_foo", + "meeting_id": 2, + "text": "test", + "amendment_ids": [19, 20], + "additional_submitter": "A man, A hairy woman", + "sequential_number": 1, + "state_id": 34, + }, + ) + self.assert_model_exists( + "motion/19", + { + "number": "AMNDMNT1", + "lead_motion_id": 18, + "origin_id": 13, + "title": "amendment1", + "meeting_id": 2, + "amendment_paragraphs": {"0": "texts"}, + "additional_submitter": "A man", + "sequential_number": 2, + "state_id": 35, + }, + ) + self.assert_model_exists( + "motion/20", + { + "number": "AMNDMNT3", + "lead_motion_id": 18, + "origin_id": 15, + "title": "amendment3", + "meeting_id": 2, + "state_id": 35, + "amendment_paragraphs": {"0": "tests"}, + "additional_submitter": None, + "amendment_ids": [21], + "sequential_number": 3, + }, + ) + self.assert_model_exists( + "motion/21", + { + "number": "AMNDMNT4", + "lead_motion_id": 20, + "origin_id": 16, + "title": "amendment4", + "meeting_id": 2, + "state_id": 35, + "amendment_paragraphs": {"0": "testssss"}, + "additional_submitter": None, + "sequential_number": 4, + }, + ) + def test_forward_to_2_meetings_1_transaction(self) -> None: """Forwarding of 1 motion to 2 meetings in 1 transaction""" self.set_models(self.test_model) @@ -687,31 +830,12 @@ def test_forward_to_2_meetings_1_transaction(self) -> None: "all_derived_motion_ids": None, "all_origin_ids": [12], "reason": "reason_jLvcgAMx2", - "submitter_ids": [1], + "submitter_ids": None, + "additional_submitter": "committee_forwarder", "state_id": 34, }, ) assert model.get("forwarded") - self.assert_model_exists( - "motion_submitter/1", - { - "meeting_id": 2, - "meeting_user_id": 3, - "motion_id": 13, - }, - ) - self.assert_model_exists( - "meeting_user/3", - { - "user_id": 2, - "meeting_id": 2, - "motion_submitter_ids": [1], - "group_ids": [112], - }, - ) - self.assert_model_exists( - "group/112", {"meeting_user_ids": [2, 3], "meeting_id": 2} - ) model = self.assert_model_exists( "motion/14", @@ -723,47 +847,12 @@ def test_forward_to_2_meetings_1_transaction(self) -> None: "all_derived_motion_ids": None, "all_origin_ids": [12], "reason": "reason_jLvcgAMx3", - "submitter_ids": [2], + "submitter_ids": None, + "additional_submitter": "committee_forwarder", "state_id": 33, }, ) assert model.get("forwarded") - self.assert_model_exists( - "motion_submitter/2", - { - "meeting_user_id": 4, - "meeting_id": 3, - "motion_id": 14, - }, - ) - self.assert_model_exists( - "meeting_user/4", - { - "user_id": 2, - "meeting_id": 3, - "motion_submitter_ids": [2], - "group_ids": [113], - }, - ) - self.assert_model_exists( - "group/113", {"meeting_user_ids": [4], "meeting_id": 3} - ) - - self.assert_model_exists( - "user/2", - { - "username": "committee_forwarder", - "last_name": "committee_forwarder", - "is_physical_person": False, - "is_active": False, - "meeting_user_ids": [3, 4], - "forwarding_committee_ids": [53], - "meeting_ids": [2, 3], - "committee_ids": [52], - }, - ) - - self.assert_model_exists("committee/53", {"forwarding_user_id": 2}) self.assert_model_exists( "motion/12", {"derived_motion_ids": [13, 14], "all_derived_motion_ids": [13, 14]}, @@ -855,3 +944,375 @@ def test_permissions(self) -> None: }, ) self.assert_status_code(response, 200) + + def test_forward_multiple_to_meeting_with_set_number(self) -> None: + """Forwarding of 1 motion to 2 meetings in 1 transaction""" + self.set_models(self.test_model) + self.set_models( + { + "meeting/1": { + "motion_ids": [12, 13], + }, + "motion/13": { + "title": "title_FcnPUXJB2", + "meeting_id": 1, + "state_id": 30, + }, + "motion_state/30": {"motion_ids": [12, 13]}, + "motion_state/34": {"set_number": True}, + } + ) + response = self.request_multi( + "motion.create_forwarded", + [ + { + "title": "title_12", + "meeting_id": 2, + "origin_id": 12, + "text": "test2", + "reason": "reason_jLvcgAMx2", + }, + { + "title": "title_13", + "meeting_id": 2, + "origin_id": 13, + "text": "test3", + "reason": "reason_jLvcgAMx3", + }, + ], + ) + self.assert_status_code(response, 200) + created = [date["id"] for date in response.json["results"][0]] + for i in range(2): + self.assert_model_exists( + f"motion/{created[i]}", {"number": f"{i+1}", "sequential_number": 1 + i} + ) + + def test_forward_multiple_to_meeting_with_set_number_and_use_original_number( + self, + ) -> None: + """Forwarding of 1 motion to 2 meetings in 1 transaction""" + self.set_models(self.test_model) + self.set_models( + { + "meeting/1": { + "motion_ids": [12, 13], + }, + "motion/13": { + "title": "title_FcnPUXJB2", + "meeting_id": 1, + "state_id": 30, + "number": "1", + }, + "motion_state/30": {"motion_ids": [12, 13]}, + "motion_state/34": {"set_number": True}, + } + ) + response = self.request_multi( + "motion.create_forwarded", + [ + { + "title": "title_12", + "meeting_id": 2, + "origin_id": 12, + "text": "test2", + "reason": "reason_jLvcgAMx2", + }, + { + "title": "title_13", + "meeting_id": 2, + "origin_id": 13, + "text": "test3", + "reason": "reason_jLvcgAMx3", + "use_original_number": True, + }, + ], + ) + self.assert_status_code(response, 200) + created = [date["id"] for date in response.json["results"][0]] + self.assert_model_exists(f"motion/{created[0]}", {"number": "1"}) + self.assert_model_exists(f"motion/{created[1]}", {"number": "1-1"}) + + def test_forward_multiple_to_meeting_with_set_number_and_use_original_number_2( + self, + ) -> None: + """Forwarding of 1 motion to 2 meetings in 1 transaction""" + self.set_models(self.test_model) + self.set_models( + { + "meeting/1": { + "motion_ids": [12, 13], + }, + "motion/12": {"number": "1"}, + "motion/13": { + "title": "title_FcnPUXJB2", + "meeting_id": 1, + "state_id": 30, + }, + "motion_state/30": {"motion_ids": [12, 13]}, + "motion_state/34": {"set_number": True}, + } + ) + response = self.request_multi( + "motion.create_forwarded", + [ + { + "title": "title_12", + "meeting_id": 2, + "origin_id": 12, + "text": "test2", + "reason": "reason_jLvcgAMx2", + "use_original_number": True, + }, + { + "title": "title_13", + "meeting_id": 2, + "origin_id": 13, + "text": "test3", + "reason": "reason_jLvcgAMx3", + }, + ], + ) + self.assert_status_code(response, 200) + created = [date["id"] for date in response.json["results"][0]] + self.assert_model_exists(f"motion/{created[0]}", {"number": "1"}) + self.assert_model_exists(f"motion/{created[1]}", {"number": "2"}) + + def test_forward_multiple_to_meeting_with_set_number_and_use_original_number_3( + self, + ) -> None: + """Forwarding of 1 motion to 2 meetings in 1 transaction""" + self.set_models(self.test_model) + self.set_models( + { + "meeting/1": { + "motion_ids": [12, 13], + }, + "meeting/2": { + "motion_ids": [14], + }, + "motion/12": {"number": "1", "submitter_ids": [12]}, + "motion/13": { + "title": "title_FcnPUXJB2", + "meeting_id": 1, + "state_id": 30, + "number": "1", + "submitter_ids": [13], + }, + "motion/14": { + "title": "title_FcnPUXJB2", + "meeting_id": 2, + "state_id": 30, + "number": "1", + }, + "motion_state/30": {"motion_ids": [12, 13]}, + "motion_submitter/12": { + "meeting_user_id": 1, + "motion_id": 12, + "meeting_id": 1, + }, + "motion_submitter/13": { + "meeting_user_id": 1, + "motion_id": 13, + "meeting_id": 1, + }, + } + ) + response = self.request_multi( + "motion.create_forwarded", + [ + { + "title": "title_12", + "meeting_id": 2, + "origin_id": 12, + "text": "test2", + "reason": "reason_jLvcgAMx2", + "use_original_number": True, + "use_original_submitter": True, + }, + { + "title": "title_13", + "meeting_id": 2, + "origin_id": 13, + "text": "test3", + "reason": "reason_jLvcgAMx3", + "use_original_number": True, + }, + ], + ) + self.assert_status_code(response, 200) + created = [date["id"] for date in response.json["results"][0]] + self.assert_model_exists( + f"motion/{created[0]}", + { + "number": "1-1", + "additional_submitter": "Worship the administrator (he · is, very, good)", + }, + ) + self.assert_model_exists(f"motion/{created[1]}", {"number": "1-2"}) + + def test_use_original_submitter_empty(self) -> None: + self.set_models(self.test_model) + response = self.request( + "motion.create_forwarded", + { + "title": "test_Xcdfgee", + "meeting_id": 2, + "origin_id": 12, + "text": "test", + "reason": "reason_jLvcgAMx", + "use_original_submitter": True, + }, + ) + self.assert_status_code(response, 200) + created_id = response.json["results"][0][0]["id"] + self.assert_model_exists( + f"motion/{created_id}", {"number": None, "submitter_ids": None} + ) + + def test_use_original_submitter_multiple(self) -> None: + self.set_models(self.test_model) + extra_user_id = self.create_user("user", [111]) + self.set_models( + { + "motion/12": { + "submitter_ids": [12, 13], + "additional_submitter": "Sue B. Mid-Edit", + }, + "motion_submitter/12": { + "meeting_user_id": 1, + "motion_id": 12, + "meeting_id": 1, + }, + "motion_submitter/13": { + "meeting_user_id": 3, + "motion_id": 12, + "meeting_id": 1, + }, + "meeting_user/3": { + "motion_submitter_ids": [13], + }, + "meeting/1": { + "meeting_user_ids": [1, 3], + "motion_submitter_ids": [12, 13], + "user_ids": [1, extra_user_id], + }, + f"user/{extra_user_id}": {"meeting_user_ids": [3], "meeting_ids": [1]}, + } + ) + response = self.request( + "motion.create_forwarded", + { + "title": "test_Xcdfgee", + "meeting_id": 2, + "origin_id": 12, + "text": "test", + "reason": "reason_jLvcgAMx", + "use_original_submitter": True, + }, + ) + self.assert_status_code(response, 200) + created_id = response.json["results"][0][0]["id"] + self.assert_model_exists( + f"motion/{created_id}", + { + "additional_submitter": "Worship the administrator (he · is, very, good), User 2, Sue B. Mid-Edit" + }, + ) + + def test_name_generation(self) -> None: + self.set_models(self.test_model) + extra_user_data: list[tuple[dict[str, Any], ...]] = [ + ({"title": "He is", "pronoun": "he"}, {"structure_level_ids": [1, 3]}), + ({"first_name": "King", "pronoun": "Kong"}, {"structure_level_ids": [2]}), + ( + { + "last_name": "Good", + }, + {"structure_level_ids": [3]}, + ), + ( + { + "title": "He,", + "first_name": "she,", + "last_name": "it", + "pronoun": "ein 's' muss mit", + }, + {}, + ), + ( + { + "title": "Grandma", + "first_name": "not", + "last_name": "see", + }, + {"structure_level_ids": []}, + ), + ] + amount = len(extra_user_data) + extra_user_ids = [self.create_user(f"user{i}", [111]) for i in range(amount)] + self.set_models( + { + "motion/12": { + "submitter_ids": list(range(12, 13 + amount)), + "additional_submitter": "Sue B. Mid-Edit", + }, + "motion_submitter/12": { + "meeting_user_id": 1, + "motion_id": 12, + "meeting_id": 1, + }, + "meeting/1": { + "meeting_user_ids": [1, *range(3, 3 + amount)], + "motion_submitter_ids": list(range(12, 13 + amount)), + "user_ids": [1, *extra_user_ids], + }, + **{ + f"user/{extra_user_ids[i]}": { + "meeting_user_ids": [i + 3], + "meeting_ids": [1], + **extra_user_data[i][0], + } + for i in range(amount) + }, + **{ + f"meeting_user/{i + 3}": { + "motion_submitter_ids": [13 + i], + **extra_user_data[i][1], + } + for i in range(amount) + }, + **{ + f"motion_submitter/{13 + i}": { + "meeting_user_id": i + 3, + "motion_id": 12, + "meeting_id": 1, + } + for i in range(amount) + }, + } + ) + response = self.request( + "motion.create_forwarded", + { + "title": "test_Xcdfgee", + "meeting_id": 2, + "origin_id": 12, + "text": "test", + "reason": "reason_jLvcgAMx", + "use_original_submitter": True, + }, + ) + self.assert_status_code(response, 200) + created_id = response.json["results"][0][0]["id"] + motion = self.assert_model_exists(f"motion/{created_id}") + for name in [ + "Worship the administrator (he · is, very, good)", + "He is User 2 (he · is, good)", + "King (Kong · very)", + "Good (good)", + "He, she, it (ein 's' muss mit)", + "Grandma not see", + "Sue B. Mid-Edit", + ]: + assert name in motion["additional_submitter"] diff --git a/tests/system/action/user/test_create.py b/tests/system/action/user/test_create.py index 39ef5705b..03e684bf7 100644 --- a/tests/system/action/user/test_create.py +++ b/tests/system/action/user/test_create.py @@ -1282,18 +1282,6 @@ def test_create_default_vote_weight_none(self) -> None: user = self.get_model("user/2") assert "default_vote_weight" not in user - def test_create_forwarding_committee_ids_not_allowed(self) -> None: - self.set_models({"meeting/1": {"is_active_in_organization_id": 1}}) - response = self.request( - "user.create", - { - "username": "test_Xcdfgee", - "forwarding_committee_ids": [], - }, - ) - self.assert_status_code(response, 403) - assert "forwarding_committee_ids is not allowed." in response.json["message"] - def test_create_negative_vote_weight(self) -> None: self.set_models( { diff --git a/tests/system/action/user/test_merge_together.py b/tests/system/action/user/test_merge_together.py index f578d285d..519f487bd 100644 --- a/tests/system/action/user/test_merge_together.py +++ b/tests/system/action/user/test_merge_together.py @@ -463,20 +463,6 @@ def test_merge_is_demo_user_error(self) -> None: response.json["message"], ) - def test_merge_forwarding_committee_ids_error(self) -> None: - self.set_models( - { - "committee/3": {"forwarding_user_id": 3}, - "user/3": {"forwarding_committee_ids": [3]}, - } - ) - response = self.request("user.merge_together", {"id": 2, "user_ids": [3, 4]}) - self.assert_status_code(response, 400) - self.assertIn( - "Cannot merge user models that have forwarding_committee_ids set: Problem in user/3", - response.json["message"], - ) - def test_merge_saml_id_error(self) -> None: self.set_models({"user/3": {"saml_id": "SAML"}}) response = self.request("user.merge_together", {"id": 2, "user_ids": [3, 4]}) diff --git a/tests/system/migrations/test_0054_remove_forwarding_user.py b/tests/system/migrations/test_0054_remove_forwarding_user.py new file mode 100644 index 000000000..7b84e0a58 --- /dev/null +++ b/tests/system/migrations/test_0054_remove_forwarding_user.py @@ -0,0 +1,68 @@ +def test_migration_forwarding_migration(write, finalize, assert_model): + committee_ids_by_user_id: dict[int, list[int]] = {2: [1, 2, 3], 3: [4], 4: []} + write( + { + "type": "create", + "fqid": "organization/1", + "fields": { + "id": 1, + "user_ids": [2, 3, 4, 5], + "committee_ids": [1, 2, 3, 4, 5], + }, + }, + *[ + { + "type": "create", + "fqid": f"user/{user_id}", + "fields": { + "id": user_id, + "organization_id": 1, + "forwarding_committee_ids": committee_ids, + }, + } + for user_id, committee_ids in committee_ids_by_user_id.items() + ], + *[ + { + "type": "create", + "fqid": f"committee/{committee_id}", + "fields": { + "id": committee_id, + "organization_id": 1, + "forwarding_user_id": user_id, + }, + } + for user_id, committee_ids in committee_ids_by_user_id.items() + for committee_id in committee_ids + ], + { + "type": "create", + "fqid": "user/5", + "fields": { + "id": 5, + "organization_id": 1, + }, + }, + { + "type": "create", + "fqid": "committee/5", + "fields": { + "id": 5, + "organization_id": 1, + }, + }, + ) + + finalize("0054_remove_forwarding_user") + + for collection, id_ in [ + *[("user", id_) for id_ in range(2, 6)], + *[("committee", id_) for id_ in range(1, 6)], + ]: + assert_model( + f"{collection}/{id_}", + { + "id": id_, + "organization_id": 1, + }, + )