From 10ea54f38db8bd1f69107d8c9c896ae9a3fe43de Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 5 Aug 2026 00:03:32 +0200 Subject: [PATCH] feat: honor temporal read context --- docs/PARTIES_DOMAIN.md | 5 ++++ src/govoplan_parties/backend/manifest.py | 2 +- src/govoplan_parties/backend/service.py | 21 ++++++++----- tests/test_parties.py | 38 +++++++++++++++++++++++- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/docs/PARTIES_DOMAIN.md b/docs/PARTIES_DOMAIN.md index aaf4d82..8bd8c5b 100644 --- a/docs/PARTIES_DOMAIN.md +++ b/docs/PARTIES_DOMAIN.md @@ -9,6 +9,11 @@ external subject. It belongs to an exact case, workflow, or decision context. Contact delivery uses frozen snapshot references so later address changes do not rewrite evidence. Preferred channels must be permitted by the procedure. +Unversioned reads follow the platform temporal-data context. An explicit +procedure-resolution instant takes precedence over the titlebar valid-time +choice. Recorded-time reconstruction remains separate from real-world +validity, while permissions and new delivery actions use current state. + ## Representation Representation powers identify representative and represented Parties, an diff --git a/src/govoplan_parties/backend/manifest.py b/src/govoplan_parties/backend/manifest.py index 1c42e3e..f627efd 100644 --- a/src/govoplan_parties/backend/manifest.py +++ b/src/govoplan_parties/backend/manifest.py @@ -66,7 +66,7 @@ manifest = ModuleManifest( id="parties.procedure-and-representation", title="Procedure parties and representation", summary="Keep procedure roles and authority distinct from subject master data.", - body="Parties stores immutable procedure participation and explicit representation powers. Powers cannot disappear silently and delivery uses frozen contact snapshots.", + body="Parties stores immutable procedure participation and explicit representation powers. Powers cannot disappear silently and delivery uses frozen contact snapshots. Unversioned reads follow the titlebar valid-time and recorded-time selection; explicit procedure resolution times take precedence and current authorization is unchanged.", layer="configured", documentation_types=("admin", "user"), audience=("user", "operator", "module_admin", "auditor"), diff --git a/src/govoplan_parties/backend/service.py b/src/govoplan_parties/backend/service.py index 4c85e51..6762c07 100644 --- a/src/govoplan_parties/backend/service.py +++ b/src/govoplan_parties/backend/service.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import UTC, datetime from typing import Any, Mapping, Sequence -from sqlalchemy import or_ from sqlalchemy.orm import Session from govoplan_core.core.institutional import ( @@ -15,6 +14,8 @@ from govoplan_core.core.institutional import ( revise_procedure_party, revoke_party_representation, ) +from govoplan_core.core.temporal import TemporalDataContext +from govoplan_core.db.temporal import apply_temporal_revision_filter from govoplan_parties.backend.db.models import ProcedurePartyRevision @@ -123,7 +124,7 @@ def get_procedure_party( ProcedurePartyRevision.party_id == party_id, ) if revision is None: - query = query.filter(ProcedurePartyRevision.superseded_at.is_(None)) + query = apply_temporal_revision_filter(query, ProcedurePartyRevision) else: query = query.filter(ProcedurePartyRevision.revision == revision) row = query.order_by(ProcedurePartyRevision.recorded_at.desc()).first() @@ -151,13 +152,17 @@ class SqlPartyResolver: ProcedurePartyRevision.procedure_owner_module == procedure_ref.owner_module, ProcedurePartyRevision.procedure_id == procedure_ref.object_id, ) - if effective_at is None: - query = query.filter(ProcedurePartyRevision.superseded_at.is_(None)) - else: - query = query.filter( - or_(ProcedurePartyRevision.valid_from.is_(None), ProcedurePartyRevision.valid_from <= effective_at), - or_(ProcedurePartyRevision.valid_to.is_(None), ProcedurePartyRevision.valid_to > effective_at), + context = None + if effective_at is not None: + context = TemporalDataContext( + validity_mode="at", + valid_at=effective_at, ) + query = apply_temporal_revision_filter( + query, + ProcedurePartyRevision, + context=context, + ) rows = query.order_by( ProcedurePartyRevision.party_id.asc(), ProcedurePartyRevision.recorded_at.desc(), diff --git a/tests/test_parties.py b/tests/test_parties.py index 91e941c..cbbf5ec 100644 --- a/tests/test_parties.py +++ b/tests/test_parties.py @@ -15,8 +15,18 @@ from govoplan_core.core.institutional import ( ProcedureParty, TemporalRevision, ) +from govoplan_core.core.temporal import ( + TemporalDataContext, + bind_temporal_data_context, + reset_temporal_data_context, +) from govoplan_parties.backend.db.models import ProcedurePartyRevision -from govoplan_parties.backend.service import PartyStoreError, SqlPartyResolver, record_procedure_party +from govoplan_parties.backend.service import ( + PartyStoreError, + SqlPartyResolver, + get_procedure_party, + record_procedure_party, +) NOW = datetime(2026, 8, 1, 11, 0, tzinfo=UTC) @@ -87,6 +97,32 @@ class PartyTests(unittest.TestCase): with self.assertRaisesRegex(Exception, "same-tenant"): SqlPartyResolver().list_procedure_parties(self.session, Principal("tenant-2"), procedure_ref=ref("case", "case-1", "cases")) + def test_read_context_can_reconstruct_recorded_state(self) -> None: + record_procedure_party(self.session, self.principal, party=party()) + revoked = representation(revision="2", revoked_at=NOW + timedelta(hours=1)) + record_procedure_party( + self.session, + self.principal, + party=party(revision="2", representations=(revoked,)), + expected_revision="1", + ) + token = bind_temporal_data_context( + TemporalDataContext( + validity_mode="at", + valid_at=NOW + timedelta(hours=1), + recorded_at=NOW + timedelta(seconds=30), + ) + ) + try: + result = get_procedure_party( + self.session, + self.principal, + party_id="representative", + ) + self.assertEqual("1", result.temporal.revision if result else None) + finally: + reset_temporal_data_context(token) + if __name__ == "__main__": unittest.main()