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