feat: initialize governed postbox module
This commit is contained in:
1
src/govoplan_postbox/backend/__init__.py
Normal file
1
src/govoplan_postbox/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend implementation for GovOPlaN Postbox."""
|
||||
33
src/govoplan_postbox/backend/db/__init__.py
Normal file
33
src/govoplan_postbox/backend/db/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from govoplan_postbox.backend.db.models import (
|
||||
Postbox,
|
||||
PostboxAccessEvent,
|
||||
PostboxAddress,
|
||||
PostboxAttachmentReference,
|
||||
PostboxBinding,
|
||||
PostboxDelivery,
|
||||
PostboxGrouping,
|
||||
PostboxGroupingSource,
|
||||
PostboxMessage,
|
||||
PostboxMessageReceipt,
|
||||
PostboxParticipant,
|
||||
PostboxRoute,
|
||||
PostboxTemplate,
|
||||
PostboxTemplateRevision,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Postbox",
|
||||
"PostboxAccessEvent",
|
||||
"PostboxAddress",
|
||||
"PostboxAttachmentReference",
|
||||
"PostboxBinding",
|
||||
"PostboxDelivery",
|
||||
"PostboxGrouping",
|
||||
"PostboxGroupingSource",
|
||||
"PostboxMessage",
|
||||
"PostboxMessageReceipt",
|
||||
"PostboxParticipant",
|
||||
"PostboxRoute",
|
||||
"PostboxTemplate",
|
||||
"PostboxTemplateRevision",
|
||||
]
|
||||
873
src/govoplan_postbox/backend/db/models.py
Normal file
873
src/govoplan_postbox/backend/db/models.py
Normal file
@@ -0,0 +1,873 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class PostboxTemplate(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_templates"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"slug",
|
||||
name="uq_postbox_templates_tenant_slug",
|
||||
),
|
||||
Index("ix_postbox_templates_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(250), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
default="draft",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
published_revision_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
retired_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
revisions: Mapped[list["PostboxTemplateRevision"]] = relationship(
|
||||
back_populates="template",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PostboxTemplateRevision.revision",
|
||||
)
|
||||
|
||||
|
||||
class PostboxTemplateRevision(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_template_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"template_id",
|
||||
"revision",
|
||||
name="uq_postbox_template_revision_number",
|
||||
),
|
||||
Index(
|
||||
"ix_postbox_template_revisions_function_scope",
|
||||
"tenant_id",
|
||||
"function_type_id",
|
||||
"scope_kind",
|
||||
"scope_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
template_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_templates.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
function_type_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
scope_kind: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="tenant",
|
||||
nullable=False,
|
||||
)
|
||||
scope_id: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
name_pattern: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
default="{unit_name} / {function_name}",
|
||||
nullable=False,
|
||||
)
|
||||
address_pattern: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
default="{template_slug}.{unit_slug}.{function_slug}",
|
||||
nullable=False,
|
||||
)
|
||||
classification: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="internal",
|
||||
nullable=False,
|
||||
)
|
||||
allow_vacant_delivery: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
)
|
||||
encryption_profile: Mapped[str] = mapped_column(
|
||||
String(80),
|
||||
default="plaintext_v1",
|
||||
nullable=False,
|
||||
)
|
||||
history_policy: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
routing_policy: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
retention_policy: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
template: Mapped[PostboxTemplate] = relationship(back_populates="revisions")
|
||||
|
||||
|
||||
class PostboxAddress(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_addresses"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"address_key",
|
||||
name="uq_postbox_addresses_tenant_key",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"address",
|
||||
name="uq_postbox_addresses_tenant_address",
|
||||
),
|
||||
Index(
|
||||
"ix_postbox_addresses_function_scope",
|
||||
"tenant_id",
|
||||
"organization_unit_id",
|
||||
"function_id",
|
||||
"context_key",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
address_key: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
address: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
template_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("postbox_templates.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
template_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("postbox_template_revisions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
organization_unit_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
organization_unit_name: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
)
|
||||
function_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
function_name: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
function_type_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
context_key: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
default="active",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
postbox: Mapped["Postbox | None"] = relationship(
|
||||
back_populates="address_record",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
|
||||
class Postbox(Base, TimestampMixin):
|
||||
__tablename__ = "postboxes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("address_id", name="uq_postboxes_address"),
|
||||
Index("ix_postboxes_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
address_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_addresses.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
default="active",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
classification: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="internal",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
encryption_profile: Mapped[str] = mapped_column(
|
||||
String(80),
|
||||
default="plaintext_v1",
|
||||
nullable=False,
|
||||
)
|
||||
key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
address_record: Mapped[PostboxAddress] = relationship(
|
||||
back_populates="postbox",
|
||||
)
|
||||
bindings: Mapped[list["PostboxBinding"]] = relationship(
|
||||
back_populates="postbox",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
messages: Mapped[list["PostboxMessage"]] = relationship(
|
||||
back_populates="postbox",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class PostboxBinding(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_bindings"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_postbox_bindings_function_scope",
|
||||
"tenant_id",
|
||||
"organization_unit_id",
|
||||
"function_id",
|
||||
"is_active",
|
||||
),
|
||||
Index("ix_postbox_bindings_postbox_active", "postbox_id", "is_active"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
postbox_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postboxes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
binding_type: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="function",
|
||||
nullable=False,
|
||||
)
|
||||
organization_unit_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
function_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
function_type_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
applies_to_subunits: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
)
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="exact",
|
||||
nullable=False,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
postbox: Mapped[Postbox] = relationship(back_populates="bindings")
|
||||
|
||||
|
||||
class PostboxMessage(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_messages"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_postbox_messages_postbox_delivered",
|
||||
"postbox_id",
|
||||
"delivered_at",
|
||||
),
|
||||
Index("ix_postbox_messages_tenant_status", "tenant_id", "status"),
|
||||
Index(
|
||||
"ix_postbox_messages_producer",
|
||||
"tenant_id",
|
||||
"producer_module",
|
||||
"producer_resource_type",
|
||||
"producer_resource_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
postbox_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postboxes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
subject: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="delivered",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
classification: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="internal",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
sender_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
producer_module: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
producer_resource_type: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
producer_resource_id: 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,
|
||||
index=True,
|
||||
)
|
||||
replaces_message_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
encryption_profile: Mapped[str] = mapped_column(
|
||||
String(80),
|
||||
default="plaintext_v1",
|
||||
nullable=False,
|
||||
)
|
||||
key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
ciphertext_ref: Mapped[str | None] = mapped_column(
|
||||
String(1000),
|
||||
nullable=True,
|
||||
)
|
||||
signed_manifest_ref: Mapped[str | None] = mapped_column(
|
||||
String(1000),
|
||||
nullable=True,
|
||||
)
|
||||
wrapped_keys: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON,
|
||||
default=list,
|
||||
nullable=False,
|
||||
)
|
||||
delivered_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
withdrawn_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
retention_hold_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
postbox: Mapped[Postbox] = relationship(back_populates="messages")
|
||||
participants: Mapped[list["PostboxParticipant"]] = relationship(
|
||||
back_populates="message",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PostboxParticipant.position",
|
||||
)
|
||||
attachments: Mapped[list["PostboxAttachmentReference"]] = relationship(
|
||||
back_populates="message",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PostboxAttachmentReference.position",
|
||||
)
|
||||
receipts: Mapped[list["PostboxMessageReceipt"]] = relationship(
|
||||
back_populates="message",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class PostboxParticipant(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_participants"
|
||||
__table_args__ = (
|
||||
Index("ix_postbox_participants_message_kind", "message_id", "kind"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
message_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
reference_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
reference_id: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
label: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
message: Mapped[PostboxMessage] = relationship(back_populates="participants")
|
||||
|
||||
|
||||
class PostboxAttachmentReference(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_attachment_references"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_postbox_attachment_references_target",
|
||||
"tenant_id",
|
||||
"reference_type",
|
||||
"reference_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
message_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
reference_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
reference_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
media_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
digest: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
ciphertext_ref: Mapped[str | None] = mapped_column(
|
||||
String(1000),
|
||||
nullable=True,
|
||||
)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
message: Mapped[PostboxMessage] = relationship(back_populates="attachments")
|
||||
|
||||
|
||||
class PostboxDelivery(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_deliveries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"producer_module",
|
||||
"idempotency_key",
|
||||
name="uq_postbox_deliveries_producer_idempotency",
|
||||
),
|
||||
Index("ix_postbox_deliveries_postbox_status", "postbox_id", "status"),
|
||||
Index(
|
||||
"ix_postbox_deliveries_producer",
|
||||
"tenant_id",
|
||||
"producer_module",
|
||||
"producer_resource_type",
|
||||
"producer_resource_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
postbox_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postboxes.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
message_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
producer_module: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
producer_resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
producer_resource_id: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(40),
|
||||
default="accepted",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
template_revision_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
organization_unit_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
function_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
holder_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
target_snapshot: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
accepted_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class PostboxRoute(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_routes"
|
||||
__table_args__ = (
|
||||
Index("ix_postbox_routes_delivery_kind", "delivery_id", "route_kind"),
|
||||
Index("ix_postbox_routes_target", "tenant_id", "target_postbox_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
delivery_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_deliveries.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source_postbox_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postboxes.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
source_message_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
target_postbox_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("postboxes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
target_message_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
route_kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
depth: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
source_route_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("postbox_routes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
policy_snapshot: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class PostboxMessageReceipt(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_message_receipts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"message_id",
|
||||
"account_id",
|
||||
name="uq_postbox_receipts_message_account",
|
||||
),
|
||||
Index(
|
||||
"ix_postbox_receipts_account_state",
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"read_at",
|
||||
"acknowledged_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
message_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
identity_id: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
assignment_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
acknowledged_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
message: Mapped[PostboxMessage] = relationship(back_populates="receipts")
|
||||
|
||||
|
||||
class PostboxGrouping(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_groupings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"name",
|
||||
name="uq_postbox_groupings_account_name",
|
||||
),
|
||||
Index("ix_postbox_groupings_account", "tenant_id", "account_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
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)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
sources: Mapped[list["PostboxGroupingSource"]] = relationship(
|
||||
back_populates="grouping",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PostboxGroupingSource.position",
|
||||
)
|
||||
|
||||
|
||||
class PostboxGroupingSource(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_grouping_sources"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"grouping_id",
|
||||
"postbox_id",
|
||||
name="uq_postbox_grouping_sources_postbox",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
grouping_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postbox_groupings.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
postbox_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("postboxes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
grouping: Mapped[PostboxGrouping] = relationship(back_populates="sources")
|
||||
|
||||
|
||||
class PostboxAccessEvent(Base, TimestampMixin):
|
||||
__tablename__ = "postbox_access_events"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_postbox_access_events_resource",
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"message_id",
|
||||
"occurred_at",
|
||||
),
|
||||
Index(
|
||||
"ix_postbox_access_events_actor",
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"occurred_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
postbox_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("postboxes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
message_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
identity_id: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
assignment_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
outcome: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
reason_code: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
occurred_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
details: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Postbox",
|
||||
"PostboxAccessEvent",
|
||||
"PostboxAddress",
|
||||
"PostboxAttachmentReference",
|
||||
"PostboxBinding",
|
||||
"PostboxDelivery",
|
||||
"PostboxGrouping",
|
||||
"PostboxGroupingSource",
|
||||
"PostboxMessage",
|
||||
"PostboxMessageReceipt",
|
||||
"PostboxParticipant",
|
||||
"PostboxRoute",
|
||||
"PostboxTemplate",
|
||||
"PostboxTemplateRevision",
|
||||
"new_uuid",
|
||||
]
|
||||
359
src/govoplan_postbox/backend/manifest.py
Normal file
359
src/govoplan_postbox/backend/manifest.py
Normal file
@@ -0,0 +1,359 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY
|
||||
from govoplan_core.core.idm import (
|
||||
CAPABILITY_IDM_DIRECTORY,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||
from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_ACCESS,
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
CAPABILITY_POSTBOX_MESSAGES,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_postbox.backend.db import models as postbox_models
|
||||
|
||||
|
||||
MODULE_ID = "postbox"
|
||||
MODULE_NAME = "Postbox"
|
||||
MODULE_VERSION = "0.1.1"
|
||||
|
||||
READ_SCOPE = "postbox:postbox:read"
|
||||
SEND_SCOPE = "postbox:message:write"
|
||||
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
|
||||
DELIVERY_SCOPE = "postbox:delivery:write"
|
||||
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
|
||||
TEMPLATE_ADMIN_SCOPE = "postbox:template:admin"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Postbox",
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View assigned postboxes",
|
||||
"Discover and read postboxes for currently effective organization-function assignments.",
|
||||
),
|
||||
_permission(
|
||||
SEND_SCOPE,
|
||||
"Send through assigned postboxes",
|
||||
"Create replies and new messages in postboxes available through the current function context.",
|
||||
),
|
||||
_permission(
|
||||
ACKNOWLEDGE_SCOPE,
|
||||
"Acknowledge postbox messages",
|
||||
"Record personal read and acknowledgement state for accessible messages.",
|
||||
),
|
||||
_permission(
|
||||
DELIVERY_SCOPE,
|
||||
"Deliver to postboxes",
|
||||
"Accept idempotent deliveries from approved platform producers.",
|
||||
),
|
||||
_permission(
|
||||
BINDING_ADMIN_SCOPE,
|
||||
"Administer postbox bindings",
|
||||
"Create, archive, and inspect exact organization-function postboxes and bindings.",
|
||||
),
|
||||
_permission(
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
"Administer postbox templates",
|
||||
"Create, revise, publish, and retire reusable function-scoped postbox templates.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="postbox_user",
|
||||
name="Postbox user",
|
||||
description="Use postboxes available through current function assignments.",
|
||||
permissions=(READ_SCOPE, SEND_SCOPE, ACKNOWLEDGE_SCOPE),
|
||||
default_authenticated=True,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="postbox_manager",
|
||||
name="Postbox manager",
|
||||
description="Administer postbox templates and concrete function-bound containers.",
|
||||
permissions=(
|
||||
READ_SCOPE,
|
||||
SEND_SCOPE,
|
||||
ACKNOWLEDGE_SCOPE,
|
||||
DELIVERY_SCOPE,
|
||||
BINDING_ADMIN_SCOPE,
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _configure(context: ModuleContext):
|
||||
from govoplan_postbox.backend.runtime import configure_runtime, get_service
|
||||
|
||||
configure_runtime(registry=context.registry)
|
||||
return get_service()
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
_configure(context)
|
||||
from govoplan_postbox.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
return {
|
||||
"postboxes": session.query(postbox_models.Postbox)
|
||||
.filter(
|
||||
postbox_models.Postbox.tenant_id == tenant_id,
|
||||
postbox_models.Postbox.status == "active",
|
||||
)
|
||||
.count(),
|
||||
"postbox_messages": session.query(postbox_models.PostboxMessage)
|
||||
.filter(postbox_models.PostboxMessage.tenant_id == tenant_id)
|
||||
.count(),
|
||||
"vacant_deliveries": session.query(postbox_models.PostboxDelivery)
|
||||
.filter(
|
||||
postbox_models.PostboxDelivery.tenant_id == tenant_id,
|
||||
postbox_models.PostboxDelivery.status == "accepted_vacant",
|
||||
)
|
||||
.count(),
|
||||
}
|
||||
|
||||
|
||||
_OWNED_TABLES = (
|
||||
postbox_models.PostboxAccessEvent,
|
||||
postbox_models.PostboxGroupingSource,
|
||||
postbox_models.PostboxGrouping,
|
||||
postbox_models.PostboxMessageReceipt,
|
||||
postbox_models.PostboxRoute,
|
||||
postbox_models.PostboxDelivery,
|
||||
postbox_models.PostboxAttachmentReference,
|
||||
postbox_models.PostboxParticipant,
|
||||
postbox_models.PostboxMessage,
|
||||
postbox_models.PostboxBinding,
|
||||
postbox_models.Postbox,
|
||||
postbox_models.PostboxAddress,
|
||||
postbox_models.PostboxTemplateRevision,
|
||||
postbox_models.PostboxTemplate,
|
||||
)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("identity", "organizations", "idm"),
|
||||
optional_dependencies=(
|
||||
"access",
|
||||
"audit",
|
||||
"campaigns",
|
||||
"files",
|
||||
"mail",
|
||||
"notifications",
|
||||
"policy",
|
||||
"portal",
|
||||
"views",
|
||||
"workflow",
|
||||
),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
CAPABILITY_IDM_DIRECTORY,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
),
|
||||
provides_interfaces=tuple(
|
||||
ModuleInterfaceProvider(name=name, version=MODULE_VERSION)
|
||||
for name in (
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
CAPABILITY_POSTBOX_ACCESS,
|
||||
CAPABILITY_POSTBOX_MESSAGES,
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
)
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
version_min="0.1.8",
|
||||
version_max_exclusive="0.2.0",
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
version_min="0.1.8",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/postbox",
|
||||
label="Postbox",
|
||||
icon="inbox",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=58,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/postbox-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/postbox",
|
||||
component="PostboxPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=58,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/postbox",
|
||||
label="Postbox",
|
||||
icon="inbox",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=58,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="postbox.inbox.directory",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Postbox directory",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="postbox.inbox.messages",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Postbox messages",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="postbox.widget.inbox",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Postbox inbox widget",
|
||||
order=25,
|
||||
),
|
||||
ViewSurface(
|
||||
id="postbox.admin.templates",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Postbox templates and bindings",
|
||||
order=30,
|
||||
),
|
||||
),
|
||||
),
|
||||
route_factory=_router,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
*_OWNED_TABLES,
|
||||
label="Postbox",
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement removes Postbox templates, addresses, "
|
||||
"messages, delivery evidence, receipts, groupings, and access events "
|
||||
"after the installer captures a database snapshot."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
postbox_models.Postbox,
|
||||
postbox_models.PostboxMessage,
|
||||
postbox_models.PostboxDelivery,
|
||||
label="Postbox",
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_POSTBOX_DIRECTORY: _configure,
|
||||
CAPABILITY_POSTBOX_ACCESS: _configure,
|
||||
CAPABILITY_POSTBOX_MESSAGES: _configure,
|
||||
CAPABILITY_POSTBOX_DELIVERY: _configure,
|
||||
CAPABILITY_POSTBOX_EVIDENCE: _configure,
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="postbox.function-bound-containers",
|
||||
title="Function-bound Postboxes",
|
||||
summary=(
|
||||
"Durable institutional message containers whose access follows "
|
||||
"effective organization-function assignments."
|
||||
),
|
||||
body=(
|
||||
"Postboxes belong to responsibilities, not individual accounts. "
|
||||
"A stable postbox remains addressable during vacancy and "
|
||||
"reassignment. Current access combines a generic Postbox "
|
||||
"permission with effective IDM assignment context. Templates "
|
||||
"can lazily materialize unit-specific addresses, while exact "
|
||||
"postboxes cover exceptional responsibilities. The first "
|
||||
"implementation persists plaintext but reserves explicit "
|
||||
"ciphertext, signed-manifest, and key-epoch fields; it does not "
|
||||
"claim end-to-end encryption until a trusted policy profile is selected."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("administrator", "user", "campaign_manager"),
|
||||
related_modules=(
|
||||
"identity",
|
||||
"idm",
|
||||
"organizations",
|
||||
"campaigns",
|
||||
"files",
|
||||
"notifications",
|
||||
),
|
||||
order=35,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
1
src/govoplan_postbox/backend/migrations/__init__.py
Normal file
1
src/govoplan_postbox/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Postbox-owned Alembic migrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Postbox release migration lineage."""
|
||||
@@ -0,0 +1,798 @@
|
||||
"""v0.1.0 Postbox baseline
|
||||
|
||||
Revision ID: c7d2e5f8a1b4
|
||||
Revises: None
|
||||
Depends on: 8f9a0b1c2d3e
|
||||
Create Date: 2026-07-28 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c7d2e5f8a1b4"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = "8f9a0b1c2d3e"
|
||||
|
||||
|
||||
def _timestamps() -> tuple[sa.Column, sa.Column]:
|
||||
return (
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"postbox_templates",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=120), nullable=False),
|
||||
sa.Column("name", sa.String(length=250), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), nullable=False),
|
||||
sa.Column("current_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("published_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
*_timestamps(),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_templates")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"slug",
|
||||
name="uq_postbox_templates_tenant_slug",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_postbox_templates_tenant_id"),
|
||||
"postbox_templates",
|
||||
["tenant_id"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_postbox_templates_status"),
|
||||
"postbox_templates",
|
||||
["status"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_postbox_templates_published_revision_id"),
|
||||
"postbox_templates",
|
||||
["published_revision_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_templates_tenant_status",
|
||||
"postbox_templates",
|
||||
["tenant_id", "status"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_template_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("function_type_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("scope_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("name_pattern", sa.String(length=500), nullable=False),
|
||||
sa.Column("address_pattern", sa.String(length=500), nullable=False),
|
||||
sa.Column("classification", sa.String(length=50), nullable=False),
|
||||
sa.Column("allow_vacant_delivery", sa.Boolean(), nullable=False),
|
||||
sa.Column("encryption_profile", sa.String(length=80), nullable=False),
|
||||
sa.Column("history_policy", sa.JSON(), nullable=False),
|
||||
sa.Column("routing_policy", sa.JSON(), nullable=False),
|
||||
sa.Column("retention_policy", sa.JSON(), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"],
|
||||
["postbox_templates.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_template_revisions_template_id_postbox_templates"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_postbox_template_revisions"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"template_id",
|
||||
"revision",
|
||||
name="uq_postbox_template_revision_number",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"template_id",
|
||||
"function_type_id",
|
||||
"scope_id",
|
||||
"published_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_template_revisions_{column}"),
|
||||
"postbox_template_revisions",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_template_revisions_function_scope",
|
||||
"postbox_template_revisions",
|
||||
["tenant_id", "function_type_id", "scope_kind", "scope_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_addresses",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("address_key", sa.String(length=500), nullable=False),
|
||||
sa.Column("address", sa.String(length=500), nullable=False),
|
||||
sa.Column("template_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("template_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("organization_unit_name", sa.String(length=500), nullable=True),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("function_name", sa.String(length=500), nullable=True),
|
||||
sa.Column("function_type_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("context_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"],
|
||||
["postbox_templates.id"],
|
||||
name=op.f("fk_postbox_addresses_template_id_postbox_templates"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_revision_id"],
|
||||
["postbox_template_revisions.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_addresses_template_revision_id_postbox_template_revisions"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_addresses")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"address_key",
|
||||
name="uq_postbox_addresses_tenant_key",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"address",
|
||||
name="uq_postbox_addresses_tenant_address",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"template_id",
|
||||
"template_revision_id",
|
||||
"organization_unit_id",
|
||||
"function_id",
|
||||
"function_type_id",
|
||||
"context_key",
|
||||
"status",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_addresses_{column}"),
|
||||
"postbox_addresses",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_addresses_function_scope",
|
||||
"postbox_addresses",
|
||||
[
|
||||
"tenant_id",
|
||||
"organization_unit_id",
|
||||
"function_id",
|
||||
"context_key",
|
||||
],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postboxes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("address_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), nullable=False),
|
||||
sa.Column("classification", sa.String(length=50), nullable=False),
|
||||
sa.Column("encryption_profile", sa.String(length=80), nullable=False),
|
||||
sa.Column("key_epoch", sa.Integer(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["address_id"],
|
||||
["postbox_addresses.id"],
|
||||
name=op.f("fk_postboxes_address_id_postbox_addresses"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_postboxes")),
|
||||
sa.UniqueConstraint("address_id", name="uq_postboxes_address"),
|
||||
)
|
||||
for column in ("tenant_id", "address_id", "status", "classification"):
|
||||
op.create_index(
|
||||
op.f(f"ix_postboxes_{column}"),
|
||||
"postboxes",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postboxes_tenant_status",
|
||||
"postboxes",
|
||||
["tenant_id", "status"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_bindings",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("postbox_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("binding_type", sa.String(length=30), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("function_type_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
|
||||
sa.Column("source", sa.String(length=30), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["postbox_id"],
|
||||
["postboxes.id"],
|
||||
name=op.f("fk_postbox_bindings_postbox_id_postboxes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_bindings")),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"organization_unit_id",
|
||||
"function_id",
|
||||
"function_type_id",
|
||||
"is_active",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_bindings_{column}"),
|
||||
"postbox_bindings",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_bindings_function_scope",
|
||||
"postbox_bindings",
|
||||
[
|
||||
"tenant_id",
|
||||
"organization_unit_id",
|
||||
"function_id",
|
||||
"is_active",
|
||||
],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_bindings_postbox_active",
|
||||
"postbox_bindings",
|
||||
["postbox_id", "is_active"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_messages",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("postbox_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("subject", sa.String(length=1000), nullable=False),
|
||||
sa.Column("body_text", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("classification", sa.String(length=50), nullable=False),
|
||||
sa.Column("sender_label", sa.String(length=500), nullable=True),
|
||||
sa.Column("producer_module", sa.String(length=100), nullable=True),
|
||||
sa.Column("producer_resource_type", sa.String(length=100), nullable=True),
|
||||
sa.Column("producer_resource_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("in_reply_to_message_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("replaces_message_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("encryption_profile", sa.String(length=80), nullable=False),
|
||||
sa.Column("key_epoch", sa.Integer(), nullable=False),
|
||||
sa.Column("ciphertext_ref", sa.String(length=1000), nullable=True),
|
||||
sa.Column("signed_manifest_ref", sa.String(length=1000), nullable=True),
|
||||
sa.Column("wrapped_keys", sa.JSON(), nullable=False),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("withdrawn_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"retention_hold_until",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["postbox_id"],
|
||||
["postboxes.id"],
|
||||
name=op.f("fk_postbox_messages_postbox_id_postboxes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["in_reply_to_message_id"],
|
||||
["postbox_messages.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_messages_in_reply_to_message_id_postbox_messages"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["replaces_message_id"],
|
||||
["postbox_messages.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_messages_replaces_message_id_postbox_messages"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_messages")),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"status",
|
||||
"classification",
|
||||
"producer_module",
|
||||
"producer_resource_type",
|
||||
"producer_resource_id",
|
||||
"in_reply_to_message_id",
|
||||
"replaces_message_id",
|
||||
"delivered_at",
|
||||
"expires_at",
|
||||
"withdrawn_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_messages_{column}"),
|
||||
"postbox_messages",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_messages_postbox_delivered",
|
||||
"postbox_messages",
|
||||
["postbox_id", "delivered_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_messages_tenant_status",
|
||||
"postbox_messages",
|
||||
["tenant_id", "status"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_messages_producer",
|
||||
"postbox_messages",
|
||||
[
|
||||
"tenant_id",
|
||||
"producer_module",
|
||||
"producer_resource_type",
|
||||
"producer_resource_id",
|
||||
],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_participants",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("message_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("reference_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("reference_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("label", sa.String(length=500), nullable=True),
|
||||
sa.Column("address", sa.String(length=500), nullable=True),
|
||||
sa.Column("position", sa.Integer(), nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["message_id"],
|
||||
["postbox_messages.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_participants_message_id_postbox_messages"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_participants")),
|
||||
)
|
||||
for column in ("tenant_id", "message_id", "reference_id"):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_participants_{column}"),
|
||||
"postbox_participants",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_participants_message_kind",
|
||||
"postbox_participants",
|
||||
["message_id", "kind"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_attachment_references",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("message_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("reference_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("reference_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=1000), nullable=True),
|
||||
sa.Column("media_type", sa.String(length=255), nullable=True),
|
||||
sa.Column("size_bytes", sa.Integer(), nullable=True),
|
||||
sa.Column("digest", sa.String(length=255), nullable=True),
|
||||
sa.Column("ciphertext_ref", sa.String(length=1000), nullable=True),
|
||||
sa.Column("position", sa.Integer(), nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["message_id"],
|
||||
["postbox_messages.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_attachment_references_message_id_postbox_messages"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_postbox_attachment_references"),
|
||||
),
|
||||
)
|
||||
for column in ("tenant_id", "message_id"):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_attachment_references_{column}"),
|
||||
"postbox_attachment_references",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_attachment_references_target",
|
||||
"postbox_attachment_references",
|
||||
["tenant_id", "reference_type", "reference_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_deliveries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("postbox_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("message_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("producer_module", sa.String(length=100), nullable=False),
|
||||
sa.Column("producer_resource_type", sa.String(length=100), nullable=False),
|
||||
sa.Column("producer_resource_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("template_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("holder_count", sa.Integer(), nullable=False),
|
||||
sa.Column("target_snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["postbox_id"],
|
||||
["postboxes.id"],
|
||||
name=op.f("fk_postbox_deliveries_postbox_id_postboxes"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["message_id"],
|
||||
["postbox_messages.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_deliveries_message_id_postbox_messages"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_deliveries")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"producer_module",
|
||||
"idempotency_key",
|
||||
name="uq_postbox_deliveries_producer_idempotency",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"message_id",
|
||||
"producer_resource_id",
|
||||
"status",
|
||||
"template_revision_id",
|
||||
"organization_unit_id",
|
||||
"function_id",
|
||||
"accepted_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_deliveries_{column}"),
|
||||
"postbox_deliveries",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_deliveries_postbox_status",
|
||||
"postbox_deliveries",
|
||||
["postbox_id", "status"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_deliveries_producer",
|
||||
"postbox_deliveries",
|
||||
[
|
||||
"tenant_id",
|
||||
"producer_module",
|
||||
"producer_resource_type",
|
||||
"producer_resource_id",
|
||||
],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_routes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("delivery_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_postbox_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_message_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("target_postbox_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("target_message_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("route_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("depth", sa.Integer(), nullable=False),
|
||||
sa.Column("source_route_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("policy_snapshot", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delivery_id"],
|
||||
["postbox_deliveries.id"],
|
||||
name=op.f("fk_postbox_routes_delivery_id_postbox_deliveries"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_postbox_id"],
|
||||
["postboxes.id"],
|
||||
name=op.f("fk_postbox_routes_source_postbox_id_postboxes"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_message_id"],
|
||||
["postbox_messages.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_routes_source_message_id_postbox_messages"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["target_postbox_id"],
|
||||
["postboxes.id"],
|
||||
name=op.f("fk_postbox_routes_target_postbox_id_postboxes"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["target_message_id"],
|
||||
["postbox_messages.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_routes_target_message_id_postbox_messages"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_route_id"],
|
||||
["postbox_routes.id"],
|
||||
name=op.f("fk_postbox_routes_source_route_id_postbox_routes"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_routes")),
|
||||
)
|
||||
for column in ("tenant_id", "delivery_id", "target_postbox_id"):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_routes_{column}"),
|
||||
"postbox_routes",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_routes_delivery_kind",
|
||||
"postbox_routes",
|
||||
["delivery_id", "route_kind"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_routes_target",
|
||||
"postbox_routes",
|
||||
["tenant_id", "target_postbox_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_message_receipts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("message_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["message_id"],
|
||||
["postbox_messages.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_message_receipts_message_id_postbox_messages"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_postbox_message_receipts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"message_id",
|
||||
"account_id",
|
||||
name="uq_postbox_receipts_message_account",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"message_id",
|
||||
"account_id",
|
||||
"identity_id",
|
||||
"assignment_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_message_receipts_{column}"),
|
||||
"postbox_message_receipts",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_receipts_account_state",
|
||||
"postbox_message_receipts",
|
||||
[
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"read_at",
|
||||
"acknowledged_at",
|
||||
],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_groupings",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=250), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_groupings")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"name",
|
||||
name="uq_postbox_groupings_account_name",
|
||||
),
|
||||
)
|
||||
for column in ("tenant_id", "account_id"):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_groupings_{column}"),
|
||||
"postbox_groupings",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_groupings_account",
|
||||
"postbox_groupings",
|
||||
["tenant_id", "account_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_grouping_sources",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("grouping_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("postbox_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("position", sa.Integer(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["grouping_id"],
|
||||
["postbox_groupings.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_grouping_sources_grouping_id_postbox_groupings"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["postbox_id"],
|
||||
["postboxes.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_grouping_sources_postbox_id_postboxes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_postbox_grouping_sources"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"grouping_id",
|
||||
"postbox_id",
|
||||
name="uq_postbox_grouping_sources_postbox",
|
||||
),
|
||||
)
|
||||
for column in ("tenant_id", "grouping_id", "postbox_id"):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_grouping_sources_{column}"),
|
||||
"postbox_grouping_sources",
|
||||
[column],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"postbox_access_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("postbox_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("message_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("account_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("identity_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("action", sa.String(length=80), nullable=False),
|
||||
sa.Column("outcome", sa.String(length=30), nullable=False),
|
||||
sa.Column("reason_code", sa.String(length=80), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("details", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.ForeignKeyConstraint(
|
||||
["postbox_id"],
|
||||
["postboxes.id"],
|
||||
name=op.f("fk_postbox_access_events_postbox_id_postboxes"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["message_id"],
|
||||
["postbox_messages.id"],
|
||||
name=op.f(
|
||||
"fk_postbox_access_events_message_id_postbox_messages"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_postbox_access_events"),
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"postbox_id",
|
||||
"message_id",
|
||||
"account_id",
|
||||
"identity_id",
|
||||
"assignment_id",
|
||||
"action",
|
||||
"outcome",
|
||||
"occurred_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_postbox_access_events_{column}"),
|
||||
"postbox_access_events",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_access_events_resource",
|
||||
"postbox_access_events",
|
||||
["tenant_id", "postbox_id", "message_id", "occurred_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_postbox_access_events_actor",
|
||||
"postbox_access_events",
|
||||
["tenant_id", "account_id", "occurred_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("postbox_access_events")
|
||||
op.drop_table("postbox_grouping_sources")
|
||||
op.drop_table("postbox_groupings")
|
||||
op.drop_table("postbox_message_receipts")
|
||||
op.drop_table("postbox_routes")
|
||||
op.drop_table("postbox_deliveries")
|
||||
op.drop_table("postbox_attachment_references")
|
||||
op.drop_table("postbox_participants")
|
||||
op.drop_table("postbox_messages")
|
||||
op.drop_table("postbox_bindings")
|
||||
op.drop_table("postboxes")
|
||||
op.drop_table("postbox_addresses")
|
||||
op.drop_table("postbox_template_revisions")
|
||||
op.drop_table("postbox_templates")
|
||||
732
src/govoplan_postbox/backend/router.py
Normal file
732
src/govoplan_postbox/backend/router.py
Normal file
@@ -0,0 +1,732 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxActorRef,
|
||||
PostboxAttachmentRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxParticipantRef,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_postbox.backend.manifest import (
|
||||
ACKNOWLEDGE_SCOPE,
|
||||
BINDING_ADMIN_SCOPE,
|
||||
DELIVERY_SCOPE,
|
||||
READ_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,
|
||||
PostboxMaterializeRequest,
|
||||
PostboxMessageItem,
|
||||
PostboxMessageListResponse,
|
||||
PostboxMessageStateRequest,
|
||||
PostboxOrganizationTargetsResponse,
|
||||
PostboxTemplateCreateRequest,
|
||||
PostboxTemplateItem,
|
||||
PostboxTemplateListResponse,
|
||||
PostboxTemplatePublishRequest,
|
||||
PostboxTemplateRevisionPayload,
|
||||
)
|
||||
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, ACKNOWLEDGE_SCOPE):
|
||||
actions.add("acknowledge")
|
||||
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
|
||||
principal,
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
):
|
||||
actions.add("administer")
|
||||
selected = assignment_context_id
|
||||
if selected is None and len(principal.function_assignment_ids) == 1:
|
||||
selected = next(iter(principal.function_assignment_ids))
|
||||
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]
|
||||
)
|
||||
|
||||
|
||||
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 _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,
|
||||
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,
|
||||
"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,
|
||||
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",
|
||||
"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.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,
|
||||
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.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,
|
||||
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()
|
||||
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
|
||||
|
||||
|
||||
@router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem)
|
||||
def api_update_postbox_grouping(
|
||||
grouping_id: str,
|
||||
payload: PostboxGroupingPayload,
|
||||
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=grouping_id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
session.commit()
|
||||
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
|
||||
|
||||
|
||||
@router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def api_delete_postbox_grouping(
|
||||
grouping_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> None:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
get_service().delete_grouping(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
actor=_actor(principal),
|
||||
grouping_id=grouping_id,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _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)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
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()
|
||||
return _directory_item(
|
||||
get_service().resolve_postbox(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/admin/postboxes/{postbox_id}",
|
||||
response_model=PostboxDirectoryItem,
|
||||
)
|
||||
def api_archive_postbox(
|
||||
postbox_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxDirectoryItem:
|
||||
_require(principal, BINDING_ADMIN_SCOPE)
|
||||
try:
|
||||
postbox = get_service().archive_postbox(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
actor_id=principal.account_id,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
session.commit()
|
||||
return _directory_item(
|
||||
get_service().resolve_postbox(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
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()
|
||||
return _template_item(template)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/templates/{template_id}/revisions",
|
||||
response_model=PostboxTemplateItem,
|
||||
)
|
||||
def api_revise_postbox_template(
|
||||
template_id: str,
|
||||
payload: PostboxTemplateRevisionPayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxTemplateItem:
|
||||
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
||||
try:
|
||||
template = get_service().revise_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()
|
||||
return _template_item(template)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/templates/{template_id}/publish",
|
||||
response_model=PostboxTemplateItem,
|
||||
)
|
||||
def api_publish_postbox_template(
|
||||
template_id: str,
|
||||
payload: PostboxTemplatePublishRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxTemplateItem:
|
||||
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
||||
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,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
session.commit()
|
||||
return _template_item(template)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/templates/{template_id}/retire",
|
||||
response_model=PostboxTemplateItem,
|
||||
)
|
||||
def api_retire_postbox_template(
|
||||
template_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxTemplateItem:
|
||||
_require(principal, TEMPLATE_ADMIN_SCOPE)
|
||||
try:
|
||||
template = get_service().retire_template(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
template_id=template_id,
|
||||
actor_id=principal.account_id,
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
session.commit()
|
||||
return _template_item(template)
|
||||
|
||||
|
||||
@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)
|
||||
31
src/govoplan_postbox/backend/runtime.py
Normal file
31
src/govoplan_postbox/backend/runtime.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
_registry: PlatformRegistry | None = None
|
||||
_service: object | None = None
|
||||
|
||||
|
||||
def configure_runtime(*, registry: object) -> None:
|
||||
global _registry, _service
|
||||
if not isinstance(registry, PlatformRegistry):
|
||||
raise RuntimeError("Postbox requires a platform registry")
|
||||
if registry is not _registry:
|
||||
_registry = registry
|
||||
_service = None
|
||||
|
||||
|
||||
def get_registry() -> PlatformRegistry:
|
||||
if _registry is None:
|
||||
raise RuntimeError("Postbox runtime has not been configured")
|
||||
return _registry
|
||||
|
||||
|
||||
def get_service():
|
||||
global _service
|
||||
if _service is None:
|
||||
from govoplan_postbox.backend.service import PostboxService
|
||||
|
||||
_service = PostboxService.from_registry(get_registry())
|
||||
return _service
|
||||
260
src/govoplan_postbox/backend/schemas.py
Normal file
260
src/govoplan_postbox/backend/schemas.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class PostboxAccessDecisionResponse(BaseModel):
|
||||
allowed: bool
|
||||
action: str
|
||||
postbox_id: str
|
||||
reason_code: str
|
||||
explanation: str
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
assignment_ids: list[str] = Field(default_factory=list)
|
||||
assignment_sources: list[str] = Field(default_factory=list)
|
||||
selected_assignment_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
|
||||
|
||||
class PostboxDirectoryItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
address: str
|
||||
address_key: str
|
||||
name: str
|
||||
status: str
|
||||
classification: str
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
context_key: str | None = None
|
||||
template_revision_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
access: PostboxAccessDecisionResponse | None = None
|
||||
|
||||
|
||||
class PostboxDirectoryResponse(BaseModel):
|
||||
postboxes: list[PostboxDirectoryItem]
|
||||
|
||||
|
||||
class PostboxParticipantPayload(BaseModel):
|
||||
kind: str = Field(min_length=1, max_length=30)
|
||||
reference_type: str = Field(min_length=1, max_length=50)
|
||||
reference_id: str | None = Field(default=None, max_length=255)
|
||||
label: str | None = Field(default=None, max_length=500)
|
||||
address: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class PostboxAttachmentPayload(BaseModel):
|
||||
reference_type: str = Field(min_length=1, max_length=50)
|
||||
reference_id: str = Field(min_length=1, max_length=255)
|
||||
name: str | None = Field(default=None, max_length=1000)
|
||||
media_type: str | None = Field(default=None, max_length=255)
|
||||
size_bytes: int | None = Field(default=None, ge=0)
|
||||
digest: str | None = Field(default=None, max_length=255)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxMessageItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
postbox_id: str
|
||||
subject: str
|
||||
body_text: str | None = None
|
||||
status: str
|
||||
classification: str
|
||||
sender_label: str | None = None
|
||||
delivered_at: datetime
|
||||
read_at: datetime | None = None
|
||||
acknowledged_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
withdrawn_at: datetime | None = None
|
||||
producer_module: str | None = None
|
||||
producer_resource_type: str | None = None
|
||||
producer_resource_id: str | None = None
|
||||
encryption_profile: str
|
||||
key_epoch: int
|
||||
ciphertext_ref: str | None = None
|
||||
signed_manifest_ref: str | None = None
|
||||
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxMessageListResponse(BaseModel):
|
||||
messages: list[PostboxMessageItem]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PostboxMessageStateRequest(BaseModel):
|
||||
state: Literal["read", "acknowledged"]
|
||||
|
||||
|
||||
class PostboxTargetPayload(BaseModel):
|
||||
postbox_id: str | None = Field(default=None, max_length=36)
|
||||
address_key: str | None = Field(default=None, max_length=500)
|
||||
template_id: str | None = Field(default=None, max_length=36)
|
||||
organization_unit_id: str | None = Field(default=None, max_length=36)
|
||||
function_id: str | None = Field(default=None, max_length=36)
|
||||
context_key: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_target(self) -> "PostboxTargetPayload":
|
||||
direct = bool(self.postbox_id or self.address_key)
|
||||
templated = bool(
|
||||
self.template_id
|
||||
and self.organization_unit_id
|
||||
and self.function_id
|
||||
)
|
||||
if direct == templated:
|
||||
raise ValueError(
|
||||
"Specify one direct Postbox target or one complete template target."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class PostboxDeliveryCreateRequest(BaseModel):
|
||||
target: PostboxTargetPayload
|
||||
producer_module: str = Field(min_length=1, max_length=100)
|
||||
producer_resource_type: str = Field(min_length=1, max_length=100)
|
||||
producer_resource_id: str | None = Field(default=None, max_length=255)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
subject: str = Field(min_length=1, max_length=1000)
|
||||
body_text: str | None = None
|
||||
sender_label: str | None = Field(default=None, max_length=500)
|
||||
classification: str = Field(default="internal", min_length=1, max_length=50)
|
||||
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||
expires_at: datetime | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxDeliveryResponse(BaseModel):
|
||||
delivery_id: str
|
||||
postbox_id: str
|
||||
message_id: str
|
||||
address: str
|
||||
status: str
|
||||
vacant: bool
|
||||
holder_count: int
|
||||
duplicate: bool = False
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxExactCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=500)
|
||||
description: str | None = None
|
||||
organization_unit_id: str = Field(min_length=1, max_length=36)
|
||||
function_id: str = Field(min_length=1, max_length=36)
|
||||
address_key: str | None = Field(default=None, max_length=120)
|
||||
classification: str = Field(default="internal", min_length=1, max_length=50)
|
||||
|
||||
|
||||
class PostboxTemplateRevisionPayload(BaseModel):
|
||||
function_type_id: str | None = Field(default=None, max_length=36)
|
||||
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=255)
|
||||
name_pattern: str = Field(
|
||||
default="{unit_name} / {function_name}",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
address_pattern: str = Field(
|
||||
default="{template_slug}.{unit_slug}.{function_slug}",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
classification: str = Field(default="internal", min_length=1, max_length=50)
|
||||
allow_vacant_delivery: bool = True
|
||||
|
||||
|
||||
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
|
||||
slug: str = Field(min_length=1, max_length=120)
|
||||
name: str = Field(min_length=1, max_length=250)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
|
||||
id: str
|
||||
revision: int
|
||||
encryption_profile: str
|
||||
history_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
routing_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
retention_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
published_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PostboxTemplateItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
status: str
|
||||
current_revision: int
|
||||
published_revision_id: str | None = None
|
||||
revisions: list[PostboxTemplateRevisionItem] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PostboxTemplateListResponse(BaseModel):
|
||||
templates: list[PostboxTemplateItem]
|
||||
|
||||
|
||||
class PostboxTemplatePublishRequest(BaseModel):
|
||||
revision: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class PostboxMaterializeRequest(BaseModel):
|
||||
organization_unit_id: str = Field(min_length=1, max_length=36)
|
||||
function_id: str = Field(min_length=1, max_length=36)
|
||||
context_key: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class PostboxOrganizationFunctionItem(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
function_type_id: str | None = None
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
|
||||
|
||||
class PostboxOrganizationUnitItem(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
unit_type_id: str | None = None
|
||||
parent_id: str | None = None
|
||||
functions: list[PostboxOrganizationFunctionItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PostboxOrganizationTargetsResponse(BaseModel):
|
||||
units: list[PostboxOrganizationUnitItem]
|
||||
|
||||
|
||||
class PostboxGroupingPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=250)
|
||||
is_default: bool = False
|
||||
postbox_ids: list[str] = Field(default_factory=list, max_length=250)
|
||||
|
||||
|
||||
class PostboxGroupingItem(PostboxGroupingPayload):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PostboxGroupingListResponse(BaseModel):
|
||||
groupings: list[PostboxGroupingItem]
|
||||
2428
src/govoplan_postbox/backend/service.py
Normal file
2428
src/govoplan_postbox/backend/service.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user