Add scoped postbox template previews
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+43
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
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]
|
||||
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,
|
||||
)
|
||||
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:
|
||||
return self._is_descendant(unit.id, 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,
|
||||
|
||||
+20
-7
@@ -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(
|
||||
{
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns(
|
||||
"postbox_template_revisions"
|
||||
)
|
||||
},
|
||||
"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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user