Files
govoplan-cases/src/govoplan_cases/backend/party_context.py
T

256 lines
7.6 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from govoplan_core.core.institutional import (
CAPABILITY_PARTY_RESOLVER,
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
PartyRepresentation,
PartyResolver,
PartySubjectReference,
ProcedureParty,
TemporalRevision,
)
CAPABILITY_CASES_PARTY_CONTEXT = "cases.party_context"
CasePartySource = Literal["provider", "compatibility"]
@dataclass(frozen=True, slots=True)
class CasePartyCompatibilityRecord:
party_id: str
role: str
subject: PartySubjectReference
valid_from: datetime
valid_to: datetime | None = None
preferred_channels: tuple[str, ...] = ()
permitted_channels: tuple[str, ...] = ()
delivery_recipient: bool = False
contact_snapshot_refs: tuple[str, ...] = ()
evidence: tuple[EvidenceReference, ...] = ()
@dataclass(frozen=True, slots=True)
class CasePartySet:
case_ref: InstitutionalReference
effective_at: datetime
source: CasePartySource
parties: tuple[ProcedureParty, ...]
@dataclass(frozen=True, slots=True)
class CasePartyDeliveryTarget:
party_ref: InstitutionalReference
subject: PartySubjectReference
role: str
channel: str
contact_snapshot_refs: tuple[str, ...]
represented_party_refs: tuple[InstitutionalReference, ...] = ()
evidence: tuple[EvidenceReference, ...] = ()
class CasePartyContext:
"""Resolve case-local participation without copying subject master data."""
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def resolve(
self,
session: object,
principal: object,
*,
case_ref: InstitutionalReference,
effective_at: datetime,
compatibility: tuple[CasePartyCompatibilityRecord, ...] = (),
) -> CasePartySet:
if case_ref.kind != "case":
raise InstitutionalContextError(
"Case party resolution requires a case reference."
)
_require_aware(effective_at)
provider = _capability(self._registry, CAPABILITY_PARTY_RESOLVER)
if isinstance(provider, PartyResolver):
candidates = tuple(
provider.list_procedure_parties(
session,
principal,
procedure_ref=case_ref,
effective_at=effective_at,
)
)
source: CasePartySource = "provider"
else:
candidates = tuple(
_compatibility_party(case_ref, item) for item in compatibility
)
source = "compatibility"
effective = tuple(
item
for item in candidates
if item.status == "active" and item.temporal.effective_at(effective_at)
)
for item in effective:
if not _same_object(item.procedure_ref, case_ref):
raise InstitutionalContextError(
"Party provider returned a party for another procedure."
)
keys = tuple(
(
item.role,
item.subject.kind,
item.subject.provider,
item.subject.subject_id,
)
for item in effective
)
if len(keys) != len(set(keys)):
raise InstitutionalContextError(
"Party resolution returned conflicting active role assignments."
)
return CasePartySet(
case_ref=case_ref,
effective_at=effective_at,
source=source,
parties=effective,
)
def delivery_targets(
self,
party_set: CasePartySet,
*,
channel: str,
) -> tuple[CasePartyDeliveryTarget, ...]:
if not channel.strip():
raise InstitutionalContextError("Delivery channel is required.")
targets: list[CasePartyDeliveryTarget] = []
for party in party_set.parties:
if not party.delivery_recipient or channel not in party.permitted_channels:
continue
if not party.contact_snapshot_refs:
raise InstitutionalContextError(
"A case delivery target requires frozen contact snapshots."
)
represented = tuple(
item.represented_party_ref
for item in party.representations
if _representation_effective(
item,
effective_at=party_set.effective_at,
action="receive",
)
)
targets.append(
CasePartyDeliveryTarget(
party_ref=party.reference,
subject=party.subject,
role=party.role,
channel=channel,
contact_snapshot_refs=party.contact_snapshot_refs,
represented_party_refs=represented,
evidence=party.evidence,
)
)
return tuple(targets)
def _compatibility_party(
case_ref: InstitutionalReference,
item: CasePartyCompatibilityRecord,
) -> ProcedureParty:
if item.subject.tenant_id != case_ref.tenant_id:
raise InstitutionalContextError(
"Compatibility party subject belongs to another tenant."
)
return ProcedureParty(
reference=InstitutionalReference(
kind="party",
owner_module="cases",
object_id=item.party_id,
tenant_id=case_ref.tenant_id,
version="compatibility-1",
valid_at=item.valid_from,
),
procedure_ref=case_ref,
role=item.role,
subject=item.subject,
temporal=TemporalRevision(
revision="compatibility-1",
valid_from=item.valid_from,
valid_to=item.valid_to,
recorded_at=item.valid_from,
change_reason="Cases compatibility party projection.",
),
preferred_channels=item.preferred_channels,
permitted_channels=item.permitted_channels,
delivery_recipient=item.delivery_recipient,
contact_snapshot_refs=item.contact_snapshot_refs,
evidence=item.evidence,
)
def _representation_effective(
representation: PartyRepresentation,
*,
effective_at: datetime,
action: str,
) -> bool:
return (
action in representation.permitted_actions
and representation.temporal.effective_at(effective_at)
and (
representation.revoked_at is None
or representation.revoked_at > effective_at
)
)
def _same_object(
left: InstitutionalReference,
right: InstitutionalReference,
) -> bool:
return (
left.kind,
left.owner_module,
left.object_id,
left.tenant_id,
) == (
right.kind,
right.owner_module,
right.object_id,
right.tenant_id,
)
def _require_aware(value: datetime) -> None:
if value.tzinfo is None or value.utcoffset() is None:
raise InstitutionalContextError(
"Case party effective time must include a timezone."
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_CASES_PARTY_CONTEXT",
"CasePartyCompatibilityRecord",
"CasePartyContext",
"CasePartyDeliveryTarget",
"CasePartySet",
]