1513 lines
48 KiB
Python
1513 lines
48 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import asdict
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
|
from govoplan_core.core.concurrency import (
|
|
ConcurrencyError,
|
|
MissingPreconditionError,
|
|
RevisionConflictError,
|
|
assert_revision_precondition,
|
|
)
|
|
from govoplan_core.core.postbox import (
|
|
PostboxActorRef,
|
|
PostboxAttachmentRef,
|
|
PostboxDeliveryRequest,
|
|
PostboxExternalRecipientTokenRef,
|
|
PostboxMessageAuthoringRequest,
|
|
PostboxParticipantRef,
|
|
PostboxTargetRef,
|
|
PostboxWrappedKeyRef,
|
|
)
|
|
from govoplan_core.core.files import (
|
|
PostboxFileReferenceRequest,
|
|
postbox_file_reference_provider,
|
|
)
|
|
from govoplan_core.core.encryption import encryption_content_cipher
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_postbox.backend.manifest import (
|
|
ACKNOWLEDGE_SCOPE,
|
|
BINDING_ADMIN_SCOPE,
|
|
DELIVERY_SCOPE,
|
|
READ_SCOPE,
|
|
REPLY_SCOPE,
|
|
SEND_SCOPE,
|
|
TEMPLATE_ADMIN_SCOPE,
|
|
)
|
|
from govoplan_postbox.backend.runtime import get_registry, get_service
|
|
from govoplan_postbox.backend.schemas import (
|
|
PostboxAccessDecisionResponse,
|
|
PostboxAttachmentResolutionItem,
|
|
PostboxAttachmentResolutionResponse,
|
|
PostboxDeliveryCreateRequest,
|
|
PostboxDeliveryResponse,
|
|
PostboxDirectoryItem,
|
|
PostboxDirectoryResponse,
|
|
PostboxExactCreateRequest,
|
|
PostboxGroupingItem,
|
|
PostboxGroupingListResponse,
|
|
PostboxGroupingPayload,
|
|
PostboxGroupingPolicyUpdateRequest,
|
|
PostboxGroupingUpdateRequest,
|
|
PostboxMaterializeRequest,
|
|
PostboxMessageItem,
|
|
PostboxMessageCreateRequest,
|
|
PostboxMessageAuthoringPayload,
|
|
PostboxMessageListResponse,
|
|
PostboxMessageStateRequest,
|
|
PostboxOrganizationTargetsResponse,
|
|
PostboxMutationRequest,
|
|
PostboxProtectionProfileItem,
|
|
PostboxProtectionProfileListResponse,
|
|
PostboxProtectionPolicyUpdateRequest,
|
|
PostboxProtectionTransformRequest,
|
|
PostboxProtectionTransitionCreateRequest,
|
|
PostboxProtectionTransitionItemResponse,
|
|
PostboxProtectionTransitionListResponse,
|
|
PostboxProtectionTransitionResponse,
|
|
PostboxRouteDryRunRequest,
|
|
PostboxRouteDryRunResponse,
|
|
PostboxTemplateCreateRequest,
|
|
PostboxTemplateItem,
|
|
PostboxTemplateListResponse,
|
|
PostboxTemplatePreviewRequest,
|
|
PostboxTemplatePreviewResponse,
|
|
PostboxTemplatePublishRequest,
|
|
PostboxTemplateReviseRequest,
|
|
)
|
|
from govoplan_postbox.backend.service import PostboxError
|
|
from govoplan_postbox.backend.grouping_policies import (
|
|
grouping_policy_conflicts,
|
|
normalize_postbox_grouping_policy,
|
|
)
|
|
from govoplan_postbox.backend.protection_profiles import (
|
|
POSTBOX_PROTECTION_PROFILE_DEFINITIONS,
|
|
POSTBOX_STANDARD_PROFILE,
|
|
)
|
|
from govoplan_postbox.backend.principals import (
|
|
PostboxPrincipalError,
|
|
actor_from_principal,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/postbox", tags=["postbox"])
|
|
|
|
|
|
def _require(principal: ApiPrincipal, scope: str) -> None:
|
|
if not has_scope(principal, scope):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"Missing scope: {scope}",
|
|
)
|
|
|
|
|
|
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
|
|
if any(has_scope(principal, scope) for scope in scopes):
|
|
return
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"Requires one of: {', '.join(scopes)}",
|
|
)
|
|
|
|
|
|
def _actor(
|
|
principal: ApiPrincipal,
|
|
*,
|
|
assignment_context_id: str | None = None,
|
|
) -> PostboxActorRef:
|
|
try:
|
|
return actor_from_principal(
|
|
principal,
|
|
assignment_context_id=assignment_context_id,
|
|
)
|
|
except PostboxPrincipalError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
def _http_error(exc: PostboxError) -> HTTPException:
|
|
if exc.code.endswith("_not_found") or exc.code in {
|
|
"message_not_found",
|
|
"postbox_not_found",
|
|
"template_not_found",
|
|
"revision_not_found",
|
|
"grouping_not_found",
|
|
"target_not_found",
|
|
"protection_transition_not_found",
|
|
"transition_item_not_found",
|
|
}:
|
|
code = status.HTTP_404_NOT_FOUND
|
|
elif exc.code in {"access_denied", "grouping_source_denied"}:
|
|
code = status.HTTP_403_FORBIDDEN
|
|
elif exc.code in {
|
|
"template_slug_exists",
|
|
"address_collision",
|
|
"idempotency_conflict",
|
|
"grouping_policy_conflict",
|
|
}:
|
|
code = status.HTTP_409_CONFLICT
|
|
else:
|
|
code = status.HTTP_400_BAD_REQUEST
|
|
return HTTPException(
|
|
status_code=code,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
)
|
|
|
|
|
|
def _require_mutation_precondition(
|
|
if_match: str | None,
|
|
*,
|
|
resource_type: str,
|
|
resource_id: str,
|
|
base_revision: int,
|
|
) -> None:
|
|
try:
|
|
assert_revision_precondition(
|
|
if_match,
|
|
resource_type=resource_type,
|
|
resource_id=resource_id,
|
|
submitted_base_revision=base_revision,
|
|
)
|
|
except MissingPreconditionError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
|
detail=exc.as_dict(),
|
|
) from exc
|
|
except ConcurrencyError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"code": "invalid_precondition", "message": str(exc)},
|
|
) from exc
|
|
|
|
|
|
def _concurrency_http_error(exc: ConcurrencyError) -> HTTPException:
|
|
if isinstance(exc, MissingPreconditionError):
|
|
return HTTPException(
|
|
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
|
detail=exc.as_dict(),
|
|
)
|
|
if isinstance(exc, RevisionConflictError):
|
|
return HTTPException(
|
|
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
|
detail=exc.as_dict(),
|
|
)
|
|
return HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail={"code": "concurrency_conflict", "message": str(exc)},
|
|
)
|
|
|
|
|
|
def _set_etag(response: Response, etag: str | None) -> None:
|
|
if etag:
|
|
response.headers["ETag"] = etag
|
|
|
|
|
|
def _authoring_request(
|
|
payload: PostboxMessageAuthoringPayload,
|
|
) -> PostboxMessageAuthoringRequest:
|
|
return PostboxMessageAuthoringRequest(
|
|
idempotency_key=payload.idempotency_key,
|
|
subject=payload.subject,
|
|
body_text=payload.body_text,
|
|
ciphertext_ref=payload.ciphertext_ref,
|
|
signed_manifest_ref=payload.signed_manifest_ref,
|
|
wrapped_keys=tuple(
|
|
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
|
|
),
|
|
classification=payload.classification,
|
|
participants=tuple(
|
|
PostboxParticipantRef(**participant.model_dump())
|
|
for participant in payload.participants
|
|
),
|
|
attachments=tuple(
|
|
PostboxAttachmentRef(**attachment.model_dump())
|
|
for attachment in payload.attachments
|
|
),
|
|
metadata=payload.metadata,
|
|
)
|
|
|
|
|
|
def _directory_item(value) -> PostboxDirectoryItem:
|
|
return PostboxDirectoryItem.model_validate(asdict(value))
|
|
|
|
|
|
def _message_item(value) -> PostboxMessageItem:
|
|
return PostboxMessageItem.model_validate(asdict(value))
|
|
|
|
|
|
def _template_item(template) -> PostboxTemplateItem:
|
|
return PostboxTemplateItem(
|
|
id=template.id,
|
|
tenant_id=template.tenant_id,
|
|
slug=template.slug,
|
|
name=template.name,
|
|
description=template.description,
|
|
status=template.status,
|
|
current_revision=template.current_revision,
|
|
resource_revision=template.resource_revision,
|
|
etag=template.strong_etag,
|
|
published_revision_id=template.published_revision_id,
|
|
revisions=[
|
|
{
|
|
"id": revision.id,
|
|
"revision": revision.revision,
|
|
"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,
|
|
"allow_vacant_delivery": revision.allow_vacant_delivery,
|
|
"portal_visible": revision.portal_visible,
|
|
"encryption_profile": revision.encryption_profile,
|
|
"encryption_vault_id": revision.encryption_vault_id,
|
|
"protection_policy": dict(revision.history_policy or {}),
|
|
"grouping_policy": dict(revision.grouping_policy or {}),
|
|
"history_policy": dict(revision.history_policy or {}),
|
|
"routing_policy": dict(revision.routing_policy or {}),
|
|
"retention_policy": dict(revision.retention_policy or {}),
|
|
"published_at": revision.published_at,
|
|
"created_at": revision.created_at,
|
|
}
|
|
for revision in template.revisions
|
|
],
|
|
created_at=template.created_at,
|
|
updated_at=template.updated_at,
|
|
)
|
|
|
|
|
|
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 {}
|
|
constraints = []
|
|
policy_sources = []
|
|
for source in grouping.sources:
|
|
if source.postbox_id not in visible_ids:
|
|
continue
|
|
settings = (
|
|
source.postbox.settings
|
|
if isinstance(source.postbox.settings, Mapping)
|
|
else {}
|
|
)
|
|
policy = normalize_postbox_grouping_policy(
|
|
settings.get("grouping_policy")
|
|
if isinstance(settings.get("grouping_policy"), Mapping)
|
|
else None
|
|
)
|
|
policy_sources.append(
|
|
(
|
|
source.postbox_id,
|
|
source.postbox.classification,
|
|
policy,
|
|
)
|
|
)
|
|
if policy["mode"] == "allow":
|
|
continue
|
|
constraints.append(
|
|
{
|
|
"code": (
|
|
"source_requires_separation"
|
|
if policy["mode"] == "separate"
|
|
else "classification_separation_required"
|
|
),
|
|
"mode": policy["mode"],
|
|
"postbox_id": source.postbox_id,
|
|
"reason": policy["reason"],
|
|
"enforced_by": "postbox_configuration",
|
|
}
|
|
)
|
|
count_source_ids = (
|
|
[]
|
|
if grouping_policy_conflicts(policy_sources)
|
|
else visible_source_ids
|
|
)
|
|
return PostboxGroupingItem(
|
|
id=grouping.id,
|
|
name=grouping.name,
|
|
is_default=grouping.is_default,
|
|
resource_revision=grouping.resource_revision,
|
|
etag=grouping.strong_etag,
|
|
postbox_ids=visible_source_ids,
|
|
total_count=sum(
|
|
int(counts.get(postbox_id, {}).get("total", 0))
|
|
for postbox_id in count_source_ids
|
|
),
|
|
unread_count=sum(
|
|
int(counts.get(postbox_id, {}).get("unread", 0))
|
|
for postbox_id in count_source_ids
|
|
),
|
|
constraints=constraints,
|
|
created_at=grouping.created_at,
|
|
updated_at=grouping.updated_at,
|
|
)
|
|
|
|
|
|
def _protection_transition_item(value) -> PostboxProtectionTransitionResponse:
|
|
return PostboxProtectionTransitionResponse(
|
|
id=value.id,
|
|
postbox_id=value.postbox_id,
|
|
source_profile=value.source_profile,
|
|
target_profile=value.target_profile,
|
|
source_vault_id=value.source_vault_id,
|
|
target_vault_id=value.target_vault_id,
|
|
history_mode=value.history_mode,
|
|
authority_mode=value.authority_mode,
|
|
required_quorum=value.required_quorum,
|
|
evidence_refs=list(value.evidence_refs or []),
|
|
reason=value.reason,
|
|
state=value.state,
|
|
message_count=value.message_count,
|
|
completed_count=value.completed_count,
|
|
failed_count=value.failed_count,
|
|
requested_by=value.requested_by,
|
|
activated_at=value.activated_at,
|
|
completed_at=value.completed_at,
|
|
resource_revision=value.resource_revision,
|
|
etag=value.strong_etag,
|
|
configuration_snapshot=dict(value.configuration_snapshot or {}),
|
|
items=[
|
|
PostboxProtectionTransitionItemResponse(
|
|
id=item.id,
|
|
message_id=item.message_id,
|
|
source_profile=item.source_profile,
|
|
target_profile=item.target_profile,
|
|
state=item.state,
|
|
source_digest=item.source_digest,
|
|
target_digest=item.target_digest,
|
|
completed_by=item.completed_by,
|
|
completed_at=item.completed_at,
|
|
error_code=item.error_code,
|
|
evidence=dict(item.evidence or {}),
|
|
)
|
|
for item in value.items
|
|
],
|
|
)
|
|
|
|
|
|
@router.get("/directory", response_model=PostboxDirectoryResponse)
|
|
def api_postbox_directory(
|
|
assignment_context_id: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxDirectoryResponse:
|
|
_require(principal, READ_SCOPE)
|
|
entries = get_service().list_visible_postboxes(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
actor=_actor(
|
|
principal,
|
|
assignment_context_id=assignment_context_id,
|
|
),
|
|
)
|
|
return PostboxDirectoryResponse(
|
|
postboxes=[_directory_item(entry) for entry in entries]
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/directory/{postbox_id}/access",
|
|
response_model=PostboxAccessDecisionResponse,
|
|
)
|
|
def api_postbox_access(
|
|
postbox_id: str,
|
|
action: Literal[
|
|
"discover",
|
|
"read",
|
|
"send",
|
|
"reply",
|
|
"acknowledge",
|
|
"administer",
|
|
] = "read",
|
|
assignment_context_id: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxAccessDecisionResponse:
|
|
_require(principal, READ_SCOPE)
|
|
try:
|
|
decision = get_service().explain_access(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_id=postbox_id,
|
|
actor=_actor(
|
|
principal,
|
|
assignment_context_id=assignment_context_id,
|
|
),
|
|
action=action,
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
return PostboxAccessDecisionResponse.model_validate(asdict(decision))
|
|
|
|
|
|
@router.get("/messages", response_model=PostboxMessageListResponse)
|
|
def api_list_postbox_messages(
|
|
postbox_id: list[str] = Query(default=[]),
|
|
assignment_context_id: str | None = None,
|
|
q: str | None = Query(default=None, max_length=200),
|
|
state_filter: Literal[
|
|
"all",
|
|
"unread",
|
|
"read",
|
|
"acknowledged",
|
|
] = Query(default="all", alias="state"),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxMessageListResponse:
|
|
_require(principal, READ_SCOPE)
|
|
actor = _actor(
|
|
principal,
|
|
assignment_context_id=assignment_context_id,
|
|
)
|
|
requested = tuple(postbox_id)
|
|
if not requested:
|
|
requested = tuple(
|
|
entry.id
|
|
for entry in get_service().list_visible_postboxes(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
actor=actor,
|
|
)
|
|
)
|
|
messages = get_service().list_messages(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_ids=requested,
|
|
actor=actor,
|
|
limit=limit,
|
|
offset=offset,
|
|
query=q,
|
|
state=state_filter,
|
|
)
|
|
total = get_service().count_messages(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_ids=requested,
|
|
actor=actor,
|
|
query=q,
|
|
state=state_filter,
|
|
)
|
|
return PostboxMessageListResponse(
|
|
messages=[_message_item(message) for message in messages],
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/messages",
|
|
response_model=PostboxMessageItem,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_postbox_message(
|
|
payload: PostboxMessageCreateRequest,
|
|
assignment_context_id: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxMessageItem:
|
|
_require(principal, SEND_SCOPE)
|
|
try:
|
|
message = get_service().create_message(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_id=payload.postbox_id,
|
|
actor=_actor(
|
|
principal,
|
|
assignment_context_id=assignment_context_id,
|
|
),
|
|
request=_authoring_request(payload),
|
|
)
|
|
except PostboxError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
session.commit()
|
|
return _message_item(message)
|
|
|
|
|
|
@router.post(
|
|
"/messages/{message_id}/replies",
|
|
response_model=PostboxMessageItem,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_reply_to_postbox_message(
|
|
message_id: str,
|
|
payload: PostboxMessageAuthoringPayload,
|
|
assignment_context_id: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxMessageItem:
|
|
_require(principal, REPLY_SCOPE)
|
|
try:
|
|
message = get_service().reply_to_message(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
message_id=message_id,
|
|
actor=_actor(
|
|
principal,
|
|
assignment_context_id=assignment_context_id,
|
|
),
|
|
request=_authoring_request(payload),
|
|
)
|
|
except PostboxError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
session.commit()
|
|
return _message_item(message)
|
|
|
|
|
|
@router.get("/messages/{message_id}", response_model=PostboxMessageItem)
|
|
def api_get_postbox_message(
|
|
message_id: str,
|
|
assignment_context_id: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxMessageItem:
|
|
_require(principal, READ_SCOPE)
|
|
try:
|
|
message = get_service().get_message(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
message_id=message_id,
|
|
actor=_actor(
|
|
principal,
|
|
assignment_context_id=assignment_context_id,
|
|
),
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
if message is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Postbox message not found.",
|
|
)
|
|
session.commit()
|
|
return _message_item(message)
|
|
|
|
|
|
@router.get(
|
|
"/messages/{message_id}/attachment-resolutions",
|
|
response_model=PostboxAttachmentResolutionResponse,
|
|
)
|
|
def api_resolve_postbox_message_attachments(
|
|
message_id: str,
|
|
assignment_context_id: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxAttachmentResolutionResponse:
|
|
_require(principal, READ_SCOPE)
|
|
try:
|
|
message = get_service().get_message(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
message_id=message_id,
|
|
actor=_actor(
|
|
principal,
|
|
assignment_context_id=assignment_context_id,
|
|
),
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
if message is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Postbox message not found.",
|
|
)
|
|
|
|
provider = postbox_file_reference_provider(get_registry())
|
|
file_types = {
|
|
"file",
|
|
"file_asset",
|
|
"files:file",
|
|
"file_version",
|
|
"files:file_version",
|
|
}
|
|
requests = tuple(
|
|
PostboxFileReferenceRequest(
|
|
reference_type=attachment.reference_type,
|
|
reference_id=attachment.reference_id,
|
|
postbox_id=message.postbox_id,
|
|
message_id=message.id,
|
|
)
|
|
for attachment in message.attachments
|
|
if attachment.reference_type.strip().casefold() in file_types
|
|
)
|
|
resolved = (
|
|
provider.resolve_postbox_references(
|
|
session,
|
|
principal,
|
|
tenant_id=principal.tenant_id,
|
|
requests=requests,
|
|
)
|
|
if provider is not None and requests
|
|
else ()
|
|
)
|
|
by_reference = {(item.reference_type, item.reference_id): item for item in resolved}
|
|
items: list[PostboxAttachmentResolutionItem] = []
|
|
for attachment in message.attachments:
|
|
resolution = by_reference.get(
|
|
(attachment.reference_type, attachment.reference_id)
|
|
)
|
|
is_file = attachment.reference_type.strip().casefold() in file_types
|
|
attachment_payload = asdict(attachment)
|
|
attachment_payload.update(
|
|
{
|
|
"name": (
|
|
resolution.filename
|
|
if resolution and resolution.filename
|
|
else attachment.name
|
|
),
|
|
"media_type": (
|
|
resolution.content_type
|
|
if resolution and resolution.content_type
|
|
else attachment.media_type
|
|
),
|
|
"size_bytes": (
|
|
resolution.size_bytes
|
|
if resolution and resolution.size_bytes is not None
|
|
else attachment.size_bytes
|
|
),
|
|
"digest": (
|
|
resolution.sha256
|
|
if resolution and resolution.sha256
|
|
else attachment.digest
|
|
),
|
|
}
|
|
)
|
|
items.append(
|
|
PostboxAttachmentResolutionItem(
|
|
**attachment_payload,
|
|
available=bool(resolution and resolution.available),
|
|
reason_code=(
|
|
resolution.reason_code
|
|
if resolution is not None
|
|
else (
|
|
"files_provider_unavailable"
|
|
if is_file
|
|
else "reference_provider_unavailable"
|
|
)
|
|
),
|
|
file_asset_id=resolution.file_asset_id if resolution else None,
|
|
file_version_id=resolution.file_version_id if resolution else None,
|
|
download_path=resolution.download_path if resolution else None,
|
|
provenance=dict(resolution.provenance) if resolution else {},
|
|
)
|
|
)
|
|
session.commit()
|
|
return PostboxAttachmentResolutionResponse(attachments=items)
|
|
|
|
|
|
@router.patch(
|
|
"/messages/{message_id}/state",
|
|
response_model=PostboxMessageItem,
|
|
)
|
|
def api_mark_postbox_message(
|
|
message_id: str,
|
|
payload: PostboxMessageStateRequest,
|
|
assignment_context_id: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxMessageItem:
|
|
_require(
|
|
principal,
|
|
ACKNOWLEDGE_SCOPE if payload.state == "acknowledged" else READ_SCOPE,
|
|
)
|
|
try:
|
|
message = get_service().mark_message(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
message_id=message_id,
|
|
actor=_actor(
|
|
principal,
|
|
assignment_context_id=assignment_context_id,
|
|
),
|
|
state=payload.state,
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
session.commit()
|
|
return _message_item(message)
|
|
|
|
|
|
@router.post(
|
|
"/deliveries",
|
|
response_model=PostboxDeliveryResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_deliver_to_postbox(
|
|
payload: PostboxDeliveryCreateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxDeliveryResponse:
|
|
_require(principal, DELIVERY_SCOPE)
|
|
request = PostboxDeliveryRequest(
|
|
tenant_id=principal.tenant_id,
|
|
target=PostboxTargetRef(**payload.target.model_dump()),
|
|
producer_module=payload.producer_module,
|
|
producer_resource_type=payload.producer_resource_type,
|
|
producer_resource_id=payload.producer_resource_id,
|
|
idempotency_key=payload.idempotency_key,
|
|
subject=payload.subject,
|
|
body_text=payload.body_text,
|
|
sender_label=payload.sender_label,
|
|
classification=payload.classification,
|
|
participants=tuple(
|
|
PostboxParticipantRef(**participant.model_dump())
|
|
for participant in payload.participants
|
|
),
|
|
attachments=tuple(
|
|
PostboxAttachmentRef(**attachment.model_dump())
|
|
for attachment in payload.attachments
|
|
),
|
|
expires_at=payload.expires_at,
|
|
ciphertext_ref=payload.ciphertext_ref,
|
|
signed_manifest_ref=payload.signed_manifest_ref,
|
|
wrapped_keys=tuple(
|
|
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
|
|
),
|
|
external_recipient_tokens=tuple(
|
|
PostboxExternalRecipientTokenRef(**item.model_dump())
|
|
for item in payload.external_recipient_tokens
|
|
),
|
|
metadata=payload.metadata,
|
|
)
|
|
try:
|
|
result = get_service().deliver(session, request)
|
|
except PostboxError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
session.commit()
|
|
return PostboxDeliveryResponse.model_validate(asdict(result))
|
|
|
|
|
|
@router.post(
|
|
"/routing/dry-run",
|
|
response_model=PostboxRouteDryRunResponse,
|
|
)
|
|
def api_preview_postbox_routing(
|
|
payload: PostboxRouteDryRunRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxRouteDryRunResponse:
|
|
_require_any(principal, DELIVERY_SCOPE, TEMPLATE_ADMIN_SCOPE)
|
|
try:
|
|
result = get_service().preview_hierarchy_routes(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
target=PostboxTargetRef(**payload.target.model_dump()),
|
|
producer_module=payload.producer_module,
|
|
classification=payload.classification,
|
|
expires_at=payload.expires_at,
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
return PostboxRouteDryRunResponse.model_validate(result)
|
|
|
|
|
|
@router.get("/groupings", response_model=PostboxGroupingListResponse)
|
|
def api_list_postbox_groupings(
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxGroupingListResponse:
|
|
_require(principal, READ_SCOPE)
|
|
actor = _actor(principal)
|
|
visible_ids = {
|
|
item.id
|
|
for item in get_service().list_visible_postboxes(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
actor=actor,
|
|
)
|
|
}
|
|
groupings = get_service().list_groupings(
|
|
session,
|
|
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,
|
|
counts_by_postbox=counts_by_postbox,
|
|
)
|
|
for grouping in groupings
|
|
]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/groupings",
|
|
response_model=PostboxGroupingItem,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_postbox_grouping(
|
|
payload: PostboxGroupingPayload,
|
|
response: Response,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxGroupingItem:
|
|
_require(principal, READ_SCOPE)
|
|
actor = _actor(principal)
|
|
try:
|
|
grouping = get_service().save_grouping(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
actor=actor,
|
|
grouping_id=None,
|
|
**payload.model_dump(),
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
session.commit()
|
|
item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem)
|
|
def api_update_postbox_grouping(
|
|
grouping_id: str,
|
|
payload: PostboxGroupingUpdateRequest,
|
|
response: Response,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxGroupingItem:
|
|
_require(principal, READ_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox_grouping",
|
|
resource_id=grouping_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
actor = _actor(principal)
|
|
try:
|
|
grouping = get_service().save_grouping(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
actor=actor,
|
|
grouping_id=grouping_id,
|
|
expected_revision=payload.base_revision,
|
|
**payload.model_dump(exclude={"base_revision"}),
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def api_delete_postbox_grouping(
|
|
grouping_id: str,
|
|
payload: PostboxMutationRequest,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> None:
|
|
_require(principal, READ_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox_grouping",
|
|
resource_id=grouping_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
try:
|
|
get_service().delete_grouping(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
actor=_actor(principal),
|
|
grouping_id=grouping_id,
|
|
expected_revision=payload.base_revision,
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
|
|
|
|
@router.get(
|
|
"/admin/organization-targets",
|
|
response_model=PostboxOrganizationTargetsResponse,
|
|
)
|
|
def api_postbox_organization_targets(
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxOrganizationTargetsResponse:
|
|
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
|
|
return PostboxOrganizationTargetsResponse(
|
|
units=list(get_service().organization_targets(tenant_id=principal.tenant_id)),
|
|
structures=list(
|
|
get_service().organization_hierarchy_targets(tenant_id=principal.tenant_id)
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("/admin/postboxes", response_model=PostboxDirectoryResponse)
|
|
def api_admin_postboxes(
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxDirectoryResponse:
|
|
_require(principal, BINDING_ADMIN_SCOPE)
|
|
return PostboxDirectoryResponse(
|
|
postboxes=[
|
|
_directory_item(item)
|
|
for item in get_service().list_admin_postboxes(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
)
|
|
]
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/admin/protection-profiles",
|
|
response_model=PostboxProtectionProfileListResponse,
|
|
)
|
|
def api_postbox_protection_profiles(
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxProtectionProfileListResponse:
|
|
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
|
|
managed_available = encryption_content_cipher(get_registry()) is not None
|
|
return PostboxProtectionProfileListResponse(
|
|
standard_profile=POSTBOX_STANDARD_PROFILE,
|
|
profiles=[
|
|
PostboxProtectionProfileItem(
|
|
**asdict(profile),
|
|
available=(
|
|
managed_available if profile.requires_encryption_module else True
|
|
),
|
|
)
|
|
for profile in POSTBOX_PROTECTION_PROFILE_DEFINITIONS
|
|
],
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/admin/postboxes/{postbox_id}/protection-transitions",
|
|
response_model=PostboxProtectionTransitionListResponse,
|
|
)
|
|
def api_list_postbox_protection_transitions(
|
|
postbox_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxProtectionTransitionListResponse:
|
|
_require(principal, BINDING_ADMIN_SCOPE)
|
|
try:
|
|
values = get_service().list_protection_transitions(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_id=postbox_id,
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
return PostboxProtectionTransitionListResponse(
|
|
transitions=[_protection_transition_item(value) for value in values]
|
|
)
|
|
|
|
|
|
@router.put(
|
|
"/admin/postboxes/{postbox_id}/protection-policy",
|
|
response_model=PostboxDirectoryItem,
|
|
)
|
|
def api_update_postbox_protection_policy(
|
|
postbox_id: str,
|
|
payload: PostboxProtectionPolicyUpdateRequest,
|
|
response: Response,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxDirectoryItem:
|
|
_require(principal, BINDING_ADMIN_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox",
|
|
resource_id=postbox_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
try:
|
|
get_service().update_protection_policy(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_id=postbox_id,
|
|
protection_policy=payload.protection_policy.model_dump(),
|
|
actor_id=principal.account_id,
|
|
expected_revision=payload.base_revision,
|
|
)
|
|
except PostboxError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
item = _directory_item(
|
|
get_service().resolve_postbox(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
target=PostboxTargetRef(postbox_id=postbox_id),
|
|
)
|
|
)
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.put(
|
|
"/admin/postboxes/{postbox_id}/grouping-policy",
|
|
response_model=PostboxDirectoryItem,
|
|
)
|
|
def api_update_postbox_grouping_policy(
|
|
postbox_id: str,
|
|
payload: PostboxGroupingPolicyUpdateRequest,
|
|
response: Response,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxDirectoryItem:
|
|
_require(principal, BINDING_ADMIN_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox",
|
|
resource_id=postbox_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
try:
|
|
get_service().update_grouping_policy(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_id=postbox_id,
|
|
grouping_policy=payload.grouping_policy.model_dump(),
|
|
actor_id=principal.account_id,
|
|
expected_revision=payload.base_revision,
|
|
)
|
|
except PostboxError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
item = _directory_item(
|
|
get_service().resolve_postbox(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
target=PostboxTargetRef(postbox_id=postbox_id),
|
|
)
|
|
)
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.post(
|
|
"/admin/postboxes/{postbox_id}/protection-transitions",
|
|
response_model=PostboxProtectionTransitionResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_postbox_protection_transition(
|
|
postbox_id: str,
|
|
payload: PostboxProtectionTransitionCreateRequest,
|
|
response: Response,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxProtectionTransitionResponse:
|
|
_require(principal, BINDING_ADMIN_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox",
|
|
resource_id=postbox_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
try:
|
|
value = get_service().create_protection_transition(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_id=postbox_id,
|
|
expected_revision=payload.base_revision,
|
|
actor_id=principal.account_id,
|
|
**payload.model_dump(
|
|
exclude={"base_revision", "acknowledge_irreversibility"}
|
|
),
|
|
)
|
|
except PostboxError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
item = _protection_transition_item(value)
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.post(
|
|
"/admin/postboxes/{postbox_id}/protection-transitions/{transition_id}/transform",
|
|
response_model=PostboxProtectionTransitionResponse,
|
|
)
|
|
def api_apply_postbox_protection_transform(
|
|
postbox_id: str,
|
|
transition_id: str,
|
|
payload: PostboxProtectionTransformRequest,
|
|
response: Response,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxProtectionTransitionResponse:
|
|
_require(principal, BINDING_ADMIN_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox_protection_transition",
|
|
resource_id=transition_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
try:
|
|
value = get_service().apply_client_protection_transform(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_id=postbox_id,
|
|
transition_id=transition_id,
|
|
expected_revision=payload.base_revision,
|
|
actor_id=principal.account_id,
|
|
wrapped_keys=tuple(
|
|
PostboxWrappedKeyRef(**item.model_dump())
|
|
for item in payload.wrapped_keys
|
|
),
|
|
**payload.model_dump(exclude={"base_revision", "wrapped_keys"}),
|
|
)
|
|
except PostboxError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
item = _protection_transition_item(value)
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.post(
|
|
"/admin/postboxes",
|
|
response_model=PostboxDirectoryItem,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_exact_postbox(
|
|
payload: PostboxExactCreateRequest,
|
|
response: Response,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxDirectoryItem:
|
|
_require(principal, BINDING_ADMIN_SCOPE)
|
|
try:
|
|
postbox = get_service().create_exact_postbox(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
actor_id=principal.account_id,
|
|
**payload.model_dump(),
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
session.commit()
|
|
item = _directory_item(
|
|
get_service().resolve_postbox(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
target=PostboxTargetRef(postbox_id=postbox.id),
|
|
)
|
|
)
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.delete(
|
|
"/admin/postboxes/{postbox_id}",
|
|
response_model=PostboxDirectoryItem,
|
|
)
|
|
def api_archive_postbox(
|
|
postbox_id: str,
|
|
payload: PostboxMutationRequest,
|
|
response: Response,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxDirectoryItem:
|
|
_require(principal, BINDING_ADMIN_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox",
|
|
resource_id=postbox_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
try:
|
|
postbox = get_service().archive_postbox(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
postbox_id=postbox_id,
|
|
actor_id=principal.account_id,
|
|
expected_revision=payload.base_revision,
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
item = _directory_item(
|
|
get_service().resolve_postbox(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
target=PostboxTargetRef(postbox_id=postbox.id),
|
|
)
|
|
)
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.get("/admin/templates", response_model=PostboxTemplateListResponse)
|
|
def api_postbox_templates(
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxTemplateListResponse:
|
|
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
|
return PostboxTemplateListResponse(
|
|
templates=[
|
|
_template_item(template)
|
|
for template in get_service().list_templates(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
)
|
|
]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/admin/templates",
|
|
response_model=PostboxTemplateItem,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_postbox_template(
|
|
payload: PostboxTemplateCreateRequest,
|
|
response: Response,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxTemplateItem:
|
|
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
|
try:
|
|
template = get_service().create_template(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
actor_id=principal.account_id,
|
|
**payload.model_dump(),
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
session.commit()
|
|
item = _template_item(template)
|
|
_set_etag(response, item.etag)
|
|
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,
|
|
)
|
|
def api_revise_postbox_template(
|
|
template_id: str,
|
|
payload: PostboxTemplateReviseRequest,
|
|
response: Response,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxTemplateItem:
|
|
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox_template",
|
|
resource_id=template_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
try:
|
|
template = get_service().revise_template(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
template_id=template_id,
|
|
actor_id=principal.account_id,
|
|
expected_revision=payload.base_revision,
|
|
**payload.model_dump(exclude={"base_revision"}),
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
item = _template_item(template)
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.post(
|
|
"/admin/templates/{template_id}/publish",
|
|
response_model=PostboxTemplateItem,
|
|
)
|
|
def api_publish_postbox_template(
|
|
template_id: str,
|
|
payload: PostboxTemplatePublishRequest,
|
|
response: Response,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxTemplateItem:
|
|
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox_template",
|
|
resource_id=template_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
try:
|
|
template = get_service().publish_template(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
template_id=template_id,
|
|
revision_number=payload.revision,
|
|
actor_id=principal.account_id,
|
|
expected_revision=payload.base_revision,
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
item = _template_item(template)
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.post(
|
|
"/admin/templates/{template_id}/retire",
|
|
response_model=PostboxTemplateItem,
|
|
)
|
|
def api_retire_postbox_template(
|
|
template_id: str,
|
|
payload: PostboxMutationRequest,
|
|
response: Response,
|
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxTemplateItem:
|
|
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
|
_require_mutation_precondition(
|
|
if_match,
|
|
resource_type="postbox_template",
|
|
resource_id=template_id,
|
|
base_revision=payload.base_revision,
|
|
)
|
|
try:
|
|
template = get_service().retire_template(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
template_id=template_id,
|
|
actor_id=principal.account_id,
|
|
expected_revision=payload.base_revision,
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
except ConcurrencyError as exc:
|
|
session.rollback()
|
|
raise _concurrency_http_error(exc) from exc
|
|
session.commit()
|
|
item = _template_item(template)
|
|
_set_etag(response, item.etag)
|
|
return item
|
|
|
|
|
|
@router.post(
|
|
"/admin/templates/{template_id}/materialize",
|
|
response_model=PostboxDirectoryItem,
|
|
)
|
|
def api_materialize_postbox_template(
|
|
template_id: str,
|
|
payload: PostboxMaterializeRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PostboxDirectoryItem:
|
|
_require(principal, BINDING_ADMIN_SCOPE)
|
|
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
|
try:
|
|
postbox = get_service().materialize_template(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
template_id=template_id,
|
|
actor_id=principal.account_id,
|
|
**payload.model_dump(),
|
|
)
|
|
except PostboxError as exc:
|
|
raise _http_error(exc) from exc
|
|
session.commit()
|
|
entry = get_service().resolve_postbox(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
target=PostboxTargetRef(postbox_id=postbox.id),
|
|
)
|
|
if entry is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Materialized Postbox could not be reloaded.",
|
|
)
|
|
return _directory_item(entry)
|