Add scoped postbox template previews

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent c53646a8aa
commit d107c09f74
15 changed files with 1300 additions and 56 deletions
+8 -1
View File
@@ -48,6 +48,12 @@ Unit-specific addresses are resolved lazily and remain stable through vacancy
and reassignment. Exact postboxes remain available for exceptional and reassignment. Exact postboxes remain available for exceptional
responsibilities or case/service contexts. responsibilities or case/service contexts.
Subtree templates select an explicit Organizations structure and optional
hierarchical relation types. The administration UI can dry-run a draft against
the current organization and incumbency state, showing generated addresses,
vacancy, existing targets, collisions, and hierarchy diagnostics without
materializing data.
Users holding several functions may group selected postboxes into unified Users holding several functions may group selected postboxes into unified
inbox views. These are query projections only: messages, address, read state, inbox views. These are query projections only: messages, address, read state,
retention, and evidence remain attached to their source postboxes. retention, and evidence remain attached to their source postboxes.
@@ -84,7 +90,8 @@ 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. Published template revisions can also opt into bounded linked administration. Grouping summaries expose batched total and unread counts for
currently visible sources. Published template revisions can also opt into bounded linked
copies through one explicit organization structure. Classification, producer, copies through one explicit organization structure. Classification, producer,
retention, stop, depth, target-template, and target-function gates are frozen retention, stop, depth, target-template, and target-function gates are frozen
at delivery time and exposed through delivery evidence and the routing dry-run at delivery time and exposed through delivery evidence and the routing dry-run
+17
View File
@@ -139,6 +139,19 @@ scope, such as a unit type, structure, or subtree. Postbox resolves a stable
unit-specific address from the tenant, template revision, concrete unit, unit-specific address from the tenant, template revision, concrete unit,
concrete function, and optional case/service context. concrete function, and optional case/service context.
Subtree scope is explicit about the Organizations structure and may restrict
the hierarchical relation types used within that structure. It does not infer
scope from the legacy `parent_id` when an administrator creates or revises a
template. This prevents an administrative, reporting, and project hierarchy
from being confused when they contain the same units.
Before saving a draft, administrators can run a read-only impact preview. It
uses the same scope, function matching, address rendering, and incumbent rules
as materialization and reports ready targets, already materialized addresses,
vacancies, collisions, cycles, depth limits, and ambiguous paths. The preview
does not create a template, address, Postbox, or delivery. A large result is
bounded in the UI while its aggregate counts remain visible.
Addresses should be resolved lazily and idempotently rather than eagerly Addresses should be resolved lazily and idempotently rather than eagerly
creating empty containers for every unit. They remain durable through vacancy creating empty containers for every unit. They remain durable through vacancy
and reassignment. A delivery snapshots the template revision and normalized and reassignment. A delivery snapshots the template revision and normalized
@@ -155,6 +168,10 @@ unified inbox views and keep other responsibilities separate. Grouping is a
query projection only. It never merges source containers, messages, read or query projection only. It never merges source containers, messages, read or
acknowledgement state, retention, encryption keys, or audit evidence. acknowledgement state, retention, encryption keys, or audit evidence.
Grouping summaries calculate total and unread counts over the currently
visible source Postboxes in one tenant-bounded query. Hidden sources retained
for later reassignment do not leak counts into the projection.
Every item and action continues to show the source function, unit, postbox, Every item and action continues to show the source function, unit, postbox,
assignment/delegation context, and classification. Policy may require some assignment/delegation context, and classification. Policy may require some
postboxes to remain separate. postboxes to remain separate.
+10
View File
@@ -121,6 +121,16 @@ class PostboxTemplateRevision(Base, TimestampMixin):
nullable=True, nullable=True,
index=True, index=True,
) )
scope_structure_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
scope_relation_type_ids: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
name_pattern: Mapped[str] = mapped_column( name_pattern: Mapped[str] = mapped_column(
String(500), String(500),
default="{unit_name} / {function_name}", default="{unit_name} / {function_name}",
+8 -1
View File
@@ -473,7 +473,12 @@ manifest = ModuleManifest(
"Hierarchy copies are independent deliveries with their own evidence; " "Hierarchy copies are independent deliveries with their own evidence; "
"vacancy escalation is delayed and separately auditable. Message expiry " "vacancy escalation is delayed and separately auditable. Message expiry "
"or withdrawal blocks future content access but cannot retract plaintext " "or withdrawal blocks future content access but cannot retract plaintext "
"already copied, exported, or printed." "already copied, exported, or printed. Subtree templates select one "
"explicit organization structure and optional relation types. Their "
"read-only impact preview reports generated addresses, current holders, "
"vacancy, collisions, cycles, depth limits, and ambiguous paths without "
"creating templates or Postboxes. Grouping totals include only source "
"Postboxes currently visible to the account."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -506,6 +511,7 @@ manifest = ModuleManifest(
"postbox.field.classification", "postbox.field.classification",
"postbox.field.retention", "postbox.field.retention",
"postbox.field.hierarchy-routing", "postbox.field.hierarchy-routing",
"postbox.action.preview-template",
"postbox.field.recipients", "postbox.field.recipients",
"postbox.action.archive", "postbox.action.archive",
"postbox.action.retire-template", "postbox.action.retire-template",
@@ -513,6 +519,7 @@ manifest = ModuleManifest(
], ],
"consequence_classes": { "consequence_classes": {
"publish_template": "Freezes an immutable address and routing revision for future materialization.", "publish_template": "Freezes an immutable address and routing revision for future materialization.",
"preview_template": "Reads current organization, hierarchy, and incumbency state without materializing any address or Postbox.",
"retire_template": "Stops new revisions and materialization while retaining existing addresses.", "retire_template": "Stops new revisions and materialization while retaining existing addresses.",
"archive_postbox": "Stops new delivery while retaining messages, receipts, and evidence.", "archive_postbox": "Stops new delivery while retaining messages, receipts, and evidence.",
"delete_grouping": "Deletes only the personal projection; source Postboxes and messages remain unchanged.", "delete_grouping": "Deletes only the personal projection; source Postboxes and messages remain unchanged.",
@@ -0,0 +1,43 @@
"""Add explicit Postbox template hierarchy scope.
Revision ID: e9f4a7b2c5d8
Revises: d8e3f6a9b2c5
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f4a7b2c5d8"
down_revision = "d8e3f6a9b2c5"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.add_column(
sa.Column("scope_structure_id", sa.String(length=36), nullable=True)
)
batch.add_column(
sa.Column(
"scope_relation_type_ids",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'"),
)
)
batch.create_index(
"ix_postbox_template_revisions_scope_structure",
["scope_structure_id"],
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.drop_index("ix_postbox_template_revisions_scope_structure")
batch.drop_column("scope_relation_type_ids")
batch.drop_column("scope_structure_id")
+60 -7
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict from dataclasses import asdict
from typing import Literal from typing import Literal
@@ -60,6 +61,8 @@ from govoplan_postbox.backend.schemas import (
PostboxTemplateCreateRequest, PostboxTemplateCreateRequest,
PostboxTemplateItem, PostboxTemplateItem,
PostboxTemplateListResponse, PostboxTemplateListResponse,
PostboxTemplatePreviewRequest,
PostboxTemplatePreviewResponse,
PostboxTemplatePublishRequest, PostboxTemplatePublishRequest,
PostboxTemplateReviseRequest, PostboxTemplateReviseRequest,
) )
@@ -252,6 +255,10 @@ def _template_item(template) -> PostboxTemplateItem:
"function_type_id": revision.function_type_id, "function_type_id": revision.function_type_id,
"scope_kind": revision.scope_kind, "scope_kind": revision.scope_kind,
"scope_id": revision.scope_id, "scope_id": revision.scope_id,
"scope_structure_id": revision.scope_structure_id,
"scope_relation_type_ids": list(
revision.scope_relation_type_ids or []
),
"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,
@@ -271,18 +278,33 @@ def _template_item(template) -> PostboxTemplateItem:
) )
def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem: def _grouping_item(
grouping,
*,
visible_ids: set[str],
counts_by_postbox: Mapping[str, Mapping[str, int]] | None = None,
) -> PostboxGroupingItem:
visible_source_ids = [
source.postbox_id
for source in grouping.sources
if source.postbox_id in visible_ids
]
counts = counts_by_postbox or {}
return PostboxGroupingItem( return PostboxGroupingItem(
id=grouping.id, id=grouping.id,
name=grouping.name, name=grouping.name,
is_default=grouping.is_default, is_default=grouping.is_default,
resource_revision=grouping.resource_revision, resource_revision=grouping.resource_revision,
etag=grouping.strong_etag, etag=grouping.strong_etag,
postbox_ids=[ postbox_ids=visible_source_ids,
source.postbox_id total_count=sum(
for source in grouping.sources int(counts.get(postbox_id, {}).get("total", 0))
if source.postbox_id in visible_ids for postbox_id in visible_source_ids
], ),
unread_count=sum(
int(counts.get(postbox_id, {}).get("unread", 0))
for postbox_id in visible_source_ids
),
created_at=grouping.created_at, created_at=grouping.created_at,
updated_at=grouping.updated_at, updated_at=grouping.updated_at,
) )
@@ -618,9 +640,19 @@ def api_list_postbox_groupings(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
actor=actor, actor=actor,
) )
counts_by_postbox = get_service().message_counts_by_postbox(
session,
tenant_id=principal.tenant_id,
postbox_ids=tuple(visible_ids),
actor=actor,
)
return PostboxGroupingListResponse( return PostboxGroupingListResponse(
groupings=[ groupings=[
_grouping_item(grouping, visible_ids=visible_ids) _grouping_item(
grouping,
visible_ids=visible_ids,
counts_by_postbox=counts_by_postbox,
)
for grouping in groupings for grouping in groupings
] ]
) )
@@ -881,6 +913,27 @@ def api_create_postbox_template(
return item return item
@router.post(
"/admin/templates/preview",
response_model=PostboxTemplatePreviewResponse,
)
def api_preview_postbox_template(
payload: PostboxTemplatePreviewRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplatePreviewResponse:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
preview = get_service().preview_template_targets(
session,
tenant_id=principal.tenant_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxTemplatePreviewResponse.model_validate(preview)
@router.post( @router.post(
"/admin/templates/{template_id}/revisions", "/admin/templates/{template_id}/revisions",
response_model=PostboxTemplateItem, response_model=PostboxTemplateItem,
+67
View File
@@ -378,6 +378,8 @@ class PostboxTemplateRevisionPayload(BaseModel):
function_type_id: str | None = Field(default=None, max_length=36) function_type_id: str | None = Field(default=None, max_length=36)
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant" scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
scope_id: str | None = Field(default=None, max_length=255) scope_id: str | None = Field(default=None, max_length=255)
scope_structure_id: str | None = Field(default=None, max_length=36)
scope_relation_type_ids: list[str] = Field(default_factory=list, max_length=20)
name_pattern: str = Field( name_pattern: str = Field(
default="{unit_name} / {function_name}", default="{unit_name} / {function_name}",
min_length=1, min_length=1,
@@ -398,6 +400,15 @@ class PostboxTemplateRevisionPayload(BaseModel):
default_factory=PostboxRoutingPolicyPayload default_factory=PostboxRoutingPolicyPayload
) )
@model_validator(mode="after")
def normalize_scope(self) -> "PostboxTemplateRevisionPayload":
self.scope_relation_type_ids = list(
dict.fromkeys(
value.strip() for value in self.scope_relation_type_ids if value.strip()
)
)
return self
@model_validator(mode="after") @model_validator(mode="after")
def validate_encryption(self) -> "PostboxTemplateRevisionPayload": def validate_encryption(self) -> "PostboxTemplateRevisionPayload":
if self.encryption_profile == "server_envelope_v1": if self.encryption_profile == "server_envelope_v1":
@@ -412,15 +423,69 @@ class PostboxTemplateRevisionPayload(BaseModel):
return self return self
def _validate_template_write_scope(
payload: PostboxTemplateRevisionPayload,
) -> None:
if payload.scope_kind == "subtree" and not payload.scope_structure_id:
raise ValueError("A subtree scope requires an organization structure.")
if payload.scope_kind != "subtree" and (
payload.scope_structure_id or payload.scope_relation_type_ids
):
raise ValueError(
"Hierarchy structure and relation filters apply only to subtree scopes."
)
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload): class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
slug: str = Field(min_length=1, max_length=120) slug: str = Field(min_length=1, max_length=120)
name: str = Field(min_length=1, max_length=250) name: str = Field(min_length=1, max_length=250)
description: str | None = None description: str | None = None
@model_validator(mode="after")
def validate_write_scope(self) -> "PostboxTemplateCreateRequest":
_validate_template_write_scope(self)
return self
class PostboxTemplatePreviewRequest(PostboxTemplateCreateRequest):
template_id: str | None = Field(default=None, max_length=36)
context_key: str | None = Field(default=None, max_length=255)
limit: int = Field(default=200, ge=1, le=500)
class PostboxTemplatePreviewTarget(BaseModel):
organization_unit_id: str
organization_unit_name: str
function_id: str
function_name: str
address: str
name: str
holder_count: int = Field(ge=0)
vacant: bool
status: str
existing_postbox_id: str | None = None
diagnostics: list[str] = Field(default_factory=list)
class PostboxTemplatePreviewResponse(BaseModel):
targets: list[PostboxTemplatePreviewTarget] = Field(default_factory=list)
total: int = Field(ge=0)
ready_count: int = Field(ge=0)
existing_count: int = Field(ge=0)
vacant_count: int = Field(ge=0)
blocked_count: int = Field(ge=0)
truncated: bool = False
diagnostics: list[str] = Field(default_factory=list)
class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload): class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
base_revision: int = Field(ge=1) base_revision: int = Field(ge=1)
@model_validator(mode="after")
def validate_write_scope(self) -> "PostboxTemplateReviseRequest":
_validate_template_write_scope(self)
return self
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload): class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
id: str id: str
@@ -523,6 +588,8 @@ class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
class PostboxGroupingItem(PostboxGroupingPayload): class PostboxGroupingItem(PostboxGroupingPayload):
id: str id: str
total_count: int = Field(default=0, ge=0)
unread_count: int = Field(default=0, ge=0)
resource_revision: int = Field(ge=1) resource_revision: int = Field(ge=1)
etag: str etag: str
created_at: datetime created_at: datetime
+579 -35
View File
@@ -10,7 +10,7 @@ from dataclasses import asdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Literal from typing import Any, Literal
from sqlalchemy import and_, func, or_ from sqlalchemy import and_, case, func, or_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, object_session, selectinload from sqlalchemy.orm import Session, object_session, selectinload
@@ -739,6 +739,63 @@ class PostboxService:
or 0 or 0
) )
def message_counts_by_postbox(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
) -> Mapping[str, Mapping[str, int]]:
"""Return one access-filtered count projection for multiple inboxes."""
db = _session(session)
allowed_ids = self._allowed_postbox_ids(
db,
tenant_id=tenant_id,
postbox_ids=postbox_ids,
actor=actor,
action="read",
)
if not allowed_ids:
return {}
rows = (
db.query(
PostboxMessage.postbox_id,
func.count(PostboxMessage.id),
func.sum(
case(
(PostboxMessageReceipt.read_at.is_(None), 1),
else_=0,
)
),
)
.outerjoin(
PostboxMessageReceipt,
and_(
PostboxMessageReceipt.tenant_id == tenant_id,
PostboxMessageReceipt.message_id == PostboxMessage.id,
PostboxMessageReceipt.account_id == actor.account_id,
),
)
.filter(
PostboxMessage.tenant_id == tenant_id,
PostboxMessage.postbox_id.in_(allowed_ids),
PostboxMessage.classification.in_(
tuple(actor.authorized_classifications)
),
)
.group_by(PostboxMessage.postbox_id)
.all()
)
return {
str(postbox_id): {
"total": int(total or 0),
"unread": int(unread or 0),
}
for postbox_id, total, unread in rows
}
def _messages_query( def _messages_query(
self, self,
session: Session, session: Session,
@@ -2866,6 +2923,208 @@ class PostboxService:
.all() .all()
) )
def preview_template_targets(
self,
session: Session,
*,
tenant_id: str,
slug: str,
name: str,
description: str | None,
function_type_id: str | None,
scope_kind: str,
scope_id: str | None,
scope_structure_id: str | None,
scope_relation_type_ids: Sequence[str],
name_pattern: str,
address_pattern: str,
classification: str,
allow_vacant_delivery: bool,
routing_policy: Mapping[str, object] | None,
encryption_profile: str,
encryption_vault_id: str | None,
template_id: str | None,
context_key: str | None,
limit: int,
) -> dict[str, object]:
del description
self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
normalized_routing_policy(routing_policy)
self._validate_scope(
tenant_id=tenant_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=scope_relation_type_ids,
)
self._validate_patterns(name_pattern, address_pattern)
if template_id:
self._get_template(
session,
tenant_id=tenant_id,
template_id=template_id,
)
units, diagnostics_by_unit, diagnostics = (
self._template_preview_scope_units(
tenant_id=tenant_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=scope_relation_type_ids,
)
)
unit_ids = tuple(unit.id for unit in units)
functions = self._template_preview_functions(
tenant_id=tenant_id,
unit_ids=unit_ids,
function_type_id=function_type_id,
)
functions_by_unit: dict[str, list[OrganizationFunctionRef]] = {}
for function in functions:
functions_by_unit.setdefault(
function.organization_unit_id,
[],
).append(function)
candidate_rows: list[dict[str, object]] = []
preview_template_id = template_id or f"preview:{_slug(slug or name)}"
for unit in units:
for function in sorted(
functions_by_unit.get(unit.id, ()),
key=lambda item: (item.name.casefold(), item.id),
):
rendered_name, rendered_address = self._render_template_target(
template_slug=_slug(slug or name, fallback="template"),
template_name=name,
unit=unit,
function=function,
context_key=context_key,
name_pattern=name_pattern,
address_pattern=address_pattern,
)
candidate_rows.append(
{
"organization_unit_id": unit.id,
"organization_unit_name": unit.name,
"function_id": function.id,
"function_name": function.name,
"address_key": self._template_address_key(
preview_template_id,
unit.id,
function.id,
context_key,
),
"address": rendered_address,
"name": rendered_name,
"diagnostics": list(
diagnostics_by_unit.get(unit.id, ())
),
}
)
function_ids = tuple(
dict.fromkeys(str(row["function_id"]) for row in candidate_rows)
)
holder_counts = self._holder_counts_for_functions(
tenant_id=tenant_id,
function_ids=function_ids,
)
addresses = tuple(str(row["address"]) for row in candidate_rows)
address_keys = tuple(str(row["address_key"]) for row in candidate_rows)
existing_by_id: dict[str, PostboxAddress] = {}
for offset in range(0, len(candidate_rows), 250):
address_batch = addresses[offset : offset + 250]
key_batch = address_keys[offset : offset + 250]
for item in (
session.query(PostboxAddress)
.options(selectinload(PostboxAddress.postbox))
.filter(
PostboxAddress.tenant_id == tenant_id,
or_(
PostboxAddress.address_key.in_(key_batch),
PostboxAddress.address.in_(address_batch),
),
)
.all()
):
existing_by_id[item.id] = item
existing_addresses = tuple(existing_by_id.values())
existing_by_key = {
item.address_key: item for item in existing_addresses
}
existing_by_address = {
item.address: item for item in existing_addresses
}
generated_address_counts = Counter(addresses)
if any(count > 1 for count in generated_address_counts.values()):
diagnostics.append("duplicate_generated_address")
targets: list[dict[str, object]] = []
ready_count = 0
existing_count = 0
vacant_count = 0
blocked_count = 0
for row in candidate_rows:
target_diagnostics = list(row.pop("diagnostics"))
address_key = str(row.pop("address_key"))
address = str(row["address"])
holder_count = holder_counts.get(str(row["function_id"]), 0)
vacant = holder_count == 0
existing = existing_by_key.get(address_key)
collision = existing_by_address.get(address)
if existing is not None:
status = "existing"
existing_count += 1
target_diagnostics.append("address_already_materialized")
elif collision is not None or generated_address_counts[address] > 1:
status = "address_collision"
blocked_count += 1
target_diagnostics.append("address_collision")
elif vacant and not allow_vacant_delivery:
status = "blocked_vacant"
blocked_count += 1
target_diagnostics.append("vacant_delivery_blocked")
else:
status = "ready"
ready_count += 1
if vacant:
vacant_count += 1
target_diagnostics.append("no_active_holder")
targets.append(
{
**row,
"holder_count": holder_count,
"vacant": vacant,
"status": status,
"existing_postbox_id": (
existing.postbox.id
if existing is not None and existing.postbox is not None
else None
),
"diagnostics": list(dict.fromkeys(target_diagnostics)),
}
)
total = len(targets)
truncated = total > limit
if truncated:
diagnostics.append("preview_truncated")
return {
"targets": targets[:limit],
"total": total,
"ready_count": ready_count,
"existing_count": existing_count,
"vacant_count": vacant_count,
"blocked_count": blocked_count,
"truncated": truncated,
"diagnostics": list(dict.fromkeys(diagnostics)),
}
def create_template( def create_template(
self, self,
session: Session, session: Session,
@@ -2882,6 +3141,8 @@ class PostboxService:
classification: str, classification: str,
allow_vacant_delivery: bool, allow_vacant_delivery: bool,
actor_id: str | None, actor_id: str | None,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
routing_policy: Mapping[str, object] | None = None, routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1", encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None, encryption_vault_id: str | None = None,
@@ -2908,6 +3169,8 @@ class PostboxService:
tenant_id=tenant_id, tenant_id=tenant_id,
scope_kind=scope_kind, scope_kind=scope_kind,
scope_id=scope_id, scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=scope_relation_type_ids,
) )
self._validate_patterns(name_pattern, address_pattern) self._validate_patterns(name_pattern, address_pattern)
template = PostboxTemplate( template = PostboxTemplate(
@@ -2926,6 +3189,8 @@ class PostboxService:
function_type_id=function_type_id, function_type_id=function_type_id,
scope_kind=scope_kind, scope_kind=scope_kind,
scope_id=scope_id, scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=list(scope_relation_type_ids),
name_pattern=name_pattern, name_pattern=name_pattern,
address_pattern=address_pattern, address_pattern=address_pattern,
classification=classification, classification=classification,
@@ -2967,6 +3232,8 @@ class PostboxService:
allow_vacant_delivery: bool, allow_vacant_delivery: bool,
actor_id: str | None, actor_id: str | None,
expected_revision: int, expected_revision: int,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
routing_policy: Mapping[str, object] | None = None, routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1", encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None, encryption_vault_id: str | None = None,
@@ -2998,6 +3265,8 @@ class PostboxService:
tenant_id=tenant_id, tenant_id=tenant_id,
scope_kind=scope_kind, scope_kind=scope_kind,
scope_id=scope_id, scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=scope_relation_type_ids,
) )
self._validate_patterns(name_pattern, address_pattern) self._validate_patterns(name_pattern, address_pattern)
next_revision = max( next_revision = max(
@@ -3011,6 +3280,8 @@ class PostboxService:
function_type_id=function_type_id, function_type_id=function_type_id,
scope_kind=scope_kind, scope_kind=scope_kind,
scope_id=scope_id, scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=list(scope_relation_type_ids),
name_pattern=name_pattern, name_pattern=name_pattern,
address_pattern=address_pattern, address_pattern=address_pattern,
classification=classification, classification=classification,
@@ -3188,6 +3459,8 @@ class PostboxService:
tenant_id=tenant_id, tenant_id=tenant_id,
scope_kind=revision.scope_kind, scope_kind=revision.scope_kind,
scope_id=revision.scope_id, scope_id=revision.scope_id,
scope_structure_id=revision.scope_structure_id,
scope_relation_type_ids=tuple(revision.scope_relation_type_ids or ()),
): ):
raise PostboxError( raise PostboxError(
"unit_out_of_scope", "unit_out_of_scope",
@@ -3207,44 +3480,21 @@ class PostboxService:
if existing is not None: if existing is not None:
return existing return existing
variables = { rendered_name, address = self._render_template_target(
"template_slug": template.slug, template_slug=template.slug,
"template_name": template.name, template_name=template.name,
"unit_id": unit.id, unit=unit,
"unit_slug": unit.slug, function=function,
"unit_name": unit.name, context_key=context_key,
"function_id": function.id, name_pattern=revision.name_pattern,
"function_slug": function.slug, address_pattern=revision.address_pattern,
"function_name": function.name,
"context_key": context_key or "",
}
try:
rendered_name = revision.name_pattern.format_map(variables).strip()
rendered_address = (
revision.address_pattern.format_map(variables).strip()
) )
except KeyError as exc:
raise PostboxError(
"invalid_template_pattern",
f"Unknown Postbox template variable: {exc.args[0]}",
) from exc
address = (
".".join(
part
for part in (
_slug(rendered_address),
_slug(context_key) if context_key else "",
)
if part
)
+ "@postbox"
)[:500]
return self._create_postbox_records( return self._create_postbox_records(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
address_key=key, address_key=key,
address=address, address=address,
name=rendered_name or f"{unit.name} / {function.name}", name=rendered_name,
description=template.description, description=template.description,
classification=revision.classification, classification=revision.classification,
organization_unit=unit, organization_unit=unit,
@@ -4340,6 +4590,8 @@ class PostboxService:
tenant_id: str, tenant_id: str,
scope_kind: str, scope_kind: str,
scope_id: str | None, scope_id: str | None,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
) -> None: ) -> None:
if scope_kind not in {"tenant", "unit", "subtree", "unit_type"}: if scope_kind not in {"tenant", "unit", "subtree", "unit_type"}:
raise PostboxError( raise PostboxError(
@@ -4347,10 +4599,10 @@ class PostboxService:
"Postbox template scope must be tenant, unit, subtree, or unit_type.", "Postbox template scope must be tenant, unit, subtree, or unit_type.",
) )
if scope_kind == "tenant": if scope_kind == "tenant":
if scope_id: if scope_id or scope_structure_id or scope_relation_type_ids:
raise PostboxError( raise PostboxError(
"invalid_scope_id", "invalid_scope_id",
"Tenant-scoped Postbox templates do not take a scope id.", "Tenant-scoped Postbox templates do not take hierarchy scope fields.",
) )
return return
if not scope_id: if not scope_id:
@@ -4365,6 +4617,51 @@ class PostboxService:
"scope_unit_not_found", "scope_unit_not_found",
"The Postbox template scope unit was not found.", "The Postbox template scope unit was not found.",
) )
if scope_kind != "subtree":
if scope_structure_id or scope_relation_type_ids:
raise PostboxError(
"invalid_scope_structure",
"Only subtree scopes can select a hierarchy structure.",
)
return
if scope_relation_type_ids and not scope_structure_id:
raise PostboxError(
"scope_structure_required",
"Subtree relation filters require an organization structure.",
)
if not scope_structure_id:
return
hierarchy = self._hierarchy_directory()
if hierarchy is None:
raise PostboxError(
"organization_hierarchy_unavailable",
"The organization hierarchy required for this subtree scope is unavailable.",
)
catalog = hierarchy.hierarchy_catalog(tenant_id)
structures = {
item.id: item
for item in catalog.structures
if item.tenant_id == tenant_id and item.status == "active"
}
if scope_structure_id not in structures:
raise PostboxError(
"scope_structure_not_found",
"The selected organization structure is not active in this tenant.",
)
allowed_relation_ids = {
item.id
for item in catalog.relation_types
if item.tenant_id == tenant_id
and item.structure_id == scope_structure_id
and item.status == "active"
and item.is_hierarchical
}
invalid_relation_ids = set(scope_relation_type_ids) - allowed_relation_ids
if invalid_relation_ids:
raise PostboxError(
"scope_relation_type_invalid",
"A selected relation type does not belong to the active hierarchy structure.",
)
def _unit_in_scope( def _unit_in_scope(
self, self,
@@ -4373,6 +4670,8 @@ class PostboxService:
tenant_id: str, tenant_id: str,
scope_kind: str, scope_kind: str,
scope_id: str | None, scope_id: str | None,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
) -> bool: ) -> bool:
if unit.tenant_id != tenant_id: if unit.tenant_id != tenant_id:
return False return False
@@ -4381,11 +4680,213 @@ class PostboxService:
if scope_kind == "unit": if scope_kind == "unit":
return unit.id == scope_id return unit.id == scope_id
if scope_kind == "subtree" and scope_id: if scope_kind == "subtree" and scope_id:
if unit.id == scope_id:
return True
if not scope_structure_id:
return self._is_descendant(unit.id, scope_id) return self._is_descendant(unit.id, scope_id)
hierarchy = self._hierarchy_directory()
if hierarchy is None:
return False
resolutions = hierarchy.resolve_hierarchy_paths(
tenant_id,
((scope_id, unit.id),),
structure_id=scope_structure_id,
relation_type_ids=tuple(scope_relation_type_ids),
direction="descendants",
max_depth=100,
)
return bool(
resolutions
and resolutions[0].status == "active"
and not resolutions[0].cycle_detected
)
if scope_kind == "unit_type": if scope_kind == "unit_type":
return unit.unit_type_id == scope_id return unit.unit_type_id == scope_id
return False return False
def _hierarchy_directory(self) -> OrganizationHierarchyDirectory | None:
if self._hierarchy is not None:
return self._hierarchy
if isinstance(self._organizations, OrganizationHierarchyDirectory):
return self._organizations
return None
def _template_preview_scope_units(
self,
*,
tenant_id: str,
scope_kind: str,
scope_id: str | None,
scope_structure_id: str | None,
scope_relation_type_ids: Sequence[str],
) -> tuple[
tuple[OrganizationUnitRef, ...],
dict[str, tuple[str, ...]],
list[str],
]:
all_units = tuple(
unit
for unit in self._organizations.organization_units_for_tenant(
tenant_id
)
if unit.tenant_id == tenant_id and unit.status == "active"
)
diagnostics: list[str] = []
diagnostics_by_unit: dict[str, tuple[str, ...]] = {}
if scope_kind == "tenant":
selected = all_units
elif scope_kind == "unit":
selected = tuple(unit for unit in all_units if unit.id == scope_id)
elif scope_kind == "unit_type":
selected = tuple(
unit for unit in all_units if unit.unit_type_id == scope_id
)
else:
hierarchy = self._hierarchy_directory()
if hierarchy is None or not scope_id or not scope_structure_id:
raise PostboxError(
"organization_hierarchy_unavailable",
"The selected organization hierarchy is unavailable for preview.",
)
resolutions = hierarchy.resolve_hierarchy_relatives(
tenant_id,
(scope_id,),
structure_id=scope_structure_id,
relation_type_ids=tuple(scope_relation_type_ids),
direction="descendants",
max_depth=100,
)
resolution = resolutions[0] if resolutions else None
if resolution is None or resolution.status not in {
"active",
"inactive",
}:
reason = (
resolution.status if resolution is not None else "missing"
)
raise PostboxError(
"organization_hierarchy_unavailable",
f"The hierarchy scope could not be resolved ({reason}).",
)
selected_by_id: dict[str, OrganizationUnitRef] = {}
if resolution.root is not None and resolution.root.status == "active":
selected_by_id[resolution.root.id] = resolution.root
duplicate_ids: set[str] = set()
for match in resolution.matches:
if match.unit.status != "active":
continue
if match.unit.id in selected_by_id:
duplicate_ids.add(match.unit.id)
selected_by_id[match.unit.id] = match.unit
if duplicate_ids:
diagnostics.append("ambiguous_scope_paths")
diagnostics_by_unit.update(
{
unit_id: ("ambiguous_scope_path",)
for unit_id in duplicate_ids
}
)
if resolution.cycle_detected:
diagnostics.append("hierarchy_cycle_detected")
if resolution.depth_limited:
diagnostics.append("hierarchy_depth_limited")
diagnostics.extend(resolution.diagnostics)
selected = tuple(selected_by_id.values())
return (
tuple(
sorted(
selected,
key=lambda item: (item.name.casefold(), item.id),
)
),
diagnostics_by_unit,
list(dict.fromkeys(diagnostics)),
)
def _template_preview_functions(
self,
*,
tenant_id: str,
unit_ids: Sequence[str],
function_type_id: str | None,
) -> tuple[OrganizationFunctionRef, ...]:
if not unit_ids:
return ()
unit_id_set = set(unit_ids)
if function_type_id:
hierarchy = self._hierarchy_directory()
if hierarchy is not None:
matches: dict[str, OrganizationFunctionRef] = {}
for offset in range(0, len(unit_ids), 400):
resolution = hierarchy.resolve_functions_by_type(
tenant_id,
function_type_id,
organization_unit_ids=tuple(
unit_ids[offset : offset + 400]
),
)
if resolution.status in {"missing", "invalid"}:
raise PostboxError(
"function_type_not_found",
"The selected function type is unavailable in this tenant.",
)
for function in resolution.matches:
if (
function.status == "active"
and function.organization_unit_id in unit_id_set
):
matches[function.id] = function
return tuple(matches.values())
functions: list[OrganizationFunctionRef] = []
for unit_id in unit_ids:
functions.extend(
function
for function in self._organizations.functions_for_organization_unit(
unit_id,
include_subunits=False,
)
if function.tenant_id == tenant_id
and function.status == "active"
and (
not function_type_id
or function.function_type_id == function_type_id
)
)
return tuple(functions)
def _holder_counts_for_functions(
self,
*,
tenant_id: str,
function_ids: Sequence[str],
) -> dict[str, int]:
if not function_ids:
return {}
counts: dict[str, int] = {}
try:
for offset in range(0, len(function_ids), 400):
incumbencies = (
self._incumbencies.organization_function_incumbencies(
tuple(function_ids[offset : offset + 400]),
tenant_id=tenant_id,
)
)
counts.update(
{
function_id: len(
{
assignment.identity_id
for assignment in incumbency.assignments
if assignment.status == "active"
}
)
for function_id, incumbency in incumbencies.items()
}
)
except ValueError:
return {}
return counts
def _validate_patterns( def _validate_patterns(
self, self,
name_pattern: str, name_pattern: str,
@@ -4413,6 +4914,49 @@ class PostboxService:
f"Invalid Postbox template pattern: {exc}", f"Invalid Postbox template pattern: {exc}",
) from exc ) from exc
def _render_template_target(
self,
*,
template_slug: str,
template_name: str,
unit: OrganizationUnitRef,
function: OrganizationFunctionRef,
context_key: str | None,
name_pattern: str,
address_pattern: str,
) -> tuple[str, str]:
variables = {
"template_slug": template_slug,
"template_name": template_name,
"unit_id": unit.id,
"unit_slug": unit.slug,
"unit_name": unit.name,
"function_id": function.id,
"function_slug": function.slug,
"function_name": function.name,
"context_key": context_key or "",
}
try:
rendered_name = name_pattern.format_map(variables).strip()
rendered_address = address_pattern.format_map(variables).strip()
except KeyError as exc:
raise PostboxError(
"invalid_template_pattern",
f"Unknown Postbox template variable: {exc.args[0]}",
) from exc
address = (
".".join(
part
for part in (
_slug(rendered_address),
_slug(context_key) if context_key else "",
)
if part
)
+ "@postbox"
)[:500]
return rendered_name or f"{unit.name} / {function.name}", address
def _record_access_event( def _record_access_event(
self, self,
session: Session, session: Session,
+17 -4
View File
@@ -30,6 +30,10 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions." "govoplan_postbox.backend.migrations.versions."
"d8e3f6a9b2c5_postbox_content_protection" "d8e3f6a9b2c5_postbox_content_protection"
) )
scope_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"e9f4a7b2c5d8_v014_template_scope_preview"
)
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
try: try:
with engine.begin() as connection: with engine.begin() as connection:
@@ -39,17 +43,20 @@ class PostboxMigrationTests(unittest.TestCase):
occ_original = occ_migration.op occ_original = occ_migration.op
envelope_original = envelope_migration.op envelope_original = envelope_migration.op
protection_original = protection_migration.op protection_original = protection_migration.op
scope_original = scope_migration.op
migration.op = operations migration.op = operations
route_migration.op = operations route_migration.op = operations
occ_migration.op = operations occ_migration.op = operations
envelope_migration.op = operations envelope_migration.op = operations
protection_migration.op = operations protection_migration.op = operations
scope_migration.op = operations
try: try:
migration.upgrade() migration.upgrade()
route_migration.upgrade() route_migration.upgrade()
occ_migration.upgrade() occ_migration.upgrade()
envelope_migration.upgrade() envelope_migration.upgrade()
protection_migration.upgrade() protection_migration.upgrade()
scope_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)
@@ -75,14 +82,18 @@ class PostboxMigrationTests(unittest.TestCase):
"encryption_resource_id", "encryption_resource_id",
}.issubset(message_columns) }.issubset(message_columns)
) )
self.assertIn( template_revision_columns = {
"encryption_vault_id",
{
column["name"] column["name"]
for column in inspect(connection).get_columns( for column in inspect(connection).get_columns(
"postbox_template_revisions" "postbox_template_revisions"
) )
}, }
self.assertTrue(
{
"encryption_vault_id",
"scope_structure_id",
"scope_relation_type_ids",
}.issubset(template_revision_columns)
) )
self.assertIn("authoring_key", message_columns) self.assertIn("authoring_key", message_columns)
for table_name in ( for table_name in (
@@ -110,6 +121,7 @@ class PostboxMigrationTests(unittest.TestCase):
route_columns route_columns
) )
) )
scope_migration.downgrade()
protection_migration.downgrade() protection_migration.downgrade()
envelope_migration.downgrade() envelope_migration.downgrade()
occ_migration.downgrade() occ_migration.downgrade()
@@ -128,6 +140,7 @@ class PostboxMigrationTests(unittest.TestCase):
occ_migration.op = occ_original occ_migration.op = occ_original
envelope_migration.op = envelope_original envelope_migration.op = envelope_original
protection_migration.op = protection_original protection_migration.op = protection_original
scope_migration.op = scope_original
finally: finally:
engine.dispose() engine.dispose()
+73
View File
@@ -250,6 +250,63 @@ class PostboxRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
self.assertIn("not active for this principal", response.text) self.assertIn("not active for this principal", response.text)
def test_template_impact_preview_is_available_without_writes(self) -> None:
with Session(self.engine) as session:
before = session.query(Postbox).count()
response = self.client.post(
"/api/v1/postbox/admin/templates/preview",
json={
"slug": "case-intake",
"name": "Case intake",
"scope_kind": "tenant",
"name_pattern": "{unit_name} / {function_name}",
"address_pattern": "{template_slug}.{unit_slug}.{function_slug}",
"classification": "internal",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual(1, response.json()["total"])
self.assertEqual(1, response.json()["ready_count"])
with Session(self.engine) as session:
self.assertEqual(before, session.query(Postbox).count())
def test_legacy_subtree_template_remains_readable_but_cannot_be_created_by_api(
self,
) -> None:
with Session(self.engine) as session:
self.service.create_template(
session,
tenant_id="tenant-1",
slug="legacy-subtree",
name="Legacy subtree",
description=None,
function_type_id="clerk-type",
scope_kind="subtree",
scope_id="unit-1",
name_pattern="{unit_name} / {function_name}",
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
classification="internal",
allow_vacant_delivery=True,
actor_id="account-1",
)
session.commit()
listing = self.client.get("/api/v1/postbox/admin/templates")
self.assertEqual(200, listing.status_code, listing.text)
revision = listing.json()["templates"][0]["revisions"][0]
self.assertIsNone(revision["scope_structure_id"])
rejected = self.client.post(
"/api/v1/postbox/admin/templates",
json={
"slug": "new-subtree",
"name": "New subtree",
"scope_kind": "subtree",
"scope_id": "unit-1",
},
)
self.assertEqual(422, rejected.status_code, rejected.text)
def test_directory_delivery_message_and_receipt_round_trip(self) -> None: def test_directory_delivery_message_and_receipt_round_trip(self) -> None:
directory = self.client.get("/api/v1/postbox/directory") directory = self.client.get("/api/v1/postbox/directory")
self.assertEqual(200, directory.status_code, directory.text) self.assertEqual(200, directory.status_code, directory.text)
@@ -289,6 +346,20 @@ class PostboxRouterTests(unittest.TestCase):
self.assertEqual(200, filtered.status_code, filtered.text) self.assertEqual(200, filtered.status_code, filtered.text)
self.assertEqual(1, filtered.json()["total"]) self.assertEqual(1, filtered.json()["total"])
grouping = self.client.post(
"/api/v1/postbox/groupings",
json={
"name": "Assigned work",
"is_default": True,
"postbox_ids": [self.postbox_id],
},
)
self.assertEqual(201, grouping.status_code, grouping.text)
grouped_before_read = self.client.get("/api/v1/postbox/groupings")
self.assertEqual(200, grouped_before_read.status_code)
self.assertEqual(1, grouped_before_read.json()["groupings"][0]["total_count"])
self.assertEqual(1, grouped_before_read.json()["groupings"][0]["unread_count"])
acknowledged = self.client.patch( acknowledged = self.client.patch(
f"/api/v1/postbox/messages/{message_id}/state", f"/api/v1/postbox/messages/{message_id}/state",
json={"state": "acknowledged"}, json={"state": "acknowledged"},
@@ -306,6 +377,8 @@ 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"])
grouped_after_read = self.client.get("/api/v1/postbox/groupings")
self.assertEqual(0, grouped_after_read.json()["groupings"][0]["unread_count"])
def test_routing_dry_run_explains_default_disabled_state(self) -> None: def test_routing_dry_run_explains_default_disabled_state(self) -> None:
response = self.client.post( response = self.client.post(
+127
View File
@@ -899,6 +899,133 @@ class PostboxServiceTests(unittest.TestCase):
revised.revisions[1].name_pattern, revised.revisions[1].name_pattern,
) )
def test_template_preview_is_read_only_and_explains_existing_vacant_and_colliding_targets(
self,
) -> None:
self.idm.assignments = [self.assignment]
with Session(self.engine) as session:
template = self.service.create_template(
session,
tenant_id="tenant-1",
slug="case-intake",
name="Case intake",
description=None,
function_type_id="case-clerk-type",
scope_kind="tenant",
scope_id=None,
name_pattern="{unit_name} / {function_name}",
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
classification="internal",
allow_vacant_delivery=True,
actor_id="admin-1",
)
self.service.publish_template(
session,
tenant_id="tenant-1",
template_id=template.id,
revision_number=None,
actor_id="admin-1",
expected_revision=template.resource_revision,
)
self.service.materialize_template(
session,
tenant_id="tenant-1",
template_id=template.id,
organization_unit_id="unit-1",
function_id="function-1",
context_key=None,
actor_id="admin-1",
)
before = session.query(Postbox).count()
preview = self.service.preview_template_targets(
session,
tenant_id="tenant-1",
template_id=template.id,
slug=template.slug,
name=template.name,
description=None,
function_type_id="case-clerk-type",
scope_kind="tenant",
scope_id=None,
scope_structure_id=None,
scope_relation_type_ids=(),
name_pattern="{unit_name} / {function_name}",
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
classification="internal",
allow_vacant_delivery=True,
routing_policy={},
encryption_profile="plaintext_v1",
encryption_vault_id=None,
context_key=None,
limit=200,
)
self.assertEqual(3, preview["total"])
self.assertEqual(1, preview["existing_count"])
self.assertEqual(2, preview["vacant_count"])
self.assertEqual(before, session.query(Postbox).count())
collision_preview = self.service.preview_template_targets(
session,
tenant_id="tenant-1",
template_id=None,
slug="collision",
name="Collision",
description=None,
function_type_id="case-clerk-type",
scope_kind="tenant",
scope_id=None,
scope_structure_id=None,
scope_relation_type_ids=(),
name_pattern="{unit_name} / {function_name}",
address_pattern="same-address",
classification="internal",
allow_vacant_delivery=True,
routing_policy={},
encryption_profile="plaintext_v1",
encryption_vault_id=None,
context_key=None,
limit=200,
)
self.assertEqual(3, collision_preview["blocked_count"])
self.assertIn(
"duplicate_generated_address",
collision_preview["diagnostics"],
)
self.organizations.duplicate_parent_match = True
hierarchy_preview = self.service.preview_template_targets(
session,
tenant_id="tenant-1",
template_id=None,
slug="hierarchy",
name="Hierarchy",
description=None,
function_type_id="case-clerk-type",
scope_kind="subtree",
scope_id="unit-child",
scope_structure_id="structure-1",
scope_relation_type_ids=("relation-type-1",),
name_pattern="{unit_name} / {function_name}",
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
classification="internal",
allow_vacant_delivery=True,
routing_policy={},
encryption_profile="plaintext_v1",
encryption_vault_id=None,
context_key=None,
limit=200,
)
self.assertIn(
"ambiguous_scope_paths",
hierarchy_preview["diagnostics"],
)
self.assertEqual(
"descendants",
self.organizations.last_hierarchy_request["direction"],
)
def test_delivery_catalog_exposes_exact_and_derived_target_choices( def test_delivery_catalog_exposes_exact_and_derived_target_choices(
self, self,
) -> None: ) -> None:
+46
View File
@@ -114,6 +114,8 @@ export type PostboxGrouping = {
name: string; name: string;
is_default: boolean; is_default: boolean;
postbox_ids: string[]; postbox_ids: string[];
total_count: number;
unread_count: number;
resource_revision: number; resource_revision: number;
etag: string; etag: string;
created_at: string; created_at: string;
@@ -192,6 +194,8 @@ export type PostboxTemplateRevision = {
function_type_id?: string | null; function_type_id?: string | null;
scope_kind: "tenant" | "unit" | "subtree" | "unit_type"; scope_kind: "tenant" | "unit" | "subtree" | "unit_type";
scope_id?: string | null; scope_id?: string | null;
scope_structure_id?: string | null;
scope_relation_type_ids: string[];
name_pattern: string; name_pattern: string;
address_pattern: string; address_pattern: string;
classification: string; classification: string;
@@ -225,6 +229,8 @@ export type PostboxTemplateRevisionPayload = Pick<
| "function_type_id" | "function_type_id"
| "scope_kind" | "scope_kind"
| "scope_id" | "scope_id"
| "scope_structure_id"
| "scope_relation_type_ids"
| "name_pattern" | "name_pattern"
| "address_pattern" | "address_pattern"
| "classification" | "classification"
@@ -238,6 +244,31 @@ export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
description?: string | null; description?: string | null;
}; };
export type PostboxTemplatePreviewTarget = {
organization_unit_id: string;
organization_unit_name: string;
function_id: string;
function_name: string;
address: string;
name: string;
holder_count: number;
vacant: boolean;
status: string;
existing_postbox_id?: string | null;
diagnostics: string[];
};
export type PostboxTemplatePreview = {
targets: PostboxTemplatePreviewTarget[];
total: number;
ready_count: number;
existing_count: number;
vacant_count: number;
blocked_count: number;
truncated: boolean;
diagnostics: string[];
};
export type PostboxExactCreatePayload = { export type PostboxExactCreatePayload = {
name: string; name: string;
description?: string | null; description?: string | null;
@@ -435,6 +466,21 @@ export function createPostboxTemplate(
return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload); return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload);
} }
export function previewPostboxTemplate(
settings: ApiSettings,
payload: PostboxTemplateCreatePayload & {
template_id?: string | null;
context_key?: string | null;
limit?: number;
}
): Promise<PostboxTemplatePreview> {
return apiPostJson(
settings,
"/api/v1/postbox/admin/templates/preview",
payload
);
}
export function revisePostboxTemplate( export function revisePostboxTemplate(
settings: ApiSettings, settings: ApiSettings,
template: PostboxTemplate, template: PostboxTemplate,
@@ -3,6 +3,7 @@ import {
Archive, Archive,
Boxes, Boxes,
Building2, Building2,
Eye,
Inbox, Inbox,
Pencil, Pencil,
Plus, Plus,
@@ -20,6 +21,7 @@ import {
DocumentationHelpLink, DocumentationHelpLink,
FormField, FormField,
IconButton, IconButton,
MetricCard,
SegmentedControl, SegmentedControl,
SelectionList, SelectionList,
SelectionListItem, SelectionListItem,
@@ -38,6 +40,7 @@ import {
listPostboxOrganizationTargets, listPostboxOrganizationTargets,
listPostboxTemplates, listPostboxTemplates,
materializePostboxTemplate, materializePostboxTemplate,
previewPostboxTemplate,
publishPostboxTemplate, publishPostboxTemplate,
retirePostboxTemplate, retirePostboxTemplate,
revisePostboxTemplate, revisePostboxTemplate,
@@ -49,6 +52,7 @@ import {
type PostboxRoutingPolicy, type PostboxRoutingPolicy,
type PostboxTemplate, type PostboxTemplate,
type PostboxTemplateCreatePayload, type PostboxTemplateCreatePayload,
type PostboxTemplatePreview,
type PostboxTemplateRevisionPayload type PostboxTemplateRevisionPayload
} from "../../api/postbox"; } from "../../api/postbox";
import { import {
@@ -102,6 +106,8 @@ const templateDefaults = (): TemplateDraft => ({
function_type_id: null, function_type_id: null,
scope_kind: "tenant", scope_kind: "tenant",
scope_id: null, scope_id: null,
scope_structure_id: null,
scope_relation_type_ids: [],
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",
@@ -143,6 +149,8 @@ export default function PostboxAdminPanel({
const [templateDialogOpen, setTemplateDialogOpen] = useState(false); const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
const [templateDraft, setTemplateDraft] = useState<TemplateDraft>(templateDefaults); const [templateDraft, setTemplateDraft] = useState<TemplateDraft>(templateDefaults);
const [templateBaseline, setTemplateBaseline] = useState<TemplateDraft>(templateDefaults); const [templateBaseline, setTemplateBaseline] = useState<TemplateDraft>(templateDefaults);
const [templatePreview, setTemplatePreview] = useState<PostboxTemplatePreview | null>(null);
const [templatePreviewLoading, setTemplatePreviewLoading] = useState(false);
const [exactDialogOpen, setExactDialogOpen] = useState(false); const [exactDialogOpen, setExactDialogOpen] = useState(false);
const [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults); const [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults);
const [exactBaseline, setExactBaseline] = useState<ExactDraft>(exactDefaults); const [exactBaseline, setExactBaseline] = useState<ExactDraft>(exactDefaults);
@@ -239,6 +247,7 @@ export default function PostboxAdminPanel({
function openNewTemplate() { function openNewTemplate() {
const next = templateDefaults(); const next = templateDefaults();
setTemplatePreview(null);
setTemplateDraft(next); setTemplateDraft(next);
setTemplateBaseline(next); setTemplateBaseline(next);
setTemplateDialogOpen(true); setTemplateDialogOpen(true);
@@ -255,12 +264,15 @@ export default function PostboxAdminPanel({
function_type_id: revision.function_type_id ?? null, function_type_id: revision.function_type_id ?? null,
scope_kind: revision.scope_kind, scope_kind: revision.scope_kind,
scope_id: revision.scope_id ?? null, scope_id: revision.scope_id ?? null,
scope_structure_id: revision.scope_structure_id ?? null,
scope_relation_type_ids: revision.scope_relation_type_ids ?? [],
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() routing_policy: revision.routing_policy ?? routingDefaults()
}; };
setTemplatePreview(null);
setTemplateDraft(next); setTemplateDraft(next);
setTemplateBaseline(next); setTemplateBaseline(next);
setTemplateDialogOpen(true); setTemplateDialogOpen(true);
@@ -303,6 +315,27 @@ export default function PostboxAdminPanel({
} }
} }
async function previewTemplate() {
setTemplatePreviewLoading(true);
setError("");
try {
const preview = await previewPostboxTemplate(settings, {
slug: templateDraft.slug,
name: templateDraft.name,
description: templateDraft.description || null,
...revisionPayload(templateDraft),
template_id: templateDraft.templateId || null,
limit: 200
});
setTemplatePreview(preview);
} catch (actionError) {
setTemplatePreview(null);
setError(errorMessage(actionError));
} finally {
setTemplatePreviewLoading(false);
}
}
async function publishSelected() { async function publishSelected() {
if (!selectedTemplate) return; if (!selectedTemplate) return;
setBusy(true); setBusy(true);
@@ -569,7 +602,13 @@ export default function PostboxAdminPanel({
functionTypes={functionTypes} functionTypes={functionTypes}
unitTypes={unitTypes} unitTypes={unitTypes}
busy={busy} busy={busy}
onChange={setTemplateDraft} preview={templatePreview}
previewLoading={templatePreviewLoading}
onChange={(draft) => {
setTemplateDraft(draft);
setTemplatePreview(null);
}}
onPreview={() => void previewTemplate()}
onClose={closeTemplateDialog} onClose={closeTemplateDialog}
onSave={() => void saveTemplate()} onSave={() => void saveTemplate()}
/> />
@@ -855,7 +894,10 @@ function TemplateDialog({
functionTypes, functionTypes,
unitTypes, unitTypes,
busy, busy,
preview,
previewLoading,
onChange, onChange,
onPreview,
onClose, onClose,
onSave onSave
}: { }: {
@@ -867,7 +909,10 @@ function TemplateDialog({
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;
preview: PostboxTemplatePreview | null;
previewLoading: boolean;
onChange: (draft: TemplateDraft) => void; onChange: (draft: TemplateDraft) => void;
onPreview: () => void;
onClose: () => void; onClose: () => void;
onSave: () => void; onSave: () => void;
}) { }) {
@@ -877,6 +922,9 @@ function TemplateDialog({
: 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 linkedCopy = draft.routing_policy.linked_copy;
const attention = draft.routing_policy.attention; const attention = draft.routing_policy.attention;
const selectedScopeStructure = structures.find(
(item) => item.id === draft.scope_structure_id
);
const selectedStructure = structures.find( const selectedStructure = structures.find(
(item) => item.id === linkedCopy.structure_id (item) => item.id === linkedCopy.structure_id
); );
@@ -900,6 +948,7 @@ function TemplateDialog({
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)) &&
(draft.scope_kind !== "subtree" || Boolean(draft.scope_structure_id)) &&
( (
!linkedCopy.enabled !linkedCopy.enabled
|| Boolean( || Boolean(
@@ -919,6 +968,13 @@ function TemplateDialog({
closeDisabled={busy} closeDisabled={busy}
footer={ footer={
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button
onClick={onPreview}
disabled={busy || previewLoading || !valid}
disabledReason={postboxBusyReason(false, busy || previewLoading) ?? (!valid ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
<Eye size={16} /> {previewLoading ? "Checking impact" : "Preview impact"}
</Button>
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button> <Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
<Button <Button
variant="primary" variant="primary"
@@ -975,7 +1031,9 @@ function TemplateDialog({
onChange({ onChange({
...draft, ...draft,
scope_kind, scope_kind,
scope_id: scope_kind === "tenant" ? null : "" scope_id: scope_kind === "tenant" ? null : "",
scope_structure_id: null,
scope_relation_type_ids: []
}); });
}} }}
> >
@@ -998,6 +1056,50 @@ function TemplateDialog({
</select> </select>
</FormField> </FormField>
) : <div />} ) : <div />}
{draft.scope_kind === "subtree" ? (
<>
<FormField label="Hierarchy structure" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={draft.scope_structure_id || ""}
onChange={(event) => onChange({
...draft,
scope_structure_id: event.target.value || null,
scope_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="Hierarchy relation types" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<div className="postbox-relation-options">
{(selectedScopeStructure?.relation_types || [])
.filter((item) => item.status === "active" && item.is_hierarchical)
.map((item) => (
<label key={item.id}>
<input
type="checkbox"
checked={draft.scope_relation_type_ids.includes(item.id)}
onChange={(event) => onChange({
...draft,
scope_relation_type_ids: event.target.checked
? [...draft.scope_relation_type_ids, item.id]
: draft.scope_relation_type_ids.filter((id) => id !== item.id)
})}
/>
<span>{item.name}</span>
</label>
))}
{selectedScopeStructure && !selectedScopeStructure.relation_types.some(
(item) => item.status === "active" && item.is_hierarchical
) ? <span className="postbox-note">No active hierarchical relation type.</span> : null}
{!selectedScopeStructure ? <span className="postbox-note">All active hierarchical relations are used unless specific types are selected.</span> : null}
</div>
</FormField>
</>
) : null}
<FormField label="Name pattern" documentation={POSTBOX_FIELD_DOCUMENTATION}> <FormField label="Name pattern" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input <input
value={draft.name_pattern} value={draft.name_pattern}
@@ -1235,6 +1337,7 @@ function TemplateDialog({
) : null} ) : null}
</div> </div>
</div> </div>
{preview ? <TemplateImpactPreview preview={preview} /> : null}
<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.
</p> </p>
@@ -1242,6 +1345,55 @@ function TemplateDialog({
); );
} }
function TemplateImpactPreview({
preview
}: {
preview: PostboxTemplatePreview;
}) {
return (
<section className="postbox-template-preview" aria-label="Template impact preview">
<div className="postbox-routing-heading">
<div>
<strong>Dry-run impact</strong>
<span>No Postboxes or addresses were created.</span>
</div>
{preview.truncated ? <StatusBadge status="warning" label="List truncated" /> : null}
</div>
<div className="metric-grid compact postbox-preview-metrics">
<MetricCard label="Targets" value={preview.total} tone="info" />
<MetricCard label="Ready" value={preview.ready_count} tone="good" />
<MetricCard label="Existing" value={preview.existing_count} />
<MetricCard label="Vacant" value={preview.vacant_count} tone="warning" />
<MetricCard label="Blocked" value={preview.blocked_count} tone="danger" />
</div>
{preview.diagnostics.length ? (
<p className="postbox-form-note">{preview.diagnostics.join(", ")}</p>
) : null}
<div className="postbox-preview-targets">
{preview.targets.map((target) => (
<div key={`${target.organization_unit_id}:${target.function_id}`}>
<div>
<strong>{target.name}</strong>
<span>{target.organization_unit_name} · {target.function_name}</span>
<code>{target.address}</code>
{target.diagnostics.length ? (
<small>{target.diagnostics.join(", ")}</small>
) : null}
</div>
<div className="postbox-preview-status">
<StatusBadge status={target.status} label={target.status.replaceAll("_", " ")} />
<span>{target.holder_count} holder{target.holder_count === 1 ? "" : "s"}</span>
</div>
</div>
))}
{!preview.targets.length ? (
<p className="postbox-note">The scope contains no matching active function.</p>
) : null}
</div>
</section>
);
}
function ExactPostboxDialog({ function ExactPostboxDialog({
open, open,
draft, draft,
@@ -1416,6 +1568,12 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
function_type_id: draft.function_type_id || null, function_type_id: draft.function_type_id || null,
scope_kind: draft.scope_kind, scope_kind: draft.scope_kind,
scope_id: draft.scope_kind === "tenant" ? null : draft.scope_id || null, scope_id: draft.scope_kind === "tenant" ? null : draft.scope_id || null,
scope_structure_id: draft.scope_kind === "subtree"
? draft.scope_structure_id || null
: null,
scope_relation_type_ids: draft.scope_kind === "subtree"
? draft.scope_relation_type_ids
: [],
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,
+3 -1
View File
@@ -605,7 +605,9 @@ export default function PostboxPage({
<option value="all">All postboxes</option> <option value="all">All postboxes</option>
{groupings.map((grouping) => ( {groupings.map((grouping) => (
<option key={grouping.id} value={grouping.id}> <option key={grouping.id} value={grouping.id}>
{grouping.name}{grouping.is_default ? " (default)" : ""} {grouping.name}
{grouping.unread_count ? ` (${grouping.unread_count})` : ""}
{grouping.is_default ? " · default" : ""}
</option> </option>
))} ))}
</select> </select>
+77
View File
@@ -466,6 +466,83 @@
font-size: 12px; font-size: 12px;
} }
.postbox-relation-options {
min-height: 38px;
display: flex;
flex-wrap: wrap;
align-content: center;
gap: 8px 14px;
}
.postbox-relation-options label {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--text-strong);
font-size: 13px;
}
.postbox-relation-options input {
width: auto;
}
.postbox-template-preview {
display: grid;
gap: 12px;
border-top: var(--border-line);
margin-top: 18px;
padding-top: 16px;
}
.postbox-preview-metrics.metric-grid {
margin: 0;
}
.postbox-preview-targets {
max-height: 280px;
display: grid;
gap: 1px;
overflow: auto;
background: var(--border-color);
border: var(--border-line);
}
.postbox-preview-targets > div {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: var(--surface);
padding: 10px 12px;
}
.postbox-preview-targets > div > div:first-child {
min-width: 0;
display: grid;
gap: 2px;
}
.postbox-preview-targets span,
.postbox-preview-targets small {
color: var(--muted);
font-size: 12px;
}
.postbox-preview-targets code {
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-preview-status {
flex: 0 0 auto;
display: grid;
justify-items: end;
gap: 4px;
}
.postbox-form-note { .postbox-form-note {
margin: 15px 0 0; margin: 15px 0 0;
color: var(--muted); color: var(--muted);