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
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
inbox views. These are query projections only: messages, address, read state,
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
message and attachment references, personal read/acknowledgement receipts,
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,
retention, stop, depth, target-template, and target-function gates are frozen
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,
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
creating empty containers for every unit. They remain durable through vacancy
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
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,
assignment/delegation context, and classification. Policy may require some
postboxes to remain separate.
+10
View File
@@ -121,6 +121,16 @@ class PostboxTemplateRevision(Base, TimestampMixin):
nullable=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(
String(500),
default="{unit_name} / {function_name}",
+8 -1
View File
@@ -473,7 +473,12 @@ manifest = ModuleManifest(
"Hierarchy copies are independent deliveries with their own evidence; "
"vacancy escalation is delayed and separately auditable. Message expiry "
"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",
documentation_types=("admin", "user"),
@@ -506,6 +511,7 @@ manifest = ModuleManifest(
"postbox.field.classification",
"postbox.field.retention",
"postbox.field.hierarchy-routing",
"postbox.action.preview-template",
"postbox.field.recipients",
"postbox.action.archive",
"postbox.action.retire-template",
@@ -513,6 +519,7 @@ manifest = ModuleManifest(
],
"consequence_classes": {
"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.",
"archive_postbox": "Stops new delivery while retaining messages, receipts, and evidence.",
"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 collections.abc import Mapping
from dataclasses import asdict
from typing import Literal
@@ -60,6 +61,8 @@ from govoplan_postbox.backend.schemas import (
PostboxTemplateCreateRequest,
PostboxTemplateItem,
PostboxTemplateListResponse,
PostboxTemplatePreviewRequest,
PostboxTemplatePreviewResponse,
PostboxTemplatePublishRequest,
PostboxTemplateReviseRequest,
)
@@ -252,6 +255,10 @@ def _template_item(template) -> PostboxTemplateItem:
"function_type_id": revision.function_type_id,
"scope_kind": revision.scope_kind,
"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,
"address_pattern": revision.address_pattern,
"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(
id=grouping.id,
name=grouping.name,
is_default=grouping.is_default,
resource_revision=grouping.resource_revision,
etag=grouping.strong_etag,
postbox_ids=[
source.postbox_id
for source in grouping.sources
if source.postbox_id in visible_ids
],
postbox_ids=visible_source_ids,
total_count=sum(
int(counts.get(postbox_id, {}).get("total", 0))
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,
updated_at=grouping.updated_at,
)
@@ -618,9 +640,19 @@ def api_list_postbox_groupings(
tenant_id=principal.tenant_id,
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(
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
]
)
@@ -881,6 +913,27 @@ def api_create_postbox_template(
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(
"/admin/templates/{template_id}/revisions",
response_model=PostboxTemplateItem,
+67
View File
@@ -378,6 +378,8 @@ class PostboxTemplateRevisionPayload(BaseModel):
function_type_id: str | None = Field(default=None, max_length=36)
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
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(
default="{unit_name} / {function_name}",
min_length=1,
@@ -398,6 +400,15 @@ class PostboxTemplateRevisionPayload(BaseModel):
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")
def validate_encryption(self) -> "PostboxTemplateRevisionPayload":
if self.encryption_profile == "server_envelope_v1":
@@ -412,15 +423,69 @@ class PostboxTemplateRevisionPayload(BaseModel):
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):
slug: str = Field(min_length=1, max_length=120)
name: str = Field(min_length=1, max_length=250)
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):
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):
id: str
@@ -523,6 +588,8 @@ class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
class PostboxGroupingItem(PostboxGroupingPayload):
id: str
total_count: int = Field(default=0, ge=0)
unread_count: int = Field(default=0, ge=0)
resource_revision: int = Field(ge=1)
etag: str
created_at: datetime
+579 -35
View File
@@ -10,7 +10,7 @@ from dataclasses import asdict
from datetime import datetime, timedelta, timezone
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.orm import Session, object_session, selectinload
@@ -739,6 +739,63 @@ class PostboxService:
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(
self,
session: Session,
@@ -2866,6 +2923,208 @@ class PostboxService:
.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(
self,
session: Session,
@@ -2882,6 +3141,8 @@ class PostboxService:
classification: str,
allow_vacant_delivery: bool,
actor_id: str | None,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
@@ -2908,6 +3169,8 @@ class PostboxService:
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)
template = PostboxTemplate(
@@ -2926,6 +3189,8 @@ class PostboxService:
function_type_id=function_type_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=list(scope_relation_type_ids),
name_pattern=name_pattern,
address_pattern=address_pattern,
classification=classification,
@@ -2967,6 +3232,8 @@ class PostboxService:
allow_vacant_delivery: bool,
actor_id: str | None,
expected_revision: int,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
@@ -2998,6 +3265,8 @@ class PostboxService:
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)
next_revision = max(
@@ -3011,6 +3280,8 @@ class PostboxService:
function_type_id=function_type_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=list(scope_relation_type_ids),
name_pattern=name_pattern,
address_pattern=address_pattern,
classification=classification,
@@ -3188,6 +3459,8 @@ class PostboxService:
tenant_id=tenant_id,
scope_kind=revision.scope_kind,
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(
"unit_out_of_scope",
@@ -3207,44 +3480,21 @@ class PostboxService:
if existing is not None:
return existing
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 = revision.name_pattern.format_map(variables).strip()
rendered_address = (
revision.address_pattern.format_map(variables).strip()
rendered_name, address = self._render_template_target(
template_slug=template.slug,
template_name=template.name,
unit=unit,
function=function,
context_key=context_key,
name_pattern=revision.name_pattern,
address_pattern=revision.address_pattern,
)
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(
session,
tenant_id=tenant_id,
address_key=key,
address=address,
name=rendered_name or f"{unit.name} / {function.name}",
name=rendered_name,
description=template.description,
classification=revision.classification,
organization_unit=unit,
@@ -4340,6 +4590,8 @@ class PostboxService:
tenant_id: str,
scope_kind: str,
scope_id: str | None,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
) -> None:
if scope_kind not in {"tenant", "unit", "subtree", "unit_type"}:
raise PostboxError(
@@ -4347,10 +4599,10 @@ class PostboxService:
"Postbox template scope must be tenant, unit, subtree, or unit_type.",
)
if scope_kind == "tenant":
if scope_id:
if scope_id or scope_structure_id or scope_relation_type_ids:
raise PostboxError(
"invalid_scope_id",
"Tenant-scoped Postbox templates do not take a scope id.",
"Tenant-scoped Postbox templates do not take hierarchy scope fields.",
)
return
if not scope_id:
@@ -4365,6 +4617,51 @@ class PostboxService:
"scope_unit_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(
self,
@@ -4373,6 +4670,8 @@ class PostboxService:
tenant_id: str,
scope_kind: str,
scope_id: str | None,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
) -> bool:
if unit.tenant_id != tenant_id:
return False
@@ -4381,11 +4680,213 @@ class PostboxService:
if scope_kind == "unit":
return unit.id == 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)
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":
return unit.unit_type_id == scope_id
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(
self,
name_pattern: str,
@@ -4413,6 +4914,49 @@ class PostboxService:
f"Invalid Postbox template pattern: {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(
self,
session: Session,
+17 -4
View File
@@ -30,6 +30,10 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions."
"d8e3f6a9b2c5_postbox_content_protection"
)
scope_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"e9f4a7b2c5d8_v014_template_scope_preview"
)
engine = create_engine("sqlite:///:memory:")
try:
with engine.begin() as connection:
@@ -39,17 +43,20 @@ class PostboxMigrationTests(unittest.TestCase):
occ_original = occ_migration.op
envelope_original = envelope_migration.op
protection_original = protection_migration.op
scope_original = scope_migration.op
migration.op = operations
route_migration.op = operations
occ_migration.op = operations
envelope_migration.op = operations
protection_migration.op = operations
scope_migration.op = operations
try:
migration.upgrade()
route_migration.upgrade()
occ_migration.upgrade()
envelope_migration.upgrade()
protection_migration.upgrade()
scope_migration.upgrade()
tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables)
@@ -75,14 +82,18 @@ class PostboxMigrationTests(unittest.TestCase):
"encryption_resource_id",
}.issubset(message_columns)
)
self.assertIn(
"encryption_vault_id",
{
template_revision_columns = {
column["name"]
for column in inspect(connection).get_columns(
"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)
for table_name in (
@@ -110,6 +121,7 @@ class PostboxMigrationTests(unittest.TestCase):
route_columns
)
)
scope_migration.downgrade()
protection_migration.downgrade()
envelope_migration.downgrade()
occ_migration.downgrade()
@@ -128,6 +140,7 @@ class PostboxMigrationTests(unittest.TestCase):
occ_migration.op = occ_original
envelope_migration.op = envelope_original
protection_migration.op = protection_original
scope_migration.op = scope_original
finally:
engine.dispose()
+73
View File
@@ -250,6 +250,63 @@ class PostboxRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 403)
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:
directory = self.client.get("/api/v1/postbox/directory")
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(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(
f"/api/v1/postbox/messages/{message_id}/state",
json={"state": "acknowledged"},
@@ -306,6 +377,8 @@ class PostboxRouterTests(unittest.TestCase):
)
self.assertEqual(200, unread.status_code, unread.text)
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:
response = self.client.post(
+127
View File
@@ -899,6 +899,133 @@ class PostboxServiceTests(unittest.TestCase):
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(
self,
) -> None:
+46
View File
@@ -114,6 +114,8 @@ export type PostboxGrouping = {
name: string;
is_default: boolean;
postbox_ids: string[];
total_count: number;
unread_count: number;
resource_revision: number;
etag: string;
created_at: string;
@@ -192,6 +194,8 @@ export type PostboxTemplateRevision = {
function_type_id?: string | null;
scope_kind: "tenant" | "unit" | "subtree" | "unit_type";
scope_id?: string | null;
scope_structure_id?: string | null;
scope_relation_type_ids: string[];
name_pattern: string;
address_pattern: string;
classification: string;
@@ -225,6 +229,8 @@ export type PostboxTemplateRevisionPayload = Pick<
| "function_type_id"
| "scope_kind"
| "scope_id"
| "scope_structure_id"
| "scope_relation_type_ids"
| "name_pattern"
| "address_pattern"
| "classification"
@@ -238,6 +244,31 @@ export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
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 = {
name: string;
description?: string | null;
@@ -435,6 +466,21 @@ export function createPostboxTemplate(
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(
settings: ApiSettings,
template: PostboxTemplate,
@@ -3,6 +3,7 @@ import {
Archive,
Boxes,
Building2,
Eye,
Inbox,
Pencil,
Plus,
@@ -20,6 +21,7 @@ import {
DocumentationHelpLink,
FormField,
IconButton,
MetricCard,
SegmentedControl,
SelectionList,
SelectionListItem,
@@ -38,6 +40,7 @@ import {
listPostboxOrganizationTargets,
listPostboxTemplates,
materializePostboxTemplate,
previewPostboxTemplate,
publishPostboxTemplate,
retirePostboxTemplate,
revisePostboxTemplate,
@@ -49,6 +52,7 @@ import {
type PostboxRoutingPolicy,
type PostboxTemplate,
type PostboxTemplateCreatePayload,
type PostboxTemplatePreview,
type PostboxTemplateRevisionPayload
} from "../../api/postbox";
import {
@@ -102,6 +106,8 @@ const templateDefaults = (): TemplateDraft => ({
function_type_id: null,
scope_kind: "tenant",
scope_id: null,
scope_structure_id: null,
scope_relation_type_ids: [],
name_pattern: "{unit_name} / {function_name}",
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
classification: "internal",
@@ -143,6 +149,8 @@ export default function PostboxAdminPanel({
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
const [templateDraft, setTemplateDraft] = 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 [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults);
const [exactBaseline, setExactBaseline] = useState<ExactDraft>(exactDefaults);
@@ -239,6 +247,7 @@ export default function PostboxAdminPanel({
function openNewTemplate() {
const next = templateDefaults();
setTemplatePreview(null);
setTemplateDraft(next);
setTemplateBaseline(next);
setTemplateDialogOpen(true);
@@ -255,12 +264,15 @@ export default function PostboxAdminPanel({
function_type_id: revision.function_type_id ?? null,
scope_kind: revision.scope_kind,
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,
address_pattern: revision.address_pattern,
classification: revision.classification,
allow_vacant_delivery: revision.allow_vacant_delivery,
routing_policy: revision.routing_policy ?? routingDefaults()
};
setTemplatePreview(null);
setTemplateDraft(next);
setTemplateBaseline(next);
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() {
if (!selectedTemplate) return;
setBusy(true);
@@ -569,7 +602,13 @@ export default function PostboxAdminPanel({
functionTypes={functionTypes}
unitTypes={unitTypes}
busy={busy}
onChange={setTemplateDraft}
preview={templatePreview}
previewLoading={templatePreviewLoading}
onChange={(draft) => {
setTemplateDraft(draft);
setTemplatePreview(null);
}}
onPreview={() => void previewTemplate()}
onClose={closeTemplateDialog}
onSave={() => void saveTemplate()}
/>
@@ -855,7 +894,10 @@ function TemplateDialog({
functionTypes,
unitTypes,
busy,
preview,
previewLoading,
onChange,
onPreview,
onClose,
onSave
}: {
@@ -867,7 +909,10 @@ function TemplateDialog({
functionTypes: Array<{ id: string; name: string }>;
unitTypes: Array<{ id: string; example: string }>;
busy: boolean;
preview: PostboxTemplatePreview | null;
previewLoading: boolean;
onChange: (draft: TemplateDraft) => void;
onPreview: () => void;
onClose: () => void;
onSave: () => void;
}) {
@@ -877,6 +922,9 @@ function TemplateDialog({
: units.map((unit) => ({ id: unit.id, label: unit.name }));
const linkedCopy = draft.routing_policy.linked_copy;
const attention = draft.routing_policy.attention;
const selectedScopeStructure = structures.find(
(item) => item.id === draft.scope_structure_id
);
const selectedStructure = structures.find(
(item) => item.id === linkedCopy.structure_id
);
@@ -900,6 +948,7 @@ function TemplateDialog({
draft.name_pattern.trim() &&
draft.address_pattern.trim() &&
(draft.scope_kind === "tenant" || Boolean(draft.scope_id)) &&
(draft.scope_kind !== "subtree" || Boolean(draft.scope_structure_id)) &&
(
!linkedCopy.enabled
|| Boolean(
@@ -919,6 +968,13 @@ function TemplateDialog({
closeDisabled={busy}
footer={
<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
variant="primary"
@@ -975,7 +1031,9 @@ function TemplateDialog({
onChange({
...draft,
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>
</FormField>
) : <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}>
<input
value={draft.name_pattern}
@@ -1235,6 +1337,7 @@ function TemplateDialog({
) : null}
</div>
</div>
{preview ? <TemplateImpactPreview preview={preview} /> : null}
<p className="postbox-form-note">
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
</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({
open,
draft,
@@ -1416,6 +1568,12 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
function_type_id: draft.function_type_id || null,
scope_kind: draft.scope_kind,
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,
address_pattern: draft.address_pattern,
classification: draft.classification,
+3 -1
View File
@@ -605,7 +605,9 @@ export default function PostboxPage({
<option value="all">All postboxes</option>
{groupings.map((grouping) => (
<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>
))}
</select>
+77
View File
@@ -466,6 +466,83 @@
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 {
margin: 15px 0 0;
color: var(--muted);