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
+20
View File
@@ -641,8 +641,20 @@ class PostboxDelivery(Base, TimestampMixin):
class PostboxRoute(Base, TimestampMixin):
__tablename__ = "postbox_routes"
__table_args__ = (
UniqueConstraint(
"delivery_id",
"target_postbox_id",
"route_kind",
"depth",
name="uq_postbox_routes_delivery_target_kind_depth",
),
Index("ix_postbox_routes_delivery_kind", "delivery_id", "route_kind"),
Index("ix_postbox_routes_target", "tenant_id", "target_postbox_id"),
Index(
"ix_postbox_routes_due",
"status",
"execute_after",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
@@ -676,6 +688,14 @@ class PostboxRoute(Base, TimestampMixin):
ForeignKey("postbox_routes.id", ondelete="SET NULL"),
nullable=True,
)
execute_after: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
processed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
policy_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
@@ -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",
]
+13 -1
View File
@@ -29,13 +29,17 @@ from govoplan_core.core.modules import (
RoleTemplate,
)
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
)
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_ROUTING,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
@@ -201,6 +205,7 @@ manifest = ModuleManifest(
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
),
provides_interfaces=tuple(
ModuleInterfaceProvider(name=name, version=MODULE_VERSION)
@@ -210,6 +215,7 @@ manifest = ModuleManifest(
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_ROUTING,
)
),
requires_interfaces=(
@@ -218,6 +224,11 @@ manifest = ModuleManifest(
version_min="0.1.8",
version_max_exclusive="0.2.0",
),
ModuleInterfaceRequirement(
name="organizations.hierarchy_directory",
version_min="0.1.0",
version_max_exclusive="0.2.0",
),
ModuleInterfaceRequirement(
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
version_min="0.1.8",
@@ -318,6 +329,7 @@ manifest = ModuleManifest(
CAPABILITY_POSTBOX_MESSAGES: _configure,
CAPABILITY_POSTBOX_DELIVERY: _configure,
CAPABILITY_POSTBOX_EVIDENCE: _configure,
CAPABILITY_POSTBOX_ROUTING: _configure,
},
documentation=(
DocumentationTopic(
@@ -0,0 +1,54 @@
"""v0.1.1 durable hierarchy routes
Revision ID: e4b7c9d2a6f1
Revises: c7d2e5f8a1b4
Create Date: 2026-07-30 04:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e4b7c9d2a6f1"
down_revision = "c7d2e5f8a1b4"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_routes") as batch_op:
batch_op.add_column(
sa.Column(
"execute_after",
sa.DateTime(timezone=True),
nullable=True,
)
)
batch_op.add_column(
sa.Column(
"processed_at",
sa.DateTime(timezone=True),
nullable=True,
)
)
batch_op.create_unique_constraint(
"uq_postbox_routes_delivery_target_kind_depth",
("delivery_id", "target_postbox_id", "route_kind", "depth"),
)
batch_op.create_index(
"ix_postbox_routes_due",
("status", "execute_after"),
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("postbox_routes") as batch_op:
batch_op.drop_index("ix_postbox_routes_due")
batch_op.drop_constraint(
"uq_postbox_routes_delivery_target_kind_depth",
type_="unique",
)
batch_op.drop_column("processed_at")
batch_op.drop_column("execute_after")
+32 -1
View File
@@ -39,6 +39,8 @@ from govoplan_postbox.backend.schemas import (
PostboxMessageListResponse,
PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse,
PostboxRouteDryRunRequest,
PostboxRouteDryRunResponse,
PostboxTemplateCreateRequest,
PostboxTemplateItem,
PostboxTemplateListResponse,
@@ -395,6 +397,30 @@ def api_deliver_to_postbox(
return PostboxDeliveryResponse.model_validate(asdict(result))
@router.post(
"/routing/dry-run",
response_model=PostboxRouteDryRunResponse,
)
def api_preview_postbox_routing(
payload: PostboxRouteDryRunRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxRouteDryRunResponse:
_require_any(principal, DELIVERY_SCOPE, TEMPLATE_ADMIN_SCOPE)
try:
result = get_service().preview_hierarchy_routes(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(**payload.target.model_dump()),
producer_module=payload.producer_module,
classification=payload.classification,
expires_at=payload.expires_at,
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxRouteDryRunResponse.model_validate(result)
@router.get("/groupings", response_model=PostboxGroupingListResponse)
def api_list_postbox_groupings(
session: Session = Depends(get_session),
@@ -502,7 +528,12 @@ def api_postbox_organization_targets(
return PostboxOrganizationTargetsResponse(
units=list(
get_service().organization_targets(tenant_id=principal.tenant_id)
)
),
structures=list(
get_service().organization_hierarchy_targets(
tenant_id=principal.tenant_id
)
),
)
+164 -1
View File
@@ -150,6 +150,144 @@ class PostboxDeliveryResponse(BaseModel):
evidence: dict[str, Any] = Field(default_factory=dict)
class PostboxLinkedCopyPolicyPayload(BaseModel):
enabled: bool = False
structure_id: str | None = Field(default=None, max_length=36)
relation_type_ids: list[str] = Field(default_factory=list, max_length=20)
max_depth: int = Field(default=1, ge=1, le=20)
stop_unit_id: str | None = Field(default=None, max_length=36)
stop_unit_type_id: str | None = Field(default=None, max_length=36)
target_function_type_id: str | None = Field(default=None, max_length=36)
target_template_id: str | None = Field(default=None, max_length=36)
fanout: Literal["nearest", "all"] = "nearest"
allowed_classifications: list[str] = Field(
default_factory=lambda: ["internal"],
max_length=20,
)
allowed_producer_modules: list[str] = Field(
default_factory=list,
max_length=50,
)
require_expiry: bool = False
max_retention_days: int | None = Field(default=None, ge=1, le=36500)
@model_validator(mode="after")
def validate_enabled_policy(self) -> "PostboxLinkedCopyPolicyPayload":
self.relation_type_ids = list(dict.fromkeys(self.relation_type_ids))
self.allowed_classifications = list(
dict.fromkeys(
value.strip() for value in self.allowed_classifications
)
)
self.allowed_producer_modules = list(
dict.fromkeys(
value.strip() for value in self.allowed_producer_modules
)
)
if any(not value for value in self.relation_type_ids):
raise ValueError("Relation type IDs must not be empty.")
if any(not value for value in self.allowed_classifications):
raise ValueError("Allowed classifications must not be empty.")
if any(not value for value in self.allowed_producer_modules):
raise ValueError("Allowed producer modules must not be empty.")
if self.enabled and not all(
(
self.structure_id,
self.target_function_type_id,
self.target_template_id,
self.allowed_classifications,
self.allowed_producer_modules,
)
):
raise ValueError(
"Enabled hierarchy copy requires a structure, target function "
"type, target template, classification gate, and producer allowlist."
)
return self
class PostboxAttentionPolicyPayload(BaseModel):
mode: Literal["none", "vacancy_escalation"] = "none"
delay_minutes: int | None = Field(default=None, ge=1, le=43200)
@model_validator(mode="after")
def validate_delay(self) -> "PostboxAttentionPolicyPayload":
if self.mode == "vacancy_escalation" and self.delay_minutes is None:
raise ValueError("Vacancy escalation requires a delay.")
if self.mode == "none":
self.delay_minutes = None
return self
class PostboxSharedVisibilityPolicyPayload(BaseModel):
mode: Literal["none"] = "none"
class PostboxRoutingPolicyPayload(BaseModel):
linked_copy: PostboxLinkedCopyPolicyPayload = Field(
default_factory=PostboxLinkedCopyPolicyPayload
)
attention: PostboxAttentionPolicyPayload = Field(
default_factory=PostboxAttentionPolicyPayload
)
shared_visibility: PostboxSharedVisibilityPolicyPayload = Field(
default_factory=PostboxSharedVisibilityPolicyPayload
)
@model_validator(mode="before")
@classmethod
def normalize_legacy_policy(cls, value: Any) -> Any:
if value in (None, {}, {"mode": "none"}):
return {}
return value
@model_validator(mode="after")
def validate_semantics(self) -> "PostboxRoutingPolicyPayload":
if (
self.attention.mode == "vacancy_escalation"
and (
not self.linked_copy.enabled
or self.linked_copy.fanout != "nearest"
)
):
raise ValueError(
"Vacancy escalation requires nearest linked-copy routing."
)
return self
class PostboxRoutePreviewTarget(BaseModel):
depth: int
organization_unit_id: str
organization_unit_name: str
function_id: str | None = None
function_name: str | None = None
target_postbox_id: str | None = None
target_address: str | None = None
status: str
vacant: bool = True
holder_count: int = 0
path: list[dict[str, Any]] = Field(default_factory=list)
diagnostics: list[str] = Field(default_factory=list)
class PostboxRouteDryRunRequest(BaseModel):
target: PostboxTargetPayload
producer_module: str = Field(min_length=1, max_length=100)
classification: str = Field(default="internal", min_length=1, max_length=50)
expires_at: datetime | None = None
class PostboxRouteDryRunResponse(BaseModel):
status: str
source_postbox_id: str | None = None
policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload
)
routes: list[PostboxRoutePreviewTarget] = Field(default_factory=list)
diagnostics: list[str] = Field(default_factory=list)
class PostboxExactCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=500)
description: str | None = None
@@ -175,6 +313,9 @@ class PostboxTemplateRevisionPayload(BaseModel):
)
classification: str = Field(default="internal", min_length=1, max_length=50)
allow_vacant_delivery: bool = True
routing_policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload
)
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
@@ -188,7 +329,6 @@ class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
revision: int
encryption_profile: str
history_policy: dict[str, Any] = Field(default_factory=dict)
routing_policy: dict[str, Any] = Field(default_factory=dict)
retention_policy: dict[str, Any] = Field(default_factory=dict)
published_at: datetime | None = None
created_at: datetime
@@ -240,8 +380,31 @@ class PostboxOrganizationUnitItem(BaseModel):
functions: list[PostboxOrganizationFunctionItem] = Field(default_factory=list)
class PostboxOrganizationRelationTypeItem(BaseModel):
id: str
slug: str
name: str
structure_id: str | None = None
is_hierarchical: bool = True
status: str = "active"
class PostboxOrganizationStructureItem(BaseModel):
id: str
slug: str
name: str
structure_kind: str
status: str = "active"
relation_types: list[PostboxOrganizationRelationTypeItem] = Field(
default_factory=list
)
class PostboxOrganizationTargetsResponse(BaseModel):
units: list[PostboxOrganizationUnitItem]
structures: list[PostboxOrganizationStructureItem] = Field(
default_factory=list
)
class PostboxGroupingPayload(BaseModel):
File diff suppressed because it is too large Load Diff