feat: route bounded hierarchy postbox copies

This commit is contained in:
2026-07-30 04:00:31 +02:00
parent d848a9a503
commit 28b60782de
16 changed files with 2715 additions and 17 deletions
@@ -0,0 +1,348 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
from govoplan_core.core.idm import (
IdmFunctionAssignmentDirectory,
OrganizationFunctionAssignmentRef,
)
from govoplan_core.core.organizations import (
OrganizationFunctionRef,
OrganizationHierarchyDirectory,
OrganizationHierarchyEdgeRef,
OrganizationUnitRef,
)
from govoplan_postbox.backend.schemas import PostboxRoutingPolicyPayload
@dataclass(frozen=True, slots=True)
class HierarchyRouteCandidate:
depth: int
unit: OrganizationUnitRef
function: OrganizationFunctionRef | None
holders: tuple[OrganizationFunctionAssignmentRef, ...]
status: str
path: tuple[Mapping[str, object], ...]
diagnostics: tuple[str, ...] = ()
@property
def holder_count(self) -> int:
return len(
{
holder.identity_id or holder.account_id or holder.id
for holder in self.holders
}
)
@dataclass(frozen=True, slots=True)
class HierarchyRoutePlan:
status: str
policy: Mapping[str, object]
candidates: tuple[HierarchyRouteCandidate, ...] = ()
diagnostics: tuple[str, ...] = ()
def normalized_routing_policy(
value: Mapping[str, object] | None,
) -> dict[str, object]:
return PostboxRoutingPolicyPayload.model_validate(value or {}).model_dump(
mode="json"
)
def plan_hierarchy_routes(
*,
hierarchy: OrganizationHierarchyDirectory | None,
incumbencies: IdmFunctionAssignmentDirectory,
tenant_id: str,
source_unit_id: str | None,
routing_policy: Mapping[str, object] | None,
producer_module: str,
classification: str,
expires_at: datetime | None,
now: datetime,
) -> HierarchyRoutePlan:
policy = normalized_routing_policy(routing_policy)
linked_copy = _mapping(policy.get("linked_copy"))
if not linked_copy.get("enabled"):
return HierarchyRoutePlan(
status="disabled",
policy=policy,
diagnostics=("hierarchy_routing_disabled",),
)
if hierarchy is None:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=("organization_hierarchy_unavailable",),
)
if not source_unit_id:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=("source_organization_unit_missing",),
)
diagnostics = list(
_policy_gate_diagnostics(
linked_copy,
producer_module=producer_module,
classification=classification,
expires_at=expires_at,
now=now,
)
)
if diagnostics:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=tuple(diagnostics),
)
structure_id = str(linked_copy["structure_id"])
relation_type_ids = tuple(
str(value) for value in linked_copy.get("relation_type_ids", ())
)
max_depth = int(linked_copy["max_depth"])
try:
resolutions = hierarchy.resolve_hierarchy_relatives(
tenant_id,
(source_unit_id,),
structure_id=structure_id,
relation_type_ids=relation_type_ids,
direction="ancestors",
max_depth=max_depth,
)
except ValueError as exc:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=(f"hierarchy_request_invalid:{exc}",),
)
if not resolutions:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=("hierarchy_resolution_missing",),
)
resolution = resolutions[0]
diagnostics.extend(resolution.diagnostics)
if resolution.cycle_detected:
diagnostics.append("hierarchy_cycle_bounded")
if resolution.depth_limited:
diagnostics.append("hierarchy_depth_limited")
if resolution.status != "active":
diagnostics.append(f"hierarchy_{resolution.status}")
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=tuple(dict.fromkeys(diagnostics)),
)
matches = []
stop_unit_id = linked_copy.get("stop_unit_id")
stop_unit_type_id = linked_copy.get("stop_unit_type_id")
for match in sorted(
resolution.matches,
key=lambda item: (item.depth, item.unit.name, item.unit.id),
):
matches.append(match)
if (
stop_unit_id
and match.unit.id == stop_unit_id
or stop_unit_type_id
and match.unit.unit_type_id == stop_unit_type_id
):
diagnostics.append("hierarchy_stop_reached")
break
if not matches:
return HierarchyRoutePlan(
status="no_route",
policy=policy,
diagnostics=tuple(
dict.fromkeys((*diagnostics, "no_hierarchy_ancestor"))
),
)
unit_ids = tuple(dict.fromkeys(match.unit.id for match in matches))
target_function_type_id = str(linked_copy["target_function_type_id"])
try:
function_resolution = hierarchy.resolve_functions_by_type(
tenant_id,
target_function_type_id,
organization_unit_ids=unit_ids,
)
except ValueError as exc:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=tuple(
dict.fromkeys(
(*diagnostics, f"function_resolution_invalid:{exc}")
)
),
)
diagnostics.extend(function_resolution.diagnostics)
if function_resolution.status != "active":
diagnostics.append(
f"target_function_type_{function_resolution.status}"
)
functions_by_unit: dict[str, list[OrganizationFunctionRef]] = {}
for function in function_resolution.matches:
if function.status != "active":
continue
functions_by_unit.setdefault(
function.organization_unit_id,
[],
).append(function)
function_ids = tuple(
function.id
for functions in functions_by_unit.values()
if len(functions) == 1
for function in functions
)
try:
holder_map = (
incumbencies.organization_function_incumbencies(
function_ids,
tenant_id=tenant_id,
)
if function_ids
else {}
)
except ValueError:
holder_map = {}
diagnostics.append("target_incumbency_unavailable")
candidates: list[HierarchyRouteCandidate] = []
seen_targets: set[tuple[str, str]] = set()
for match in matches:
functions = functions_by_unit.get(match.unit.id, [])
candidate_diagnostics: list[str] = []
function = functions[0] if len(functions) == 1 else None
if match.unit.status != "active":
status = "unit_inactive"
candidate_diagnostics.append("target_unit_inactive")
elif not functions:
status = "function_missing"
candidate_diagnostics.append("target_function_missing")
elif len(functions) > 1:
status = "function_ambiguous"
candidate_diagnostics.append("target_function_ambiguous")
elif (match.unit.id, function.id) in seen_targets:
status = "duplicate"
candidate_diagnostics.append("duplicate_target_suppressed")
else:
seen_targets.add((match.unit.id, function.id))
holders = tuple(
holder_map.get(function.id).assignments
if function.id in holder_map
else ()
)
status = "available" if holders else "vacant"
candidates.append(
HierarchyRouteCandidate(
depth=match.depth,
unit=match.unit,
function=function,
holders=holders,
status=status,
path=tuple(_edge_snapshot(edge) for edge in match.path),
)
)
continue
candidates.append(
HierarchyRouteCandidate(
depth=match.depth,
unit=match.unit,
function=function,
holders=(),
status=status,
path=tuple(_edge_snapshot(edge) for edge in match.path),
diagnostics=tuple(candidate_diagnostics),
)
)
routable = [
candidate
for candidate in candidates
if candidate.status in {"available", "vacant"}
]
status = "planned" if routable else "no_route"
if not routable:
diagnostics.append("no_available_hierarchy_target")
return HierarchyRoutePlan(
status=status,
policy=policy,
candidates=tuple(candidates),
diagnostics=tuple(dict.fromkeys(diagnostics)),
)
def _policy_gate_diagnostics(
linked_copy: Mapping[str, object],
*,
producer_module: str,
classification: str,
expires_at: datetime | None,
now: datetime,
) -> tuple[str, ...]:
diagnostics: list[str] = []
classifications = {
str(value) for value in linked_copy.get("allowed_classifications", ())
}
if classification not in classifications:
diagnostics.append("classification_not_allowed")
producers = {
str(value) for value in linked_copy.get("allowed_producer_modules", ())
}
if producer_module not in producers and "*" not in producers:
diagnostics.append("producer_not_authorized")
normalized_expiry = _as_utc(expires_at) if expires_at else None
if linked_copy.get("require_expiry") and normalized_expiry is None:
diagnostics.append("expiry_required")
max_retention_days = linked_copy.get("max_retention_days")
if (
normalized_expiry is not None
and max_retention_days is not None
and normalized_expiry
> _as_utc(now) + timedelta(days=int(max_retention_days))
):
diagnostics.append("retention_limit_exceeded")
if normalized_expiry is not None and normalized_expiry <= _as_utc(now):
diagnostics.append("message_already_expired")
return tuple(diagnostics)
def _edge_snapshot(edge: OrganizationHierarchyEdgeRef) -> dict[str, object]:
return {
"edge_id": edge.id,
"structure_id": edge.structure.id,
"structure_slug": edge.structure.slug,
"relation_type_id": edge.relation_type.id,
"relation_type_slug": edge.relation_type.slug,
"source_unit_id": edge.source_unit_id,
"target_unit_id": edge.target_unit_id,
}
def _mapping(value: object) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = [
"HierarchyRouteCandidate",
"HierarchyRoutePlan",
"normalized_routing_policy",
"plan_hierarchy_routes",
]