feat: honor temporal read context

This commit is contained in:
2026-08-05 00:03:32 +02:00
parent bc98372d93
commit 10ea54f38d
4 changed files with 56 additions and 10 deletions
+5
View File
@@ -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 Contact delivery uses frozen snapshot references so later address changes do
not rewrite evidence. Preferred channels must be permitted by the procedure. 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
Representation powers identify representative and represented Parties, an Representation powers identify representative and represented Parties, an
+1 -1
View File
@@ -66,7 +66,7 @@ manifest = ModuleManifest(
id="parties.procedure-and-representation", id="parties.procedure-and-representation",
title="Procedure parties and representation", title="Procedure parties and representation",
summary="Keep procedure roles and authority distinct from subject master data.", 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", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"), audience=("user", "operator", "module_admin", "auditor"),
+13 -8
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, Mapping, Sequence from typing import Any, Mapping, Sequence
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.institutional import ( from govoplan_core.core.institutional import (
@@ -15,6 +14,8 @@ from govoplan_core.core.institutional import (
revise_procedure_party, revise_procedure_party,
revoke_party_representation, 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 from govoplan_parties.backend.db.models import ProcedurePartyRevision
@@ -123,7 +124,7 @@ def get_procedure_party(
ProcedurePartyRevision.party_id == party_id, ProcedurePartyRevision.party_id == party_id,
) )
if revision is None: if revision is None:
query = query.filter(ProcedurePartyRevision.superseded_at.is_(None)) query = apply_temporal_revision_filter(query, ProcedurePartyRevision)
else: else:
query = query.filter(ProcedurePartyRevision.revision == revision) query = query.filter(ProcedurePartyRevision.revision == revision)
row = query.order_by(ProcedurePartyRevision.recorded_at.desc()).first() row = query.order_by(ProcedurePartyRevision.recorded_at.desc()).first()
@@ -151,12 +152,16 @@ class SqlPartyResolver:
ProcedurePartyRevision.procedure_owner_module == procedure_ref.owner_module, ProcedurePartyRevision.procedure_owner_module == procedure_ref.owner_module,
ProcedurePartyRevision.procedure_id == procedure_ref.object_id, ProcedurePartyRevision.procedure_id == procedure_ref.object_id,
) )
if effective_at is None: context = None
query = query.filter(ProcedurePartyRevision.superseded_at.is_(None)) if effective_at is not None:
else: context = TemporalDataContext(
query = query.filter( validity_mode="at",
or_(ProcedurePartyRevision.valid_from.is_(None), ProcedurePartyRevision.valid_from <= effective_at), valid_at=effective_at,
or_(ProcedurePartyRevision.valid_to.is_(None), ProcedurePartyRevision.valid_to > effective_at), )
query = apply_temporal_revision_filter(
query,
ProcedurePartyRevision,
context=context,
) )
rows = query.order_by( rows = query.order_by(
ProcedurePartyRevision.party_id.asc(), ProcedurePartyRevision.party_id.asc(),
+37 -1
View File
@@ -15,8 +15,18 @@ from govoplan_core.core.institutional import (
ProcedureParty, ProcedureParty,
TemporalRevision, 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.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) NOW = datetime(2026, 8, 1, 11, 0, tzinfo=UTC)
@@ -87,6 +97,32 @@ class PartyTests(unittest.TestCase):
with self.assertRaisesRegex(Exception, "same-tenant"): with self.assertRaisesRegex(Exception, "same-tenant"):
SqlPartyResolver().list_procedure_parties(self.session, Principal("tenant-2"), procedure_ref=ref("case", "case-1", "cases")) 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__": if __name__ == "__main__":
unittest.main() unittest.main()