Add scoped postbox template previews
This commit is contained in:
@@ -121,6 +121,16 @@ class PostboxTemplateRevision(Base, TimestampMixin):
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
scope_structure_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
scope_relation_type_ids: Mapped[list[str]] = mapped_column(
|
||||
JSON,
|
||||
default=list,
|
||||
nullable=False,
|
||||
)
|
||||
name_pattern: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
default="{unit_name} / {function_name}",
|
||||
|
||||
@@ -473,7 +473,12 @@ manifest = ModuleManifest(
|
||||
"Hierarchy copies are independent deliveries with their own evidence; "
|
||||
"vacancy escalation is delayed and separately auditable. Message expiry "
|
||||
"or withdrawal blocks future content access but cannot retract plaintext "
|
||||
"already copied, exported, or printed."
|
||||
"already copied, exported, or printed. Subtree templates select one "
|
||||
"explicit organization structure and optional relation types. Their "
|
||||
"read-only impact preview reports generated addresses, current holders, "
|
||||
"vacancy, collisions, cycles, depth limits, and ambiguous paths without "
|
||||
"creating templates or Postboxes. Grouping totals include only source "
|
||||
"Postboxes currently visible to the account."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -506,6 +511,7 @@ manifest = ModuleManifest(
|
||||
"postbox.field.classification",
|
||||
"postbox.field.retention",
|
||||
"postbox.field.hierarchy-routing",
|
||||
"postbox.action.preview-template",
|
||||
"postbox.field.recipients",
|
||||
"postbox.action.archive",
|
||||
"postbox.action.retire-template",
|
||||
@@ -513,6 +519,7 @@ manifest = ModuleManifest(
|
||||
],
|
||||
"consequence_classes": {
|
||||
"publish_template": "Freezes an immutable address and routing revision for future materialization.",
|
||||
"preview_template": "Reads current organization, hierarchy, and incumbency state without materializing any address or Postbox.",
|
||||
"retire_template": "Stops new revisions and materialization while retaining existing addresses.",
|
||||
"archive_postbox": "Stops new delivery while retaining messages, receipts, and evidence.",
|
||||
"delete_grouping": "Deletes only the personal projection; source Postboxes and messages remain unchanged.",
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
"""Add explicit Postbox template hierarchy scope.
|
||||
|
||||
Revision ID: e9f4a7b2c5d8
|
||||
Revises: d8e3f6a9b2c5
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e9f4a7b2c5d8"
|
||||
down_revision = "d8e3f6a9b2c5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("postbox_template_revisions") as batch:
|
||||
batch.add_column(
|
||||
sa.Column("scope_structure_id", sa.String(length=36), nullable=True)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"scope_relation_type_ids",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'"),
|
||||
)
|
||||
)
|
||||
batch.create_index(
|
||||
"ix_postbox_template_revisions_scope_structure",
|
||||
["scope_structure_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("postbox_template_revisions") as batch:
|
||||
batch.drop_index("ix_postbox_template_revisions_scope_structure")
|
||||
batch.drop_column("scope_relation_type_ids")
|
||||
batch.drop_column("scope_structure_id")
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict
|
||||
from typing import Literal
|
||||
|
||||
@@ -60,6 +61,8 @@ from govoplan_postbox.backend.schemas import (
|
||||
PostboxTemplateCreateRequest,
|
||||
PostboxTemplateItem,
|
||||
PostboxTemplateListResponse,
|
||||
PostboxTemplatePreviewRequest,
|
||||
PostboxTemplatePreviewResponse,
|
||||
PostboxTemplatePublishRequest,
|
||||
PostboxTemplateReviseRequest,
|
||||
)
|
||||
@@ -252,6 +255,10 @@ def _template_item(template) -> PostboxTemplateItem:
|
||||
"function_type_id": revision.function_type_id,
|
||||
"scope_kind": revision.scope_kind,
|
||||
"scope_id": revision.scope_id,
|
||||
"scope_structure_id": revision.scope_structure_id,
|
||||
"scope_relation_type_ids": list(
|
||||
revision.scope_relation_type_ids or []
|
||||
),
|
||||
"name_pattern": revision.name_pattern,
|
||||
"address_pattern": revision.address_pattern,
|
||||
"classification": revision.classification,
|
||||
@@ -271,18 +278,33 @@ def _template_item(template) -> PostboxTemplateItem:
|
||||
)
|
||||
|
||||
|
||||
def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem:
|
||||
def _grouping_item(
|
||||
grouping,
|
||||
*,
|
||||
visible_ids: set[str],
|
||||
counts_by_postbox: Mapping[str, Mapping[str, int]] | None = None,
|
||||
) -> PostboxGroupingItem:
|
||||
visible_source_ids = [
|
||||
source.postbox_id
|
||||
for source in grouping.sources
|
||||
if source.postbox_id in visible_ids
|
||||
]
|
||||
counts = counts_by_postbox or {}
|
||||
return PostboxGroupingItem(
|
||||
id=grouping.id,
|
||||
name=grouping.name,
|
||||
is_default=grouping.is_default,
|
||||
resource_revision=grouping.resource_revision,
|
||||
etag=grouping.strong_etag,
|
||||
postbox_ids=[
|
||||
source.postbox_id
|
||||
for source in grouping.sources
|
||||
if source.postbox_id in visible_ids
|
||||
],
|
||||
postbox_ids=visible_source_ids,
|
||||
total_count=sum(
|
||||
int(counts.get(postbox_id, {}).get("total", 0))
|
||||
for postbox_id in visible_source_ids
|
||||
),
|
||||
unread_count=sum(
|
||||
int(counts.get(postbox_id, {}).get("unread", 0))
|
||||
for postbox_id in visible_source_ids
|
||||
),
|
||||
created_at=grouping.created_at,
|
||||
updated_at=grouping.updated_at,
|
||||
)
|
||||
@@ -618,9 +640,19 @@ def api_list_postbox_groupings(
|
||||
tenant_id=principal.tenant_id,
|
||||
actor=actor,
|
||||
)
|
||||
counts_by_postbox = get_service().message_counts_by_postbox(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
postbox_ids=tuple(visible_ids),
|
||||
actor=actor,
|
||||
)
|
||||
return PostboxGroupingListResponse(
|
||||
groupings=[
|
||||
_grouping_item(grouping, visible_ids=visible_ids)
|
||||
_grouping_item(
|
||||
grouping,
|
||||
visible_ids=visible_ids,
|
||||
counts_by_postbox=counts_by_postbox,
|
||||
)
|
||||
for grouping in groupings
|
||||
]
|
||||
)
|
||||
@@ -881,6 +913,27 @@ def api_create_postbox_template(
|
||||
return item
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/templates/preview",
|
||||
response_model=PostboxTemplatePreviewResponse,
|
||||
)
|
||||
def api_preview_postbox_template(
|
||||
payload: PostboxTemplatePreviewRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxTemplatePreviewResponse:
|
||||
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
||||
try:
|
||||
preview = get_service().preview_template_targets(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return PostboxTemplatePreviewResponse.model_validate(preview)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/templates/{template_id}/revisions",
|
||||
response_model=PostboxTemplateItem,
|
||||
|
||||
@@ -378,6 +378,8 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
||||
function_type_id: str | None = Field(default=None, max_length=36)
|
||||
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=255)
|
||||
scope_structure_id: str | None = Field(default=None, max_length=36)
|
||||
scope_relation_type_ids: list[str] = Field(default_factory=list, max_length=20)
|
||||
name_pattern: str = Field(
|
||||
default="{unit_name} / {function_name}",
|
||||
min_length=1,
|
||||
@@ -398,6 +400,15 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
||||
default_factory=PostboxRoutingPolicyPayload
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_scope(self) -> "PostboxTemplateRevisionPayload":
|
||||
self.scope_relation_type_ids = list(
|
||||
dict.fromkeys(
|
||||
value.strip() for value in self.scope_relation_type_ids if value.strip()
|
||||
)
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_encryption(self) -> "PostboxTemplateRevisionPayload":
|
||||
if self.encryption_profile == "server_envelope_v1":
|
||||
@@ -412,15 +423,69 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
def _validate_template_write_scope(
|
||||
payload: PostboxTemplateRevisionPayload,
|
||||
) -> None:
|
||||
if payload.scope_kind == "subtree" and not payload.scope_structure_id:
|
||||
raise ValueError("A subtree scope requires an organization structure.")
|
||||
if payload.scope_kind != "subtree" and (
|
||||
payload.scope_structure_id or payload.scope_relation_type_ids
|
||||
):
|
||||
raise ValueError(
|
||||
"Hierarchy structure and relation filters apply only to subtree scopes."
|
||||
)
|
||||
|
||||
|
||||
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
|
||||
slug: str = Field(min_length=1, max_length=120)
|
||||
name: str = Field(min_length=1, max_length=250)
|
||||
description: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_write_scope(self) -> "PostboxTemplateCreateRequest":
|
||||
_validate_template_write_scope(self)
|
||||
return self
|
||||
|
||||
|
||||
class PostboxTemplatePreviewRequest(PostboxTemplateCreateRequest):
|
||||
template_id: str | None = Field(default=None, max_length=36)
|
||||
context_key: str | None = Field(default=None, max_length=255)
|
||||
limit: int = Field(default=200, ge=1, le=500)
|
||||
|
||||
|
||||
class PostboxTemplatePreviewTarget(BaseModel):
|
||||
organization_unit_id: str
|
||||
organization_unit_name: str
|
||||
function_id: str
|
||||
function_name: str
|
||||
address: str
|
||||
name: str
|
||||
holder_count: int = Field(ge=0)
|
||||
vacant: bool
|
||||
status: str
|
||||
existing_postbox_id: str | None = None
|
||||
diagnostics: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PostboxTemplatePreviewResponse(BaseModel):
|
||||
targets: list[PostboxTemplatePreviewTarget] = Field(default_factory=list)
|
||||
total: int = Field(ge=0)
|
||||
ready_count: int = Field(ge=0)
|
||||
existing_count: int = Field(ge=0)
|
||||
vacant_count: int = Field(ge=0)
|
||||
blocked_count: int = Field(ge=0)
|
||||
truncated: bool = False
|
||||
diagnostics: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_write_scope(self) -> "PostboxTemplateReviseRequest":
|
||||
_validate_template_write_scope(self)
|
||||
return self
|
||||
|
||||
|
||||
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
|
||||
id: str
|
||||
@@ -523,6 +588,8 @@ class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
|
||||
|
||||
class PostboxGroupingItem(PostboxGroupingPayload):
|
||||
id: str
|
||||
total_count: int = Field(default=0, ge=0)
|
||||
unread_count: int = Field(default=0, ge=0)
|
||||
resource_revision: int = Field(ge=1)
|
||||
etag: str
|
||||
created_at: datetime
|
||||
|
||||
@@ -10,7 +10,7 @@ from dataclasses import asdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy import and_, case, func, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, object_session, selectinload
|
||||
|
||||
@@ -739,6 +739,63 @@ class PostboxService:
|
||||
or 0
|
||||
)
|
||||
|
||||
def message_counts_by_postbox(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_ids: Sequence[str],
|
||||
actor: PostboxActorRef,
|
||||
) -> Mapping[str, Mapping[str, int]]:
|
||||
"""Return one access-filtered count projection for multiple inboxes."""
|
||||
|
||||
db = _session(session)
|
||||
allowed_ids = self._allowed_postbox_ids(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
postbox_ids=postbox_ids,
|
||||
actor=actor,
|
||||
action="read",
|
||||
)
|
||||
if not allowed_ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(
|
||||
PostboxMessage.postbox_id,
|
||||
func.count(PostboxMessage.id),
|
||||
func.sum(
|
||||
case(
|
||||
(PostboxMessageReceipt.read_at.is_(None), 1),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
PostboxMessageReceipt,
|
||||
and_(
|
||||
PostboxMessageReceipt.tenant_id == tenant_id,
|
||||
PostboxMessageReceipt.message_id == PostboxMessage.id,
|
||||
PostboxMessageReceipt.account_id == actor.account_id,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
PostboxMessage.tenant_id == tenant_id,
|
||||
PostboxMessage.postbox_id.in_(allowed_ids),
|
||||
PostboxMessage.classification.in_(
|
||||
tuple(actor.authorized_classifications)
|
||||
),
|
||||
)
|
||||
.group_by(PostboxMessage.postbox_id)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
str(postbox_id): {
|
||||
"total": int(total or 0),
|
||||
"unread": int(unread or 0),
|
||||
}
|
||||
for postbox_id, total, unread in rows
|
||||
}
|
||||
|
||||
def _messages_query(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -2866,6 +2923,208 @@ class PostboxService:
|
||||
.all()
|
||||
)
|
||||
|
||||
def preview_template_targets(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
function_type_id: str | None,
|
||||
scope_kind: str,
|
||||
scope_id: str | None,
|
||||
scope_structure_id: str | None,
|
||||
scope_relation_type_ids: Sequence[str],
|
||||
name_pattern: str,
|
||||
address_pattern: str,
|
||||
classification: str,
|
||||
allow_vacant_delivery: bool,
|
||||
routing_policy: Mapping[str, object] | None,
|
||||
encryption_profile: str,
|
||||
encryption_vault_id: str | None,
|
||||
template_id: str | None,
|
||||
context_key: str | None,
|
||||
limit: int,
|
||||
) -> dict[str, object]:
|
||||
del description
|
||||
self._validate_classification(classification)
|
||||
_validate_encryption_configuration(
|
||||
encryption_profile,
|
||||
encryption_vault_id,
|
||||
)
|
||||
normalized_routing_policy(routing_policy)
|
||||
self._validate_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_kind=scope_kind,
|
||||
scope_id=scope_id,
|
||||
scope_structure_id=scope_structure_id,
|
||||
scope_relation_type_ids=scope_relation_type_ids,
|
||||
)
|
||||
self._validate_patterns(name_pattern, address_pattern)
|
||||
if template_id:
|
||||
self._get_template(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
template_id=template_id,
|
||||
)
|
||||
|
||||
units, diagnostics_by_unit, diagnostics = (
|
||||
self._template_preview_scope_units(
|
||||
tenant_id=tenant_id,
|
||||
scope_kind=scope_kind,
|
||||
scope_id=scope_id,
|
||||
scope_structure_id=scope_structure_id,
|
||||
scope_relation_type_ids=scope_relation_type_ids,
|
||||
)
|
||||
)
|
||||
unit_ids = tuple(unit.id for unit in units)
|
||||
functions = self._template_preview_functions(
|
||||
tenant_id=tenant_id,
|
||||
unit_ids=unit_ids,
|
||||
function_type_id=function_type_id,
|
||||
)
|
||||
functions_by_unit: dict[str, list[OrganizationFunctionRef]] = {}
|
||||
for function in functions:
|
||||
functions_by_unit.setdefault(
|
||||
function.organization_unit_id,
|
||||
[],
|
||||
).append(function)
|
||||
|
||||
candidate_rows: list[dict[str, object]] = []
|
||||
preview_template_id = template_id or f"preview:{_slug(slug or name)}"
|
||||
for unit in units:
|
||||
for function in sorted(
|
||||
functions_by_unit.get(unit.id, ()),
|
||||
key=lambda item: (item.name.casefold(), item.id),
|
||||
):
|
||||
rendered_name, rendered_address = self._render_template_target(
|
||||
template_slug=_slug(slug or name, fallback="template"),
|
||||
template_name=name,
|
||||
unit=unit,
|
||||
function=function,
|
||||
context_key=context_key,
|
||||
name_pattern=name_pattern,
|
||||
address_pattern=address_pattern,
|
||||
)
|
||||
candidate_rows.append(
|
||||
{
|
||||
"organization_unit_id": unit.id,
|
||||
"organization_unit_name": unit.name,
|
||||
"function_id": function.id,
|
||||
"function_name": function.name,
|
||||
"address_key": self._template_address_key(
|
||||
preview_template_id,
|
||||
unit.id,
|
||||
function.id,
|
||||
context_key,
|
||||
),
|
||||
"address": rendered_address,
|
||||
"name": rendered_name,
|
||||
"diagnostics": list(
|
||||
diagnostics_by_unit.get(unit.id, ())
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
function_ids = tuple(
|
||||
dict.fromkeys(str(row["function_id"]) for row in candidate_rows)
|
||||
)
|
||||
holder_counts = self._holder_counts_for_functions(
|
||||
tenant_id=tenant_id,
|
||||
function_ids=function_ids,
|
||||
)
|
||||
addresses = tuple(str(row["address"]) for row in candidate_rows)
|
||||
address_keys = tuple(str(row["address_key"]) for row in candidate_rows)
|
||||
existing_by_id: dict[str, PostboxAddress] = {}
|
||||
for offset in range(0, len(candidate_rows), 250):
|
||||
address_batch = addresses[offset : offset + 250]
|
||||
key_batch = address_keys[offset : offset + 250]
|
||||
for item in (
|
||||
session.query(PostboxAddress)
|
||||
.options(selectinload(PostboxAddress.postbox))
|
||||
.filter(
|
||||
PostboxAddress.tenant_id == tenant_id,
|
||||
or_(
|
||||
PostboxAddress.address_key.in_(key_batch),
|
||||
PostboxAddress.address.in_(address_batch),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
):
|
||||
existing_by_id[item.id] = item
|
||||
existing_addresses = tuple(existing_by_id.values())
|
||||
existing_by_key = {
|
||||
item.address_key: item for item in existing_addresses
|
||||
}
|
||||
existing_by_address = {
|
||||
item.address: item for item in existing_addresses
|
||||
}
|
||||
generated_address_counts = Counter(addresses)
|
||||
if any(count > 1 for count in generated_address_counts.values()):
|
||||
diagnostics.append("duplicate_generated_address")
|
||||
|
||||
targets: list[dict[str, object]] = []
|
||||
ready_count = 0
|
||||
existing_count = 0
|
||||
vacant_count = 0
|
||||
blocked_count = 0
|
||||
for row in candidate_rows:
|
||||
target_diagnostics = list(row.pop("diagnostics"))
|
||||
address_key = str(row.pop("address_key"))
|
||||
address = str(row["address"])
|
||||
holder_count = holder_counts.get(str(row["function_id"]), 0)
|
||||
vacant = holder_count == 0
|
||||
existing = existing_by_key.get(address_key)
|
||||
collision = existing_by_address.get(address)
|
||||
if existing is not None:
|
||||
status = "existing"
|
||||
existing_count += 1
|
||||
target_diagnostics.append("address_already_materialized")
|
||||
elif collision is not None or generated_address_counts[address] > 1:
|
||||
status = "address_collision"
|
||||
blocked_count += 1
|
||||
target_diagnostics.append("address_collision")
|
||||
elif vacant and not allow_vacant_delivery:
|
||||
status = "blocked_vacant"
|
||||
blocked_count += 1
|
||||
target_diagnostics.append("vacant_delivery_blocked")
|
||||
else:
|
||||
status = "ready"
|
||||
ready_count += 1
|
||||
if vacant:
|
||||
vacant_count += 1
|
||||
target_diagnostics.append("no_active_holder")
|
||||
targets.append(
|
||||
{
|
||||
**row,
|
||||
"holder_count": holder_count,
|
||||
"vacant": vacant,
|
||||
"status": status,
|
||||
"existing_postbox_id": (
|
||||
existing.postbox.id
|
||||
if existing is not None and existing.postbox is not None
|
||||
else None
|
||||
),
|
||||
"diagnostics": list(dict.fromkeys(target_diagnostics)),
|
||||
}
|
||||
)
|
||||
|
||||
total = len(targets)
|
||||
truncated = total > limit
|
||||
if truncated:
|
||||
diagnostics.append("preview_truncated")
|
||||
return {
|
||||
"targets": targets[:limit],
|
||||
"total": total,
|
||||
"ready_count": ready_count,
|
||||
"existing_count": existing_count,
|
||||
"vacant_count": vacant_count,
|
||||
"blocked_count": blocked_count,
|
||||
"truncated": truncated,
|
||||
"diagnostics": list(dict.fromkeys(diagnostics)),
|
||||
}
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -2882,6 +3141,8 @@ class PostboxService:
|
||||
classification: str,
|
||||
allow_vacant_delivery: bool,
|
||||
actor_id: str | None,
|
||||
scope_structure_id: str | None = None,
|
||||
scope_relation_type_ids: Sequence[str] = (),
|
||||
routing_policy: Mapping[str, object] | None = None,
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
@@ -2908,6 +3169,8 @@ class PostboxService:
|
||||
tenant_id=tenant_id,
|
||||
scope_kind=scope_kind,
|
||||
scope_id=scope_id,
|
||||
scope_structure_id=scope_structure_id,
|
||||
scope_relation_type_ids=scope_relation_type_ids,
|
||||
)
|
||||
self._validate_patterns(name_pattern, address_pattern)
|
||||
template = PostboxTemplate(
|
||||
@@ -2926,6 +3189,8 @@ class PostboxService:
|
||||
function_type_id=function_type_id,
|
||||
scope_kind=scope_kind,
|
||||
scope_id=scope_id,
|
||||
scope_structure_id=scope_structure_id,
|
||||
scope_relation_type_ids=list(scope_relation_type_ids),
|
||||
name_pattern=name_pattern,
|
||||
address_pattern=address_pattern,
|
||||
classification=classification,
|
||||
@@ -2967,6 +3232,8 @@ class PostboxService:
|
||||
allow_vacant_delivery: bool,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
scope_structure_id: str | None = None,
|
||||
scope_relation_type_ids: Sequence[str] = (),
|
||||
routing_policy: Mapping[str, object] | None = None,
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
@@ -2998,6 +3265,8 @@ class PostboxService:
|
||||
tenant_id=tenant_id,
|
||||
scope_kind=scope_kind,
|
||||
scope_id=scope_id,
|
||||
scope_structure_id=scope_structure_id,
|
||||
scope_relation_type_ids=scope_relation_type_ids,
|
||||
)
|
||||
self._validate_patterns(name_pattern, address_pattern)
|
||||
next_revision = max(
|
||||
@@ -3011,6 +3280,8 @@ class PostboxService:
|
||||
function_type_id=function_type_id,
|
||||
scope_kind=scope_kind,
|
||||
scope_id=scope_id,
|
||||
scope_structure_id=scope_structure_id,
|
||||
scope_relation_type_ids=list(scope_relation_type_ids),
|
||||
name_pattern=name_pattern,
|
||||
address_pattern=address_pattern,
|
||||
classification=classification,
|
||||
@@ -3188,6 +3459,8 @@ class PostboxService:
|
||||
tenant_id=tenant_id,
|
||||
scope_kind=revision.scope_kind,
|
||||
scope_id=revision.scope_id,
|
||||
scope_structure_id=revision.scope_structure_id,
|
||||
scope_relation_type_ids=tuple(revision.scope_relation_type_ids or ()),
|
||||
):
|
||||
raise PostboxError(
|
||||
"unit_out_of_scope",
|
||||
@@ -3207,44 +3480,21 @@ class PostboxService:
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
variables = {
|
||||
"template_slug": template.slug,
|
||||
"template_name": template.name,
|
||||
"unit_id": unit.id,
|
||||
"unit_slug": unit.slug,
|
||||
"unit_name": unit.name,
|
||||
"function_id": function.id,
|
||||
"function_slug": function.slug,
|
||||
"function_name": function.name,
|
||||
"context_key": context_key or "",
|
||||
}
|
||||
try:
|
||||
rendered_name = revision.name_pattern.format_map(variables).strip()
|
||||
rendered_address = (
|
||||
revision.address_pattern.format_map(variables).strip()
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise PostboxError(
|
||||
"invalid_template_pattern",
|
||||
f"Unknown Postbox template variable: {exc.args[0]}",
|
||||
) from exc
|
||||
address = (
|
||||
".".join(
|
||||
part
|
||||
for part in (
|
||||
_slug(rendered_address),
|
||||
_slug(context_key) if context_key else "",
|
||||
)
|
||||
if part
|
||||
)
|
||||
+ "@postbox"
|
||||
)[:500]
|
||||
rendered_name, address = self._render_template_target(
|
||||
template_slug=template.slug,
|
||||
template_name=template.name,
|
||||
unit=unit,
|
||||
function=function,
|
||||
context_key=context_key,
|
||||
name_pattern=revision.name_pattern,
|
||||
address_pattern=revision.address_pattern,
|
||||
)
|
||||
return self._create_postbox_records(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
address_key=key,
|
||||
address=address,
|
||||
name=rendered_name or f"{unit.name} / {function.name}",
|
||||
name=rendered_name,
|
||||
description=template.description,
|
||||
classification=revision.classification,
|
||||
organization_unit=unit,
|
||||
@@ -4340,6 +4590,8 @@ class PostboxService:
|
||||
tenant_id: str,
|
||||
scope_kind: str,
|
||||
scope_id: str | None,
|
||||
scope_structure_id: str | None = None,
|
||||
scope_relation_type_ids: Sequence[str] = (),
|
||||
) -> None:
|
||||
if scope_kind not in {"tenant", "unit", "subtree", "unit_type"}:
|
||||
raise PostboxError(
|
||||
@@ -4347,10 +4599,10 @@ class PostboxService:
|
||||
"Postbox template scope must be tenant, unit, subtree, or unit_type.",
|
||||
)
|
||||
if scope_kind == "tenant":
|
||||
if scope_id:
|
||||
if scope_id or scope_structure_id or scope_relation_type_ids:
|
||||
raise PostboxError(
|
||||
"invalid_scope_id",
|
||||
"Tenant-scoped Postbox templates do not take a scope id.",
|
||||
"Tenant-scoped Postbox templates do not take hierarchy scope fields.",
|
||||
)
|
||||
return
|
||||
if not scope_id:
|
||||
@@ -4365,6 +4617,51 @@ class PostboxService:
|
||||
"scope_unit_not_found",
|
||||
"The Postbox template scope unit was not found.",
|
||||
)
|
||||
if scope_kind != "subtree":
|
||||
if scope_structure_id or scope_relation_type_ids:
|
||||
raise PostboxError(
|
||||
"invalid_scope_structure",
|
||||
"Only subtree scopes can select a hierarchy structure.",
|
||||
)
|
||||
return
|
||||
if scope_relation_type_ids and not scope_structure_id:
|
||||
raise PostboxError(
|
||||
"scope_structure_required",
|
||||
"Subtree relation filters require an organization structure.",
|
||||
)
|
||||
if not scope_structure_id:
|
||||
return
|
||||
hierarchy = self._hierarchy_directory()
|
||||
if hierarchy is None:
|
||||
raise PostboxError(
|
||||
"organization_hierarchy_unavailable",
|
||||
"The organization hierarchy required for this subtree scope is unavailable.",
|
||||
)
|
||||
catalog = hierarchy.hierarchy_catalog(tenant_id)
|
||||
structures = {
|
||||
item.id: item
|
||||
for item in catalog.structures
|
||||
if item.tenant_id == tenant_id and item.status == "active"
|
||||
}
|
||||
if scope_structure_id not in structures:
|
||||
raise PostboxError(
|
||||
"scope_structure_not_found",
|
||||
"The selected organization structure is not active in this tenant.",
|
||||
)
|
||||
allowed_relation_ids = {
|
||||
item.id
|
||||
for item in catalog.relation_types
|
||||
if item.tenant_id == tenant_id
|
||||
and item.structure_id == scope_structure_id
|
||||
and item.status == "active"
|
||||
and item.is_hierarchical
|
||||
}
|
||||
invalid_relation_ids = set(scope_relation_type_ids) - allowed_relation_ids
|
||||
if invalid_relation_ids:
|
||||
raise PostboxError(
|
||||
"scope_relation_type_invalid",
|
||||
"A selected relation type does not belong to the active hierarchy structure.",
|
||||
)
|
||||
|
||||
def _unit_in_scope(
|
||||
self,
|
||||
@@ -4373,6 +4670,8 @@ class PostboxService:
|
||||
tenant_id: str,
|
||||
scope_kind: str,
|
||||
scope_id: str | None,
|
||||
scope_structure_id: str | None = None,
|
||||
scope_relation_type_ids: Sequence[str] = (),
|
||||
) -> bool:
|
||||
if unit.tenant_id != tenant_id:
|
||||
return False
|
||||
@@ -4381,11 +4680,213 @@ class PostboxService:
|
||||
if scope_kind == "unit":
|
||||
return unit.id == scope_id
|
||||
if scope_kind == "subtree" and scope_id:
|
||||
return self._is_descendant(unit.id, scope_id)
|
||||
if unit.id == scope_id:
|
||||
return True
|
||||
if not scope_structure_id:
|
||||
return self._is_descendant(unit.id, scope_id)
|
||||
hierarchy = self._hierarchy_directory()
|
||||
if hierarchy is None:
|
||||
return False
|
||||
resolutions = hierarchy.resolve_hierarchy_paths(
|
||||
tenant_id,
|
||||
((scope_id, unit.id),),
|
||||
structure_id=scope_structure_id,
|
||||
relation_type_ids=tuple(scope_relation_type_ids),
|
||||
direction="descendants",
|
||||
max_depth=100,
|
||||
)
|
||||
return bool(
|
||||
resolutions
|
||||
and resolutions[0].status == "active"
|
||||
and not resolutions[0].cycle_detected
|
||||
)
|
||||
if scope_kind == "unit_type":
|
||||
return unit.unit_type_id == scope_id
|
||||
return False
|
||||
|
||||
def _hierarchy_directory(self) -> OrganizationHierarchyDirectory | None:
|
||||
if self._hierarchy is not None:
|
||||
return self._hierarchy
|
||||
if isinstance(self._organizations, OrganizationHierarchyDirectory):
|
||||
return self._organizations
|
||||
return None
|
||||
|
||||
def _template_preview_scope_units(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_kind: str,
|
||||
scope_id: str | None,
|
||||
scope_structure_id: str | None,
|
||||
scope_relation_type_ids: Sequence[str],
|
||||
) -> tuple[
|
||||
tuple[OrganizationUnitRef, ...],
|
||||
dict[str, tuple[str, ...]],
|
||||
list[str],
|
||||
]:
|
||||
all_units = tuple(
|
||||
unit
|
||||
for unit in self._organizations.organization_units_for_tenant(
|
||||
tenant_id
|
||||
)
|
||||
if unit.tenant_id == tenant_id and unit.status == "active"
|
||||
)
|
||||
diagnostics: list[str] = []
|
||||
diagnostics_by_unit: dict[str, tuple[str, ...]] = {}
|
||||
if scope_kind == "tenant":
|
||||
selected = all_units
|
||||
elif scope_kind == "unit":
|
||||
selected = tuple(unit for unit in all_units if unit.id == scope_id)
|
||||
elif scope_kind == "unit_type":
|
||||
selected = tuple(
|
||||
unit for unit in all_units if unit.unit_type_id == scope_id
|
||||
)
|
||||
else:
|
||||
hierarchy = self._hierarchy_directory()
|
||||
if hierarchy is None or not scope_id or not scope_structure_id:
|
||||
raise PostboxError(
|
||||
"organization_hierarchy_unavailable",
|
||||
"The selected organization hierarchy is unavailable for preview.",
|
||||
)
|
||||
resolutions = hierarchy.resolve_hierarchy_relatives(
|
||||
tenant_id,
|
||||
(scope_id,),
|
||||
structure_id=scope_structure_id,
|
||||
relation_type_ids=tuple(scope_relation_type_ids),
|
||||
direction="descendants",
|
||||
max_depth=100,
|
||||
)
|
||||
resolution = resolutions[0] if resolutions else None
|
||||
if resolution is None or resolution.status not in {
|
||||
"active",
|
||||
"inactive",
|
||||
}:
|
||||
reason = (
|
||||
resolution.status if resolution is not None else "missing"
|
||||
)
|
||||
raise PostboxError(
|
||||
"organization_hierarchy_unavailable",
|
||||
f"The hierarchy scope could not be resolved ({reason}).",
|
||||
)
|
||||
selected_by_id: dict[str, OrganizationUnitRef] = {}
|
||||
if resolution.root is not None and resolution.root.status == "active":
|
||||
selected_by_id[resolution.root.id] = resolution.root
|
||||
duplicate_ids: set[str] = set()
|
||||
for match in resolution.matches:
|
||||
if match.unit.status != "active":
|
||||
continue
|
||||
if match.unit.id in selected_by_id:
|
||||
duplicate_ids.add(match.unit.id)
|
||||
selected_by_id[match.unit.id] = match.unit
|
||||
if duplicate_ids:
|
||||
diagnostics.append("ambiguous_scope_paths")
|
||||
diagnostics_by_unit.update(
|
||||
{
|
||||
unit_id: ("ambiguous_scope_path",)
|
||||
for unit_id in duplicate_ids
|
||||
}
|
||||
)
|
||||
if resolution.cycle_detected:
|
||||
diagnostics.append("hierarchy_cycle_detected")
|
||||
if resolution.depth_limited:
|
||||
diagnostics.append("hierarchy_depth_limited")
|
||||
diagnostics.extend(resolution.diagnostics)
|
||||
selected = tuple(selected_by_id.values())
|
||||
return (
|
||||
tuple(
|
||||
sorted(
|
||||
selected,
|
||||
key=lambda item: (item.name.casefold(), item.id),
|
||||
)
|
||||
),
|
||||
diagnostics_by_unit,
|
||||
list(dict.fromkeys(diagnostics)),
|
||||
)
|
||||
|
||||
def _template_preview_functions(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
unit_ids: Sequence[str],
|
||||
function_type_id: str | None,
|
||||
) -> tuple[OrganizationFunctionRef, ...]:
|
||||
if not unit_ids:
|
||||
return ()
|
||||
unit_id_set = set(unit_ids)
|
||||
if function_type_id:
|
||||
hierarchy = self._hierarchy_directory()
|
||||
if hierarchy is not None:
|
||||
matches: dict[str, OrganizationFunctionRef] = {}
|
||||
for offset in range(0, len(unit_ids), 400):
|
||||
resolution = hierarchy.resolve_functions_by_type(
|
||||
tenant_id,
|
||||
function_type_id,
|
||||
organization_unit_ids=tuple(
|
||||
unit_ids[offset : offset + 400]
|
||||
),
|
||||
)
|
||||
if resolution.status in {"missing", "invalid"}:
|
||||
raise PostboxError(
|
||||
"function_type_not_found",
|
||||
"The selected function type is unavailable in this tenant.",
|
||||
)
|
||||
for function in resolution.matches:
|
||||
if (
|
||||
function.status == "active"
|
||||
and function.organization_unit_id in unit_id_set
|
||||
):
|
||||
matches[function.id] = function
|
||||
return tuple(matches.values())
|
||||
functions: list[OrganizationFunctionRef] = []
|
||||
for unit_id in unit_ids:
|
||||
functions.extend(
|
||||
function
|
||||
for function in self._organizations.functions_for_organization_unit(
|
||||
unit_id,
|
||||
include_subunits=False,
|
||||
)
|
||||
if function.tenant_id == tenant_id
|
||||
and function.status == "active"
|
||||
and (
|
||||
not function_type_id
|
||||
or function.function_type_id == function_type_id
|
||||
)
|
||||
)
|
||||
return tuple(functions)
|
||||
|
||||
def _holder_counts_for_functions(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
function_ids: Sequence[str],
|
||||
) -> dict[str, int]:
|
||||
if not function_ids:
|
||||
return {}
|
||||
counts: dict[str, int] = {}
|
||||
try:
|
||||
for offset in range(0, len(function_ids), 400):
|
||||
incumbencies = (
|
||||
self._incumbencies.organization_function_incumbencies(
|
||||
tuple(function_ids[offset : offset + 400]),
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
)
|
||||
counts.update(
|
||||
{
|
||||
function_id: len(
|
||||
{
|
||||
assignment.identity_id
|
||||
for assignment in incumbency.assignments
|
||||
if assignment.status == "active"
|
||||
}
|
||||
)
|
||||
for function_id, incumbency in incumbencies.items()
|
||||
}
|
||||
)
|
||||
except ValueError:
|
||||
return {}
|
||||
return counts
|
||||
|
||||
def _validate_patterns(
|
||||
self,
|
||||
name_pattern: str,
|
||||
@@ -4413,6 +4914,49 @@ class PostboxService:
|
||||
f"Invalid Postbox template pattern: {exc}",
|
||||
) from exc
|
||||
|
||||
def _render_template_target(
|
||||
self,
|
||||
*,
|
||||
template_slug: str,
|
||||
template_name: str,
|
||||
unit: OrganizationUnitRef,
|
||||
function: OrganizationFunctionRef,
|
||||
context_key: str | None,
|
||||
name_pattern: str,
|
||||
address_pattern: str,
|
||||
) -> tuple[str, str]:
|
||||
variables = {
|
||||
"template_slug": template_slug,
|
||||
"template_name": template_name,
|
||||
"unit_id": unit.id,
|
||||
"unit_slug": unit.slug,
|
||||
"unit_name": unit.name,
|
||||
"function_id": function.id,
|
||||
"function_slug": function.slug,
|
||||
"function_name": function.name,
|
||||
"context_key": context_key or "",
|
||||
}
|
||||
try:
|
||||
rendered_name = name_pattern.format_map(variables).strip()
|
||||
rendered_address = address_pattern.format_map(variables).strip()
|
||||
except KeyError as exc:
|
||||
raise PostboxError(
|
||||
"invalid_template_pattern",
|
||||
f"Unknown Postbox template variable: {exc.args[0]}",
|
||||
) from exc
|
||||
address = (
|
||||
".".join(
|
||||
part
|
||||
for part in (
|
||||
_slug(rendered_address),
|
||||
_slug(context_key) if context_key else "",
|
||||
)
|
||||
if part
|
||||
)
|
||||
+ "@postbox"
|
||||
)[:500]
|
||||
return rendered_name or f"{unit.name} / {function.name}", address
|
||||
|
||||
def _record_access_event(
|
||||
self,
|
||||
session: Session,
|
||||
|
||||
Reference in New Issue
Block a user