Add Postbox message authoring and concurrency
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user