feat: route bounded hierarchy postbox copies
This commit is contained in:
@@ -80,7 +80,17 @@ addresses, exact function-bound Postboxes, current IDM assignment access
|
|||||||
decisions, vacancy status, idempotent producer delivery, source-preserving
|
decisions, vacancy status, idempotent producer delivery, source-preserving
|
||||||
message and attachment references, personal read/acknowledgement receipts,
|
message and attachment references, personal read/acknowledgement receipts,
|
||||||
unified inbox projections, access evidence, an inbox route, and tenant
|
unified inbox projections, access evidence, an inbox route, and tenant
|
||||||
administration.
|
administration. Published template revisions can also opt into bounded linked
|
||||||
|
copies through one explicit organization structure. Classification, producer,
|
||||||
|
retention, stop, depth, target-template, and target-function gates are frozen
|
||||||
|
at delivery time and exposed through delivery evidence and the routing dry-run
|
||||||
|
API.
|
||||||
|
|
||||||
|
Vacancy escalation is a separate attention policy. It creates no personal
|
||||||
|
account grant: a durable route waits for its configured delay and then creates
|
||||||
|
an independently readable copy in the next frozen function Postbox. The
|
||||||
|
`govoplan.postbox.dispatch_routes` periodic Core worker drains due routes when
|
||||||
|
Celery beat and a worker consuming the `postbox` queue are enabled.
|
||||||
|
|
||||||
The persistence model reserves ciphertext manifests, wrapped keys, key epochs,
|
The persistence model reserves ciphertext manifests, wrapped keys, key epochs,
|
||||||
expiry, and withdrawal state. The active profile remains `plaintext_v1`; the
|
expiry, and withdrawal state. The active profile remains `plaintext_v1`; the
|
||||||
|
|||||||
@@ -176,6 +176,24 @@ Vacancy is a visible delivery/attention state rather than an automatic grant
|
|||||||
to an unrelated personal account. Policy may trigger a bounded escalation
|
to an unrelated personal account. Policy may trigger a bounded escalation
|
||||||
after a delay.
|
after a delay.
|
||||||
|
|
||||||
|
The implemented policy keeps the three semantics separate:
|
||||||
|
|
||||||
|
- `linked_copy` can target the nearest matching ancestor or every bounded
|
||||||
|
matching ancestor in one selected structure.
|
||||||
|
- `attention` currently supports delayed vacancy escalation over the remaining
|
||||||
|
delivery-time target snapshot.
|
||||||
|
- `shared_visibility` remains explicitly disabled until its access and
|
||||||
|
encryption semantics are implemented.
|
||||||
|
|
||||||
|
Routing is off unless an immutable template revision enables it and supplies a
|
||||||
|
target template, target function type, producer allowlist, classification
|
||||||
|
allowlist, depth, and structure. Optional relation, stop-unit, stop-unit-type,
|
||||||
|
expiry, and maximum-retention gates narrow the route further. The dry-run API
|
||||||
|
returns blocked and unavailable candidates without materializing addresses.
|
||||||
|
Delivery materializes only frozen candidates, stores path-edge provenance, and
|
||||||
|
creates source-preserving copies with independent read and acknowledgement
|
||||||
|
receipts.
|
||||||
|
|
||||||
## Campaign Distribution
|
## Campaign Distribution
|
||||||
|
|
||||||
Campaign can use Postbox as an explicit delivery channel through
|
Campaign can use Postbox as an explicit delivery channel through
|
||||||
|
|||||||
@@ -641,8 +641,20 @@ class PostboxDelivery(Base, TimestampMixin):
|
|||||||
class PostboxRoute(Base, TimestampMixin):
|
class PostboxRoute(Base, TimestampMixin):
|
||||||
__tablename__ = "postbox_routes"
|
__tablename__ = "postbox_routes"
|
||||||
__table_args__ = (
|
__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_delivery_kind", "delivery_id", "route_kind"),
|
||||||
Index("ix_postbox_routes_target", "tenant_id", "target_postbox_id"),
|
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)
|
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"),
|
ForeignKey("postbox_routes.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
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(
|
policy_snapshot: Mapped[dict[str, Any]] = mapped_column(
|
||||||
JSON,
|
JSON,
|
||||||
default=dict,
|
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,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
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 (
|
from govoplan_core.core.postbox import (
|
||||||
CAPABILITY_POSTBOX_ACCESS,
|
CAPABILITY_POSTBOX_ACCESS,
|
||||||
CAPABILITY_POSTBOX_DELIVERY,
|
CAPABILITY_POSTBOX_DELIVERY,
|
||||||
CAPABILITY_POSTBOX_DIRECTORY,
|
CAPABILITY_POSTBOX_DIRECTORY,
|
||||||
CAPABILITY_POSTBOX_EVIDENCE,
|
CAPABILITY_POSTBOX_EVIDENCE,
|
||||||
CAPABILITY_POSTBOX_MESSAGES,
|
CAPABILITY_POSTBOX_MESSAGES,
|
||||||
|
CAPABILITY_POSTBOX_ROUTING,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -201,6 +205,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_IDM_DIRECTORY,
|
CAPABILITY_IDM_DIRECTORY,
|
||||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
|
||||||
),
|
),
|
||||||
provides_interfaces=tuple(
|
provides_interfaces=tuple(
|
||||||
ModuleInterfaceProvider(name=name, version=MODULE_VERSION)
|
ModuleInterfaceProvider(name=name, version=MODULE_VERSION)
|
||||||
@@ -210,6 +215,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_POSTBOX_MESSAGES,
|
CAPABILITY_POSTBOX_MESSAGES,
|
||||||
CAPABILITY_POSTBOX_DELIVERY,
|
CAPABILITY_POSTBOX_DELIVERY,
|
||||||
CAPABILITY_POSTBOX_EVIDENCE,
|
CAPABILITY_POSTBOX_EVIDENCE,
|
||||||
|
CAPABILITY_POSTBOX_ROUTING,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
@@ -218,6 +224,11 @@ manifest = ModuleManifest(
|
|||||||
version_min="0.1.8",
|
version_min="0.1.8",
|
||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="organizations.hierarchy_directory",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
),
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
|
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
version_min="0.1.8",
|
version_min="0.1.8",
|
||||||
@@ -318,6 +329,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_POSTBOX_MESSAGES: _configure,
|
CAPABILITY_POSTBOX_MESSAGES: _configure,
|
||||||
CAPABILITY_POSTBOX_DELIVERY: _configure,
|
CAPABILITY_POSTBOX_DELIVERY: _configure,
|
||||||
CAPABILITY_POSTBOX_EVIDENCE: _configure,
|
CAPABILITY_POSTBOX_EVIDENCE: _configure,
|
||||||
|
CAPABILITY_POSTBOX_ROUTING: _configure,
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
DocumentationTopic(
|
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,
|
PostboxMessageListResponse,
|
||||||
PostboxMessageStateRequest,
|
PostboxMessageStateRequest,
|
||||||
PostboxOrganizationTargetsResponse,
|
PostboxOrganizationTargetsResponse,
|
||||||
|
PostboxRouteDryRunRequest,
|
||||||
|
PostboxRouteDryRunResponse,
|
||||||
PostboxTemplateCreateRequest,
|
PostboxTemplateCreateRequest,
|
||||||
PostboxTemplateItem,
|
PostboxTemplateItem,
|
||||||
PostboxTemplateListResponse,
|
PostboxTemplateListResponse,
|
||||||
@@ -395,6 +397,30 @@ def api_deliver_to_postbox(
|
|||||||
return PostboxDeliveryResponse.model_validate(asdict(result))
|
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)
|
@router.get("/groupings", response_model=PostboxGroupingListResponse)
|
||||||
def api_list_postbox_groupings(
|
def api_list_postbox_groupings(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -502,7 +528,12 @@ def api_postbox_organization_targets(
|
|||||||
return PostboxOrganizationTargetsResponse(
|
return PostboxOrganizationTargetsResponse(
|
||||||
units=list(
|
units=list(
|
||||||
get_service().organization_targets(tenant_id=principal.tenant_id)
|
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)
|
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):
|
class PostboxExactCreateRequest(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=500)
|
name: str = Field(min_length=1, max_length=500)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -175,6 +313,9 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
|||||||
)
|
)
|
||||||
classification: str = Field(default="internal", min_length=1, max_length=50)
|
classification: str = Field(default="internal", min_length=1, max_length=50)
|
||||||
allow_vacant_delivery: bool = True
|
allow_vacant_delivery: bool = True
|
||||||
|
routing_policy: PostboxRoutingPolicyPayload = Field(
|
||||||
|
default_factory=PostboxRoutingPolicyPayload
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
|
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
|
||||||
@@ -188,7 +329,6 @@ class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
|
|||||||
revision: int
|
revision: int
|
||||||
encryption_profile: str
|
encryption_profile: str
|
||||||
history_policy: dict[str, Any] = Field(default_factory=dict)
|
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)
|
retention_policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
published_at: datetime | None = None
|
published_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -240,8 +380,31 @@ class PostboxOrganizationUnitItem(BaseModel):
|
|||||||
functions: list[PostboxOrganizationFunctionItem] = Field(default_factory=list)
|
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):
|
class PostboxOrganizationTargetsResponse(BaseModel):
|
||||||
units: list[PostboxOrganizationUnitItem]
|
units: list[PostboxOrganizationUnitItem]
|
||||||
|
structures: list[PostboxOrganizationStructureItem] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PostboxGroupingPayload(BaseModel):
|
class PostboxGroupingPayload(BaseModel):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ from govoplan_core.core.postbox import (
|
|||||||
CAPABILITY_POSTBOX_DIRECTORY,
|
CAPABILITY_POSTBOX_DIRECTORY,
|
||||||
CAPABILITY_POSTBOX_EVIDENCE,
|
CAPABILITY_POSTBOX_EVIDENCE,
|
||||||
CAPABILITY_POSTBOX_MESSAGES,
|
CAPABILITY_POSTBOX_MESSAGES,
|
||||||
|
CAPABILITY_POSTBOX_ROUTING,
|
||||||
)
|
)
|
||||||
from govoplan_postbox.backend.manifest import get_manifest
|
from govoplan_postbox.backend.manifest import get_manifest
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ class PostboxManifestTests(unittest.TestCase):
|
|||||||
CAPABILITY_POSTBOX_MESSAGES,
|
CAPABILITY_POSTBOX_MESSAGES,
|
||||||
CAPABILITY_POSTBOX_DELIVERY,
|
CAPABILITY_POSTBOX_DELIVERY,
|
||||||
CAPABILITY_POSTBOX_EVIDENCE,
|
CAPABILITY_POSTBOX_EVIDENCE,
|
||||||
|
CAPABILITY_POSTBOX_ROUTING,
|
||||||
},
|
},
|
||||||
set(manifest.capability_factories),
|
set(manifest.capability_factories),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,14 +14,21 @@ class PostboxMigrationTests(unittest.TestCase):
|
|||||||
"govoplan_postbox.backend.migrations.versions."
|
"govoplan_postbox.backend.migrations.versions."
|
||||||
"c7d2e5f8a1b4_v010_postbox_baseline"
|
"c7d2e5f8a1b4_v010_postbox_baseline"
|
||||||
)
|
)
|
||||||
|
route_migration = importlib.import_module(
|
||||||
|
"govoplan_postbox.backend.migrations.versions."
|
||||||
|
"e4b7c9d2a6f1_v011_hierarchy_routes"
|
||||||
|
)
|
||||||
engine = create_engine("sqlite:///:memory:")
|
engine = create_engine("sqlite:///:memory:")
|
||||||
try:
|
try:
|
||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
operations = Operations(MigrationContext.configure(connection))
|
operations = Operations(MigrationContext.configure(connection))
|
||||||
original = migration.op
|
original = migration.op
|
||||||
|
route_original = route_migration.op
|
||||||
migration.op = operations
|
migration.op = operations
|
||||||
|
route_migration.op = operations
|
||||||
try:
|
try:
|
||||||
migration.upgrade()
|
migration.upgrade()
|
||||||
|
route_migration.upgrade()
|
||||||
tables = set(inspect(connection).get_table_names())
|
tables = set(inspect(connection).get_table_names())
|
||||||
self.assertIn("postboxes", tables)
|
self.assertIn("postboxes", tables)
|
||||||
self.assertIn("postbox_messages", tables)
|
self.assertIn("postbox_messages", tables)
|
||||||
@@ -43,6 +50,18 @@ class PostboxMigrationTests(unittest.TestCase):
|
|||||||
"withdrawn_at",
|
"withdrawn_at",
|
||||||
}.issubset(message_columns)
|
}.issubset(message_columns)
|
||||||
)
|
)
|
||||||
|
route_columns = {
|
||||||
|
column["name"]
|
||||||
|
for column in inspect(connection).get_columns(
|
||||||
|
"postbox_routes"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.assertTrue(
|
||||||
|
{"execute_after", "processed_at"}.issubset(
|
||||||
|
route_columns
|
||||||
|
)
|
||||||
|
)
|
||||||
|
route_migration.downgrade()
|
||||||
migration.downgrade()
|
migration.downgrade()
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
{
|
{
|
||||||
@@ -53,6 +72,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
migration.op = original
|
migration.op = original
|
||||||
|
route_migration.op = route_original
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,23 @@ class PostboxRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(200, unread.status_code, unread.text)
|
self.assertEqual(200, unread.status_code, unread.text)
|
||||||
self.assertEqual(0, unread.json()["total"])
|
self.assertEqual(0, unread.json()["total"])
|
||||||
|
|
||||||
|
def test_routing_dry_run_explains_default_disabled_state(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/postbox/routing/dry-run",
|
||||||
|
json={
|
||||||
|
"target": {"postbox_id": self.postbox_id},
|
||||||
|
"producer_module": "campaigns",
|
||||||
|
"classification": "internal",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
self.assertEqual("disabled", response.json()["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
["hierarchy_routing_disabled"],
|
||||||
|
response.json()["diagnostics"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,7 +14,17 @@ from govoplan_core.core.idm import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.organizations import (
|
from govoplan_core.core.organizations import (
|
||||||
OrganizationFunctionRef,
|
OrganizationFunctionRef,
|
||||||
|
OrganizationFunctionTypeRef,
|
||||||
|
OrganizationFunctionTypeResolution,
|
||||||
|
OrganizationHierarchyCatalogRef,
|
||||||
|
OrganizationHierarchyEdgeRef,
|
||||||
|
OrganizationHierarchyMatchRef,
|
||||||
|
OrganizationHierarchyPathResolution,
|
||||||
|
OrganizationHierarchyResolution,
|
||||||
|
OrganizationRelationTypeRef,
|
||||||
|
OrganizationStructureRef,
|
||||||
OrganizationUnitRef,
|
OrganizationUnitRef,
|
||||||
|
OrganizationUnitTypeResolution,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.postbox import (
|
from govoplan_core.core.postbox import (
|
||||||
PostboxActorRef,
|
PostboxActorRef,
|
||||||
@@ -173,6 +183,13 @@ class FakeOrganizationDirectory:
|
|||||||
unit_type_id="desk",
|
unit_type_id="desk",
|
||||||
parent_id="unit-1",
|
parent_id="unit-1",
|
||||||
),
|
),
|
||||||
|
"unit-top": OrganizationUnitRef(
|
||||||
|
id="unit-top",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="central-office",
|
||||||
|
name="Central Office",
|
||||||
|
unit_type_id="office",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
self.functions = {
|
self.functions = {
|
||||||
"function-1": OrganizationFunctionRef(
|
"function-1": OrganizationFunctionRef(
|
||||||
@@ -193,7 +210,47 @@ class FakeOrganizationDirectory:
|
|||||||
function_type_id="case-clerk-type",
|
function_type_id="case-clerk-type",
|
||||||
delegable=True,
|
delegable=True,
|
||||||
),
|
),
|
||||||
|
"function-top": OrganizationFunctionRef(
|
||||||
|
id="function-top",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
organization_unit_id="unit-top",
|
||||||
|
slug="case-clerk",
|
||||||
|
name="Case Clerk",
|
||||||
|
function_type_id="case-clerk-type",
|
||||||
|
delegable=True,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
self.structure = OrganizationStructureRef(
|
||||||
|
id="structure-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="administrative",
|
||||||
|
name="Administrative hierarchy",
|
||||||
|
structure_kind="administrative",
|
||||||
|
)
|
||||||
|
self.parallel_structure = OrganizationStructureRef(
|
||||||
|
id="structure-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="reporting",
|
||||||
|
name="Reporting hierarchy",
|
||||||
|
structure_kind="reporting",
|
||||||
|
)
|
||||||
|
self.relation_type = OrganizationRelationTypeRef(
|
||||||
|
id="relation-type-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="reports-to",
|
||||||
|
name="Reports to",
|
||||||
|
structure_id=self.structure.id,
|
||||||
|
)
|
||||||
|
self.parallel_relation_type = OrganizationRelationTypeRef(
|
||||||
|
id="relation-type-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="reports-to",
|
||||||
|
name="Reports to",
|
||||||
|
structure_id=self.parallel_structure.id,
|
||||||
|
)
|
||||||
|
self.last_hierarchy_request: dict[str, object] = {}
|
||||||
|
self.duplicate_parent_match = False
|
||||||
|
self.cycle_detected = False
|
||||||
|
|
||||||
def get_organization_unit(self, organization_unit_id: str):
|
def get_organization_unit(self, organization_unit_id: str):
|
||||||
return self.units.get(organization_unit_id)
|
return self.units.get(organization_unit_id)
|
||||||
@@ -218,6 +275,214 @@ class FakeOrganizationDirectory:
|
|||||||
if function.organization_unit_id == organization_unit_id
|
if function.organization_unit_id == organization_unit_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def hierarchy_catalog(self, tenant_id: str):
|
||||||
|
if tenant_id != "tenant-1":
|
||||||
|
return OrganizationHierarchyCatalogRef(tenant_id=tenant_id)
|
||||||
|
return OrganizationHierarchyCatalogRef(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structures=(self.structure, self.parallel_structure),
|
||||||
|
relation_types=(
|
||||||
|
self.relation_type,
|
||||||
|
self.parallel_relation_type,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_unit_type(self, tenant_id: str, unit_type_id: str):
|
||||||
|
del tenant_id, unit_type_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_function_type(self, tenant_id: str, function_type_id: str):
|
||||||
|
if tenant_id != "tenant-1" or function_type_id != "case-clerk-type":
|
||||||
|
return None
|
||||||
|
return OrganizationFunctionTypeRef(
|
||||||
|
id=function_type_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
slug="case-clerk",
|
||||||
|
name="Case Clerk",
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_functions_by_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
function_type_id: str,
|
||||||
|
*,
|
||||||
|
organization_unit_ids=(),
|
||||||
|
):
|
||||||
|
if tenant_id != "tenant-1":
|
||||||
|
return OrganizationFunctionTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_type_id=function_type_id,
|
||||||
|
requested_unit_ids=tuple(organization_unit_ids),
|
||||||
|
status="missing",
|
||||||
|
)
|
||||||
|
return OrganizationFunctionTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_type_id=function_type_id,
|
||||||
|
requested_unit_ids=tuple(organization_unit_ids),
|
||||||
|
status="active",
|
||||||
|
function_type=self.get_function_type(
|
||||||
|
tenant_id,
|
||||||
|
function_type_id,
|
||||||
|
),
|
||||||
|
matches=tuple(
|
||||||
|
function
|
||||||
|
for function in self.functions.values()
|
||||||
|
if function.function_type_id == function_type_id
|
||||||
|
and (
|
||||||
|
not organization_unit_ids
|
||||||
|
or function.organization_unit_id
|
||||||
|
in organization_unit_ids
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_units_by_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_type_id: str,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
del kwargs
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status="active",
|
||||||
|
matches=tuple(
|
||||||
|
unit
|
||||||
|
for unit in self.units.values()
|
||||||
|
if unit.tenant_id == tenant_id
|
||||||
|
and unit.unit_type_id == unit_type_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_hierarchy_relatives(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
organization_unit_ids,
|
||||||
|
*,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids=(),
|
||||||
|
direction="ancestors",
|
||||||
|
max_depth=10,
|
||||||
|
):
|
||||||
|
self.last_hierarchy_request = {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"structure_id": structure_id,
|
||||||
|
"relation_type_ids": tuple(relation_type_ids),
|
||||||
|
"direction": direction,
|
||||||
|
"max_depth": max_depth,
|
||||||
|
}
|
||||||
|
results = []
|
||||||
|
for root_id in organization_unit_ids:
|
||||||
|
root = self.units.get(root_id)
|
||||||
|
if root is None or root.tenant_id != tenant_id:
|
||||||
|
results.append(
|
||||||
|
OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=tuple(relation_type_ids),
|
||||||
|
max_depth=max_depth,
|
||||||
|
status="missing",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if structure_id == self.parallel_structure.id:
|
||||||
|
matches = ()
|
||||||
|
else:
|
||||||
|
first_edge = OrganizationHierarchyEdgeRef(
|
||||||
|
id="edge-child-parent",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure=self.structure,
|
||||||
|
relation_type=self.relation_type,
|
||||||
|
source_unit_id=root_id,
|
||||||
|
target_unit_id="unit-1",
|
||||||
|
)
|
||||||
|
second_edge = OrganizationHierarchyEdgeRef(
|
||||||
|
id="edge-parent-top",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure=self.structure,
|
||||||
|
relation_type=self.relation_type,
|
||||||
|
source_unit_id="unit-1",
|
||||||
|
target_unit_id="unit-top",
|
||||||
|
)
|
||||||
|
available = (
|
||||||
|
OrganizationHierarchyMatchRef(
|
||||||
|
unit=self.units["unit-1"],
|
||||||
|
depth=1,
|
||||||
|
path=(first_edge,),
|
||||||
|
),
|
||||||
|
OrganizationHierarchyMatchRef(
|
||||||
|
unit=self.units["unit-top"],
|
||||||
|
depth=2,
|
||||||
|
path=(first_edge, second_edge),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if self.duplicate_parent_match:
|
||||||
|
duplicate_edge = OrganizationHierarchyEdgeRef(
|
||||||
|
id="edge-child-parent-duplicate",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure=self.structure,
|
||||||
|
relation_type=self.relation_type,
|
||||||
|
source_unit_id=root_id,
|
||||||
|
target_unit_id="unit-1",
|
||||||
|
)
|
||||||
|
available = (
|
||||||
|
available[0],
|
||||||
|
OrganizationHierarchyMatchRef(
|
||||||
|
unit=self.units["unit-1"],
|
||||||
|
depth=1,
|
||||||
|
path=(duplicate_edge,),
|
||||||
|
),
|
||||||
|
available[1],
|
||||||
|
)
|
||||||
|
matches = tuple(
|
||||||
|
match for match in available if match.depth <= max_depth
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=tuple(relation_type_ids),
|
||||||
|
max_depth=max_depth,
|
||||||
|
status="active",
|
||||||
|
root=root,
|
||||||
|
matches=matches,
|
||||||
|
cycle_detected=self.cycle_detected,
|
||||||
|
depth_limited=(
|
||||||
|
structure_id == self.structure.id and max_depth < 2
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
def resolve_hierarchy_paths(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_pairs,
|
||||||
|
*,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids=(),
|
||||||
|
direction="descendants",
|
||||||
|
max_depth=10,
|
||||||
|
):
|
||||||
|
return tuple(
|
||||||
|
OrganizationHierarchyPathResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_unit_id=source_id,
|
||||||
|
target_unit_id=target_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=tuple(relation_type_ids),
|
||||||
|
max_depth=max_depth,
|
||||||
|
status="unreachable",
|
||||||
|
)
|
||||||
|
for source_id, target_id in unit_pairs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeNotificationDispatch:
|
class FakeNotificationDispatch:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -280,6 +545,117 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
actor_id="admin-1",
|
actor_id="admin-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _routing_policy(
|
||||||
|
self,
|
||||||
|
target_template_id: str,
|
||||||
|
*,
|
||||||
|
structure_id: str = "structure-1",
|
||||||
|
max_depth: int = 2,
|
||||||
|
producer_modules: tuple[str, ...] = ("campaigns",),
|
||||||
|
classifications: tuple[str, ...] = ("internal",),
|
||||||
|
vacancy_escalation: bool = False,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"linked_copy": {
|
||||||
|
"enabled": True,
|
||||||
|
"structure_id": structure_id,
|
||||||
|
"relation_type_ids": ["relation-type-1"],
|
||||||
|
"max_depth": max_depth,
|
||||||
|
"target_function_type_id": "case-clerk-type",
|
||||||
|
"target_template_id": target_template_id,
|
||||||
|
"fanout": "nearest",
|
||||||
|
"allowed_classifications": list(classifications),
|
||||||
|
"allowed_producer_modules": list(producer_modules),
|
||||||
|
"require_expiry": True,
|
||||||
|
"max_retention_days": 30,
|
||||||
|
},
|
||||||
|
"attention": {
|
||||||
|
"mode": (
|
||||||
|
"vacancy_escalation"
|
||||||
|
if vacancy_escalation
|
||||||
|
else "none"
|
||||||
|
),
|
||||||
|
"delay_minutes": 1 if vacancy_escalation else None,
|
||||||
|
},
|
||||||
|
"shared_visibility": {"mode": "none"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _create_routing_source(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
vacancy_escalation: bool = False,
|
||||||
|
structure_id: str = "structure-1",
|
||||||
|
max_depth: int = 2,
|
||||||
|
) -> tuple[PostboxService, Postbox, PostboxTemplate]:
|
||||||
|
service = PostboxService(
|
||||||
|
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
|
||||||
|
idm=self.idm, # type: ignore[arg-type]
|
||||||
|
incumbencies=self.idm, # type: ignore[arg-type]
|
||||||
|
organizations=self.organizations, # type: ignore[arg-type]
|
||||||
|
hierarchy=self.organizations, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
target_template = service.create_template(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="hierarchy-target",
|
||||||
|
name="Hierarchy target",
|
||||||
|
description=None,
|
||||||
|
function_type_id="case-clerk-type",
|
||||||
|
scope_kind="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
name_pattern="{unit_name} / {function_name} Routed",
|
||||||
|
address_pattern="routed.{unit_slug}.{function_slug}",
|
||||||
|
classification="internal",
|
||||||
|
allow_vacant_delivery=True,
|
||||||
|
actor_id="admin-1",
|
||||||
|
)
|
||||||
|
service.publish_template(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template_id=target_template.id,
|
||||||
|
revision_number=None,
|
||||||
|
actor_id="admin-1",
|
||||||
|
)
|
||||||
|
source_template = service.create_template(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="hierarchy-source",
|
||||||
|
name="Hierarchy source",
|
||||||
|
description=None,
|
||||||
|
function_type_id="case-clerk-type",
|
||||||
|
scope_kind="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
name_pattern="{unit_name} / {function_name} Source",
|
||||||
|
address_pattern="source.{unit_slug}.{function_slug}",
|
||||||
|
classification="internal",
|
||||||
|
allow_vacant_delivery=True,
|
||||||
|
actor_id="admin-1",
|
||||||
|
routing_policy=self._routing_policy(
|
||||||
|
target_template.id,
|
||||||
|
vacancy_escalation=vacancy_escalation,
|
||||||
|
structure_id=structure_id,
|
||||||
|
max_depth=max_depth,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
service.publish_template(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template_id=source_template.id,
|
||||||
|
revision_number=None,
|
||||||
|
actor_id="admin-1",
|
||||||
|
)
|
||||||
|
source = service.materialize_template(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template_id=source_template.id,
|
||||||
|
organization_unit_id="unit-child",
|
||||||
|
function_id="function-child",
|
||||||
|
context_key="case-42",
|
||||||
|
actor_id="admin-1",
|
||||||
|
)
|
||||||
|
return service, source, target_template
|
||||||
|
|
||||||
def test_access_follows_current_assignment_and_reports_vacancy(self) -> None:
|
def test_access_follows_current_assignment_and_reports_vacancy(self) -> None:
|
||||||
with Session(self.engine) as session:
|
with Session(self.engine) as session:
|
||||||
postbox = self._create_exact(session)
|
postbox = self._create_exact(session)
|
||||||
@@ -595,6 +971,260 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
[event.type for event in events],
|
[event.type for event in events],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.idm.assignments.append(self.assignment)
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
service, source, _target_template = (
|
||||||
|
self._create_routing_source(session)
|
||||||
|
)
|
||||||
|
result = service.deliver(
|
||||||
|
session,
|
||||||
|
PostboxDeliveryRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=source.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
producer_resource_type="campaign_recipient",
|
||||||
|
producer_resource_id="recipient-1",
|
||||||
|
idempotency_key="routing-copy-1",
|
||||||
|
subject="Routed decision",
|
||||||
|
body_text="The decision is available.",
|
||||||
|
classification="internal",
|
||||||
|
expires_at=utc_now() + timedelta(days=7),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
routes = session.query(PostboxRoute).all()
|
||||||
|
self.assertEqual(1, len(routes))
|
||||||
|
route = routes[0]
|
||||||
|
self.assertEqual("linked_copy", route.route_kind)
|
||||||
|
self.assertEqual("accepted", route.status)
|
||||||
|
self.assertNotEqual(result.message_id, route.target_message_id)
|
||||||
|
self.assertEqual(
|
||||||
|
["edge-child-parent"],
|
||||||
|
[
|
||||||
|
edge["edge_id"]
|
||||||
|
for edge in route.policy_snapshot["target"]["path"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
route.id,
|
||||||
|
result.evidence["hierarchy_routes"][0]["route_id"],
|
||||||
|
)
|
||||||
|
service.mark_message(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
message_id=route.target_message_id,
|
||||||
|
actor=self.actor,
|
||||||
|
state="read",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
receipts = session.query(PostboxMessageReceipt).all()
|
||||||
|
self.assertEqual([route.target_message_id], [item.message_id for item in receipts])
|
||||||
|
|
||||||
|
def test_hierarchy_dry_run_explains_gates_depth_and_parallel_structure(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
service, source, _target_template = (
|
||||||
|
self._create_routing_source(session, max_depth=1)
|
||||||
|
)
|
||||||
|
blocked = service.preview_hierarchy_routes(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=source.id),
|
||||||
|
producer_module="mail",
|
||||||
|
classification="restricted",
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", blocked["status"])
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"classification_not_allowed",
|
||||||
|
"producer_not_authorized",
|
||||||
|
"expiry_required",
|
||||||
|
}.issubset(set(blocked["diagnostics"]))
|
||||||
|
)
|
||||||
|
|
||||||
|
planned = service.preview_hierarchy_routes(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=source.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
classification="internal",
|
||||||
|
expires_at=utc_now() + timedelta(days=7),
|
||||||
|
)
|
||||||
|
self.assertEqual("planned", planned["status"])
|
||||||
|
self.assertEqual(1, len(planned["routes"]))
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
self.organizations.last_hierarchy_request["max_depth"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"hierarchy_depth_limited",
|
||||||
|
planned["diagnostics"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
service, source, _target_template = self._create_routing_source(
|
||||||
|
session,
|
||||||
|
structure_id="structure-2",
|
||||||
|
)
|
||||||
|
no_route = service.preview_hierarchy_routes(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=source.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
classification="internal",
|
||||||
|
expires_at=utc_now() + timedelta(days=7),
|
||||||
|
)
|
||||||
|
self.assertEqual("no_route", no_route["status"])
|
||||||
|
self.assertIn("no_hierarchy_ancestor", no_route["diagnostics"])
|
||||||
|
|
||||||
|
def test_hierarchy_preview_bounds_cycles_deduplicates_and_is_tenant_safe(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.organizations.duplicate_parent_match = True
|
||||||
|
self.organizations.cycle_detected = True
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
service, source, _target_template = (
|
||||||
|
self._create_routing_source(session)
|
||||||
|
)
|
||||||
|
preview = service.preview_hierarchy_routes(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=source.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
classification="internal",
|
||||||
|
expires_at=utc_now() + timedelta(days=7),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("hierarchy_cycle_bounded", preview["diagnostics"])
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
sum(
|
||||||
|
route["status"] == "duplicate"
|
||||||
|
for route in preview["routes"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ValueError,
|
||||||
|
"existing source Postbox",
|
||||||
|
):
|
||||||
|
service.preview_hierarchy_routes(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
target=PostboxTargetRef(postbox_id=source.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
classification="internal",
|
||||||
|
expires_at=utc_now() + timedelta(days=7),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_vacancy_escalation_is_delayed_and_uses_frozen_targets(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
top_assignment = OrganizationFunctionAssignmentRef(
|
||||||
|
id="assignment-top",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-top",
|
||||||
|
account_id="account-top",
|
||||||
|
function_id="function-top",
|
||||||
|
organization_unit_id="unit-top",
|
||||||
|
source="direct",
|
||||||
|
)
|
||||||
|
self.idm.assignments.append(top_assignment)
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
service, source, _target_template = self._create_routing_source(
|
||||||
|
session,
|
||||||
|
vacancy_escalation=True,
|
||||||
|
)
|
||||||
|
service.deliver(
|
||||||
|
session,
|
||||||
|
PostboxDeliveryRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=source.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
producer_resource_type="campaign_recipient",
|
||||||
|
producer_resource_id="recipient-2",
|
||||||
|
idempotency_key="routing-vacancy-1",
|
||||||
|
subject="Escalated decision",
|
||||||
|
classification="internal",
|
||||||
|
expires_at=utc_now() + timedelta(days=7),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
routes = (
|
||||||
|
session.query(PostboxRoute)
|
||||||
|
.order_by(PostboxRoute.depth)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["accepted_vacant", "pending_vacancy_escalation"],
|
||||||
|
[route.status for route in routes],
|
||||||
|
)
|
||||||
|
pending = routes[1]
|
||||||
|
frozen_target_id = pending.target_postbox_id
|
||||||
|
pending.execute_after = utc_now() - timedelta(seconds=1)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
result = service.dispatch_due_routes(session)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(pending)
|
||||||
|
self.assertEqual(1, result["delivered"])
|
||||||
|
self.assertEqual("accepted", pending.status)
|
||||||
|
self.assertEqual(frozen_target_id, pending.target_postbox_id)
|
||||||
|
self.assertIsNotNone(pending.target_message_id)
|
||||||
|
self.assertEqual(3, session.query(PostboxMessage).count())
|
||||||
|
|
||||||
|
def test_vacancy_escalation_stops_when_previous_function_is_filled(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
service, source, _target_template = self._create_routing_source(
|
||||||
|
session,
|
||||||
|
vacancy_escalation=True,
|
||||||
|
)
|
||||||
|
service.deliver(
|
||||||
|
session,
|
||||||
|
PostboxDeliveryRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=source.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
producer_resource_type="campaign_recipient",
|
||||||
|
producer_resource_id="recipient-3",
|
||||||
|
idempotency_key="routing-vacancy-resolved-1",
|
||||||
|
subject="No longer escalated",
|
||||||
|
classification="internal",
|
||||||
|
expires_at=utc_now() + timedelta(days=7),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
pending = (
|
||||||
|
session.query(PostboxRoute)
|
||||||
|
.filter(
|
||||||
|
PostboxRoute.status
|
||||||
|
== "pending_vacancy_escalation"
|
||||||
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
pending.execute_after = utc_now() - timedelta(seconds=1)
|
||||||
|
self.idm.assignments.append(self.assignment)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
result = service.dispatch_due_routes(session)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(pending)
|
||||||
|
|
||||||
|
self.assertEqual(1, result["cancelled"])
|
||||||
|
self.assertEqual(
|
||||||
|
"cancelled_vacancy_resolved",
|
||||||
|
pending.status,
|
||||||
|
)
|
||||||
|
self.assertIsNone(pending.target_message_id)
|
||||||
|
self.assertEqual(2, session.query(PostboxMessage).count())
|
||||||
|
|
||||||
def test_grouping_update_retains_temporarily_hidden_sources(self) -> None:
|
def test_grouping_update_retains_temporarily_hidden_sources(self) -> None:
|
||||||
child_assignment = OrganizationFunctionAssignmentRef(
|
child_assignment = OrganizationFunctionAssignmentRef(
|
||||||
id="assignment-child",
|
id="assignment-child",
|
||||||
|
|||||||
@@ -110,6 +110,54 @@ export type PostboxOrganizationUnit = {
|
|||||||
functions: PostboxOrganizationFunction[];
|
functions: PostboxOrganizationFunction[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PostboxOrganizationRelationType = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
structure_id?: string | null;
|
||||||
|
is_hierarchical: boolean;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostboxOrganizationStructure = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
structure_kind: string;
|
||||||
|
status: string;
|
||||||
|
relation_types: PostboxOrganizationRelationType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostboxOrganizationTargets = {
|
||||||
|
units: PostboxOrganizationUnit[];
|
||||||
|
structures: PostboxOrganizationStructure[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PostboxRoutingPolicy = {
|
||||||
|
linked_copy: {
|
||||||
|
enabled: boolean;
|
||||||
|
structure_id?: string | null;
|
||||||
|
relation_type_ids: string[];
|
||||||
|
max_depth: number;
|
||||||
|
stop_unit_id?: string | null;
|
||||||
|
stop_unit_type_id?: string | null;
|
||||||
|
target_function_type_id?: string | null;
|
||||||
|
target_template_id?: string | null;
|
||||||
|
fanout: "nearest" | "all";
|
||||||
|
allowed_classifications: string[];
|
||||||
|
allowed_producer_modules: string[];
|
||||||
|
require_expiry: boolean;
|
||||||
|
max_retention_days?: number | null;
|
||||||
|
};
|
||||||
|
attention: {
|
||||||
|
mode: "none" | "vacancy_escalation";
|
||||||
|
delay_minutes?: number | null;
|
||||||
|
};
|
||||||
|
shared_visibility: {
|
||||||
|
mode: "none";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type PostboxTemplateRevision = {
|
export type PostboxTemplateRevision = {
|
||||||
id: string;
|
id: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
@@ -122,7 +170,7 @@ export type PostboxTemplateRevision = {
|
|||||||
allow_vacant_delivery: boolean;
|
allow_vacant_delivery: boolean;
|
||||||
encryption_profile: string;
|
encryption_profile: string;
|
||||||
history_policy: Record<string, unknown>;
|
history_policy: Record<string, unknown>;
|
||||||
routing_policy: Record<string, unknown>;
|
routing_policy: PostboxRoutingPolicy;
|
||||||
retention_policy: Record<string, unknown>;
|
retention_policy: Record<string, unknown>;
|
||||||
published_at?: string | null;
|
published_at?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -151,6 +199,7 @@ export type PostboxTemplateRevisionPayload = Pick<
|
|||||||
| "address_pattern"
|
| "address_pattern"
|
||||||
| "classification"
|
| "classification"
|
||||||
| "allow_vacant_delivery"
|
| "allow_vacant_delivery"
|
||||||
|
| "routing_policy"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
|
export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
|
||||||
@@ -269,12 +318,11 @@ export async function listAdminPostboxes(settings: ApiSettings): Promise<Postbox
|
|||||||
|
|
||||||
export async function listPostboxOrganizationTargets(
|
export async function listPostboxOrganizationTargets(
|
||||||
settings: ApiSettings
|
settings: ApiSettings
|
||||||
): Promise<PostboxOrganizationUnit[]> {
|
): Promise<PostboxOrganizationTargets> {
|
||||||
const response = await apiFetch<{ units: PostboxOrganizationUnit[] }>(
|
return apiFetch<PostboxOrganizationTargets>(
|
||||||
settings,
|
settings,
|
||||||
"/api/v1/postbox/admin/organization-targets"
|
"/api/v1/postbox/admin/organization-targets"
|
||||||
);
|
);
|
||||||
return response.units;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createExactPostbox(
|
export function createExactPostbox(
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ import {
|
|||||||
type PostboxDirectoryItem,
|
type PostboxDirectoryItem,
|
||||||
type PostboxExactCreatePayload,
|
type PostboxExactCreatePayload,
|
||||||
type PostboxOrganizationFunction,
|
type PostboxOrganizationFunction,
|
||||||
|
type PostboxOrganizationStructure,
|
||||||
type PostboxOrganizationUnit,
|
type PostboxOrganizationUnit,
|
||||||
|
type PostboxRoutingPolicy,
|
||||||
type PostboxTemplate,
|
type PostboxTemplate,
|
||||||
type PostboxTemplateCreatePayload,
|
type PostboxTemplateCreatePayload,
|
||||||
type PostboxTemplateRevisionPayload
|
type PostboxTemplateRevisionPayload
|
||||||
@@ -56,6 +58,31 @@ type MaterializeDraft = {
|
|||||||
context_key: string;
|
context_key: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const routingDefaults = (): PostboxRoutingPolicy => ({
|
||||||
|
linked_copy: {
|
||||||
|
enabled: false,
|
||||||
|
structure_id: null,
|
||||||
|
relation_type_ids: [],
|
||||||
|
max_depth: 1,
|
||||||
|
stop_unit_id: null,
|
||||||
|
stop_unit_type_id: null,
|
||||||
|
target_function_type_id: null,
|
||||||
|
target_template_id: null,
|
||||||
|
fanout: "nearest",
|
||||||
|
allowed_classifications: ["internal"],
|
||||||
|
allowed_producer_modules: [],
|
||||||
|
require_expiry: false,
|
||||||
|
max_retention_days: null
|
||||||
|
},
|
||||||
|
attention: {
|
||||||
|
mode: "none",
|
||||||
|
delay_minutes: null
|
||||||
|
},
|
||||||
|
shared_visibility: {
|
||||||
|
mode: "none"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const templateDefaults = (): TemplateDraft => ({
|
const templateDefaults = (): TemplateDraft => ({
|
||||||
templateId: "",
|
templateId: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
@@ -67,7 +94,8 @@ const templateDefaults = (): TemplateDraft => ({
|
|||||||
name_pattern: "{unit_name} / {function_name}",
|
name_pattern: "{unit_name} / {function_name}",
|
||||||
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
|
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
|
||||||
classification: "internal",
|
classification: "internal",
|
||||||
allow_vacant_delivery: true
|
allow_vacant_delivery: true,
|
||||||
|
routing_policy: routingDefaults()
|
||||||
});
|
});
|
||||||
|
|
||||||
const exactDefaults = (): ExactDraft => ({
|
const exactDefaults = (): ExactDraft => ({
|
||||||
@@ -94,6 +122,7 @@ export default function PostboxAdminPanel({
|
|||||||
const [templates, setTemplates] = useState<PostboxTemplate[]>([]);
|
const [templates, setTemplates] = useState<PostboxTemplate[]>([]);
|
||||||
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
|
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
|
||||||
const [units, setUnits] = useState<PostboxOrganizationUnit[]>([]);
|
const [units, setUnits] = useState<PostboxOrganizationUnit[]>([]);
|
||||||
|
const [structures, setStructures] = useState<PostboxOrganizationStructure[]>([]);
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
||||||
const [selectedPostboxId, setSelectedPostboxId] = useState("");
|
const [selectedPostboxId, setSelectedPostboxId] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -146,14 +175,15 @@ export default function PostboxAdminPanel({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [nextTemplates, nextPostboxes, nextUnits] = await Promise.all([
|
const [nextTemplates, nextPostboxes, organizationTargets] = await Promise.all([
|
||||||
canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]),
|
canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]),
|
||||||
canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]),
|
canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]),
|
||||||
listPostboxOrganizationTargets(settings)
|
listPostboxOrganizationTargets(settings)
|
||||||
]);
|
]);
|
||||||
setTemplates(nextTemplates);
|
setTemplates(nextTemplates);
|
||||||
setPostboxes(nextPostboxes);
|
setPostboxes(nextPostboxes);
|
||||||
setUnits(nextUnits);
|
setUnits(organizationTargets.units);
|
||||||
|
setStructures(organizationTargets.structures);
|
||||||
setSelectedTemplateId((current) =>
|
setSelectedTemplateId((current) =>
|
||||||
current && nextTemplates.some((template) => template.id === current)
|
current && nextTemplates.some((template) => template.id === current)
|
||||||
? current
|
? current
|
||||||
@@ -194,7 +224,8 @@ export default function PostboxAdminPanel({
|
|||||||
name_pattern: revision.name_pattern,
|
name_pattern: revision.name_pattern,
|
||||||
address_pattern: revision.address_pattern,
|
address_pattern: revision.address_pattern,
|
||||||
classification: revision.classification,
|
classification: revision.classification,
|
||||||
allow_vacant_delivery: revision.allow_vacant_delivery
|
allow_vacant_delivery: revision.allow_vacant_delivery,
|
||||||
|
routing_policy: revision.routing_policy ?? routingDefaults()
|
||||||
});
|
});
|
||||||
setTemplateDialogOpen(true);
|
setTemplateDialogOpen(true);
|
||||||
}
|
}
|
||||||
@@ -409,6 +440,8 @@ export default function PostboxAdminPanel({
|
|||||||
open={templateDialogOpen}
|
open={templateDialogOpen}
|
||||||
draft={templateDraft}
|
draft={templateDraft}
|
||||||
units={units}
|
units={units}
|
||||||
|
structures={structures}
|
||||||
|
templates={templates}
|
||||||
functionTypes={functionTypes}
|
functionTypes={functionTypes}
|
||||||
unitTypes={unitTypes}
|
unitTypes={unitTypes}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -531,6 +564,22 @@ function TemplateWorkspace({
|
|||||||
<div><dt>Scope</dt><dd>{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}</dd></div>
|
<div><dt>Scope</dt><dd>{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}</dd></div>
|
||||||
<div><dt>Classification</dt><dd>{revision.classification}</dd></div>
|
<div><dt>Classification</dt><dd>{revision.classification}</dd></div>
|
||||||
<div><dt>Vacant delivery</dt><dd>{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}</dd></div>
|
<div><dt>Vacant delivery</dt><dd>{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Hierarchy copies</dt>
|
||||||
|
<dd>
|
||||||
|
{revision.routing_policy.linked_copy.enabled
|
||||||
|
? `${revision.routing_policy.linked_copy.fanout} · depth ${revision.routing_policy.linked_copy.max_depth}`
|
||||||
|
: "Disabled"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Vacancy escalation</dt>
|
||||||
|
<dd>
|
||||||
|
{revision.routing_policy.attention.mode === "vacancy_escalation"
|
||||||
|
? `${revision.routing_policy.attention.delay_minutes} minutes`
|
||||||
|
: "Disabled"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div><dt>Encryption</dt><dd>{revision.encryption_profile}</dd></div>
|
<div><dt>Encryption</dt><dd>{revision.encryption_profile}</dd></div>
|
||||||
<div><dt>Name pattern</dt><dd>{revision.name_pattern}</dd></div>
|
<div><dt>Name pattern</dt><dd>{revision.name_pattern}</dd></div>
|
||||||
<div><dt>Address pattern</dt><dd>{revision.address_pattern}</dd></div>
|
<div><dt>Address pattern</dt><dd>{revision.address_pattern}</dd></div>
|
||||||
@@ -642,6 +691,8 @@ function TemplateDialog({
|
|||||||
open,
|
open,
|
||||||
draft,
|
draft,
|
||||||
units,
|
units,
|
||||||
|
structures,
|
||||||
|
templates,
|
||||||
functionTypes,
|
functionTypes,
|
||||||
unitTypes,
|
unitTypes,
|
||||||
busy,
|
busy,
|
||||||
@@ -652,6 +703,8 @@ function TemplateDialog({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
draft: TemplateDraft;
|
draft: TemplateDraft;
|
||||||
units: PostboxOrganizationUnit[];
|
units: PostboxOrganizationUnit[];
|
||||||
|
structures: PostboxOrganizationStructure[];
|
||||||
|
templates: PostboxTemplate[];
|
||||||
functionTypes: Array<{ id: string; name: string }>;
|
functionTypes: Array<{ id: string; name: string }>;
|
||||||
unitTypes: Array<{ id: string; example: string }>;
|
unitTypes: Array<{ id: string; example: string }>;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
@@ -663,12 +716,41 @@ function TemplateDialog({
|
|||||||
const scopeOptions = draft.scope_kind === "unit_type"
|
const scopeOptions = draft.scope_kind === "unit_type"
|
||||||
? unitTypes.map((item) => ({ id: item.id, label: `${item.id} (${item.example})` }))
|
? unitTypes.map((item) => ({ id: item.id, label: `${item.id} (${item.example})` }))
|
||||||
: units.map((unit) => ({ id: unit.id, label: unit.name }));
|
: units.map((unit) => ({ id: unit.id, label: unit.name }));
|
||||||
|
const linkedCopy = draft.routing_policy.linked_copy;
|
||||||
|
const attention = draft.routing_policy.attention;
|
||||||
|
const selectedStructure = structures.find(
|
||||||
|
(item) => item.id === linkedCopy.structure_id
|
||||||
|
);
|
||||||
|
const updateRouting = (routing_policy: PostboxRoutingPolicy) => {
|
||||||
|
onChange({ ...draft, routing_policy });
|
||||||
|
};
|
||||||
|
const updateLinkedCopy = (
|
||||||
|
next: Partial<PostboxRoutingPolicy["linked_copy"]>
|
||||||
|
) => {
|
||||||
|
updateRouting({
|
||||||
|
...draft.routing_policy,
|
||||||
|
linked_copy: {
|
||||||
|
...linkedCopy,
|
||||||
|
...next
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
const valid =
|
const valid =
|
||||||
draft.name.trim() &&
|
draft.name.trim() &&
|
||||||
draft.slug.trim() &&
|
draft.slug.trim() &&
|
||||||
draft.name_pattern.trim() &&
|
draft.name_pattern.trim() &&
|
||||||
draft.address_pattern.trim() &&
|
draft.address_pattern.trim() &&
|
||||||
(draft.scope_kind === "tenant" || Boolean(draft.scope_id));
|
(draft.scope_kind === "tenant" || Boolean(draft.scope_id)) &&
|
||||||
|
(
|
||||||
|
!linkedCopy.enabled
|
||||||
|
|| Boolean(
|
||||||
|
linkedCopy.structure_id
|
||||||
|
&& linkedCopy.target_function_type_id
|
||||||
|
&& linkedCopy.target_template_id
|
||||||
|
&& linkedCopy.allowed_classifications.length
|
||||||
|
&& linkedCopy.allowed_producer_modules.length
|
||||||
|
)
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -777,6 +859,212 @@ function TemplateDialog({
|
|||||||
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
|
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="postbox-routing-section">
|
||||||
|
<div className="postbox-routing-heading">
|
||||||
|
<div>
|
||||||
|
<strong>Hierarchy linked copies</strong>
|
||||||
|
<span>Copy to explicitly bounded function Postboxes in one selected structure.</span>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Enable hierarchy linked copies"
|
||||||
|
checked={linkedCopy.enabled}
|
||||||
|
onChange={(enabled) => updateLinkedCopy({
|
||||||
|
enabled,
|
||||||
|
allowed_classifications: linkedCopy.allowed_classifications.length
|
||||||
|
? linkedCopy.allowed_classifications
|
||||||
|
: [draft.classification]
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{linkedCopy.enabled ? (
|
||||||
|
<div className="postbox-form-grid two-columns">
|
||||||
|
<FormField label="Organization structure">
|
||||||
|
<select
|
||||||
|
value={linkedCopy.structure_id || ""}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
structure_id: event.target.value || null,
|
||||||
|
relation_type_ids: []
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">Select structure</option>
|
||||||
|
{structures.filter((item) => item.status === "active").map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Relation type">
|
||||||
|
<select
|
||||||
|
value={linkedCopy.relation_type_ids[0] || ""}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
relation_type_ids: event.target.value ? [event.target.value] : []
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">All hierarchical relations</option>
|
||||||
|
{(selectedStructure?.relation_types || [])
|
||||||
|
.filter((item) => item.status === "active" && item.is_hierarchical)
|
||||||
|
.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Target function type">
|
||||||
|
<select
|
||||||
|
value={linkedCopy.target_function_type_id || ""}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
target_function_type_id: event.target.value || null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">Select function type</option>
|
||||||
|
{functionTypes.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Target Postbox template">
|
||||||
|
<select
|
||||||
|
value={linkedCopy.target_template_id || ""}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
target_template_id: event.target.value || null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">Select published template</option>
|
||||||
|
{templates
|
||||||
|
.filter((item) => item.status === "published")
|
||||||
|
.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Maximum hierarchy depth">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={linkedCopy.max_depth}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
max_depth: Math.max(1, Math.min(20, Number(event.target.value) || 1))
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Copy behavior">
|
||||||
|
<select
|
||||||
|
value={linkedCopy.fanout}
|
||||||
|
onChange={(event) => {
|
||||||
|
const fanout = event.target.value as "nearest" | "all";
|
||||||
|
updateRouting({
|
||||||
|
...draft.routing_policy,
|
||||||
|
linked_copy: { ...linkedCopy, fanout },
|
||||||
|
attention: fanout === "all"
|
||||||
|
? { mode: "none", delay_minutes: null }
|
||||||
|
: attention
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="nearest">Nearest matching ancestor</option>
|
||||||
|
<option value="all">All matching ancestors</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Optional stop unit">
|
||||||
|
<select
|
||||||
|
value={linkedCopy.stop_unit_id || ""}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
stop_unit_id: event.target.value || null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">No unit stop</option>
|
||||||
|
{units.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Optional stop unit type">
|
||||||
|
<select
|
||||||
|
value={linkedCopy.stop_unit_type_id || ""}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
stop_unit_type_id: event.target.value || null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">No unit-type stop</option>
|
||||||
|
{unitTypes.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.id}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Allowed classifications">
|
||||||
|
<input
|
||||||
|
value={linkedCopy.allowed_classifications.join(", ")}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
allowed_classifications: commaSeparated(event.target.value)
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Authorized producer modules">
|
||||||
|
<input
|
||||||
|
placeholder="campaigns, workflow"
|
||||||
|
value={linkedCopy.allowed_producer_modules.join(", ")}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
allowed_producer_modules: commaSeparated(event.target.value)
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="postbox-toggle-field">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Require message expiry"
|
||||||
|
checked={linkedCopy.require_expiry}
|
||||||
|
onChange={(require_expiry) => updateLinkedCopy({ require_expiry })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField label="Maximum retention days">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={36500}
|
||||||
|
value={linkedCopy.max_retention_days ?? ""}
|
||||||
|
onChange={(event) => updateLinkedCopy({
|
||||||
|
max_retention_days: event.target.value
|
||||||
|
? Math.max(1, Math.min(36500, Number(event.target.value)))
|
||||||
|
: null
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="postbox-toggle-field">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Escalate when the nearest target remains vacant"
|
||||||
|
checked={attention.mode === "vacancy_escalation"}
|
||||||
|
onChange={(checked) => updateRouting({
|
||||||
|
...draft.routing_policy,
|
||||||
|
attention: checked
|
||||||
|
? {
|
||||||
|
mode: "vacancy_escalation",
|
||||||
|
delay_minutes: attention.delay_minutes || 1440
|
||||||
|
}
|
||||||
|
: { mode: "none", delay_minutes: null }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{attention.mode === "vacancy_escalation" ? (
|
||||||
|
<FormField label="Vacancy escalation delay (minutes)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={43200}
|
||||||
|
value={attention.delay_minutes ?? 1440}
|
||||||
|
onChange={(event) => updateRouting({
|
||||||
|
...draft.routing_policy,
|
||||||
|
attention: {
|
||||||
|
mode: "vacancy_escalation",
|
||||||
|
delay_minutes: Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(43200, Number(event.target.value) || 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
) : <div />}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="postbox-form-note">
|
<p className="postbox-form-note">
|
||||||
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
|
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
|
||||||
@@ -954,7 +1242,8 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
|
|||||||
name_pattern: draft.name_pattern,
|
name_pattern: draft.name_pattern,
|
||||||
address_pattern: draft.address_pattern,
|
address_pattern: draft.address_pattern,
|
||||||
classification: draft.classification,
|
classification: draft.classification,
|
||||||
allow_vacant_delivery: draft.allow_vacant_delivery
|
allow_vacant_delivery: draft.allow_vacant_delivery,
|
||||||
|
routing_policy: draft.routing_policy
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,3 +1264,7 @@ function compatibleTargets(
|
|||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : "Postbox request failed";
|
return error instanceof Error ? error.message : "Postbox request failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commaSeparated(value: string): string[] {
|
||||||
|
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|||||||
@@ -441,6 +441,31 @@
|
|||||||
padding-bottom: 7px;
|
padding-bottom: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.postbox-routing-section {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.postbox-routing-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.postbox-routing-heading > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.postbox-routing-heading span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.postbox-form-note {
|
.postbox-form-note {
|
||||||
margin: 15px 0 0;
|
margin: 15px 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -637,4 +662,9 @@
|
|||||||
.postbox-message-detail {
|
.postbox-message-detail {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.postbox-routing-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user