feat: implement governed cases workspace
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user