Add Postbox message authoring and concurrency
This commit is contained in:
@@ -17,6 +17,7 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.core.concurrency import strong_resource_etag
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
@@ -47,6 +48,11 @@ class PostboxTemplate(Base, TimestampMixin):
|
||||
index=True,
|
||||
)
|
||||
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
resource_revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
published_revision_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
@@ -65,6 +71,14 @@ class PostboxTemplate(Base, TimestampMixin):
|
||||
order_by="PostboxTemplateRevision.revision",
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"postbox_template",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class PostboxTemplateRevision(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_template_revisions"
|
||||
@@ -264,6 +278,11 @@ class Postbox(Base, TimestampMixin):
|
||||
nullable=False,
|
||||
)
|
||||
key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
resource_revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
@@ -286,6 +305,14 @@ class Postbox(Base, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"postbox",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class PostboxBinding(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_bindings"
|
||||
@@ -376,6 +403,12 @@ class PostboxMessage(Base, TimestampMixin):
|
||||
"producer_resource_type",
|
||||
"producer_resource_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"authoring_key",
|
||||
name="uq_postbox_messages_authoring_key",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
@@ -415,6 +448,11 @@ class PostboxMessage(Base, TimestampMixin):
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
authoring_key: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
in_reply_to_message_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
@@ -774,6 +812,11 @@ class PostboxGrouping(Base, TimestampMixin):
|
||||
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(250), nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
resource_revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
@@ -786,6 +829,14 @@ class PostboxGrouping(Base, TimestampMixin):
|
||||
order_by="PostboxGroupingSource.position",
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"postbox_grouping",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class PostboxGroupingSource(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_grouping_sources"
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
"""v0.1.2 message authoring and optimistic concurrency
|
||||
|
||||
Revision ID: f5c8d0e3b7a2
|
||||
Revises: e4b7c9d2a6f1
|
||||
Create Date: 2026-07-31 16:30:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f5c8d0e3b7a2"
|
||||
down_revision = "e4b7c9d2a6f1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table_name in (
|
||||
"postbox_templates",
|
||||
"postboxes",
|
||||
"postbox_groupings",
|
||||
):
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"resource_revision",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("1"),
|
||||
)
|
||||
)
|
||||
|
||||
with op.batch_alter_table("postbox_messages") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("authoring_key", sa.String(length=255), nullable=True)
|
||||
)
|
||||
batch_op.create_index(
|
||||
"ix_postbox_messages_authoring_key",
|
||||
("authoring_key",),
|
||||
unique=False,
|
||||
)
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_postbox_messages_authoring_key",
|
||||
("tenant_id", "postbox_id", "authoring_key"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("postbox_messages") as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
"uq_postbox_messages_authoring_key",
|
||||
type_="unique",
|
||||
)
|
||||
batch_op.drop_index("ix_postbox_messages_authoring_key")
|
||||
batch_op.drop_column("authoring_key")
|
||||
|
||||
for table_name in (
|
||||
"postbox_groupings",
|
||||
"postboxes",
|
||||
"postbox_templates",
|
||||
):
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.drop_column("resource_revision")
|
||||
@@ -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(
|
||||
|
||||
@@ -49,6 +49,8 @@ class PostboxDirectoryItem(BaseModel):
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
access: PostboxAccessDecisionResponse | None = None
|
||||
resource_revision: int = Field(default=1, ge=1)
|
||||
etag: str | None = None
|
||||
|
||||
|
||||
class PostboxDirectoryResponse(BaseModel):
|
||||
@@ -91,6 +93,8 @@ class PostboxMessageItem(BaseModel):
|
||||
producer_module: str | None = None
|
||||
producer_resource_type: str | None = None
|
||||
producer_resource_id: str | None = None
|
||||
in_reply_to_message_id: str | None = None
|
||||
replaces_message_id: str | None = None
|
||||
encryption_profile: str
|
||||
key_epoch: int
|
||||
ciphertext_ref: str | None = None
|
||||
@@ -111,6 +115,20 @@ class PostboxMessageStateRequest(BaseModel):
|
||||
state: Literal["read", "acknowledged"]
|
||||
|
||||
|
||||
class PostboxMessageAuthoringPayload(BaseModel):
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
subject: str = Field(min_length=1, max_length=1000)
|
||||
body_text: str | None = None
|
||||
classification: PostboxClassification = "internal"
|
||||
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxMessageCreateRequest(PostboxMessageAuthoringPayload):
|
||||
postbox_id: str = Field(min_length=1, max_length=36)
|
||||
|
||||
|
||||
class PostboxTargetPayload(BaseModel):
|
||||
postbox_id: str | None = Field(default=None, max_length=36)
|
||||
address_key: str | None = Field(default=None, max_length=500)
|
||||
@@ -336,6 +354,10 @@ class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
|
||||
id: str
|
||||
revision: int
|
||||
@@ -354,6 +376,8 @@ class PostboxTemplateItem(BaseModel):
|
||||
description: str | None = None
|
||||
status: str
|
||||
current_revision: int
|
||||
resource_revision: int = Field(ge=1)
|
||||
etag: str
|
||||
published_revision_id: str | None = None
|
||||
revisions: list[PostboxTemplateRevisionItem] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
@@ -366,6 +390,11 @@ class PostboxTemplateListResponse(BaseModel):
|
||||
|
||||
class PostboxTemplatePublishRequest(BaseModel):
|
||||
revision: int | None = Field(default=None, ge=1)
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PostboxMutationRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PostboxMaterializeRequest(BaseModel):
|
||||
@@ -425,8 +454,14 @@ class PostboxGroupingPayload(BaseModel):
|
||||
postbox_ids: list[str] = Field(default_factory=list, max_length=250)
|
||||
|
||||
|
||||
class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PostboxGroupingItem(PostboxGroupingPayload):
|
||||
id: str
|
||||
resource_revision: int = Field(ge=1)
|
||||
etag: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -19,6 +20,7 @@ from govoplan_core.core.events import (
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.core.concurrency import MissingPreconditionError, claim_revision
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
IdentityDirectory,
|
||||
@@ -57,6 +59,7 @@ from govoplan_core.core.postbox import (
|
||||
PostboxDeliveryTemplateRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxMessageAvailability,
|
||||
PostboxMessageAuthoringRequest,
|
||||
PostboxMessageRef,
|
||||
PostboxMessageListState,
|
||||
PostboxOrganizationFunctionTargetRef,
|
||||
@@ -124,6 +127,49 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
|
||||
return dict(value or {})
|
||||
|
||||
|
||||
def _authoring_digest(
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
*,
|
||||
in_reply_to: PostboxMessage | None,
|
||||
) -> str:
|
||||
payload = {
|
||||
"subject": request.subject.strip() or "(No subject)",
|
||||
"body_text": request.body_text,
|
||||
"classification": request.classification.strip().casefold(),
|
||||
"in_reply_to_message_id": in_reply_to.id if in_reply_to else None,
|
||||
"participants": [
|
||||
{
|
||||
"kind": item.kind,
|
||||
"reference_type": item.reference_type,
|
||||
"reference_id": item.reference_id,
|
||||
"label": item.label,
|
||||
"address": item.address,
|
||||
}
|
||||
for item in request.participants
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"reference_type": item.reference_type,
|
||||
"reference_id": item.reference_id,
|
||||
"name": item.name,
|
||||
"media_type": item.media_type,
|
||||
"size_bytes": item.size_bytes,
|
||||
"digest": item.digest,
|
||||
"metadata": dict(item.metadata),
|
||||
}
|
||||
for item in request.attachments
|
||||
],
|
||||
"metadata": dict(request.metadata),
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
@@ -772,6 +818,268 @@ class PostboxService:
|
||||
db.refresh(message)
|
||||
return self._message_ref(message, account_id=actor.account_id)
|
||||
|
||||
def create_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_id: str,
|
||||
actor: PostboxActorRef,
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
) -> PostboxMessageRef:
|
||||
db = _session(session)
|
||||
postbox = self._get_postbox(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
)
|
||||
return self._author_message(
|
||||
db,
|
||||
postbox=postbox,
|
||||
actor=actor,
|
||||
request=request,
|
||||
action="send",
|
||||
in_reply_to=None,
|
||||
)
|
||||
|
||||
def reply_to_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
actor: PostboxActorRef,
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
) -> PostboxMessageRef:
|
||||
db = _session(session)
|
||||
parent = self._get_message(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
availability = _message_availability(parent)
|
||||
if availability != "available":
|
||||
raise PostboxError(
|
||||
f"message_{availability}",
|
||||
f"This Postbox message is {availability} and cannot be replied to.",
|
||||
)
|
||||
postbox = self._get_postbox(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
postbox_id=parent.postbox_id,
|
||||
)
|
||||
classification = self._validate_classification(request.classification)
|
||||
if not postbox_classification_allows(
|
||||
classification,
|
||||
parent.classification,
|
||||
):
|
||||
raise PostboxError(
|
||||
"reply_classification_too_low",
|
||||
"A reply cannot be classified below its parent message.",
|
||||
)
|
||||
return self._author_message(
|
||||
db,
|
||||
postbox=postbox,
|
||||
actor=actor,
|
||||
request=request,
|
||||
action="reply",
|
||||
in_reply_to=parent,
|
||||
)
|
||||
|
||||
def _author_message(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
postbox: Postbox,
|
||||
actor: PostboxActorRef,
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
action: Literal["send", "reply"],
|
||||
in_reply_to: PostboxMessage | None,
|
||||
) -> PostboxMessageRef:
|
||||
classification = self._validate_classification(request.classification)
|
||||
if not postbox_classification_allows(
|
||||
postbox.classification,
|
||||
classification,
|
||||
):
|
||||
raise PostboxError(
|
||||
"classification_not_allowed",
|
||||
"The message classification exceeds the Postbox classification.",
|
||||
)
|
||||
decision = self._access_decision(
|
||||
postbox,
|
||||
actor=actor,
|
||||
action=action,
|
||||
assignments=self._assignments_for_actor(
|
||||
actor,
|
||||
tenant_id=postbox.tenant_id,
|
||||
),
|
||||
holder_cache={},
|
||||
classification=classification,
|
||||
)
|
||||
if not decision.allowed:
|
||||
self._record_access_event(
|
||||
session,
|
||||
tenant_id=postbox.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
message_id=in_reply_to.id if in_reply_to else None,
|
||||
actor=actor,
|
||||
action=f"message.{action}",
|
||||
outcome="denied",
|
||||
reason_code=decision.reason_code,
|
||||
assignment_id=decision.selected_assignment_id,
|
||||
)
|
||||
raise PostboxError("access_denied", decision.explanation)
|
||||
|
||||
authoring_key = request.idempotency_key.strip()
|
||||
if not authoring_key:
|
||||
raise PostboxError(
|
||||
"idempotency_key_required",
|
||||
"Message authoring requires an idempotency key.",
|
||||
)
|
||||
digest = _authoring_digest(request, in_reply_to=in_reply_to)
|
||||
existing = (
|
||||
session.query(PostboxMessage)
|
||||
.options(
|
||||
selectinload(PostboxMessage.participants),
|
||||
selectinload(PostboxMessage.attachments),
|
||||
selectinload(PostboxMessage.receipts),
|
||||
)
|
||||
.filter(
|
||||
PostboxMessage.tenant_id == postbox.tenant_id,
|
||||
PostboxMessage.postbox_id == postbox.id,
|
||||
PostboxMessage.authoring_key == authoring_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
authoring = _mapping(existing.metadata_).get("authoring")
|
||||
existing_digest = (
|
||||
str(authoring.get("digest"))
|
||||
if isinstance(authoring, Mapping) and authoring.get("digest")
|
||||
else None
|
||||
)
|
||||
if existing_digest != digest:
|
||||
raise PostboxError(
|
||||
"idempotency_conflict",
|
||||
"The message idempotency key was already used for different content.",
|
||||
)
|
||||
return self._message_ref(existing, account_id=actor.account_id)
|
||||
|
||||
identity = (
|
||||
self._identities.get_identity(actor.identity_id)
|
||||
if actor.identity_id
|
||||
else self._identities.identity_for_account(actor.account_id)
|
||||
)
|
||||
sender_label = (
|
||||
identity.display_name
|
||||
if identity is not None and identity.display_name
|
||||
else actor.account_id
|
||||
)
|
||||
metadata = _mapping(request.metadata)
|
||||
metadata["authoring"] = {
|
||||
"digest": digest,
|
||||
"account_id": actor.account_id,
|
||||
"identity_id": actor.identity_id,
|
||||
"assignment_id": decision.selected_assignment_id,
|
||||
"action": action,
|
||||
}
|
||||
message = PostboxMessage(
|
||||
tenant_id=postbox.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
subject=request.subject.strip() or "(No subject)",
|
||||
body_text=request.body_text,
|
||||
status="sent",
|
||||
classification=classification,
|
||||
sender_label=sender_label,
|
||||
producer_module="postbox",
|
||||
producer_resource_type="account_authored_message",
|
||||
producer_resource_id=actor.account_id,
|
||||
authoring_key=authoring_key,
|
||||
in_reply_to_message_id=(in_reply_to.id if in_reply_to else None),
|
||||
encryption_profile=postbox.encryption_profile,
|
||||
key_epoch=postbox.key_epoch,
|
||||
delivered_at=utc_now(),
|
||||
metadata_=metadata,
|
||||
)
|
||||
message.participants.append(
|
||||
PostboxParticipant(
|
||||
tenant_id=postbox.tenant_id,
|
||||
kind="author",
|
||||
reference_type="account",
|
||||
reference_id=actor.account_id,
|
||||
label=sender_label,
|
||||
position=0,
|
||||
metadata_={"assignment_id": decision.selected_assignment_id},
|
||||
)
|
||||
)
|
||||
for position, participant in enumerate(request.participants, start=1):
|
||||
message.participants.append(
|
||||
PostboxParticipant(
|
||||
tenant_id=postbox.tenant_id,
|
||||
kind=participant.kind,
|
||||
reference_type=participant.reference_type,
|
||||
reference_id=participant.reference_id,
|
||||
label=participant.label,
|
||||
address=participant.address,
|
||||
position=position,
|
||||
metadata_={},
|
||||
)
|
||||
)
|
||||
for position, attachment in enumerate(request.attachments):
|
||||
message.attachments.append(
|
||||
PostboxAttachmentReference(
|
||||
tenant_id=postbox.tenant_id,
|
||||
reference_type=attachment.reference_type,
|
||||
reference_id=attachment.reference_id,
|
||||
name=attachment.name,
|
||||
media_type=attachment.media_type,
|
||||
size_bytes=attachment.size_bytes,
|
||||
digest=attachment.digest,
|
||||
position=position,
|
||||
metadata_=_mapping(attachment.metadata),
|
||||
)
|
||||
)
|
||||
session.add(message)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise PostboxError(
|
||||
"idempotency_conflict",
|
||||
"The message idempotency key was accepted concurrently.",
|
||||
) from exc
|
||||
self._record_access_event(
|
||||
session,
|
||||
tenant_id=postbox.tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
message_id=message.id,
|
||||
actor=actor,
|
||||
action=f"message.{action}",
|
||||
outcome="allowed",
|
||||
reason_code=decision.reason_code,
|
||||
assignment_id=decision.selected_assignment_id,
|
||||
details={"in_reply_to_message_id": message.in_reply_to_message_id},
|
||||
)
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
(
|
||||
"postbox.message.replied.v1"
|
||||
if in_reply_to is not None
|
||||
else "postbox.message.authored.v1"
|
||||
),
|
||||
tenant_id=postbox.tenant_id,
|
||||
resource_type="postbox_message",
|
||||
resource_id=message.id,
|
||||
postbox_id=postbox.id,
|
||||
actor_type="account",
|
||||
actor_id=actor.account_id,
|
||||
payload={
|
||||
"in_reply_to_message_id": message.in_reply_to_message_id,
|
||||
"classification": classification,
|
||||
"assignment_id": decision.selected_assignment_id,
|
||||
},
|
||||
)
|
||||
return self._message_ref(message, account_id=actor.account_id)
|
||||
|
||||
# Capability: delivery
|
||||
def deliver(
|
||||
self,
|
||||
@@ -2258,12 +2566,21 @@ class PostboxService:
|
||||
tenant_id: str,
|
||||
postbox_id: str,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
) -> Postbox:
|
||||
postbox = self._get_postbox(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
)
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=Postbox,
|
||||
resource=postbox,
|
||||
resource_type="postbox",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
postbox.status = "archived"
|
||||
postbox.archived_at = utc_now()
|
||||
postbox.address_record.status = "archived"
|
||||
@@ -2405,6 +2722,7 @@ class PostboxService:
|
||||
classification: str,
|
||||
allow_vacant_delivery: bool,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
routing_policy: Mapping[str, object] | None = None,
|
||||
) -> PostboxTemplate:
|
||||
classification = self._validate_classification(classification)
|
||||
@@ -2418,6 +2736,14 @@ class PostboxService:
|
||||
"template_retired",
|
||||
"A retired Postbox template cannot be revised.",
|
||||
)
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxTemplate,
|
||||
resource=template,
|
||||
resource_type="postbox_template",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
self._validate_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_kind=scope_kind,
|
||||
@@ -2470,12 +2796,21 @@ class PostboxService:
|
||||
template_id: str,
|
||||
revision_number: int | None,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
) -> PostboxTemplate:
|
||||
template = self._get_template(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
template_id=template_id,
|
||||
)
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxTemplate,
|
||||
resource=template,
|
||||
resource_type="postbox_template",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
revision = next(
|
||||
(
|
||||
item
|
||||
@@ -2519,12 +2854,21 @@ class PostboxService:
|
||||
tenant_id: str,
|
||||
template_id: str,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
) -> PostboxTemplate:
|
||||
template = self._get_template(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
template_id=template_id,
|
||||
)
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxTemplate,
|
||||
resource=template,
|
||||
resource_type="postbox_template",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
template.status = "retired"
|
||||
template.retired_at = utc_now()
|
||||
template.updated_by = actor_id
|
||||
@@ -2773,6 +3117,7 @@ class PostboxService:
|
||||
name: str,
|
||||
is_default: bool,
|
||||
postbox_ids: Sequence[str],
|
||||
expected_revision: int | None = None,
|
||||
) -> PostboxGrouping:
|
||||
grouping = (
|
||||
session.query(PostboxGrouping)
|
||||
@@ -2788,6 +3133,16 @@ class PostboxService:
|
||||
)
|
||||
if grouping_id and grouping is None:
|
||||
raise PostboxError("grouping_not_found", "Postbox grouping not found.")
|
||||
if grouping is not None:
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxGrouping,
|
||||
resource=grouping,
|
||||
resource_type="postbox_grouping",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
account_id=actor.account_id,
|
||||
)
|
||||
existing_ids = tuple(
|
||||
source.postbox_id for source in grouping.sources
|
||||
) if grouping is not None else ()
|
||||
@@ -2822,6 +3177,7 @@ class PostboxService:
|
||||
settings={},
|
||||
)
|
||||
session.add(grouping)
|
||||
session.flush()
|
||||
else:
|
||||
grouping.name = name.strip()
|
||||
grouping.is_default = is_default
|
||||
@@ -2833,8 +3189,16 @@ class PostboxService:
|
||||
PostboxGrouping.tenant_id == tenant_id,
|
||||
PostboxGrouping.account_id == actor.account_id,
|
||||
PostboxGrouping.id != grouping.id,
|
||||
PostboxGrouping.is_default.is_(True),
|
||||
)
|
||||
.update(
|
||||
{
|
||||
PostboxGrouping.is_default: False,
|
||||
PostboxGrouping.resource_revision: (
|
||||
PostboxGrouping.resource_revision + 1
|
||||
),
|
||||
}
|
||||
)
|
||||
.update({PostboxGrouping.is_default: False})
|
||||
)
|
||||
for position, postbox_id in enumerate(saved_ids):
|
||||
grouping.sources.append(
|
||||
@@ -2854,6 +3218,7 @@ class PostboxService:
|
||||
tenant_id: str,
|
||||
actor: PostboxActorRef,
|
||||
grouping_id: str,
|
||||
expected_revision: int,
|
||||
) -> None:
|
||||
grouping = (
|
||||
session.query(PostboxGrouping)
|
||||
@@ -2866,10 +3231,53 @@ class PostboxService:
|
||||
)
|
||||
if grouping is None:
|
||||
raise PostboxError("grouping_not_found", "Postbox grouping not found.")
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=PostboxGrouping,
|
||||
resource=grouping,
|
||||
resource_type="postbox_grouping",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
account_id=actor.account_id,
|
||||
)
|
||||
session.delete(grouping)
|
||||
session.flush()
|
||||
|
||||
# Internal helpers
|
||||
def _claim_resource_revision(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
model: type[Any],
|
||||
resource: Any,
|
||||
resource_type: str,
|
||||
tenant_id: str,
|
||||
expected_revision: int | None,
|
||||
account_id: str | None = None,
|
||||
) -> int:
|
||||
if expected_revision is None:
|
||||
raise MissingPreconditionError(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource.id,
|
||||
)
|
||||
filters = [
|
||||
model.id == resource.id,
|
||||
model.tenant_id == tenant_id,
|
||||
]
|
||||
if account_id is not None:
|
||||
filters.append(model.account_id == account_id)
|
||||
next_revision = claim_revision(
|
||||
session,
|
||||
model=model,
|
||||
filters=filters,
|
||||
revision_attribute="resource_revision",
|
||||
expected_revision=expected_revision,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource.id,
|
||||
)
|
||||
resource.resource_revision = next_revision
|
||||
return next_revision
|
||||
|
||||
def _get_postbox(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -3282,6 +3690,8 @@ class PostboxService:
|
||||
holder_count=holder_count,
|
||||
vacant=holder_count == 0,
|
||||
access=decision,
|
||||
resource_revision=postbox.resource_revision,
|
||||
etag=postbox.strong_etag,
|
||||
)
|
||||
|
||||
def _message_ref(
|
||||
@@ -3318,6 +3728,8 @@ class PostboxService:
|
||||
producer_module=message.producer_module,
|
||||
producer_resource_type=message.producer_resource_type,
|
||||
producer_resource_id=message.producer_resource_id,
|
||||
in_reply_to_message_id=message.in_reply_to_message_id,
|
||||
replaces_message_id=message.replaces_message_id,
|
||||
encryption_profile=message.encryption_profile,
|
||||
key_epoch=message.key_epoch,
|
||||
ciphertext_ref=message.ciphertext_ref,
|
||||
|
||||
Reference in New Issue
Block a user