Add scoped postbox template previews

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent c53646a8aa
commit d107c09f74
15 changed files with 1300 additions and 56 deletions
+10
View File
@@ -121,6 +121,16 @@ class PostboxTemplateRevision(Base, TimestampMixin):
nullable=True,
index=True,
)
scope_structure_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
scope_relation_type_ids: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
name_pattern: Mapped[str] = mapped_column(
String(500),
default="{unit_name} / {function_name}",
+8 -1
View File
@@ -473,7 +473,12 @@ manifest = ModuleManifest(
"Hierarchy copies are independent deliveries with their own evidence; "
"vacancy escalation is delayed and separately auditable. Message expiry "
"or withdrawal blocks future content access but cannot retract plaintext "
"already copied, exported, or printed."
"already copied, exported, or printed. Subtree templates select one "
"explicit organization structure and optional relation types. Their "
"read-only impact preview reports generated addresses, current holders, "
"vacancy, collisions, cycles, depth limits, and ambiguous paths without "
"creating templates or Postboxes. Grouping totals include only source "
"Postboxes currently visible to the account."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -506,6 +511,7 @@ manifest = ModuleManifest(
"postbox.field.classification",
"postbox.field.retention",
"postbox.field.hierarchy-routing",
"postbox.action.preview-template",
"postbox.field.recipients",
"postbox.action.archive",
"postbox.action.retire-template",
@@ -513,6 +519,7 @@ manifest = ModuleManifest(
],
"consequence_classes": {
"publish_template": "Freezes an immutable address and routing revision for future materialization.",
"preview_template": "Reads current organization, hierarchy, and incumbency state without materializing any address or Postbox.",
"retire_template": "Stops new revisions and materialization while retaining existing addresses.",
"archive_postbox": "Stops new delivery while retaining messages, receipts, and evidence.",
"delete_grouping": "Deletes only the personal projection; source Postboxes and messages remain unchanged.",
@@ -0,0 +1,43 @@
"""Add explicit Postbox template hierarchy scope.
Revision ID: e9f4a7b2c5d8
Revises: d8e3f6a9b2c5
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f4a7b2c5d8"
down_revision = "d8e3f6a9b2c5"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.add_column(
sa.Column("scope_structure_id", sa.String(length=36), nullable=True)
)
batch.add_column(
sa.Column(
"scope_relation_type_ids",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'"),
)
)
batch.create_index(
"ix_postbox_template_revisions_scope_structure",
["scope_structure_id"],
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.drop_index("ix_postbox_template_revisions_scope_structure")
batch.drop_column("scope_relation_type_ids")
batch.drop_column("scope_structure_id")
+60 -7
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict
from typing import Literal
@@ -60,6 +61,8 @@ from govoplan_postbox.backend.schemas import (
PostboxTemplateCreateRequest,
PostboxTemplateItem,
PostboxTemplateListResponse,
PostboxTemplatePreviewRequest,
PostboxTemplatePreviewResponse,
PostboxTemplatePublishRequest,
PostboxTemplateReviseRequest,
)
@@ -252,6 +255,10 @@ def _template_item(template) -> PostboxTemplateItem:
"function_type_id": revision.function_type_id,
"scope_kind": revision.scope_kind,
"scope_id": revision.scope_id,
"scope_structure_id": revision.scope_structure_id,
"scope_relation_type_ids": list(
revision.scope_relation_type_ids or []
),
"name_pattern": revision.name_pattern,
"address_pattern": revision.address_pattern,
"classification": revision.classification,
@@ -271,18 +278,33 @@ def _template_item(template) -> PostboxTemplateItem:
)
def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem:
def _grouping_item(
grouping,
*,
visible_ids: set[str],
counts_by_postbox: Mapping[str, Mapping[str, int]] | None = None,
) -> PostboxGroupingItem:
visible_source_ids = [
source.postbox_id
for source in grouping.sources
if source.postbox_id in visible_ids
]
counts = counts_by_postbox or {}
return PostboxGroupingItem(
id=grouping.id,
name=grouping.name,
is_default=grouping.is_default,
resource_revision=grouping.resource_revision,
etag=grouping.strong_etag,
postbox_ids=[
source.postbox_id
for source in grouping.sources
if source.postbox_id in visible_ids
],
postbox_ids=visible_source_ids,
total_count=sum(
int(counts.get(postbox_id, {}).get("total", 0))
for postbox_id in visible_source_ids
),
unread_count=sum(
int(counts.get(postbox_id, {}).get("unread", 0))
for postbox_id in visible_source_ids
),
created_at=grouping.created_at,
updated_at=grouping.updated_at,
)
@@ -618,9 +640,19 @@ def api_list_postbox_groupings(
tenant_id=principal.tenant_id,
actor=actor,
)
counts_by_postbox = get_service().message_counts_by_postbox(
session,
tenant_id=principal.tenant_id,
postbox_ids=tuple(visible_ids),
actor=actor,
)
return PostboxGroupingListResponse(
groupings=[
_grouping_item(grouping, visible_ids=visible_ids)
_grouping_item(
grouping,
visible_ids=visible_ids,
counts_by_postbox=counts_by_postbox,
)
for grouping in groupings
]
)
@@ -881,6 +913,27 @@ def api_create_postbox_template(
return item
@router.post(
"/admin/templates/preview",
response_model=PostboxTemplatePreviewResponse,
)
def api_preview_postbox_template(
payload: PostboxTemplatePreviewRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplatePreviewResponse:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
preview = get_service().preview_template_targets(
session,
tenant_id=principal.tenant_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxTemplatePreviewResponse.model_validate(preview)
@router.post(
"/admin/templates/{template_id}/revisions",
response_model=PostboxTemplateItem,
+67
View File
@@ -378,6 +378,8 @@ class PostboxTemplateRevisionPayload(BaseModel):
function_type_id: str | None = Field(default=None, max_length=36)
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
scope_id: str | None = Field(default=None, max_length=255)
scope_structure_id: str | None = Field(default=None, max_length=36)
scope_relation_type_ids: list[str] = Field(default_factory=list, max_length=20)
name_pattern: str = Field(
default="{unit_name} / {function_name}",
min_length=1,
@@ -398,6 +400,15 @@ class PostboxTemplateRevisionPayload(BaseModel):
default_factory=PostboxRoutingPolicyPayload
)
@model_validator(mode="after")
def normalize_scope(self) -> "PostboxTemplateRevisionPayload":
self.scope_relation_type_ids = list(
dict.fromkeys(
value.strip() for value in self.scope_relation_type_ids if value.strip()
)
)
return self
@model_validator(mode="after")
def validate_encryption(self) -> "PostboxTemplateRevisionPayload":
if self.encryption_profile == "server_envelope_v1":
@@ -412,15 +423,69 @@ class PostboxTemplateRevisionPayload(BaseModel):
return self
def _validate_template_write_scope(
payload: PostboxTemplateRevisionPayload,
) -> None:
if payload.scope_kind == "subtree" and not payload.scope_structure_id:
raise ValueError("A subtree scope requires an organization structure.")
if payload.scope_kind != "subtree" and (
payload.scope_structure_id or payload.scope_relation_type_ids
):
raise ValueError(
"Hierarchy structure and relation filters apply only to subtree scopes."
)
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
slug: str = Field(min_length=1, max_length=120)
name: str = Field(min_length=1, max_length=250)
description: str | None = None
@model_validator(mode="after")
def validate_write_scope(self) -> "PostboxTemplateCreateRequest":
_validate_template_write_scope(self)
return self
class PostboxTemplatePreviewRequest(PostboxTemplateCreateRequest):
template_id: str | None = Field(default=None, max_length=36)
context_key: str | None = Field(default=None, max_length=255)
limit: int = Field(default=200, ge=1, le=500)
class PostboxTemplatePreviewTarget(BaseModel):
organization_unit_id: str
organization_unit_name: str
function_id: str
function_name: str
address: str
name: str
holder_count: int = Field(ge=0)
vacant: bool
status: str
existing_postbox_id: str | None = None
diagnostics: list[str] = Field(default_factory=list)
class PostboxTemplatePreviewResponse(BaseModel):
targets: list[PostboxTemplatePreviewTarget] = Field(default_factory=list)
total: int = Field(ge=0)
ready_count: int = Field(ge=0)
existing_count: int = Field(ge=0)
vacant_count: int = Field(ge=0)
blocked_count: int = Field(ge=0)
truncated: bool = False
diagnostics: list[str] = Field(default_factory=list)
class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
base_revision: int = Field(ge=1)
@model_validator(mode="after")
def validate_write_scope(self) -> "PostboxTemplateReviseRequest":
_validate_template_write_scope(self)
return self
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
id: str
@@ -523,6 +588,8 @@ class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
class PostboxGroupingItem(PostboxGroupingPayload):
id: str
total_count: int = Field(default=0, ge=0)
unread_count: int = Field(default=0, ge=0)
resource_revision: int = Field(ge=1)
etag: str
created_at: datetime
+581 -37
View File
@@ -10,7 +10,7 @@ from dataclasses import asdict
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from sqlalchemy import and_, func, or_
from sqlalchemy import and_, case, func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, object_session, selectinload
@@ -739,6 +739,63 @@ class PostboxService:
or 0
)
def message_counts_by_postbox(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
) -> Mapping[str, Mapping[str, int]]:
"""Return one access-filtered count projection for multiple inboxes."""
db = _session(session)
allowed_ids = self._allowed_postbox_ids(
db,
tenant_id=tenant_id,
postbox_ids=postbox_ids,
actor=actor,
action="read",
)
if not allowed_ids:
return {}
rows = (
db.query(
PostboxMessage.postbox_id,
func.count(PostboxMessage.id),
func.sum(
case(
(PostboxMessageReceipt.read_at.is_(None), 1),
else_=0,
)
),
)
.outerjoin(
PostboxMessageReceipt,
and_(
PostboxMessageReceipt.tenant_id == tenant_id,
PostboxMessageReceipt.message_id == PostboxMessage.id,
PostboxMessageReceipt.account_id == actor.account_id,
),
)
.filter(
PostboxMessage.tenant_id == tenant_id,
PostboxMessage.postbox_id.in_(allowed_ids),
PostboxMessage.classification.in_(
tuple(actor.authorized_classifications)
),
)
.group_by(PostboxMessage.postbox_id)
.all()
)
return {
str(postbox_id): {
"total": int(total or 0),
"unread": int(unread or 0),
}
for postbox_id, total, unread in rows
}
def _messages_query(
self,
session: Session,
@@ -2866,6 +2923,208 @@ class PostboxService:
.all()
)
def preview_template_targets(
self,
session: Session,
*,
tenant_id: str,
slug: str,
name: str,
description: str | None,
function_type_id: str | None,
scope_kind: str,
scope_id: str | None,
scope_structure_id: str | None,
scope_relation_type_ids: Sequence[str],
name_pattern: str,
address_pattern: str,
classification: str,
allow_vacant_delivery: bool,
routing_policy: Mapping[str, object] | None,
encryption_profile: str,
encryption_vault_id: str | None,
template_id: str | None,
context_key: str | None,
limit: int,
) -> dict[str, object]:
del description
self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
normalized_routing_policy(routing_policy)
self._validate_scope(
tenant_id=tenant_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=scope_relation_type_ids,
)
self._validate_patterns(name_pattern, address_pattern)
if template_id:
self._get_template(
session,
tenant_id=tenant_id,
template_id=template_id,
)
units, diagnostics_by_unit, diagnostics = (
self._template_preview_scope_units(
tenant_id=tenant_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=scope_relation_type_ids,
)
)
unit_ids = tuple(unit.id for unit in units)
functions = self._template_preview_functions(
tenant_id=tenant_id,
unit_ids=unit_ids,
function_type_id=function_type_id,
)
functions_by_unit: dict[str, list[OrganizationFunctionRef]] = {}
for function in functions:
functions_by_unit.setdefault(
function.organization_unit_id,
[],
).append(function)
candidate_rows: list[dict[str, object]] = []
preview_template_id = template_id or f"preview:{_slug(slug or name)}"
for unit in units:
for function in sorted(
functions_by_unit.get(unit.id, ()),
key=lambda item: (item.name.casefold(), item.id),
):
rendered_name, rendered_address = self._render_template_target(
template_slug=_slug(slug or name, fallback="template"),
template_name=name,
unit=unit,
function=function,
context_key=context_key,
name_pattern=name_pattern,
address_pattern=address_pattern,
)
candidate_rows.append(
{
"organization_unit_id": unit.id,
"organization_unit_name": unit.name,
"function_id": function.id,
"function_name": function.name,
"address_key": self._template_address_key(
preview_template_id,
unit.id,
function.id,
context_key,
),
"address": rendered_address,
"name": rendered_name,
"diagnostics": list(
diagnostics_by_unit.get(unit.id, ())
),
}
)
function_ids = tuple(
dict.fromkeys(str(row["function_id"]) for row in candidate_rows)
)
holder_counts = self._holder_counts_for_functions(
tenant_id=tenant_id,
function_ids=function_ids,
)
addresses = tuple(str(row["address"]) for row in candidate_rows)
address_keys = tuple(str(row["address_key"]) for row in candidate_rows)
existing_by_id: dict[str, PostboxAddress] = {}
for offset in range(0, len(candidate_rows), 250):
address_batch = addresses[offset : offset + 250]
key_batch = address_keys[offset : offset + 250]
for item in (
session.query(PostboxAddress)
.options(selectinload(PostboxAddress.postbox))
.filter(
PostboxAddress.tenant_id == tenant_id,
or_(
PostboxAddress.address_key.in_(key_batch),
PostboxAddress.address.in_(address_batch),
),
)
.all()
):
existing_by_id[item.id] = item
existing_addresses = tuple(existing_by_id.values())
existing_by_key = {
item.address_key: item for item in existing_addresses
}
existing_by_address = {
item.address: item for item in existing_addresses
}
generated_address_counts = Counter(addresses)
if any(count > 1 for count in generated_address_counts.values()):
diagnostics.append("duplicate_generated_address")
targets: list[dict[str, object]] = []
ready_count = 0
existing_count = 0
vacant_count = 0
blocked_count = 0
for row in candidate_rows:
target_diagnostics = list(row.pop("diagnostics"))
address_key = str(row.pop("address_key"))
address = str(row["address"])
holder_count = holder_counts.get(str(row["function_id"]), 0)
vacant = holder_count == 0
existing = existing_by_key.get(address_key)
collision = existing_by_address.get(address)
if existing is not None:
status = "existing"
existing_count += 1
target_diagnostics.append("address_already_materialized")
elif collision is not None or generated_address_counts[address] > 1:
status = "address_collision"
blocked_count += 1
target_diagnostics.append("address_collision")
elif vacant and not allow_vacant_delivery:
status = "blocked_vacant"
blocked_count += 1
target_diagnostics.append("vacant_delivery_blocked")
else:
status = "ready"
ready_count += 1
if vacant:
vacant_count += 1
target_diagnostics.append("no_active_holder")
targets.append(
{
**row,
"holder_count": holder_count,
"vacant": vacant,
"status": status,
"existing_postbox_id": (
existing.postbox.id
if existing is not None and existing.postbox is not None
else None
),
"diagnostics": list(dict.fromkeys(target_diagnostics)),
}
)
total = len(targets)
truncated = total > limit
if truncated:
diagnostics.append("preview_truncated")
return {
"targets": targets[:limit],
"total": total,
"ready_count": ready_count,
"existing_count": existing_count,
"vacant_count": vacant_count,
"blocked_count": blocked_count,
"truncated": truncated,
"diagnostics": list(dict.fromkeys(diagnostics)),
}
def create_template(
self,
session: Session,
@@ -2882,6 +3141,8 @@ class PostboxService:
classification: str,
allow_vacant_delivery: bool,
actor_id: str | None,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
@@ -2908,6 +3169,8 @@ class PostboxService:
tenant_id=tenant_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=scope_relation_type_ids,
)
self._validate_patterns(name_pattern, address_pattern)
template = PostboxTemplate(
@@ -2926,6 +3189,8 @@ class PostboxService:
function_type_id=function_type_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=list(scope_relation_type_ids),
name_pattern=name_pattern,
address_pattern=address_pattern,
classification=classification,
@@ -2967,6 +3232,8 @@ class PostboxService:
allow_vacant_delivery: bool,
actor_id: str | None,
expected_revision: int,
scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (),
routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
@@ -2998,6 +3265,8 @@ class PostboxService:
tenant_id=tenant_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=scope_relation_type_ids,
)
self._validate_patterns(name_pattern, address_pattern)
next_revision = max(
@@ -3011,6 +3280,8 @@ class PostboxService:
function_type_id=function_type_id,
scope_kind=scope_kind,
scope_id=scope_id,
scope_structure_id=scope_structure_id,
scope_relation_type_ids=list(scope_relation_type_ids),
name_pattern=name_pattern,
address_pattern=address_pattern,
classification=classification,
@@ -3188,6 +3459,8 @@ class PostboxService:
tenant_id=tenant_id,
scope_kind=revision.scope_kind,
scope_id=revision.scope_id,
scope_structure_id=revision.scope_structure_id,
scope_relation_type_ids=tuple(revision.scope_relation_type_ids or ()),
):
raise PostboxError(
"unit_out_of_scope",
@@ -3207,44 +3480,21 @@ class PostboxService:
if existing is not None:
return existing
variables = {
"template_slug": template.slug,
"template_name": template.name,
"unit_id": unit.id,
"unit_slug": unit.slug,
"unit_name": unit.name,
"function_id": function.id,
"function_slug": function.slug,
"function_name": function.name,
"context_key": context_key or "",
}
try:
rendered_name = revision.name_pattern.format_map(variables).strip()
rendered_address = (
revision.address_pattern.format_map(variables).strip()
)
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,