Add Postbox message authoring and concurrency

This commit is contained in:
2026-07-31 18:40:39 +02:00
parent 0b6ebc3c74
commit dc6f81dd33
12 changed files with 1392 additions and 68 deletions
+251 -14
View File
@@ -3,14 +3,21 @@ from __future__ import annotations
from dataclasses import asdict
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
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,
PostboxMessageAuthoringRequest,
PostboxParticipantRef,
PostboxTargetRef,
)
@@ -37,18 +44,22 @@ from govoplan_postbox.backend.schemas import (
PostboxGroupingItem,
PostboxGroupingListResponse,
PostboxGroupingPayload,
PostboxGroupingUpdateRequest,
PostboxMaterializeRequest,
PostboxMessageItem,
PostboxMessageCreateRequest,
PostboxMessageAuthoringPayload,
PostboxMessageListResponse,
PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse,
PostboxMutationRequest,
PostboxRouteDryRunRequest,
PostboxRouteDryRunResponse,
PostboxTemplateCreateRequest,
PostboxTemplateItem,
PostboxTemplateListResponse,
PostboxTemplatePublishRequest,
PostboxTemplateRevisionPayload,
PostboxTemplateReviseRequest,
)
from govoplan_postbox.backend.service import PostboxError
@@ -136,6 +147,74 @@ def _http_error(exc: PostboxError) -> HTTPException:
)
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))
@@ -153,6 +232,8 @@ def _template_item(template) -> PostboxTemplateItem:
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=[
{
@@ -184,6 +265,8 @@ def _grouping_item(grouping, *, visible_ids: set[str]) -> 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
@@ -306,6 +389,67 @@ def api_list_postbox_messages(
)
@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,
@@ -468,6 +612,7 @@ def api_list_postbox_groupings(
)
def api_create_postbox_grouping(
payload: PostboxGroupingPayload,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingItem:
@@ -484,17 +629,27 @@ def api_create_postbox_grouping(
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
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: PostboxGroupingPayload,
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(
@@ -502,30 +657,48 @@ def api_update_postbox_grouping(
tenant_id=principal.tenant_id,
actor=actor,
grouping_id=grouping_id,
**payload.model_dump(),
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()
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
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()
@@ -573,6 +746,7 @@ def api_admin_postboxes(
)
def api_create_exact_postbox(
payload: PostboxExactCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
@@ -587,13 +761,15 @@ def api_create_exact_postbox(
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _directory_item(
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(
@@ -602,27 +778,42 @@ def api_create_exact_postbox(
)
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()
return _directory_item(
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)
@@ -649,6 +840,7 @@ def api_postbox_templates(
)
def api_create_postbox_template(
payload: PostboxTemplateCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
@@ -663,7 +855,9 @@ def api_create_postbox_template(
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _template_item(template)
item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post(
@@ -672,23 +866,37 @@ def api_create_postbox_template(
)
def api_revise_postbox_template(
template_id: str,
payload: PostboxTemplateRevisionPayload,
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,
**payload.model_dump(),
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()
return _template_item(template)
item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post(
@@ -698,10 +906,18 @@ def api_revise_postbox_template(
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,
@@ -709,11 +925,17 @@ def api_publish_postbox_template(
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()
return _template_item(template)
item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post(
@@ -722,21 +944,36 @@ def api_publish_postbox_template(
)
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()
return _template_item(template)
item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post(