feat: route bounded hierarchy postbox copies
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
+54
@@ -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")
|
||||
@@ -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
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user