From e067631cd01e06ba036e5f172fc85f29a3925c9d Mon Sep 17 00:00:00 2001 From: vkrasnovyd Date: Mon, 6 Jul 2026 17:28:43 +0200 Subject: [PATCH 1/8] (temporarily) disable locked tests --- tests/system/action/meeting/test_delete.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/system/action/meeting/test_delete.py b/tests/system/action/meeting/test_delete.py index 1f4ea2ae4c..27ea481b75 100644 --- a/tests/system/action/meeting/test_delete.py +++ b/tests/system/action/meeting/test_delete.py @@ -1,6 +1,7 @@ from datetime import datetime from zoneinfo import ZoneInfo +import pytest from psycopg.types.json import Jsonb from openslides_backend.models.models import Poll @@ -232,6 +233,7 @@ def test_delete_archived_meeting(self) -> None: self.assert_status_code(response, 200) self.assert_model_not_exists("meeting/1") + @pytest.mark.skip() def test_delete_with_poll_candidates_and_speakers(self) -> None: self.set_committee_management_level([60]) self.create_user("user/2", [3]) @@ -385,6 +387,7 @@ def test_delete_with_public_orga_file(self) -> None: self.assert_model_exists("mediafile/1", {"meeting_mediafile_ids": None}) +@pytest.mark.skip() class MeetingDeleteActionFullDataTest(BaseActionTestCase): def test_delete_full_meeting(self) -> None: self.load_example_data() From 73dd8fdbbed21e7918717d234c0b4c4b2da7dcab Mon Sep 17 00:00:00 2001 From: vkrasnovyd Date: Tue, 7 Jul 2026 08:05:19 +0200 Subject: [PATCH 2/8] Add changed_fields field --- meta | 2 +- openslides_backend/models/models.py | 1 + openslides_backend/shared/history_events.py | 12 +++++++++--- openslides_backend/shared/patterns.py | 2 +- openslides_backend/shared/typing.py | 12 +++++++++--- tests/database/reader/system/test_filter.py | 1 + 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/meta b/meta index ec166875b6..4b4257fee7 160000 --- a/meta +++ b/meta @@ -1 +1 @@ -Subproject commit ec166875b618c19810cec43e934284f026efb9ed +Subproject commit 4b4257fee7a4ef9b8ac8da255c4658ca28ed3af7 diff --git a/openslides_backend/models/models.py b/openslides_backend/models/models.py index bbcfe6c07b..5cf7155e92 100644 --- a/openslides_backend/models/models.py +++ b/openslides_backend/models/models.py @@ -535,6 +535,7 @@ class HistoryEntry(Model): id = fields.IntegerField(required=True, constant=True) entries = fields.TextArrayField() + changed_fields = fields.JSONField() original_model_id = fields.CharField(constant=True) model_id = fields.GenericRelationField( to={ diff --git a/openslides_backend/shared/history_events.py b/openslides_backend/shared/history_events.py index 1f0eaef75c..ea32f485b2 100644 --- a/openslides_backend/shared/history_events.py +++ b/openslides_backend/shared/history_events.py @@ -19,8 +19,13 @@ def calculate_history_event_payloads( timestamp: int | None = None, ) -> list[EventPayload]: transformed_information = [ - (model_fqid_to_entry_id[fqid], fqid, entries) - for fqid, entries in information.items() + ( + model_fqid_to_entry_id[fqid], + fqid, + data["entries"], + data.get("changed_fields"), + ) + for fqid, data in information.items() ] create_events: list[EventPayload] = [ ( @@ -28,13 +33,14 @@ def calculate_history_event_payloads( { "id": id_, "entries": entries, + "changed_fields": changed_fields, "position_id": position_id, "original_model_id": fqid, "model_id": (fqid if fqid in existing_fqids else None), "meeting_id": model_fqid_to_meeting_id.get(fqid, None), }, ) - for id_, fqid, entries in transformed_information + for id_, fqid, entries, changed_fields in transformed_information ] create_events.append( ( diff --git a/openslides_backend/shared/patterns.py b/openslides_backend/shared/patterns.py index 3519e27cd3..6687622ede 100644 --- a/openslides_backend/shared/patterns.py +++ b/openslides_backend/shared/patterns.py @@ -137,7 +137,7 @@ def collection_and_id_from_fqid(fqid: str) -> tuple[str, int]: # Build FQIDs -def fqid_from_collection_and_id(collection: str, id: str | int) -> str: +def fqid_from_collection_and_id(collection: str, id: str | int) -> FullQualifiedId: return f"{collection}{KEYSEPARATOR}{id}" diff --git a/openslides_backend/shared/typing.py b/openslides_backend/shared/typing.py index fbba186e7b..f7b3ebe97c 100644 --- a/openslides_backend/shared/typing.py +++ b/openslides_backend/shared/typing.py @@ -1,6 +1,6 @@ -from typing import Any, Union +from typing import Any, NotRequired, TypedDict, Union -from .patterns import Collection, Id +from .patterns import Collection, Field, FullQualifiedId, Id PartialModel = dict[str, Any] Model = dict[str, Any] @@ -8,7 +8,13 @@ Schema = dict[str, Any] -HistoryInformation = dict[str, list[str]] + +class HistoryInformationData(TypedDict): + entries: list[str] + changed_fields: NotRequired[dict[Field, Any]] + + +HistoryInformation = dict[FullQualifiedId, HistoryInformationData] JSON = Union[str, int, float, bool, None, dict[str, Any], list[Any]] diff --git a/tests/database/reader/system/test_filter.py b/tests/database/reader/system/test_filter.py index 7f83e16244..42c6bdea42 100644 --- a/tests/database/reader/system/test_filter.py +++ b/tests/database/reader/system/test_filter.py @@ -176,6 +176,7 @@ def test_types_str_list(db_connection: Connection) -> None: 1: { "id": 1, "entries": ["User added to meetings"], + "changed_fields": None, "meeting_id": 1, "model_id": "user/1", "model_id_assignment_id": None, From aff0b921f97bb6347f204d6cd8dc287ccf503877 Mon Sep 17 00:00:00 2001 From: vkrasnovyd Date: Tue, 7 Jul 2026 13:46:55 +0200 Subject: [PATCH 3/8] Change HistoryInformation type --- openslides_backend/action/action.py | 6 +++-- .../actions/meeting_user/base_delete.py | 10 +++++---- .../action/actions/meeting_user/create.py | 16 +++++++------- .../actions/meeting_user/history_mixin.py | 22 +++++++++++-------- .../actions/motion/base_create_forwarded.py | 10 ++++----- .../action/actions/motion/delete.py | 17 ++++++++++---- .../motion_state_history_information_mixin.py | 12 ++++++---- .../action/actions/motion/update.py | 18 +++++++-------- .../motion_comment/create_delete_update.py | 14 +++++++----- .../action/actions/user/merge_mixins.py | 14 ++++++------ .../action/actions/user/merge_together.py | 14 +++++++----- .../action/actions/user/set_present.py | 10 +++++---- .../action/actions/user/user_mixins.py | 22 +++++++++---------- 13 files changed, 106 insertions(+), 79 deletions(-) diff --git a/openslides_backend/action/action.py b/openslides_backend/action/action.py index 2e32659e16..8e0ab4688a 100644 --- a/openslides_backend/action/action.py +++ b/openslides_backend/action/action.py @@ -559,7 +559,7 @@ def get_history_information(self) -> HistoryInformation | None: fqid_from_collection_and_id(self.model.collection, instance["id"]) ) for fqid in fqids: - information[fqid] = [self.history_information] + information[fqid] = {"entries": [self.history_information]} return information def get_instances_with_fields( @@ -777,7 +777,9 @@ def merge_history_informations( b = {} for fqid, information in b.items(): if fqid in a: - a[fqid].extend(information) + a[fqid]["entries"].extend(information["entries"]) + if changed_fields := information.get("changed_fields"): + a[fqid].setdefault("changed_fields", dict()).update(changed_fields) else: a[fqid] = information return a diff --git a/openslides_backend/action/actions/meeting_user/base_delete.py b/openslides_backend/action/actions/meeting_user/base_delete.py index 171deb21c0..546db2e970 100644 --- a/openslides_backend/action/actions/meeting_user/base_delete.py +++ b/openslides_backend/action/actions/meeting_user/base_delete.py @@ -17,9 +17,11 @@ class MeetingUserBaseDelete(DeleteAction): def get_history_information(self) -> HistoryInformation | None: users = self.get_instances_with_fields(["user_id", "meeting_id"]) return { - fqid_from_collection_and_id("user", user["user_id"]): [ - "Participant removed from meeting {}", - fqid_from_collection_and_id("meeting", user["meeting_id"]), - ] + fqid_from_collection_and_id("user", user["user_id"]): { + "entries": [ + "Participant removed from meeting {}", + fqid_from_collection_and_id("meeting", user["meeting_id"]), + ] + } for user in users } diff --git a/openslides_backend/action/actions/meeting_user/create.py b/openslides_backend/action/actions/meeting_user/create.py index f71b2edf6c..b49aa1751b 100644 --- a/openslides_backend/action/actions/meeting_user/create.py +++ b/openslides_backend/action/actions/meeting_user/create.py @@ -51,9 +51,9 @@ def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]: return super().update_instance(instance) def get_history_information(self) -> HistoryInformation | None: - information = {} + information: HistoryInformation = {} for instance in self.instances: - instance_information = [] + entries = [] fqids_per_collection = { collection_name: [ fqid_from_collection_and_id( @@ -65,15 +65,15 @@ def get_history_information(self) -> HistoryInformation | None: for collection_name in ["group", "structure_level"] if (ids := instance.get(f"{collection_name}_ids")) } - instance_information.append( + entries.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( + entries.extend(fqids) + entries.append( fqid_from_collection_and_id("meeting", instance["meeting_id"]), ) - information[fqid_from_collection_and_id("user", instance["user_id"])] = ( - instance_information - ) + information[fqid_from_collection_and_id("user", instance["user_id"])] = { + "entries": entries + } return information diff --git a/openslides_backend/action/actions/meeting_user/history_mixin.py b/openslides_backend/action/actions/meeting_user/history_mixin.py index 76a33e3263..1a3a0842ba 100644 --- a/openslides_backend/action/actions/meeting_user/history_mixin.py +++ b/openslides_backend/action/actions/meeting_user/history_mixin.py @@ -76,7 +76,7 @@ def create_events(self, instance: dict[str, Any]) -> Iterable[Event]: ) def get_history_information(self) -> HistoryInformation | None: - information: dict[str, list[tuple[str, ...]]] = {} + information: dict[str, dict[str, list[tuple[str, ...]]]] = {} # Scan the instances and collect the info for the history information # Copy instances first since they are modified @@ -97,7 +97,9 @@ def get_history_information(self) -> HistoryInformation | None: ) return { - fqid: [string for entry in history for string in entry] + fqid: { + "entries": [string for entry in history["entries"] for string in entry] + } for fqid, history in information.items() } @@ -105,7 +107,7 @@ def add_updated_meeting_user_history_information( self, instance: dict[str, Any], db_instance: dict[str, Any], - information: dict[str, list[tuple[str, ...]]], + information: dict[str, dict[str, list[tuple[str, ...]]]], ) -> None: instance_information: list[tuple[str, ...]] = [] user_id = db_instance["user_id"] @@ -138,7 +140,9 @@ def add_updated_meeting_user_history_information( ) def add_created_meeting_user_history_information( - self, instance: dict[str, Any], information: dict[str, list[tuple[str, ...]]] + self, + instance: dict[str, Any], + information: dict[str, dict[str, list[tuple[str, ...]]]], ) -> None: db_instance = self.datastore.get( fqid_from_collection_and_id(self.model.collection, instance["id"]), @@ -180,7 +184,7 @@ def add_created_meeting_user_history_information( def add_entries_to_history_information( self, - information: dict[str, list[tuple[str, ...]]], + information: dict[str, dict[str, list[tuple[str, ...]]]], entries: list[tuple[str, ...]], for_user_id: int | None = None, for_meeting_user_id: int | None = None, @@ -197,11 +201,11 @@ def add_entries_to_history_information( user_id = for_user_id fqid = fqid_from_collection_and_id("user", user_id) if fqid not in information: - information[fqid] = entries + information[fqid] = {"entries": entries} else: for entry in entries: - if entry not in information[fqid]: - information[fqid].append(entry) + if entry not in information[fqid]["entries"]: + information[fqid]["entries"].append(entry) def compose_history_string( self, fqids_per_collection: list[tuple[str, list[str]]] @@ -277,7 +281,7 @@ def handle_group_updates( def handle_delegations( self, - information: dict[str, list[tuple[str, ...]]], + information: dict[str, dict[str, list[tuple[str, ...]]]], instance_information: list[tuple[str, ...]], instance: dict[str, Any], db_instance: dict[str, Any], diff --git a/openslides_backend/action/actions/motion/base_create_forwarded.py b/openslides_backend/action/actions/motion/base_create_forwarded.py index c5a808bad7..acea58fc2c 100644 --- a/openslides_backend/action/actions/motion/base_create_forwarded.py +++ b/openslides_backend/action/actions/motion/base_create_forwarded.py @@ -641,20 +641,20 @@ def forward_mediafiles( return instance def get_history_information(self) -> HistoryInformation | None: - forwarded_entries = defaultdict(list) + forwarded_entries: HistoryInformation = defaultdict(lambda: defaultdict(list)) for instance in self.instances: forwarded_entries[ fqid_from_collection_and_id("motion", instance["origin_id"]) - ].extend( + ]["entries"].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)" - ] + fqid_from_collection_and_id("motion", instance["id"]): { + "entries": ["Motion created (forwarded)"] + } for instance in self.instances } diff --git a/openslides_backend/action/actions/motion/delete.py b/openslides_backend/action/actions/motion/delete.py index fd1f9883c5..0aaae93fe2 100644 --- a/openslides_backend/action/actions/motion/delete.py +++ b/openslides_backend/action/actions/motion/delete.py @@ -80,10 +80,15 @@ def get_history_information(self) -> HistoryInformation | None: fqid_from_collection_and_id("motion", id_) for id_ in self.all_motion_ids ] if not information: - information = {fqid: [self.history_information] for fqid in fqids} + information = { + fqid: {"entries": [self.history_information]} for fqid in fqids + } else: for fqid in fqids: - information[fqid] = [self.history_information] + if fqid in information: + information[fqid]["entries"] = [self.history_information] + else: + information[fqid] = {"entries": [self.history_information]} return information def get_full_history_information(self) -> HistoryInformation | None: @@ -97,12 +102,16 @@ def get_full_history_information(self) -> HistoryInformation | None: return merge_history_informations( information or {}, { - fqid_from_collection_and_id("motion", id): ["Forwarded motion deleted"] + fqid_from_collection_and_id("motion", id): { + "entries": ["Forwarded motion deleted"] + } for instance in instances for id in instance.get("all_origin_ids", []) }, { - fqid_from_collection_and_id("motion", id): ["Origin motion deleted"] + fqid_from_collection_and_id("motion", id): { + "entries": ["Origin motion deleted"] + } for instance in instances for id in instance.get("all_derived_motion_ids", []) }, diff --git a/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py b/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py index 11ea727bcb..3954b79e63 100644 --- a/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py +++ b/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py @@ -9,9 +9,13 @@ def _get_state_history_information( self, instance_field: str, verbose_model: str ) -> HistoryInformation: return { - fqid_from_collection_and_id(self.model.collection, instance["id"]): [ - verbose_model + " set to {}", - fqid_from_collection_and_id("motion_state", instance[instance_field]), - ] + fqid_from_collection_and_id(self.model.collection, instance["id"]): { + "entries": [ + verbose_model + " set to {}", + fqid_from_collection_and_id( + "motion_state", instance[instance_field] + ), + ] + } for instance in self.instances } diff --git a/openslides_backend/action/actions/motion/update.py b/openslides_backend/action/actions/motion/update.py index b5884a759a..72a2d7efa2 100644 --- a/openslides_backend/action/actions/motion/update.py +++ b/openslides_backend/action/actions/motion/update.py @@ -223,19 +223,17 @@ def check_permissions(self, instance: dict[str, Any]) -> None: raise PermissionDenied(msg) def get_history_information(self) -> HistoryInformation | None: - information = {} + information: HistoryInformation = {} for instance in deepcopy(self.instances): - instance_information = [] + entries = [] # workflow timestamp changed if "workflow_timestamp" in instance: timestamp = instance.pop("workflow_timestamp") - instance_information.extend( - ["Workflow_timestamp set to {}", f"{timestamp}"] - ) + entries.extend(["Workflow_timestamp set to {}", f"{timestamp}"]) # category changed - instance_information.extend( + entries.extend( self.create_history_information_for_field( instance, "category_id", @@ -245,7 +243,7 @@ def get_history_information(self) -> HistoryInformation | None: ) # block changed - instance_information.extend( + entries.extend( self.create_history_information_for_field( instance, "block_id", "motion_block", "Motion block" ) @@ -263,12 +261,12 @@ def get_history_information(self) -> HistoryInformation | None: ] if any(field in instance for field in generic_update_fields): # still other fields given, so we also add the generic "updated" message - instance_information.append("Motion updated") + entries.append("Motion updated") - if instance_information: + if entries: information[ fqid_from_collection_and_id(self.model.collection, instance["id"]) - ] = instance_information + ] = {"entries": entries} return information diff --git a/openslides_backend/action/actions/motion_comment/create_delete_update.py b/openslides_backend/action/actions/motion_comment/create_delete_update.py index eb23817b89..5d1151887e 100644 --- a/openslides_backend/action/actions/motion_comment/create_delete_update.py +++ b/openslides_backend/action/actions/motion_comment/create_delete_update.py @@ -103,12 +103,14 @@ def get_history_information(self) -> HistoryInformation | None: instances = self.get_instances_with_fields(["motion_id", "section_id"]) _, action = self.name.split(".") return { - fqid_from_collection_and_id("motion", instance["motion_id"]): [ - "Comment {} " + action + "d", - fqid_from_collection_and_id( - "motion_comment_section", instance["section_id"] - ), - ] + fqid_from_collection_and_id("motion", instance["motion_id"]): { + "entries": [ + "Comment {} " + action + "d", + fqid_from_collection_and_id( + "motion_comment_section", instance["section_id"] + ), + ] + } for instance in instances } diff --git a/openslides_backend/action/actions/user/merge_mixins.py b/openslides_backend/action/actions/user/merge_mixins.py index e47fce259e..9576d9a0d7 100644 --- a/openslides_backend/action/actions/user/merge_mixins.py +++ b/openslides_backend/action/actions/user/merge_mixins.py @@ -99,9 +99,9 @@ def get_full_history_information(self) -> HistoryInformation | None: ]: assignment_ids.add(data["assignment_id"]) for assignment_id in assignment_ids: - information[fqid_from_collection_and_id("assignment", assignment_id)] = [ - "Candidates merged" - ] + information[fqid_from_collection_and_id("assignment", assignment_id)] = { + "entries": ["Candidates merged"] + } return information @@ -130,9 +130,9 @@ def get_full_history_information(self) -> HistoryInformation | None: for motion_id in motion_ids: fqid = fqid_from_collection_and_id("motion", motion_id) if fqid not in information: - information[fqid] = ["Submitters merged"] + information[fqid] = {"entries": ["Submitters merged"]} else: - information[fqid].append("Submitters merged") + information[fqid]["entries"].append("Submitters merged") return information @@ -157,9 +157,9 @@ def get_full_history_information(self) -> HistoryInformation | None: for motion_id in motion_ids: fqid = fqid_from_collection_and_id("motion", motion_id) if fqid not in information: - information[fqid] = ["Supporters merged"] + information[fqid] = {"entries": ["Supporters merged"]} else: - information[fqid].append("Supporters merged") + information[fqid]["entries"].append("Supporters merged") return information diff --git a/openslides_backend/action/actions/user/merge_together.py b/openslides_backend/action/actions/user/merge_together.py index 5f690b0235..3eda126787 100644 --- a/openslides_backend/action/actions/user/merge_together.py +++ b/openslides_backend/action/actions/user/merge_together.py @@ -449,12 +449,16 @@ def get_full_history_information(self) -> HistoryInformation | None: deleted_string = " and ".join( ["{}" for i in range(len(deleted_fqids))] ) - information[main_fqid] = [ - "Updated with data from " + deleted_string, - *deleted_fqids, - ] + information[main_fqid] = { + "entries": [ + "Updated with data from " + deleted_string, + *deleted_fqids, + ] + } for deleted_fqid in deleted_fqids: - information[deleted_fqid] = ["Merged into {}", main_fqid] + information[deleted_fqid] = { + "entries": ["Merged into {}", main_fqid] + } else: raise BadCodingException("No id found for user history generation") return information diff --git a/openslides_backend/action/actions/user/set_present.py b/openslides_backend/action/actions/user/set_present.py index d9ea8650bb..fd3f7ac916 100644 --- a/openslides_backend/action/actions/user/set_present.py +++ b/openslides_backend/action/actions/user/set_present.py @@ -94,9 +94,11 @@ def check_permissions(self, instance: dict[str, Any]) -> None: def get_history_information(self) -> HistoryInformation | None: return { - fqid_from_collection_and_id(self.model.collection, instance["id"]): [ - f"Set {'not ' if not instance['present'] else ''}present in meeting {{}}", - fqid_from_collection_and_id("meeting", instance["meeting_id"]), - ] + fqid_from_collection_and_id(self.model.collection, instance["id"]): { + "entries": [ + f"Set {'not ' if not instance['present'] else ''}present in meeting {{}}", + fqid_from_collection_and_id("meeting", instance["meeting_id"]), + ] + } for instance in self.action_data } diff --git a/openslides_backend/action/actions/user/user_mixins.py b/openslides_backend/action/actions/user/user_mixins.py index 5d0b652383..70a6bb63a7 100644 --- a/openslides_backend/action/actions/user/user_mixins.py +++ b/openslides_backend/action/actions/user/user_mixins.py @@ -154,12 +154,12 @@ def meeting_user_set_data(self, instance: dict[str, Any]) -> None: class UpdateHistoryMixin(Action): def get_history_information(self) -> HistoryInformation | None: - information = {} + information: HistoryInformation = {} # Scan the instances and collect the info for the history information # Copy instances first since they are modified for instance in deepcopy(self.instances): - instance_information = [] + entries = [] # Fetch the current instance from the db to diff with the given instance db_instance = self.datastore.get( @@ -190,23 +190,23 @@ def get_history_information(self) -> HistoryInformation | None: "default_vote_weight", ] if any(field in instance for field in update_fields): - instance_information.append("Personal data changed") + entries.append("Personal data changed") # other fields if "organization_management_level" in instance: - instance_information.append("Organization Management Level changed") + entries.append("Organization Management Level changed") if "committee_management_ids" in instance: - instance_information.append("Committee management changed") + entries.append("Committee management changed") if "is_active" in instance: if instance["is_active"]: - instance_information.append("Set active") + entries.append("Set active") else: - instance_information.append("Set inactive") + entries.append("Set inactive") - if instance_information: - information[fqid_from_collection_and_id("user", instance["id"])] = ( - instance_information - ) + if entries: + information[fqid_from_collection_and_id("user", instance["id"])] = { + "entries": entries + } return information From 84c7c5bb4f9f2f59e49411d8217cbd75eafb5ed0 Mon Sep 17 00:00:00 2001 From: vkrasnovyd Date: Wed, 8 Jul 2026 12:39:22 +0200 Subject: [PATCH 4/8] Use helper methods --- openslides_backend/action/action.py | 8 ++-- .../actions/meeting_user/base_delete.py | 11 +++-- .../action/actions/meeting_user/create.py | 9 ++-- .../actions/meeting_user/history_mixin.py | 7 ++-- .../actions/motion/base_create_forwarded.py | 24 ++++++----- .../action/actions/motion/delete.py | 39 +++++++++-------- .../motion_state_history_information_mixin.py | 11 +++-- .../action/actions/motion/update.py | 9 ++-- .../motion_comment/create_delete_update.py | 11 +++-- .../action/actions/user/merge_mixins.py | 29 +++++++------ .../action/actions/user/merge_together.py | 21 ++++++---- .../action/actions/user/set_present.py | 11 +++-- .../action/actions/user/user_mixins.py | 9 ++-- openslides_backend/shared/history_events.py | 42 ++++++++++++++++++- 14 files changed, 160 insertions(+), 81 deletions(-) diff --git a/openslides_backend/action/action.py b/openslides_backend/action/action.py index 8e0ab4688a..63a6d2f59d 100644 --- a/openslides_backend/action/action.py +++ b/openslides_backend/action/action.py @@ -9,6 +9,7 @@ from psycopg.types.json import Jsonb from openslides_backend.shared.base_service_provider import BaseServiceProvider +from openslides_backend.shared.history_events import update_history_information_multi from ..models.base import Model, model_registry from ..models.fields import BaseRelationField, GenericRelationField @@ -540,7 +541,7 @@ def get_history_information(self) -> HistoryInformation | None: if self.history_information is None: return None - information = {} + information: HistoryInformation = {} instances = ( self.get_instances_with_fields(["id", self.history_relation_field]) if self.history_relation_field @@ -558,8 +559,9 @@ def get_history_information(self) -> HistoryInformation | None: fqids.append( fqid_from_collection_and_id(self.model.collection, instance["id"]) ) - for fqid in fqids: - information[fqid] = {"entries": [self.history_information]} + update_history_information_multi( + information, fqids, [self.history_information] + ) return information def get_instances_with_fields( diff --git a/openslides_backend/action/actions/meeting_user/base_delete.py b/openslides_backend/action/actions/meeting_user/base_delete.py index 546db2e970..da1b06eac5 100644 --- a/openslides_backend/action/actions/meeting_user/base_delete.py +++ b/openslides_backend/action/actions/meeting_user/base_delete.py @@ -1,3 +1,4 @@ +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.patterns import fqid_from_collection_and_id from openslides_backend.shared.typing import HistoryInformation @@ -17,11 +18,13 @@ class MeetingUserBaseDelete(DeleteAction): def get_history_information(self) -> HistoryInformation | None: users = self.get_instances_with_fields(["user_id", "meeting_id"]) return { - fqid_from_collection_and_id("user", user["user_id"]): { - "entries": [ + fqid_from_collection_and_id( + "user", user["user_id"] + ): build_history_information_data( + [ "Participant removed from meeting {}", fqid_from_collection_and_id("meeting", user["meeting_id"]), - ] - } + ], + ) for user in users } diff --git a/openslides_backend/action/actions/meeting_user/create.py b/openslides_backend/action/actions/meeting_user/create.py index b49aa1751b..07168f1471 100644 --- a/openslides_backend/action/actions/meeting_user/create.py +++ b/openslides_backend/action/actions/meeting_user/create.py @@ -1,6 +1,7 @@ from typing import Any from openslides_backend.shared.exceptions import ActionException +from openslides_backend.shared.history_events import update_history_information from openslides_backend.shared.patterns import fqid_from_collection_and_id from openslides_backend.shared.typing import HistoryInformation @@ -73,7 +74,9 @@ def get_history_information(self) -> HistoryInformation | None: entries.append( fqid_from_collection_and_id("meeting", instance["meeting_id"]), ) - information[fqid_from_collection_and_id("user", instance["user_id"])] = { - "entries": entries - } + update_history_information( + information, + fqid_from_collection_and_id("user", instance["user_id"]), + entries, + ) return information diff --git a/openslides_backend/action/actions/meeting_user/history_mixin.py b/openslides_backend/action/actions/meeting_user/history_mixin.py index 1a3a0842ba..67e3ac8e24 100644 --- a/openslides_backend/action/actions/meeting_user/history_mixin.py +++ b/openslides_backend/action/actions/meeting_user/history_mixin.py @@ -3,6 +3,7 @@ from typing import Any from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.interfaces.event import Event, EventType from ....services.database.interface import GetManyRequest @@ -97,9 +98,9 @@ def get_history_information(self) -> HistoryInformation | None: ) return { - fqid: { - "entries": [string for entry in history["entries"] for string in entry] - } + fqid: build_history_information_data( + [string for entry in history["entries"] for string in entry] + ) for fqid, history in information.items() } diff --git a/openslides_backend/action/actions/motion/base_create_forwarded.py b/openslides_backend/action/actions/motion/base_create_forwarded.py index acea58fc2c..6d675e7c0d 100644 --- a/openslides_backend/action/actions/motion/base_create_forwarded.py +++ b/openslides_backend/action/actions/motion/base_create_forwarded.py @@ -6,6 +6,7 @@ from psycopg.types.json import Jsonb from openslides_backend.action.actions.motion.mixins import TextHashMixin +from openslides_backend.shared.history_events import update_history_information from openslides_backend.shared.typing import HistoryInformation from ....i18n.translator import Translator @@ -641,22 +642,23 @@ def forward_mediafiles( return instance def get_history_information(self) -> HistoryInformation | None: - forwarded_entries: HistoryInformation = defaultdict(lambda: defaultdict(list)) + information: HistoryInformation = {} for instance in self.instances: - forwarded_entries[ - fqid_from_collection_and_id("motion", instance["origin_id"]) - ]["entries"].extend( + update_history_information( + information, + fqid_from_collection_and_id("motion", instance["origin_id"]), [ "Forwarded to {}", fqid_from_collection_and_id("meeting", instance["meeting_id"]), - ] + ], ) - return forwarded_entries | { - fqid_from_collection_and_id("motion", instance["id"]): { - "entries": ["Motion created (forwarded)"] - } - for instance in self.instances - } + for instance in self.instances: + update_history_information( + information, + fqid_from_collection_and_id("motion", instance["id"]), + ["Motion created (forwarded)"], + ) + return information def check_can_forward_with_attachments(self) -> None: organization = self.datastore.get( diff --git a/openslides_backend/action/actions/motion/delete.py b/openslides_backend/action/actions/motion/delete.py index 0aaae93fe2..0b8e18d7fd 100644 --- a/openslides_backend/action/actions/motion/delete.py +++ b/openslides_backend/action/actions/motion/delete.py @@ -2,6 +2,10 @@ from typing import Any from openslides_backend.action.action import merge_history_informations +from openslides_backend.shared.history_events import ( + build_history_information_data, + update_history_information_multi, +) from openslides_backend.shared.typing import HistoryInformation from ....models.models import Motion @@ -76,19 +80,16 @@ def get_history_information(self) -> HistoryInformation | None: if self.history_information is None: return information # generate the history informations for the deleted amendments - fqids = [ + if not information: + information = {} + all_fqids = [ fqid_from_collection_and_id("motion", id_) for id_ in self.all_motion_ids ] - if not information: - information = { - fqid: {"entries": [self.history_information]} for fqid in fqids - } - else: - for fqid in fqids: - if fqid in information: - information[fqid]["entries"] = [self.history_information] - else: - information[fqid] = {"entries": [self.history_information]} + update_history_information_multi( + information, + [fqid for fqid in all_fqids if fqid not in information], + [self.history_information], + ) return information def get_full_history_information(self) -> HistoryInformation | None: @@ -102,16 +103,20 @@ def get_full_history_information(self) -> HistoryInformation | None: return merge_history_informations( information or {}, { - fqid_from_collection_and_id("motion", id): { - "entries": ["Forwarded motion deleted"] - } + fqid_from_collection_and_id( + "motion", id + ): build_history_information_data( + ["Forwarded motion deleted"], + ) for instance in instances for id in instance.get("all_origin_ids", []) }, { - fqid_from_collection_and_id("motion", id): { - "entries": ["Origin motion deleted"] - } + fqid_from_collection_and_id( + "motion", id + ): build_history_information_data( + ["Origin motion deleted"], + ) for instance in instances for id in instance.get("all_derived_motion_ids", []) }, diff --git a/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py b/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py index 3954b79e63..5273101619 100644 --- a/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py +++ b/openslides_backend/action/actions/motion/motion_state_history_information_mixin.py @@ -1,3 +1,4 @@ +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.patterns import fqid_from_collection_and_id from openslides_backend.shared.typing import HistoryInformation @@ -9,13 +10,15 @@ def _get_state_history_information( self, instance_field: str, verbose_model: str ) -> HistoryInformation: return { - fqid_from_collection_and_id(self.model.collection, instance["id"]): { - "entries": [ + fqid_from_collection_and_id( + self.model.collection, instance["id"] + ): build_history_information_data( + [ verbose_model + " set to {}", fqid_from_collection_and_id( "motion_state", instance[instance_field] ), - ] - } + ], + ) for instance in self.instances } diff --git a/openslides_backend/action/actions/motion/update.py b/openslides_backend/action/actions/motion/update.py index 72a2d7efa2..27bde7caa2 100644 --- a/openslides_backend/action/actions/motion/update.py +++ b/openslides_backend/action/actions/motion/update.py @@ -4,6 +4,7 @@ from psycopg.types.json import Jsonb +from openslides_backend.shared.history_events import update_history_information from openslides_backend.shared.typing import HistoryInformation from ....models.models import Motion @@ -264,9 +265,11 @@ def get_history_information(self) -> HistoryInformation | None: entries.append("Motion updated") if entries: - information[ - fqid_from_collection_and_id(self.model.collection, instance["id"]) - ] = {"entries": entries} + update_history_information( + information, + fqid_from_collection_and_id(self.model.collection, instance["id"]), + entries, + ) return information diff --git a/openslides_backend/action/actions/motion_comment/create_delete_update.py b/openslides_backend/action/actions/motion_comment/create_delete_update.py index 5d1151887e..4141659144 100644 --- a/openslides_backend/action/actions/motion_comment/create_delete_update.py +++ b/openslides_backend/action/actions/motion_comment/create_delete_update.py @@ -2,6 +2,7 @@ from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin from openslides_backend.permissions.management_levels import OrganizationManagementLevel +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.typing import HistoryInformation from ....models.models import MotionComment @@ -103,14 +104,16 @@ def get_history_information(self) -> HistoryInformation | None: instances = self.get_instances_with_fields(["motion_id", "section_id"]) _, action = self.name.split(".") return { - fqid_from_collection_and_id("motion", instance["motion_id"]): { - "entries": [ + fqid_from_collection_and_id( + "motion", instance["motion_id"] + ): build_history_information_data( + [ "Comment {} " + action + "d", fqid_from_collection_and_id( "motion_comment_section", instance["section_id"] ), - ] - } + ], + ) for instance in instances } diff --git a/openslides_backend/action/actions/user/merge_mixins.py b/openslides_backend/action/actions/user/merge_mixins.py index 9576d9a0d7..ee260e07bb 100644 --- a/openslides_backend/action/actions/user/merge_mixins.py +++ b/openslides_backend/action/actions/user/merge_mixins.py @@ -1,6 +1,7 @@ from typing import Any from openslides_backend.services.database.interface import PartialModel +from openslides_backend.shared.history_events import update_history_information from ....models.models import ( AssignmentCandidate, @@ -99,9 +100,11 @@ def get_full_history_information(self) -> HistoryInformation | None: ]: assignment_ids.add(data["assignment_id"]) for assignment_id in assignment_ids: - information[fqid_from_collection_and_id("assignment", assignment_id)] = { - "entries": ["Candidates merged"] - } + update_history_information( + information, + fqid_from_collection_and_id("assignment", assignment_id), + ["Candidates merged"], + ) return information @@ -128,11 +131,11 @@ def get_full_history_information(self) -> HistoryInformation | None: ]: motion_ids.add(data["motion_id"]) for motion_id in motion_ids: - fqid = fqid_from_collection_and_id("motion", motion_id) - if fqid not in information: - information[fqid] = {"entries": ["Submitters merged"]} - else: - information[fqid]["entries"].append("Submitters merged") + update_history_information( + information, + fqid_from_collection_and_id("motion", motion_id), + ["Submitters merged"], + ) return information @@ -155,11 +158,11 @@ def get_full_history_information(self) -> HistoryInformation | None: ]: motion_ids.add(data["motion_id"]) for motion_id in motion_ids: - fqid = fqid_from_collection_and_id("motion", motion_id) - if fqid not in information: - information[fqid] = {"entries": ["Supporters merged"]} - else: - information[fqid]["entries"].append("Supporters merged") + update_history_information( + information, + fqid_from_collection_and_id("motion", motion_id), + ["Supporters merged"], + ) return information diff --git a/openslides_backend/action/actions/user/merge_together.py b/openslides_backend/action/actions/user/merge_together.py index 3eda126787..2dbe1e5e4f 100644 --- a/openslides_backend/action/actions/user/merge_together.py +++ b/openslides_backend/action/actions/user/merge_together.py @@ -1,6 +1,10 @@ from typing import Any from openslides_backend.services.database.interface import PartialModel +from openslides_backend.shared.history_events import ( + update_history_information, + update_history_information_multi, +) from ....action.mixins.archived_meeting_check_mixin import CheckForArchivedMeetingMixin from ....models.models import User @@ -449,16 +453,17 @@ def get_full_history_information(self) -> HistoryInformation | None: deleted_string = " and ".join( ["{}" for i in range(len(deleted_fqids))] ) - information[main_fqid] = { - "entries": [ + update_history_information( + information, + main_fqid, + [ "Updated with data from " + deleted_string, *deleted_fqids, - ] - } - for deleted_fqid in deleted_fqids: - information[deleted_fqid] = { - "entries": ["Merged into {}", main_fqid] - } + ], + ) + update_history_information_multi( + information, deleted_fqids, ["Merged into {}", main_fqid] + ) else: raise BadCodingException("No id found for user history generation") return information diff --git a/openslides_backend/action/actions/user/set_present.py b/openslides_backend/action/actions/user/set_present.py index fd3f7ac916..fa59c0dac7 100644 --- a/openslides_backend/action/actions/user/set_present.py +++ b/openslides_backend/action/actions/user/set_present.py @@ -1,5 +1,6 @@ from typing import Any +from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.typing import HistoryInformation from ....action.mixins.archived_meeting_check_mixin import CheckForArchivedMeetingMixin @@ -94,11 +95,13 @@ def check_permissions(self, instance: dict[str, Any]) -> None: def get_history_information(self) -> HistoryInformation | None: return { - fqid_from_collection_and_id(self.model.collection, instance["id"]): { - "entries": [ + fqid_from_collection_and_id( + self.model.collection, instance["id"] + ): build_history_information_data( + [ f"Set {'not ' if not instance['present'] else ''}present in meeting {{}}", fqid_from_collection_and_id("meeting", instance["meeting_id"]), - ] - } + ], + ) for instance in self.action_data } diff --git a/openslides_backend/action/actions/user/user_mixins.py b/openslides_backend/action/actions/user/user_mixins.py index 70a6bb63a7..68870246c4 100644 --- a/openslides_backend/action/actions/user/user_mixins.py +++ b/openslides_backend/action/actions/user/user_mixins.py @@ -4,6 +4,7 @@ from openslides_backend.services.database.commands import GetManyRequest from openslides_backend.services.database.interface import PartialModel +from openslides_backend.shared.history_events import update_history_information from openslides_backend.shared.typing import HistoryInformation from openslides_backend.shared.util import ONE_ORGANIZATION_FQID @@ -204,9 +205,11 @@ def get_history_information(self) -> HistoryInformation | None: entries.append("Set inactive") if entries: - information[fqid_from_collection_and_id("user", instance["id"])] = { - "entries": entries - } + update_history_information( + information, + fqid_from_collection_and_id("user", instance["id"]), + entries, + ) return information diff --git a/openslides_backend/shared/history_events.py b/openslides_backend/shared/history_events.py index ea32f485b2..1eacb342ad 100644 --- a/openslides_backend/shared/history_events.py +++ b/openslides_backend/shared/history_events.py @@ -4,11 +4,51 @@ from .interfaces.event import ListFields from .patterns import FullQualifiedId, fqid_from_collection_and_id -from .typing import HistoryInformation +from .typing import HistoryInformation, HistoryInformationData EventPayload = tuple[FullQualifiedId, dict[str, Any] | ListFields] +def build_history_information_data( + entries: list[str], + changed_fields: dict[str, Any] | None = None, +) -> HistoryInformationData: + data: HistoryInformationData = {"entries": entries} + if changed_fields is not None: + data["changed_fields"] = changed_fields + return data + + +def update_history_information( + information: HistoryInformation, + fqid: FullQualifiedId, + entries: list[str], + changed_fields: dict[str, Any] | None = None, +) -> None: + """Updates history information for fqid""" + if fqid not in information: + information[fqid] = build_history_information_data(entries, changed_fields) + else: + information[fqid]["entries"].extend(entries) + if changed_fields: + information[fqid].setdefault("changed_fields", dict()).update( + changed_fields + ) + + +def update_history_information_multi( + information: HistoryInformation, + fqids: list[FullQualifiedId], + entries: list[str], + changed_fields: dict[str, Any] | None = None, +) -> None: + """ + Adds given HistoryInformation to the given information for every fqid in fqids. + """ + for fqid in fqids: + update_history_information(information, fqid, entries, changed_fields) + + def calculate_history_event_payloads( user_id: int | None, information: HistoryInformation, From fcf4165d1b8c63d9cf69155e6b217ef1018ed35a Mon Sep 17 00:00:00 2001 From: vkrasnovyd Date: Wed, 8 Jul 2026 15:04:28 +0200 Subject: [PATCH 5/8] Save changed is_present --- .../action/actions/user/set_present.py | 27 +++++++++--- openslides_backend/shared/history_events.py | 4 +- tests/system/action/base.py | 34 +++++++++------ tests/system/action/user/test_set_present.py | 43 +++++++++++++------ 4 files changed, 76 insertions(+), 32 deletions(-) diff --git a/openslides_backend/action/actions/user/set_present.py b/openslides_backend/action/actions/user/set_present.py index fa59c0dac7..33473fa54c 100644 --- a/openslides_backend/action/actions/user/set_present.py +++ b/openslides_backend/action/actions/user/set_present.py @@ -41,6 +41,7 @@ def get_updated_instances(self, action_data: ActionData) -> ActionData: add meeting_id if present is True. remove meeting_id if present is False. """ + self.base_history_information = {} for instance in action_data: meeting_id = instance.pop("meeting_id") present = instance.pop("present") @@ -48,17 +49,28 @@ def get_updated_instances(self, action_data: ActionData) -> ActionData: fqid_from_collection_and_id(self.model.collection, instance["id"]), ["is_present_in_meeting_ids"], ) + self.base_history_information[instance["id"]] = { + "present": present, + "meeting_id": meeting_id, + } if present: if meeting_id not in user.get("is_present_in_meeting_ids", []): - instance["is_present_in_meeting_ids"] = user.get( - "is_present_in_meeting_ids", [] - ) + [meeting_id] + is_present = user.get("is_present_in_meeting_ids", []) + [ + meeting_id + ] + instance["is_present_in_meeting_ids"] = is_present + self.base_history_information[instance["id"]][ + "is_present_in_meeting_ids" + ] = is_present yield instance elif present is False: is_present = user.get("is_present_in_meeting_ids", []) if meeting_id in is_present: is_present.remove(meeting_id) instance["is_present_in_meeting_ids"] = is_present + self.base_history_information[instance["id"]][ + "is_present_in_meeting_ids" + ] = is_present yield instance def check_permissions(self, instance: dict[str, Any]) -> None: @@ -96,12 +108,13 @@ def check_permissions(self, instance: dict[str, Any]) -> None: def get_history_information(self) -> HistoryInformation | None: return { fqid_from_collection_and_id( - self.model.collection, instance["id"] + self.model.collection, id_ ): build_history_information_data( [ - f"Set {'not ' if not instance['present'] else ''}present in meeting {{}}", - fqid_from_collection_and_id("meeting", instance["meeting_id"]), + f"Set {'not ' if not data['present'] else ''}present in meeting {{}}", + fqid_from_collection_and_id("meeting", data["meeting_id"]), ], + {"is_present_in_meeting_ids": data["is_present_in_meeting_ids"]}, ) - for instance in self.action_data + for id_, data in self.base_history_information.items() } diff --git a/openslides_backend/shared/history_events.py b/openslides_backend/shared/history_events.py index 1eacb342ad..8f096dbe61 100644 --- a/openslides_backend/shared/history_events.py +++ b/openslides_backend/shared/history_events.py @@ -2,6 +2,8 @@ from typing import Any from zoneinfo import ZoneInfo +from psycopg.types.json import Jsonb + from .interfaces.event import ListFields from .patterns import FullQualifiedId, fqid_from_collection_and_id from .typing import HistoryInformation, HistoryInformationData @@ -73,7 +75,7 @@ def calculate_history_event_payloads( { "id": id_, "entries": entries, - "changed_fields": changed_fields, + "changed_fields": Jsonb(changed_fields), "position_id": position_id, "original_model_id": fqid, "model_id": (fqid if fqid in existing_fqids else None), diff --git a/tests/system/action/base.py b/tests/system/action/base.py index 6a138b3a1d..a8a3bc475c 100644 --- a/tests/system/action/base.py +++ b/tests/system/action/base.py @@ -15,6 +15,7 @@ from openslides_backend.permissions.management_levels import OrganizationManagementLevel from openslides_backend.permissions.permissions import Permission from openslides_backend.services.database.commands import GetManyRequest +from openslides_backend.services.database.interface import PartialModel from openslides_backend.shared.exceptions import AuthenticationException from openslides_backend.shared.filters import FilterOperator from openslides_backend.shared.patterns import ( @@ -297,7 +298,9 @@ def base_locked_out_superadmin_permission_test( True, ) - def get_last_history_information(self, fqid: FullQualifiedId) -> list[str] | None: + def get_last_history_information( + self, fqid: FullQualifiedId + ) -> PartialModel | None: entry_id = self.datastore.max( "history_entry", FilterOperator("original_model_id", "=", fqid), @@ -305,37 +308,44 @@ def get_last_history_information(self, fqid: FullQualifiedId) -> list[str] | Non lock_result=False, ) if entry_id: - history_entry = self.datastore.get( + return self.datastore.get( fqid_from_collection_and_id("history_entry", entry_id), - ["entries"], + ["entries", "changed_fields"], lock_result=False, ) - return history_entry.get("entries") else: return None def assert_history_information( - self, fqid: FullQualifiedId, information: list[str] | None + self, + fqid: FullQualifiedId, + entries: list[str] | None, + changed_fields: dict[str, Any] | None = None, ) -> None: """ Asserts that the last history information for the given model is the given information. """ last_information = self.get_last_history_information(fqid) - if information is None: - assert not last_information + if entries is None and changed_fields is None: + assert ( + not last_information + ), f"Expected no history information to be generated for {fqid}. Got:\n{last_information}" else: - assert last_information - self.assertEqual(last_information, information) + assert ( + last_information + ), f"No history information was be generated for {fqid}." + self.assertEqual(last_information.get("entries"), entries) + self.assertEqual(last_information.get("changed_fields"), changed_fields) def assert_history_information_contains( - self, fqid: FullQualifiedId, information: str + self, fqid: FullQualifiedId, entry: str ) -> None: """ Asserts that the last history information for the given model is the given information. """ last_information = self.get_last_history_information(fqid) - assert last_information - assert information in last_information + assert last_information, f"No history information was be generated for {fqid}." + self.assertIn(entry, last_information["entries"]) def assert_logged_in(self) -> None: self.auth.authenticate() # assert that no exception is thrown diff --git a/tests/system/action/user/test_set_present.py b/tests/system/action/user/test_set_present.py index 17b759b660..da18e3f5d0 100644 --- a/tests/system/action/user/test_set_present.py +++ b/tests/system/action/user/test_set_present.py @@ -18,23 +18,40 @@ def test_set_present_add_correct(self) -> None: "user.set_present", {"id": 111, "meeting_id": 1, "present": True} ) self.assert_status_code(response, 200) - model = self.get_model("user/111") - assert model.get("is_present_in_meeting_ids") == [1] - meeting = self.get_model("meeting/1") - assert meeting.get("present_user_ids") == [111] + self.assert_model_exists("user/111", {"is_present_in_meeting_ids": [1]}) + self.assert_model_exists("meeting/1", {"present_user_ids": [111]}) self.assert_history_information( - "user/111", ["Set present in meeting {}", "meeting/1"] + "user/111", + ["Set present in meeting {}", "meeting/1"], + {"is_present_in_meeting_ids": [1]}, + ) + + def test_set_present_add_second_correct(self) -> None: + self.set_models( + { + "meeting/1": {"present_user_ids": [111]}, + "user/111": {"username": "username_srtgb123"}, + } + ) + self.create_meeting(4) + response = self.request( + "user.set_present", {"id": 111, "meeting_id": 4, "present": True} + ) + self.assert_status_code(response, 200) + self.assert_model_exists("user/111", {"is_present_in_meeting_ids": [1, 4]}) + self.assert_model_exists("meeting/1", {"present_user_ids": [111]}) + self.assert_model_exists("meeting/4", {"present_user_ids": [111]}) + self.assert_history_information( + "user/111", + ["Set present in meeting {}", "meeting/4"], + {"is_present_in_meeting_ids": [1, 4]}, ) def test_set_present_del_correct(self) -> None: self.set_models( { - "meeting/1": { - "present_user_ids": [111], - }, - "user/111": { - "username": "username_srtgb123", - }, + "meeting/1": {"present_user_ids": [111]}, + "user/111": {"username": "username_srtgb123"}, } ) response = self.request( @@ -44,7 +61,9 @@ def test_set_present_del_correct(self) -> None: self.assert_model_exists("user/111", {"is_present_in_meeting_ids": None}) self.assert_model_exists("meeting/1", {"present_user_ids": None}) self.assert_history_information( - "user/111", ["Set not present in meeting {}", "meeting/1"] + "user/111", + ["Set not present in meeting {}", "meeting/1"], + {"is_present_in_meeting_ids": []}, ) def test_set_present_null_action(self) -> None: From 6316536a2a0da05229e98e1d62c51380a6d9c793 Mon Sep 17 00:00:00 2001 From: vkrasnovyd Date: Thu, 9 Jul 2026 16:16:16 +0200 Subject: [PATCH 6/8] Save changed group_ids --- .../actions/meeting_user/history_mixin.py | 90 +++++++++++++++---- openslides_backend/shared/history_events.py | 15 ++-- openslides_backend/shared/typing.py | 2 +- tests/system/action/user/test_create.py | 1 + .../action/user/test_delegation_history.py | 18 +++- tests/system/action/user/test_update.py | 9 ++ 6 files changed, 109 insertions(+), 26 deletions(-) diff --git a/openslides_backend/action/actions/meeting_user/history_mixin.py b/openslides_backend/action/actions/meeting_user/history_mixin.py index 67e3ac8e24..134c8e0e92 100644 --- a/openslides_backend/action/actions/meeting_user/history_mixin.py +++ b/openslides_backend/action/actions/meeting_user/history_mixin.py @@ -1,17 +1,26 @@ from collections.abc import Iterable from copy import deepcopy -from typing import Any +from typing import Any, NotRequired, TypedDict from openslides_backend.action.mixins.extend_history_mixin import ExtendHistoryMixin +from openslides_backend.shared.filters import FilterOperator from openslides_backend.shared.history_events import build_history_information_data from openslides_backend.shared.interfaces.event import Event, EventType from ....services.database.interface import GetManyRequest -from ....shared.patterns import fqid_from_collection_and_id +from ....shared.patterns import Field, FullQualifiedId, fqid_from_collection_and_id from ....shared.typing import HistoryInformation from ...action import Action +class ActionHistoryInformationData(TypedDict): + entries: list[tuple[str, ...]] + changed_fields: NotRequired[dict[Field, Any]] + + +ActionHistoryInformation = dict[FullQualifiedId, ActionHistoryInformationData] + + class MeetingUserHistoryMixin(ExtendHistoryMixin, Action): extend_history_to = "user_id" @@ -77,7 +86,7 @@ def create_events(self, instance: dict[str, Any]) -> Iterable[Event]: ) def get_history_information(self) -> HistoryInformation | None: - information: dict[str, dict[str, list[tuple[str, ...]]]] = {} + information: ActionHistoryInformation = {} # Scan the instances and collect the info for the history information # Copy instances first since they are modified @@ -99,7 +108,8 @@ def get_history_information(self) -> HistoryInformation | None: return { fqid: build_history_information_data( - [string for entry in history["entries"] for string in entry] + [string for entry in history.get("entries", []) for string in entry], + history.get("changed_fields", {}), ) for fqid, history in information.items() } @@ -108,9 +118,10 @@ def add_updated_meeting_user_history_information( self, instance: dict[str, Any], db_instance: dict[str, Any], - information: dict[str, dict[str, list[tuple[str, ...]]]], + information: ActionHistoryInformation, ) -> None: - instance_information: list[tuple[str, ...]] = [] + instance_entries: list[tuple[str, ...]] = [] + instance_changed_fields: dict[str, list[int]] = {} user_id = db_instance["user_id"] meeting_id = db_instance["meeting_id"] @@ -123,27 +134,33 @@ def add_updated_meeting_user_history_information( # meeting specific data update_fields = ["structure_level_ids", "number", "vote_weight"] if any(field in instance for field in update_fields): - instance_information.append( + instance_entries.append( ( "Participant data updated in meeting {}", fqid_from_collection_and_id("meeting", meeting_id), ) ) - self.handle_group_updates(instance_information, instance, db_instance) - self.handle_delegations( - information, instance_information, instance, db_instance + self.handle_group_updates( + instance_entries, + instance_changed_fields, + instance, + db_instance, ) + self.handle_delegations(information, instance_entries, instance, db_instance) - if instance_information: + if instance_entries or instance_changed_fields: self.add_entries_to_history_information( - information, instance_information, for_user_id=user_id + information, + instance_entries, + for_user_id=user_id, + changed_fields=instance_changed_fields, ) def add_created_meeting_user_history_information( self, instance: dict[str, Any], - information: dict[str, dict[str, list[tuple[str, ...]]]], + information: ActionHistoryInformation, ) -> None: db_instance = self.datastore.get( fqid_from_collection_and_id(self.model.collection, instance["id"]), @@ -180,15 +197,21 @@ def add_created_meeting_user_history_information( if instance_information: self.add_entries_to_history_information( - information, instance_information, for_user_id=db_instance["user_id"] + information, + instance_information, + for_user_id=db_instance["user_id"], + changed_fields={ + "group_ids": self.get_changed_group_ids(db_instance["user_id"]) + }, ) def add_entries_to_history_information( self, - information: dict[str, dict[str, list[tuple[str, ...]]]], + information: ActionHistoryInformation, entries: list[tuple[str, ...]], for_user_id: int | None = None, for_meeting_user_id: int | None = None, + changed_fields: dict[Field, Any] | None = None, ) -> None: if not for_user_id: if not for_meeting_user_id: @@ -203,10 +226,16 @@ def add_entries_to_history_information( fqid = fqid_from_collection_and_id("user", user_id) if fqid not in information: information[fqid] = {"entries": entries} + if changed_fields: + information[fqid]["changed_fields"] = changed_fields else: for entry in entries: if entry not in information[fqid]["entries"]: information[fqid]["entries"].append(entry) + if changed_fields: + information[fqid].setdefault("changed_fields", dict()).update( + changed_fields + ) def compose_history_string( self, fqids_per_collection: list[tuple[str, list[str]]] @@ -236,7 +265,8 @@ def compose_history_string( def handle_group_updates( self, - instance_information: list[tuple[str, ...]], + entries: list[tuple[str, ...]], + changed_fields: dict[str, list[int]], instance: dict[str, Any], db_instance: dict[str, Any], ) -> None: @@ -247,6 +277,11 @@ def handle_group_updates( added = instance_group_ids - db_group_ids removed = db_group_ids - instance_group_ids + if added or removed: + changed_fields["group_ids"] = self.get_changed_group_ids( + db_instance["user_id"], {meeting_id: instance["group_ids"]} + ) + # remove default groups meeting = self.datastore.get( fqid_from_collection_and_id("meeting", meeting_id), @@ -278,11 +313,11 @@ def handle_group_updates( fqid_from_collection_and_id("meeting", meeting_id) ) if group_information: - instance_information.append(tuple(group_information)) + entries.append(tuple(group_information)) def handle_delegations( self, - information: dict[str, dict[str, list[tuple[str, ...]]]], + information: ActionHistoryInformation, instance_information: list[tuple[str, ...]], instance: dict[str, Any], db_instance: dict[str, Any], @@ -452,3 +487,22 @@ def handle_delegations( ], for_meeting_user_id=muser_id, ) + + def get_changed_group_ids( + self, user_id: int, update_data: dict[int, list[int]] = {} + ) -> list[int]: + db_groups: dict[int, dict[str, Any]] = self.datastore.filter( + "meeting_user", + FilterOperator("user_id", "=", user_id), + ["meeting_id", "group_ids"], + lock_result=False, + ) + changed_groups = [ + ( + updated_groups + if (updated_groups := update_data.get(db_data["meeting_id"])) + else db_data["group_ids"] + ) + for db_data in db_groups.values() + ] + return [id_ for group_ids in changed_groups for id_ in group_ids] diff --git a/openslides_backend/shared/history_events.py b/openslides_backend/shared/history_events.py index 8f096dbe61..0b980d210a 100644 --- a/openslides_backend/shared/history_events.py +++ b/openslides_backend/shared/history_events.py @@ -12,10 +12,12 @@ def build_history_information_data( - entries: list[str], + entries: list[str] | None = None, changed_fields: dict[str, Any] | None = None, ) -> HistoryInformationData: - data: HistoryInformationData = {"entries": entries} + data: HistoryInformationData = {} + if entries is not None: + data["entries"] = entries if changed_fields is not None: data["changed_fields"] = changed_fields return data @@ -24,14 +26,15 @@ def build_history_information_data( def update_history_information( information: HistoryInformation, fqid: FullQualifiedId, - entries: list[str], + entries: list[str] | None = None, changed_fields: dict[str, Any] | None = None, ) -> None: """Updates history information for fqid""" if fqid not in information: information[fqid] = build_history_information_data(entries, changed_fields) else: - information[fqid]["entries"].extend(entries) + if entries: + information[fqid].setdefault("entries", list()).extend(entries) if changed_fields: information[fqid].setdefault("changed_fields", dict()).update( changed_fields @@ -41,7 +44,7 @@ def update_history_information( def update_history_information_multi( information: HistoryInformation, fqids: list[FullQualifiedId], - entries: list[str], + entries: list[str] | None = None, changed_fields: dict[str, Any] | None = None, ) -> None: """ @@ -75,7 +78,7 @@ def calculate_history_event_payloads( { "id": id_, "entries": entries, - "changed_fields": Jsonb(changed_fields), + "changed_fields": Jsonb(changed_fields) if changed_fields else None, "position_id": position_id, "original_model_id": fqid, "model_id": (fqid if fqid in existing_fqids else None), diff --git a/openslides_backend/shared/typing.py b/openslides_backend/shared/typing.py index f7b3ebe97c..e0a2d531f6 100644 --- a/openslides_backend/shared/typing.py +++ b/openslides_backend/shared/typing.py @@ -10,7 +10,7 @@ class HistoryInformationData(TypedDict): - entries: list[str] + entries: NotRequired[list[str]] changed_fields: NotRequired[dict[Field, Any]] diff --git a/tests/system/action/user/test_create.py b/tests/system/action/user/test_create.py index 1a3040ef3b..a4208090f9 100644 --- a/tests/system/action/user/test_create.py +++ b/tests/system/action/user/test_create.py @@ -133,6 +133,7 @@ def test_create_some_more_fields(self) -> None: "group/114", "meeting/114", ], + {"group_ids": [114]}, ) def test_create_comment(self) -> None: diff --git a/tests/system/action/user/test_delegation_history.py b/tests/system/action/user/test_delegation_history.py index df5ce87bc3..b491b0f988 100644 --- a/tests/system/action/user/test_delegation_history.py +++ b/tests/system/action/user/test_delegation_history.py @@ -44,6 +44,8 @@ def assert_delegated_to( to_id: int, prepend_to: list[str] = [], prepend_from: list[str] = [], + changed_fields_to: dict[str, list[int]] | None = None, + changed_fields_from: dict[str, list[int]] | None = None, ) -> None: self.assert_history_information( f"user/{to_id}", @@ -53,6 +55,7 @@ def assert_delegated_to( f"user/{from_id}", "meeting/1", ], + changed_fields_to, ) self.assert_history_information( f"user/{from_id}", @@ -62,9 +65,15 @@ def assert_delegated_to( f"user/{to_id}", "meeting/1", ], + changed_fields_from, ) - def assert_alice_redelegated_to(self, who_id: int, prepend: list[str] = []) -> None: + def assert_alice_redelegated_to( + self, + who_id: int, + prepend: list[str] = [], + changed_fields: dict[str, list[int]] | None = None, + ) -> None: self.assert_history_information( f"user/{who_id}", [ @@ -73,6 +82,7 @@ def assert_alice_redelegated_to(self, who_id: int, prepend: list[str] = []) -> N f"user/{self.alice_id}", "meeting/1", ], + changed_fields, ) self.assert_history_information( f"user/{self.alice_id}", @@ -116,6 +126,7 @@ def test_create_delegate_vote(self) -> None: "group/3", "meeting/1", ], + changed_fields_from={"group_ids": [3]}, ) def test_create_receive_delegated_vote(self) -> None: @@ -133,6 +144,7 @@ def test_create_receive_delegated_vote(self) -> None: "group/3", "meeting/1", ], + changed_fields_to={"group_ids": [3]}, ) def test_update_re_delegate_vote(self) -> None: @@ -162,6 +174,7 @@ def test_create_re_delegate_vote_reverse(self) -> None: "group/3", "meeting/1", ], + changed_fields={"group_ids": [3]}, ) def test_update_re_delegate_received_votes(self) -> None: @@ -371,6 +384,7 @@ def test_create_multiple_from_ids(self) -> None: ], "meeting/1", ], + {"group_ids": [3]}, ) for id_ in [self.alice_id, self.bob_id, self.colin_id, eric_id, fredric_id]: self.assert_history_information( @@ -404,6 +418,7 @@ def test_update_create_meeting_user_receiving_delegation(self) -> None: f"user/{self.alice_id}", "meeting/1", ], + {"group_ids": [3]}, ) def test_update_create_meeting_user_with_delegation(self) -> None: @@ -431,4 +446,5 @@ def test_update_create_meeting_user_with_delegation(self) -> None: f"user/{self.alice_id}", "meeting/1", ], + {"group_ids": [3]}, ) diff --git a/tests/system/action/user/test_update.py b/tests/system/action/user/test_update.py index faa8b64173..1299dd7480 100644 --- a/tests/system/action/user/test_update.py +++ b/tests/system/action/user/test_update.py @@ -289,6 +289,7 @@ def test_update_with_meeting_user_fields(self) -> None: "meeting/1", "Committee management changed", ], + {"group_ids": [1]}, ) self.assert_history_information( "user/23", ["Vote delegated to {} in meeting {}", "user/22", "meeting/1"] @@ -490,6 +491,7 @@ def test_committee_manager_without_committee_ids(self) -> None: "Personal data changed", "Committee management changed", ], + {"group_ids": []}, ) def test_committee_manager_remove_committee_ids(self) -> None: @@ -2680,6 +2682,7 @@ def test_update_history_add_group(self) -> None: self.assert_history_information( f"user/{user_id}", ["Participant added to group {} in meeting {}", "group/3", "meeting/1"], + {"group_ids": [2, 3, 10, 11, 12]}, ) def test_update_history_add_group_to_default_group(self) -> None: @@ -2700,6 +2703,7 @@ def test_update_history_add_group_to_default_group(self) -> None: self.assert_history_information( f"user/{user_id}", ["Participant added to group {} in meeting {}", "group/2", "meeting/1"], + {"group_ids": [2, 10, 11, 12]}, ) def test_update_history_add_multiple_groups(self) -> None: @@ -2720,6 +2724,7 @@ def test_update_history_add_multiple_groups(self) -> None: self.assert_history_information( f"user/{user_id}", ["Participant added to multiple groups in meeting {}", "meeting/1"], + {"group_ids": [2, 3, 10, 11, 12]}, ) def test_update_history_add_multiple_groups_with_default_group(self) -> None: @@ -2739,6 +2744,7 @@ def test_update_history_add_multiple_groups_with_default_group(self) -> None: self.assert_history_information( f"user/{user_id}", ["Participant added to group {} in meeting {}", "group/2", "meeting/1"], + {"group_ids": [1, 2]}, ) def test_update_history_remove_group(self) -> None: @@ -2763,6 +2769,7 @@ def test_update_history_remove_group(self) -> None: self.assert_history_information( f"user/{user_id}", ["Participant removed from meeting {}", "meeting/1"], + {"group_ids": []}, ) def test_update_fields_with_equal_value_no_history(self) -> None: @@ -2859,6 +2866,7 @@ def test_update_participant_data_with_existing_meetings(self) -> None: "group/4", "meeting/4", ], + {"group_ids": [1, 4]}, ) def test_update_participant_data_in_multiple_meetings_with_existing_meetings( @@ -2908,6 +2916,7 @@ def test_update_participant_data_in_multiple_meetings_with_existing_meetings( "group/7", "meeting/7", ], + {"group_ids": [1, 4, 7]}, ) def test_update_saml_id__can_change_own_password_error(self) -> None: From 1f6b3e494e46da32953cca285ed68a4d2b6aea4c Mon Sep 17 00:00:00 2001 From: vkrasnovyd Date: Fri, 10 Jul 2026 16:44:25 +0200 Subject: [PATCH 7/8] Update meta hash --- meta | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meta b/meta index 4b4257fee7..d16e66eb6a 160000 --- a/meta +++ b/meta @@ -1 +1 @@ -Subproject commit 4b4257fee7a4ef9b8ac8da255c4658ca28ed3af7 +Subproject commit d16e66eb6a061264f5c799df79556e9efe7643d6 From c8cc83f0ad670e03406f096b615389cb705b8bf1 Mon Sep 17 00:00:00 2001 From: vkrasnovyd Date: Fri, 10 Jul 2026 16:45:01 +0200 Subject: [PATCH 8/8] Revert "(temporarily) disable locked tests" This reverts commit e067631cd01e06ba036e5f172fc85f29a3925c9d. --- tests/system/action/meeting/test_delete.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/system/action/meeting/test_delete.py b/tests/system/action/meeting/test_delete.py index 27ea481b75..1f4ea2ae4c 100644 --- a/tests/system/action/meeting/test_delete.py +++ b/tests/system/action/meeting/test_delete.py @@ -1,7 +1,6 @@ from datetime import datetime from zoneinfo import ZoneInfo -import pytest from psycopg.types.json import Jsonb from openslides_backend.models.models import Poll @@ -233,7 +232,6 @@ def test_delete_archived_meeting(self) -> None: self.assert_status_code(response, 200) self.assert_model_not_exists("meeting/1") - @pytest.mark.skip() def test_delete_with_poll_candidates_and_speakers(self) -> None: self.set_committee_management_level([60]) self.create_user("user/2", [3]) @@ -387,7 +385,6 @@ def test_delete_with_public_orga_file(self) -> None: self.assert_model_exists("mediafile/1", {"meeting_mediafile_ids": None}) -@pytest.mark.skip() class MeetingDeleteActionFullDataTest(BaseActionTestCase): def test_delete_full_meeting(self) -> None: self.load_example_data()