from __future__ import annotations 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.db.session import get_session from govoplan_postbox.backend.manifest import ( ACKNOWLEDGE_SCOPE, BINDING_ADMIN_SCOPE, CONFIDENTIAL_SCOPE, DELIVERY_SCOPE, READ_SCOPE, REPLY_SCOPE, RESTRICTED_SCOPE, SEND_SCOPE, TEMPLATE_ADMIN_SCOPE, ) from govoplan_postbox.backend.runtime import get_service from govoplan_postbox.backend.schemas import ( PostboxAccessDecisionResponse, PostboxDeliveryCreateRequest, PostboxDeliveryResponse, PostboxDirectoryItem, PostboxDirectoryResponse, PostboxExactCreateRequest, PostboxGroupingItem, PostboxGroupingListResponse, PostboxGroupingPayload, PostboxGroupingUpdateRequest, PostboxMaterializeRequest, PostboxMessageItem, PostboxMessageCreateRequest, PostboxMessageAuthoringPayload, PostboxMessageListResponse, PostboxMessageStateRequest, PostboxOrganizationTargetsResponse, PostboxMutationRequest, PostboxRouteDryRunRequest, PostboxRouteDryRunResponse, PostboxTemplateCreateRequest, PostboxTemplateItem, PostboxTemplateListResponse, PostboxTemplatePublishRequest, PostboxTemplateReviseRequest, ) from govoplan_postbox.backend.service import PostboxError 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: actions: set[str] = set() if has_scope(principal, READ_SCOPE): actions.update(("discover", "read")) if has_scope(principal, SEND_SCOPE): actions.add("send") if has_scope(principal, REPLY_SCOPE): actions.add("reply") if has_scope(principal, ACKNOWLEDGE_SCOPE): actions.add("acknowledge") if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope( principal, TEMPLATE_ADMIN_SCOPE, ): actions.add("administer") if ( assignment_context_id is not None and assignment_context_id not in principal.function_assignment_ids ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="The selected assignment context is not active for this principal.", ) selected = assignment_context_id or principal.acting_assignment_id if selected is None and len(principal.function_assignment_ids) == 1: selected = next(iter(principal.function_assignment_ids)) classifications = {"public", "internal"} if has_scope(principal, CONFIDENTIAL_SCOPE): classifications.add("confidential") if has_scope(principal, RESTRICTED_SCOPE): classifications.update(("confidential", "restricted")) return PostboxActorRef( account_id=principal.account_id, identity_id=principal.identity_id, selected_assignment_id=selected, acting_for_account_id=principal.acting_for_account_id, authorized_actions=frozenset(actions), # type: ignore[arg-type] authorized_classifications=frozenset(classifications), # type: ignore[arg-type] ) 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", }: 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", }: 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, 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, "name_pattern": revision.name_pattern, "address_pattern": revision.address_pattern, "classification": revision.classification, "allow_vacant_delivery": revision.allow_vacant_delivery, "encryption_profile": revision.encryption_profile, "encryption_vault_id": revision.encryption_vault_id, "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]) -> PostboxGroupingItem: 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 ], created_at=grouping.created_at, updated_at=grouping.updated_at, ) @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.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, ) return PostboxGroupingListResponse( groupings=[ _grouping_item(grouping, visible_ids=visible_ids) 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.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/{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)