Implement governed distribution lists
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
DistributionExpansionRequest,
|
||||
DistributionExpansionResult,
|
||||
DistributionListSourceRef,
|
||||
DistributionSnapshotRef,
|
||||
DistributionWriteDecision,
|
||||
)
|
||||
from govoplan_dist_lists.backend.expansion import (
|
||||
expand_distribution_list,
|
||||
get_snapshot_ref,
|
||||
)
|
||||
from govoplan_dist_lists.backend.service import (
|
||||
get_distribution_list,
|
||||
get_distribution_list_revision,
|
||||
list_distribution_lists,
|
||||
source_ref,
|
||||
)
|
||||
|
||||
|
||||
READ_SCOPE = "dist_lists:list:read"
|
||||
WRITE_SCOPE = "dist_lists:list:write"
|
||||
ADMIN_SCOPE = "dist_lists:list:admin"
|
||||
|
||||
|
||||
class SqlDistributionListCapabilities:
|
||||
def __init__(self, registry: object | None = None) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def list_sources(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> tuple[DistributionListSourceRef, ...]:
|
||||
typed_session, typed_principal = _contracts(session, principal)
|
||||
return tuple(
|
||||
source_ref(
|
||||
item,
|
||||
get_distribution_list_revision(typed_session, item),
|
||||
)
|
||||
for item in list_distribution_lists(
|
||||
typed_session,
|
||||
typed_principal,
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
def get_source(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
list_id: str,
|
||||
revision: int | None = None,
|
||||
) -> DistributionListSourceRef | None:
|
||||
typed_session, typed_principal = _contracts(session, principal)
|
||||
try:
|
||||
item = get_distribution_list(typed_session, typed_principal, list_id)
|
||||
item_revision = get_distribution_list_revision(
|
||||
typed_session,
|
||||
item,
|
||||
revision=revision,
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
return source_ref(item, item_revision)
|
||||
|
||||
def expand(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DistributionExpansionRequest,
|
||||
) -> DistributionExpansionResult:
|
||||
typed_session, typed_principal = _contracts(session, principal)
|
||||
return expand_distribution_list(
|
||||
typed_session,
|
||||
typed_principal,
|
||||
registry=self.registry,
|
||||
request=request,
|
||||
)
|
||||
|
||||
def get_snapshot(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
snapshot_id: str,
|
||||
) -> DistributionSnapshotRef | None:
|
||||
typed_session, typed_principal = _contracts(session, principal)
|
||||
return get_snapshot_ref(typed_session, typed_principal, snapshot_id)
|
||||
|
||||
def explain_write(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
list_id: str | None,
|
||||
operation: str,
|
||||
) -> DistributionWriteDecision:
|
||||
typed_session, typed_principal = _contracts(session, principal)
|
||||
required_scope = ADMIN_SCOPE if operation == "admin" else WRITE_SCOPE
|
||||
if not typed_principal.has(required_scope) and not typed_principal.has(ADMIN_SCOPE):
|
||||
return DistributionWriteDecision(
|
||||
list_id=list_id,
|
||||
operation=operation,
|
||||
allowed=False,
|
||||
reason_code="permission_missing",
|
||||
explanation=f"This operation requires {required_scope}.",
|
||||
required_scopes=(required_scope,),
|
||||
)
|
||||
if list_id is None:
|
||||
return DistributionWriteDecision(
|
||||
list_id=None,
|
||||
operation=operation,
|
||||
allowed=True,
|
||||
reason_code="allowed",
|
||||
explanation="The current principal may create distribution lists.",
|
||||
required_scopes=(required_scope,),
|
||||
)
|
||||
try:
|
||||
item = get_distribution_list(typed_session, typed_principal, list_id)
|
||||
except ValueError as exc:
|
||||
return DistributionWriteDecision(
|
||||
list_id=list_id,
|
||||
operation=operation,
|
||||
allowed=False,
|
||||
reason_code="not_visible",
|
||||
explanation=str(exc),
|
||||
read_only=True,
|
||||
required_scopes=(required_scope,),
|
||||
)
|
||||
scope_allowed = (
|
||||
typed_principal.has(ADMIN_SCOPE)
|
||||
or item.scope_type == "tenant"
|
||||
or (item.scope_type == "user" and item.scope_id == typed_principal.account_id)
|
||||
or (item.scope_type == "group" and item.scope_id in typed_principal.group_ids)
|
||||
)
|
||||
return DistributionWriteDecision(
|
||||
list_id=list_id,
|
||||
operation=operation,
|
||||
allowed=scope_allowed,
|
||||
reason_code="allowed" if scope_allowed else "scope_read_only",
|
||||
explanation=(
|
||||
"The distribution list can be changed."
|
||||
if scope_allowed
|
||||
else "The distribution list is inherited from a read-only scope."
|
||||
),
|
||||
read_only=not scope_allowed,
|
||||
required_scopes=(required_scope,),
|
||||
provenance={"scope_type": item.scope_type, "scope_id": item.scope_id},
|
||||
)
|
||||
|
||||
|
||||
def capability(context: object | None = None) -> SqlDistributionListCapabilities:
|
||||
return SqlDistributionListCapabilities(getattr(context, "registry", None))
|
||||
|
||||
|
||||
def _contracts(session: object, principal: object):
|
||||
if not hasattr(session, "scalar") or not hasattr(session, "scalars"):
|
||||
raise TypeError("Distribution Lists requires a SQLAlchemy session.")
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise TypeError("Distribution Lists requires an API principal.")
|
||||
return session, principal
|
||||
|
||||
|
||||
__all__ = ["SqlDistributionListCapabilities", "capability"]
|
||||
@@ -0,0 +1,329 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.dataflows import dataflow_dataset_output
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_SEARCH,
|
||||
IdentitySearchProvider,
|
||||
)
|
||||
from govoplan_core.core.organizations import (
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
OrganizationDirectory,
|
||||
)
|
||||
from govoplan_dist_lists.backend.schemas import (
|
||||
ExplanationResponse,
|
||||
ProviderCatalogueResponse,
|
||||
ProviderOptionResponse,
|
||||
SourceReferenceModel,
|
||||
)
|
||||
from govoplan_dist_lists.backend.service import (
|
||||
get_distribution_list_revision,
|
||||
list_distribution_lists,
|
||||
source_ref,
|
||||
)
|
||||
|
||||
|
||||
ADDRESSES_LOOKUP = "addresses.lookup"
|
||||
ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
||||
|
||||
|
||||
def provider_catalogue(
|
||||
session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
registry: object | None,
|
||||
query: str = "",
|
||||
limit: int = 50,
|
||||
) -> ProviderCatalogueResponse:
|
||||
bounded_limit = max(1, min(limit, 100))
|
||||
items: list[ProviderOptionResponse] = []
|
||||
unavailable: list[ExplanationResponse] = []
|
||||
items.extend(_local_options(session, principal, query=query, limit=bounded_limit))
|
||||
|
||||
addresses = _capability(registry, ADDRESSES_RECIPIENT_SOURCE)
|
||||
if addresses is not None and hasattr(addresses, "list_sources"):
|
||||
try:
|
||||
for source in addresses.list_sources(session, principal)[:bounded_limit]:
|
||||
label = str(getattr(source, "source_label", "Address source"))
|
||||
if query and query.casefold() not in label.casefold():
|
||||
continue
|
||||
source_id = str(getattr(source, "source_id"))
|
||||
source_kind = str(getattr(source, "source_kind", "address_list"))
|
||||
items.append(
|
||||
ProviderOptionResponse(
|
||||
key=f"addresses:{source_id}",
|
||||
kind=(
|
||||
"address_list"
|
||||
if "list" in source_id or source_kind == "address_list"
|
||||
else "address_contact"
|
||||
),
|
||||
label=label,
|
||||
description=f"{int(getattr(source, 'recipient_count', 0))} recipients",
|
||||
provider="addresses",
|
||||
source=SourceReferenceModel(
|
||||
provider="addresses",
|
||||
resource_type=source_kind,
|
||||
resource_id=source_id,
|
||||
revision=str(getattr(source, "source_revision", "")) or None,
|
||||
label=label,
|
||||
metadata=dict(getattr(source, "provenance", {}) or {}),
|
||||
),
|
||||
)
|
||||
)
|
||||
except (LookupError, PermissionError, ValueError) as exc:
|
||||
unavailable.append(_unavailable("addresses", str(exc)))
|
||||
else:
|
||||
unavailable.append(
|
||||
_unavailable("addresses", "Addresses recipient sources are not installed or enabled.")
|
||||
)
|
||||
|
||||
address_lookup = _capability(registry, ADDRESSES_LOOKUP)
|
||||
if address_lookup is not None and hasattr(address_lookup, "lookup"):
|
||||
try:
|
||||
for candidate in address_lookup.lookup(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=bounded_limit,
|
||||
):
|
||||
email = str(getattr(candidate, "email", "") or "").strip()
|
||||
if not email:
|
||||
continue
|
||||
contact_id = str(getattr(candidate, "contact_id"))
|
||||
label = str(getattr(candidate, "display_name", "") or contact_id)
|
||||
revision = str(getattr(candidate, "source_revision", "") or "")
|
||||
items.append(
|
||||
ProviderOptionResponse(
|
||||
key=f"address-email:{contact_id}:{email.casefold()}",
|
||||
kind="address_email",
|
||||
label=label,
|
||||
description=email,
|
||||
provider="addresses",
|
||||
source=SourceReferenceModel(
|
||||
provider="addresses",
|
||||
resource_type="contact",
|
||||
resource_id=contact_id,
|
||||
revision=revision or None,
|
||||
label=label,
|
||||
metadata={
|
||||
"address_book_id": str(
|
||||
getattr(candidate, "address_book_id", "")
|
||||
),
|
||||
"email": email,
|
||||
"email_label": getattr(
|
||||
candidate,
|
||||
"email_label",
|
||||
None,
|
||||
),
|
||||
"provenance": dict(
|
||||
getattr(candidate, "provenance", {}) or {}
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
except (LookupError, PermissionError, ValueError) as exc:
|
||||
unavailable.append(_unavailable("addresses", str(exc)))
|
||||
else:
|
||||
unavailable.append(
|
||||
_unavailable(
|
||||
"addresses",
|
||||
"Address contact search is not installed or enabled.",
|
||||
severity="info",
|
||||
)
|
||||
)
|
||||
|
||||
identity_search = _typed_capability(
|
||||
registry,
|
||||
CAPABILITY_IDENTITY_SEARCH,
|
||||
IdentitySearchProvider,
|
||||
)
|
||||
if identity_search is not None:
|
||||
for identity in identity_search.search_identities(
|
||||
query or None,
|
||||
include_inactive=False,
|
||||
limit=bounded_limit,
|
||||
):
|
||||
items.append(
|
||||
ProviderOptionResponse(
|
||||
key=f"identity:{identity.id}",
|
||||
kind="idm_identity",
|
||||
label=identity.display_name or identity.id,
|
||||
description=identity.source,
|
||||
provider="identity",
|
||||
source=SourceReferenceModel(
|
||||
provider="identity",
|
||||
resource_type="identity",
|
||||
resource_id=identity.id,
|
||||
label=identity.display_name,
|
||||
metadata={"status": identity.status},
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
unavailable.append(
|
||||
_unavailable("identity", "Identity search is not installed or enabled.")
|
||||
)
|
||||
unavailable.append(
|
||||
_unavailable(
|
||||
"idm",
|
||||
"Typed-group selection is unavailable until an IDM group-directory capability is installed.",
|
||||
severity="info",
|
||||
)
|
||||
)
|
||||
|
||||
organizations = _typed_capability(
|
||||
registry,
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
OrganizationDirectory,
|
||||
)
|
||||
if organizations is not None:
|
||||
units = organizations.organization_units_for_tenant(principal.tenant_id)
|
||||
for unit in units:
|
||||
if query and query.casefold() not in unit.name.casefold():
|
||||
unit_matches = False
|
||||
else:
|
||||
unit_matches = True
|
||||
if unit.status == "active" and unit_matches:
|
||||
items.append(
|
||||
ProviderOptionResponse(
|
||||
key=f"organization-unit:{unit.id}",
|
||||
kind="organization_unit",
|
||||
label=unit.name,
|
||||
provider="organizations",
|
||||
source=SourceReferenceModel(
|
||||
provider="organizations",
|
||||
resource_type="organization_unit",
|
||||
resource_id=unit.id,
|
||||
label=unit.name,
|
||||
),
|
||||
)
|
||||
)
|
||||
for function in organizations.functions_for_organization_unit(unit.id):
|
||||
if function.status != "active":
|
||||
continue
|
||||
label = f"{function.name} ({unit.name})"
|
||||
if query and query.casefold() not in label.casefold():
|
||||
continue
|
||||
items.append(
|
||||
ProviderOptionResponse(
|
||||
key=f"function:{function.id}",
|
||||
kind="effective_function_incumbent",
|
||||
label=label,
|
||||
provider="organizations",
|
||||
source=SourceReferenceModel(
|
||||
provider="organizations",
|
||||
resource_type="function",
|
||||
resource_id=function.id,
|
||||
label=label,
|
||||
metadata={"organization_unit_id": unit.id},
|
||||
),
|
||||
)
|
||||
)
|
||||
if len(items) >= bounded_limit * 4:
|
||||
break
|
||||
else:
|
||||
unavailable.append(
|
||||
_unavailable("organizations", "Organization lookup is not installed or enabled.")
|
||||
)
|
||||
|
||||
dataflow = dataflow_dataset_output(registry)
|
||||
if dataflow is not None:
|
||||
for output in dataflow.list_outputs(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=bounded_limit,
|
||||
):
|
||||
items.append(
|
||||
ProviderOptionResponse(
|
||||
key=f"dataflow:{output.pipeline_ref}:{output.revision}",
|
||||
kind="dataflow_result",
|
||||
label=output.name,
|
||||
description=output.description,
|
||||
provider="dataflow",
|
||||
source=SourceReferenceModel(
|
||||
provider="dataflow",
|
||||
resource_type="pipeline_output",
|
||||
resource_id=output.pipeline_ref,
|
||||
revision=str(output.revision),
|
||||
fingerprint=output.definition_hash,
|
||||
label=output.name,
|
||||
metadata=dict(output.provenance),
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
unavailable.append(
|
||||
_unavailable("dataflow", "Dataflow dataset output is not installed or enabled.")
|
||||
)
|
||||
|
||||
items.sort(key=lambda item: (item.provider, item.label.casefold(), item.key))
|
||||
return ProviderCatalogueResponse(
|
||||
items=items[: bounded_limit * 5],
|
||||
unavailable_providers=unavailable,
|
||||
)
|
||||
|
||||
|
||||
def _local_options(session, principal, *, query: str, limit: int):
|
||||
options: list[ProviderOptionResponse] = []
|
||||
for item in list_distribution_lists(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=limit,
|
||||
):
|
||||
revision = get_distribution_list_revision(session, item)
|
||||
source = source_ref(item, revision)
|
||||
options.append(
|
||||
ProviderOptionResponse(
|
||||
key=f"distribution-list:{item.id}",
|
||||
kind="distribution_list",
|
||||
label=item.name,
|
||||
description=item.description,
|
||||
provider="dist_lists",
|
||||
source=SourceReferenceModel(
|
||||
provider="dist_lists",
|
||||
resource_type="distribution_list",
|
||||
resource_id=item.id,
|
||||
revision=str(revision.revision),
|
||||
fingerprint=revision.definition_hash,
|
||||
label=item.name,
|
||||
metadata=dict(source.provenance),
|
||||
),
|
||||
)
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def _unavailable(
|
||||
provider: str,
|
||||
message: str,
|
||||
*,
|
||||
severity: str = "warning",
|
||||
) -> ExplanationResponse:
|
||||
return ExplanationResponse(
|
||||
code="provider.unavailable",
|
||||
message=message,
|
||||
severity=severity,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _typed_capability(registry: object | None, name: str, protocol):
|
||||
capability = _capability(registry, name)
|
||||
return capability if isinstance(capability, protocol) else None
|
||||
|
||||
|
||||
__all__ = ["provider_catalogue"]
|
||||
@@ -0,0 +1,13 @@
|
||||
from govoplan_dist_lists.backend.db.models import (
|
||||
DistributionList,
|
||||
DistributionListEntry,
|
||||
DistributionListRevision,
|
||||
DistributionListSnapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DistributionList",
|
||||
"DistributionListEntry",
|
||||
"DistributionListRevision",
|
||||
"DistributionListSnapshot",
|
||||
]
|
||||
@@ -0,0 +1,313 @@
|
||||
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,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.core.concurrency import strong_resource_etag
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class DistributionList(Base, TimestampMixin):
|
||||
__tablename__ = "dist_lists_lists"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_dist_lists_lists_tenant_status",
|
||||
"tenant_id",
|
||||
"status",
|
||||
"updated_at",
|
||||
),
|
||||
Index(
|
||||
"uq_dist_lists_lists_active_tenant_name",
|
||||
"tenant_id",
|
||||
"name",
|
||||
unique=True,
|
||||
sqlite_where=text(
|
||||
"deleted_at IS NULL AND scope_type = 'tenant'"
|
||||
),
|
||||
postgresql_where=text(
|
||||
"deleted_at IS NULL AND scope_type = 'tenant'"
|
||||
),
|
||||
),
|
||||
Index(
|
||||
"uq_dist_lists_lists_active_scoped_name",
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"name",
|
||||
unique=True,
|
||||
sqlite_where=text(
|
||||
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
|
||||
),
|
||||
postgresql_where=text(
|
||||
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
scope_type: Mapped[str] = mapped_column(
|
||||
String(20), default="tenant", nullable=False, index=True
|
||||
)
|
||||
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30), default="active", nullable=False, index=True
|
||||
)
|
||||
current_revision_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
updated_by_account_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata", JSON, default=dict, nullable=False
|
||||
)
|
||||
|
||||
revisions: Mapped[list["DistributionListRevision"]] = relationship(
|
||||
back_populates="distribution_list",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="DistributionListRevision.revision",
|
||||
)
|
||||
snapshots: Mapped[list["DistributionListSnapshot"]] = relationship(
|
||||
back_populates="distribution_list",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="DistributionListSnapshot.created_at",
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"distribution_list",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class DistributionListRevision(Base, TimestampMixin):
|
||||
__tablename__ = "dist_lists_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"distribution_list_id",
|
||||
"revision",
|
||||
name="uq_dist_lists_revision_number",
|
||||
),
|
||||
Index(
|
||||
"ix_dist_lists_revisions_tenant_list",
|
||||
"tenant_id",
|
||||
"distribution_list_id",
|
||||
),
|
||||
Index(
|
||||
"ix_dist_lists_revisions_hash",
|
||||
"tenant_id",
|
||||
"definition_hash",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
distribution_list_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("dist_lists_lists.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
definition_kind: Mapped[str] = mapped_column(
|
||||
String(30), default="static", nullable=False, index=True
|
||||
)
|
||||
definition_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
parameter_schema: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
constraints: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
|
||||
distribution_list: Mapped[DistributionList] = relationship(
|
||||
back_populates="revisions"
|
||||
)
|
||||
entries: Mapped[list["DistributionListEntry"]] = relationship(
|
||||
back_populates="revision",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="DistributionListEntry.order_index",
|
||||
)
|
||||
snapshots: Mapped[list["DistributionListSnapshot"]] = relationship(
|
||||
back_populates="revision"
|
||||
)
|
||||
|
||||
|
||||
class DistributionListEntry(Base, TimestampMixin):
|
||||
__tablename__ = "dist_lists_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"revision_id",
|
||||
"entry_key",
|
||||
name="uq_dist_lists_entry_key_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_dist_lists_entries_revision_order",
|
||||
"revision_id",
|
||||
"order_index",
|
||||
),
|
||||
Index(
|
||||
"ix_dist_lists_entries_source",
|
||||
"tenant_id",
|
||||
"kind",
|
||||
"source_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)
|
||||
revision_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("dist_lists_revisions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
entry_key: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
mode: Mapped[str] = mapped_column(
|
||||
String(20), default="include", nullable=False, index=True
|
||||
)
|
||||
source_provider: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
source_resource_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
source_resource_id: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
source_revision: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
source_fingerprint: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
source_label: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
source_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
label: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
requested_channels: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
effective_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
effective_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
configuration: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
|
||||
revision: Mapped[DistributionListRevision] = relationship(back_populates="entries")
|
||||
|
||||
|
||||
class DistributionListSnapshot(Base, TimestampMixin):
|
||||
__tablename__ = "dist_lists_snapshots"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_dist_lists_snapshots_tenant_list",
|
||||
"tenant_id",
|
||||
"distribution_list_id",
|
||||
"created_at",
|
||||
),
|
||||
Index(
|
||||
"ix_dist_lists_snapshots_hash",
|
||||
"tenant_id",
|
||||
"expansion_hash",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_dist_lists_snapshot_idempotency",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
distribution_list_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("dist_lists_lists.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("dist_lists_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
request_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"request", JSON, default=dict, nullable=False
|
||||
)
|
||||
expansion_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
effective_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
recipient_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
excluded_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
recipients: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
excluded: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
provider_evidence: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
stale: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
|
||||
distribution_list: Mapped[DistributionList] = relationship(
|
||||
back_populates="snapshots"
|
||||
)
|
||||
revision: Mapped[DistributionListRevision] = relationship(
|
||||
back_populates="snapshots"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DistributionList",
|
||||
"DistributionListEntry",
|
||||
"DistributionListRevision",
|
||||
"DistributionListSnapshot",
|
||||
"new_uuid",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import DocumentationTopic, ModuleInterfaceProvider, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
||||
CAPABILITY_DISTRIBUTION_LIST_SOURCE,
|
||||
CAPABILITY_DISTRIBUTION_LIST_WRITER,
|
||||
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
||||
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
|
||||
)
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
CAPABILITY_IDENTITY_SEARCH,
|
||||
)
|
||||
from govoplan_core.core.idm import 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,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_dist_lists.backend.db import models as dist_list_models
|
||||
|
||||
MODULE_ID = "dist_lists"
|
||||
MODULE_NAME = "Distribution Lists"
|
||||
MODULE_VERSION = "0.1.8"
|
||||
MODULE_VERSION = "0.1.14"
|
||||
|
||||
READ_SCOPE = "dist_lists:list:read"
|
||||
WRITE_SCOPE = "dist_lists:list:write"
|
||||
@@ -60,6 +97,36 @@ DOCUMENTATION = (
|
||||
)
|
||||
|
||||
|
||||
def _router(_context: ModuleContext):
|
||||
from govoplan_dist_lists.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _capability(context: ModuleContext):
|
||||
from govoplan_dist_lists.backend.capabilities import capability
|
||||
|
||||
return capability(context)
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
return {
|
||||
"distribution_lists": (
|
||||
session.query(dist_list_models.DistributionList)
|
||||
.filter(
|
||||
dist_list_models.DistributionList.tenant_id == tenant_id,
|
||||
dist_list_models.DistributionList.deleted_at.is_(None),
|
||||
)
|
||||
.count()
|
||||
),
|
||||
"distribution_list_snapshots": (
|
||||
session.query(dist_list_models.DistributionListSnapshot)
|
||||
.filter(dist_list_models.DistributionListSnapshot.tenant_id == tenant_id)
|
||||
.count()
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
@@ -69,8 +136,15 @@ manifest = ModuleManifest(
|
||||
"addresses",
|
||||
"access",
|
||||
"identity",
|
||||
"idm",
|
||||
"organizations",
|
||||
"datasources",
|
||||
"connectors",
|
||||
"dataflow",
|
||||
"policy",
|
||||
"campaigns",
|
||||
"templates",
|
||||
"reporting",
|
||||
"mail",
|
||||
"postbox",
|
||||
"notifications",
|
||||
@@ -79,14 +153,126 @@ manifest = ModuleManifest(
|
||||
"workflow_engine",
|
||||
"tasks",
|
||||
),
|
||||
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
CAPABILITY_IDENTITY_SEARCH,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
||||
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
|
||||
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="dist_lists.source", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name="dist_lists.expand", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name="dist_lists.writer", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_DISTRIBUTION_LIST_SOURCE,
|
||||
version=MODULE_VERSION,
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
||||
version=MODULE_VERSION,
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_DISTRIBUTION_LIST_WRITER,
|
||||
version=MODULE_VERSION,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/distribution-lists",
|
||||
label="Distribution Lists",
|
||||
icon="list-tree",
|
||||
required_any=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
|
||||
order=74,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/dist-lists-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/distribution-lists",
|
||||
component="DistributionListsPage",
|
||||
required_any=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
|
||||
order=74,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/distribution-lists",
|
||||
label="Distribution Lists",
|
||||
icon="list-tree",
|
||||
required_any=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
|
||||
order=74,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="dist_lists.page",
|
||||
module_id=MODULE_ID,
|
||||
kind="route",
|
||||
label="Distribution Lists",
|
||||
order=74,
|
||||
),
|
||||
ViewSurface(
|
||||
id="dist_lists.editor",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Distribution-list editor",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="dist_lists.preview",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Distribution-list expansion preview",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="dist_lists.picker",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Distribution-list picker",
|
||||
order=30,
|
||||
),
|
||||
),
|
||||
),
|
||||
route_factory=_router,
|
||||
capability_factories={
|
||||
CAPABILITY_DISTRIBUTION_LIST_SOURCE: _capability,
|
||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND: _capability,
|
||||
CAPABILITY_DISTRIBUTION_LIST_WRITER: _capability,
|
||||
},
|
||||
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(
|
||||
dist_list_models.DistributionListSnapshot,
|
||||
dist_list_models.DistributionListEntry,
|
||||
dist_list_models.DistributionListRevision,
|
||||
dist_list_models.DistributionList,
|
||||
label=MODULE_NAME,
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement drops distribution-list definitions, immutable revisions, "
|
||||
"and frozen expansion evidence after the installer captures a database snapshot."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
dist_list_models.DistributionList,
|
||||
dist_list_models.DistributionListRevision,
|
||||
dist_list_models.DistributionListEntry,
|
||||
dist_list_models.DistributionListSnapshot,
|
||||
label=MODULE_NAME,
|
||||
),
|
||||
),
|
||||
documentation=DOCUMENTATION,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Distribution Lists migrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Distribution Lists Alembic revisions."""
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
"""distribution lists baseline
|
||||
|
||||
Revision ID: e7c3a9d1b5f2
|
||||
Revises: None
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e7c3a9d1b5f2"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"dist_lists_lists",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("name", sa.String(length=300), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("current_revision_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("current_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_lists")),
|
||||
)
|
||||
op.create_index(op.f("ix_dist_lists_lists_tenant_id"), "dist_lists_lists", ["tenant_id"])
|
||||
op.create_index(op.f("ix_dist_lists_lists_scope_type"), "dist_lists_lists", ["scope_type"])
|
||||
op.create_index(op.f("ix_dist_lists_lists_scope_id"), "dist_lists_lists", ["scope_id"])
|
||||
op.create_index(op.f("ix_dist_lists_lists_status"), "dist_lists_lists", ["status"])
|
||||
op.create_index(op.f("ix_dist_lists_lists_created_by_account_id"), "dist_lists_lists", ["created_by_account_id"])
|
||||
op.create_index(op.f("ix_dist_lists_lists_updated_by_account_id"), "dist_lists_lists", ["updated_by_account_id"])
|
||||
op.create_index(op.f("ix_dist_lists_lists_deleted_at"), "dist_lists_lists", ["deleted_at"])
|
||||
op.create_index(
|
||||
"ix_dist_lists_lists_tenant_status",
|
||||
"dist_lists_lists",
|
||||
["tenant_id", "status", "updated_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"uq_dist_lists_lists_active_tenant_name",
|
||||
"dist_lists_lists",
|
||||
["tenant_id", "name"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("deleted_at IS NULL AND scope_type = 'tenant'"),
|
||||
postgresql_where=sa.text("deleted_at IS NULL AND scope_type = 'tenant'"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_dist_lists_lists_active_scoped_name",
|
||||
"dist_lists_lists",
|
||||
["tenant_id", "scope_type", "scope_id", "name"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text(
|
||||
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
|
||||
),
|
||||
postgresql_where=sa.text(
|
||||
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"dist_lists_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("distribution_list_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("definition_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("definition_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("parameter_schema", sa.JSON(), nullable=False),
|
||||
sa.Column("constraints", sa.JSON(), nullable=False),
|
||||
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
|
||||
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["distribution_list_id"],
|
||||
["dist_lists_lists.id"],
|
||||
name=op.f("fk_dist_lists_revisions_distribution_list_id_dist_lists_lists"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_revisions")),
|
||||
sa.UniqueConstraint(
|
||||
"distribution_list_id",
|
||||
"revision",
|
||||
name="uq_dist_lists_revision_number",
|
||||
),
|
||||
)
|
||||
op.create_index(op.f("ix_dist_lists_revisions_tenant_id"), "dist_lists_revisions", ["tenant_id"])
|
||||
op.create_index(op.f("ix_dist_lists_revisions_distribution_list_id"), "dist_lists_revisions", ["distribution_list_id"])
|
||||
op.create_index(op.f("ix_dist_lists_revisions_definition_kind"), "dist_lists_revisions", ["definition_kind"])
|
||||
op.create_index(op.f("ix_dist_lists_revisions_created_by_account_id"), "dist_lists_revisions", ["created_by_account_id"])
|
||||
op.create_index("ix_dist_lists_revisions_tenant_list", "dist_lists_revisions", ["tenant_id", "distribution_list_id"])
|
||||
op.create_index("ix_dist_lists_revisions_hash", "dist_lists_revisions", ["tenant_id", "definition_hash"])
|
||||
|
||||
op.create_table(
|
||||
"dist_lists_entries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("revision_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("entry_key", sa.String(length=80), nullable=False),
|
||||
sa.Column("kind", sa.String(length=50), nullable=False),
|
||||
sa.Column("mode", sa.String(length=20), nullable=False),
|
||||
sa.Column("source_provider", sa.String(length=80), nullable=False),
|
||||
sa.Column("source_resource_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("source_resource_id", sa.String(length=500), nullable=False),
|
||||
sa.Column("source_revision", sa.String(length=500), nullable=True),
|
||||
sa.Column("source_fingerprint", sa.String(length=255), nullable=True),
|
||||
sa.Column("source_label", sa.String(length=300), nullable=True),
|
||||
sa.Column("source_metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("label", sa.String(length=300), nullable=True),
|
||||
sa.Column("purpose", sa.String(length=120), nullable=True),
|
||||
sa.Column("requested_channels", sa.JSON(), nullable=False),
|
||||
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("effective_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("configuration", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["revision_id"],
|
||||
["dist_lists_revisions.id"],
|
||||
name=op.f("fk_dist_lists_entries_revision_id_dist_lists_revisions"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_entries")),
|
||||
sa.UniqueConstraint(
|
||||
"revision_id",
|
||||
"entry_key",
|
||||
name="uq_dist_lists_entry_key_revision",
|
||||
),
|
||||
)
|
||||
op.create_index(op.f("ix_dist_lists_entries_tenant_id"), "dist_lists_entries", ["tenant_id"])
|
||||
op.create_index(op.f("ix_dist_lists_entries_revision_id"), "dist_lists_entries", ["revision_id"])
|
||||
op.create_index(op.f("ix_dist_lists_entries_kind"), "dist_lists_entries", ["kind"])
|
||||
op.create_index(op.f("ix_dist_lists_entries_mode"), "dist_lists_entries", ["mode"])
|
||||
op.create_index("ix_dist_lists_entries_revision_order", "dist_lists_entries", ["revision_id", "order_index"])
|
||||
op.create_index("ix_dist_lists_entries_source", "dist_lists_entries", ["tenant_id", "kind", "source_resource_id"])
|
||||
|
||||
op.create_table(
|
||||
"dist_lists_snapshots",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("distribution_list_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("revision_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("revision_number", sa.Integer(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("request", sa.JSON(), nullable=False),
|
||||
sa.Column("expansion_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("effective_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("recipient_count", sa.Integer(), nullable=False),
|
||||
sa.Column("excluded_count", sa.Integer(), nullable=False),
|
||||
sa.Column("recipients", sa.JSON(), nullable=False),
|
||||
sa.Column("excluded", sa.JSON(), nullable=False),
|
||||
sa.Column("diagnostics", sa.JSON(), nullable=False),
|
||||
sa.Column("provider_evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("stale", sa.Boolean(), nullable=False),
|
||||
sa.Column("truncated", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["distribution_list_id"],
|
||||
["dist_lists_lists.id"],
|
||||
name=op.f("fk_dist_lists_snapshots_distribution_list_id_dist_lists_lists"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["revision_id"],
|
||||
["dist_lists_revisions.id"],
|
||||
name=op.f("fk_dist_lists_snapshots_revision_id_dist_lists_revisions"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_snapshots")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_dist_lists_snapshot_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(op.f("ix_dist_lists_snapshots_tenant_id"), "dist_lists_snapshots", ["tenant_id"])
|
||||
op.create_index(op.f("ix_dist_lists_snapshots_distribution_list_id"), "dist_lists_snapshots", ["distribution_list_id"])
|
||||
op.create_index(op.f("ix_dist_lists_snapshots_revision_id"), "dist_lists_snapshots", ["revision_id"])
|
||||
op.create_index(op.f("ix_dist_lists_snapshots_idempotency_key"), "dist_lists_snapshots", ["idempotency_key"])
|
||||
op.create_index(op.f("ix_dist_lists_snapshots_created_by_account_id"), "dist_lists_snapshots", ["created_by_account_id"])
|
||||
op.create_index("ix_dist_lists_snapshots_tenant_list", "dist_lists_snapshots", ["tenant_id", "distribution_list_id", "created_at"])
|
||||
op.create_index("ix_dist_lists_snapshots_hash", "dist_lists_snapshots", ["tenant_id", "expansion_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("dist_lists_snapshots")
|
||||
op.drop_table("dist_lists_entries")
|
||||
op.drop_table("dist_lists_revisions")
|
||||
op.drop_table("dist_lists_lists")
|
||||
@@ -0,0 +1,558 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.concurrency import (
|
||||
ConcurrencyError,
|
||||
MissingPreconditionError,
|
||||
RevisionConflictError,
|
||||
assert_revision_precondition,
|
||||
)
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
DistributionExpansionLimits,
|
||||
DistributionExpansionRequest,
|
||||
DistributionListConflictError,
|
||||
DistributionListNotFoundError,
|
||||
DistributionListUnavailableError,
|
||||
)
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_dist_lists.backend.capabilities import SqlDistributionListCapabilities
|
||||
from govoplan_dist_lists.backend.catalogue import provider_catalogue
|
||||
from govoplan_dist_lists.backend.db.models import DistributionListSnapshot
|
||||
from govoplan_dist_lists.backend.expansion import snapshot_ref
|
||||
from govoplan_dist_lists.backend.schemas import (
|
||||
DeleteRequest,
|
||||
DistributionListCreateRequest,
|
||||
DistributionListEntryResponse,
|
||||
DistributionListListResponse,
|
||||
DistributionListResponse,
|
||||
DistributionListRevisionResponse,
|
||||
DistributionSnapshotResponse,
|
||||
DistributionListUpdateRequest,
|
||||
ExpansionRequestModel,
|
||||
ExpansionResultResponse,
|
||||
ProviderCatalogueResponse,
|
||||
SnapshotListItemResponse,
|
||||
SnapshotListResponse,
|
||||
SourceReferenceModel,
|
||||
WriteDecisionResponse,
|
||||
)
|
||||
from govoplan_dist_lists.backend.service import (
|
||||
create_distribution_list,
|
||||
delete_distribution_list,
|
||||
get_distribution_list,
|
||||
get_distribution_list_revision,
|
||||
list_distribution_lists,
|
||||
update_distribution_list,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/dist-lists", tags=["distribution-lists"])
|
||||
|
||||
READ_SCOPE = "dist_lists:list:read"
|
||||
WRITE_SCOPE = "dist_lists:list:write"
|
||||
ADMIN_SCOPE = "dist_lists:list:admin"
|
||||
|
||||
|
||||
def _require(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
if not any(principal.has(scope) for scope in scopes):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires one of: {', '.join(scopes)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/lists", response_model=DistributionListListResponse)
|
||||
def api_list_distribution_lists(
|
||||
query: str = Query(default="", max_length=200),
|
||||
include_deleted: bool = False,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> DistributionListListResponse:
|
||||
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
items = list_distribution_lists(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
include_deleted=include_deleted,
|
||||
limit=limit,
|
||||
)
|
||||
return DistributionListListResponse(
|
||||
items=[
|
||||
_list_response(
|
||||
item,
|
||||
get_distribution_list_revision(session, item),
|
||||
)
|
||||
for item in items
|
||||
],
|
||||
total=len(items),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/lists",
|
||||
response_model=DistributionListResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_create_distribution_list(
|
||||
payload: DistributionListCreateRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> DistributionListResponse:
|
||||
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
item, revision = create_distribution_list(session, principal, payload)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
action="dist_lists.list.created",
|
||||
item_id=item.id,
|
||||
details={
|
||||
"name": item.name,
|
||||
"definition_hash": revision.definition_hash,
|
||||
"definition_kind": revision.definition_kind,
|
||||
"entry_count": len(revision.entries),
|
||||
},
|
||||
)
|
||||
_event(session, principal, item.id, "dist_lists.list.created.v1")
|
||||
session.commit()
|
||||
except (DistributionListConflictError, IntegrityError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
session.refresh(item)
|
||||
response.headers["ETag"] = item.strong_etag
|
||||
return _list_response(item, revision)
|
||||
|
||||
|
||||
@router.get("/lists/{list_id}", response_model=DistributionListResponse)
|
||||
def api_get_distribution_list(
|
||||
list_id: str,
|
||||
response: Response,
|
||||
revision: int | None = Query(default=None, ge=1),
|
||||
include_deleted: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> DistributionListResponse:
|
||||
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
item = get_distribution_list(
|
||||
session,
|
||||
principal,
|
||||
list_id,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
item_revision = get_distribution_list_revision(
|
||||
session,
|
||||
item,
|
||||
revision=revision,
|
||||
)
|
||||
except DistributionListNotFoundError as exc:
|
||||
raise _error(exc) from exc
|
||||
response.headers["ETag"] = item.strong_etag
|
||||
return _list_response(item, item_revision)
|
||||
|
||||
|
||||
@router.put("/lists/{list_id}", response_model=DistributionListResponse)
|
||||
def api_update_distribution_list(
|
||||
list_id: str,
|
||||
payload: DistributionListUpdateRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> DistributionListResponse:
|
||||
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
item = get_distribution_list(session, principal, list_id)
|
||||
assert_revision_precondition(
|
||||
if_match,
|
||||
resource_type="distribution_list",
|
||||
resource_id=item.id,
|
||||
submitted_base_revision=payload.base_revision,
|
||||
)
|
||||
item, revision = update_distribution_list(
|
||||
session,
|
||||
principal,
|
||||
item,
|
||||
payload,
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
action="dist_lists.list.updated",
|
||||
item_id=item.id,
|
||||
details={
|
||||
"definition_hash": revision.definition_hash,
|
||||
"definition_revision": revision.revision,
|
||||
"resource_revision": item.resource_revision,
|
||||
"entry_count": len(revision.entries),
|
||||
},
|
||||
)
|
||||
_event(session, principal, item.id, "dist_lists.list.updated.v1")
|
||||
session.commit()
|
||||
except (
|
||||
ConcurrencyError,
|
||||
DistributionListConflictError,
|
||||
DistributionListNotFoundError,
|
||||
IntegrityError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
session.refresh(item)
|
||||
response.headers["ETag"] = item.strong_etag
|
||||
return _list_response(item, revision)
|
||||
|
||||
|
||||
@router.delete("/lists/{list_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def api_delete_distribution_list(
|
||||
list_id: str,
|
||||
payload: DeleteRequest,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> Response:
|
||||
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
item = get_distribution_list(session, principal, list_id)
|
||||
assert_revision_precondition(
|
||||
if_match,
|
||||
resource_type="distribution_list",
|
||||
resource_id=item.id,
|
||||
submitted_base_revision=payload.base_revision,
|
||||
)
|
||||
delete_distribution_list(
|
||||
session,
|
||||
principal,
|
||||
item,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
action="dist_lists.list.deleted",
|
||||
item_id=item.id,
|
||||
details={"resource_revision": item.resource_revision},
|
||||
)
|
||||
_event(session, principal, item.id, "dist_lists.list.deleted.v1")
|
||||
session.commit()
|
||||
except (
|
||||
ConcurrencyError,
|
||||
DistributionListConflictError,
|
||||
DistributionListNotFoundError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/lists/{list_id}/expand", response_model=ExpansionResultResponse)
|
||||
def api_expand_distribution_list(
|
||||
list_id: str,
|
||||
payload: ExpansionRequestModel,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> ExpansionResultResponse:
|
||||
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
request = DistributionExpansionRequest(
|
||||
list_id=list_id,
|
||||
revision=payload.revision,
|
||||
effective_at=payload.effective_at,
|
||||
purpose=payload.purpose,
|
||||
requested_channels=tuple(payload.requested_channels),
|
||||
parameters=dict(payload.parameters),
|
||||
preview=payload.preview,
|
||||
freeze=payload.freeze,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
limits=DistributionExpansionLimits(
|
||||
max_entries=payload.max_entries,
|
||||
max_results=payload.max_results,
|
||||
max_depth=payload.max_depth,
|
||||
max_provider_results=payload.max_provider_results,
|
||||
),
|
||||
)
|
||||
try:
|
||||
result = SqlDistributionListCapabilities(get_registry()).expand(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
if payload.freeze:
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
action="dist_lists.snapshot.created",
|
||||
item_id=result.snapshot_id or list_id,
|
||||
details={
|
||||
"list_id": list_id,
|
||||
"definition_hash": result.source.definition_hash,
|
||||
"expansion_hash": result.expansion_hash,
|
||||
"recipient_count": len(result.recipients),
|
||||
"excluded_count": len(result.excluded),
|
||||
"stale": result.stale,
|
||||
},
|
||||
)
|
||||
_event(
|
||||
session,
|
||||
principal,
|
||||
result.snapshot_id or list_id,
|
||||
"dist_lists.snapshot.created.v1",
|
||||
resource_type="distribution_list_snapshot",
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
DistributionListConflictError,
|
||||
DistributionListNotFoundError,
|
||||
DistributionListUnavailableError,
|
||||
IntegrityError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return _expansion_response(result)
|
||||
|
||||
|
||||
@router.get("/lists/{list_id}/snapshots", response_model=SnapshotListResponse)
|
||||
def api_list_distribution_list_snapshots(
|
||||
list_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SnapshotListResponse:
|
||||
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
get_distribution_list(session, principal, list_id)
|
||||
rows = list(
|
||||
session.scalars(
|
||||
select(DistributionListSnapshot)
|
||||
.where(
|
||||
DistributionListSnapshot.tenant_id == principal.tenant_id,
|
||||
DistributionListSnapshot.distribution_list_id == list_id,
|
||||
)
|
||||
.order_by(
|
||||
DistributionListSnapshot.created_at.desc(),
|
||||
DistributionListSnapshot.id.desc(),
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
return SnapshotListResponse(
|
||||
items=[
|
||||
SnapshotListItemResponse(
|
||||
id=item.id,
|
||||
list_id=item.distribution_list_id,
|
||||
revision_id=item.revision_id,
|
||||
revision=item.revision_number,
|
||||
expansion_hash=item.expansion_hash,
|
||||
effective_at=item.effective_at,
|
||||
recipient_count=item.recipient_count,
|
||||
excluded_count=item.excluded_count,
|
||||
stale=item.stale,
|
||||
truncated=item.truncated,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
for item in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/snapshots/{snapshot_id}",
|
||||
response_model=DistributionSnapshotResponse,
|
||||
)
|
||||
def api_get_distribution_list_snapshot(
|
||||
snapshot_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> DistributionSnapshotResponse:
|
||||
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
row = session.scalar(
|
||||
select(DistributionListSnapshot).where(
|
||||
DistributionListSnapshot.id == snapshot_id,
|
||||
DistributionListSnapshot.tenant_id == principal.tenant_id,
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Distribution-list snapshot not found.")
|
||||
return DistributionSnapshotResponse.model_validate(asdict(snapshot_ref(row)))
|
||||
|
||||
|
||||
@router.get("/providers", response_model=ProviderCatalogueResponse)
|
||||
def api_distribution_list_providers(
|
||||
query: str = Query(default="", max_length=200),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> ProviderCatalogueResponse:
|
||||
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||
return provider_catalogue(
|
||||
session,
|
||||
principal,
|
||||
registry=get_registry(),
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/write-decision", response_model=WriteDecisionResponse)
|
||||
def api_distribution_list_write_decision(
|
||||
operation: str = Query(default="create", max_length=50),
|
||||
list_id: str | None = Query(default=None, max_length=36),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WriteDecisionResponse:
|
||||
decision = SqlDistributionListCapabilities(get_registry()).explain_write(
|
||||
session,
|
||||
principal,
|
||||
list_id=list_id,
|
||||
operation=operation,
|
||||
)
|
||||
return WriteDecisionResponse.model_validate(asdict(decision))
|
||||
|
||||
|
||||
def _list_response(item, revision) -> DistributionListResponse:
|
||||
return DistributionListResponse(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
scope_type=item.scope_type,
|
||||
scope_id=item.scope_id,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
status=item.status,
|
||||
current_revision=item.current_revision,
|
||||
resource_revision=item.resource_revision,
|
||||
etag=item.strong_etag,
|
||||
metadata=dict(item.metadata_),
|
||||
deleted_at=item.deleted_at,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
revision=DistributionListRevisionResponse(
|
||||
id=revision.id,
|
||||
revision=revision.revision,
|
||||
definition_kind=revision.definition_kind,
|
||||
definition_hash=revision.definition_hash,
|
||||
parameters=list(revision.parameter_schema),
|
||||
constraints=dict(revision.constraints),
|
||||
source_fingerprints=list(revision.source_fingerprints),
|
||||
entries=[
|
||||
DistributionListEntryResponse(
|
||||
id=entry.id,
|
||||
entry_key=entry.entry_key,
|
||||
kind=entry.kind,
|
||||
mode=entry.mode,
|
||||
source=SourceReferenceModel(
|
||||
provider=entry.source_provider,
|
||||
resource_type=entry.source_resource_type,
|
||||
resource_id=entry.source_resource_id,
|
||||
revision=entry.source_revision,
|
||||
fingerprint=entry.source_fingerprint,
|
||||
label=entry.source_label,
|
||||
metadata=dict(entry.source_metadata),
|
||||
),
|
||||
label=entry.label,
|
||||
purpose=entry.purpose,
|
||||
requested_channels=list(entry.requested_channels),
|
||||
effective_from=entry.effective_from,
|
||||
effective_until=entry.effective_until,
|
||||
order=entry.order_index,
|
||||
configuration=dict(entry.configuration),
|
||||
)
|
||||
for entry in revision.entries
|
||||
],
|
||||
created_by_account_id=revision.created_by_account_id,
|
||||
created_at=revision.created_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _expansion_response(result) -> ExpansionResultResponse:
|
||||
return ExpansionResultResponse.model_validate(
|
||||
{
|
||||
"list_id": result.source.id,
|
||||
"revision_id": result.source.revision_id,
|
||||
"revision": result.source.revision,
|
||||
"definition_hash": result.source.definition_hash,
|
||||
"recipients": [asdict(item) for item in result.recipients],
|
||||
"excluded": [asdict(item) for item in result.excluded],
|
||||
"diagnostics": [asdict(item) for item in result.diagnostics],
|
||||
"provider_evidence": [asdict(item) for item in result.provider_evidence],
|
||||
"expansion_hash": result.expansion_hash,
|
||||
"generated_at": result.generated_at,
|
||||
"snapshot_id": result.snapshot_id,
|
||||
"stale": result.stale,
|
||||
"truncated": result.truncated,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
action: str,
|
||||
item_id: str,
|
||||
details: dict[str, object],
|
||||
) -> None:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=action,
|
||||
object_type="distribution_list",
|
||||
object_id=item_id,
|
||||
details=details,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
|
||||
def _event(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
resource_id: str,
|
||||
event_type: str,
|
||||
*,
|
||||
resource_type: str = "distribution_list",
|
||||
) -> None:
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=event_type,
|
||||
module_id="dist_lists",
|
||||
actor=EventActorRef(type="account", id=principal.account_id),
|
||||
tenant=EventTenantRef(id=principal.tenant_id),
|
||||
resource=EventObjectRef(type=resource_type, id=resource_id),
|
||||
classification="internal",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, MissingPreconditionError):
|
||||
return HTTPException(status_code=428, detail=exc.as_dict())
|
||||
if isinstance(exc, RevisionConflictError):
|
||||
return HTTPException(status_code=412, detail=exc.as_dict())
|
||||
if isinstance(exc, ConcurrencyError):
|
||||
return HTTPException(status_code=409, detail=str(exc))
|
||||
if isinstance(exc, DistributionListNotFoundError):
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
if isinstance(exc, DistributionListUnavailableError):
|
||||
return HTTPException(status_code=503, detail=str(exc))
|
||||
if isinstance(exc, IntegrityError):
|
||||
return HTTPException(status_code=409, detail="Distribution-list data conflicts with an existing record.")
|
||||
return HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
DefinitionKind = Literal["static", "parameterized", "dynamic", "template"]
|
||||
EntryMode = Literal["include", "exclude", "override"]
|
||||
EntryKind = Literal[
|
||||
"address_contact",
|
||||
"address_list",
|
||||
"address_email",
|
||||
"raw_email",
|
||||
"raw_postal_address",
|
||||
"internal_mail",
|
||||
"portal",
|
||||
"idm_identity",
|
||||
"idm_group",
|
||||
"organization_unit",
|
||||
"function",
|
||||
"effective_function_incumbent",
|
||||
"dataflow_result",
|
||||
"distribution_list",
|
||||
]
|
||||
Channel = Literal["email", "postal", "internal_mail", "portal"]
|
||||
Outcome = Literal[
|
||||
"usable",
|
||||
"unresolved",
|
||||
"invalid",
|
||||
"suppressed",
|
||||
"ambiguous",
|
||||
"duplicate",
|
||||
"policy_blocked",
|
||||
"provider_unavailable",
|
||||
"stale",
|
||||
]
|
||||
|
||||
|
||||
class SourceReferenceModel(BaseModel):
|
||||
provider: str = Field(min_length=1, max_length=80)
|
||||
resource_type: str = Field(min_length=1, max_length=80)
|
||||
resource_id: str = Field(min_length=1, max_length=500)
|
||||
revision: str | None = Field(default=None, max_length=500)
|
||||
fingerprint: str | None = Field(default=None, max_length=255)
|
||||
label: str | None = Field(default=None, max_length=300)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ParameterDefinitionModel(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=80, pattern=r"^[A-Za-z_][A-Za-z0-9_.-]*$")
|
||||
value_type: Literal[
|
||||
"string",
|
||||
"integer",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
"datetime",
|
||||
"string_list",
|
||||
]
|
||||
label: str | None = Field(default=None, max_length=200)
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
allowed_values: list[Any] = Field(default_factory=list, max_length=200)
|
||||
minimum: float | None = None
|
||||
maximum: float | None = None
|
||||
pattern: str | None = Field(default=None, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_bounds(self) -> "ParameterDefinitionModel":
|
||||
if (
|
||||
self.minimum is not None
|
||||
and self.maximum is not None
|
||||
and self.minimum > self.maximum
|
||||
):
|
||||
raise ValueError("Parameter minimum cannot exceed maximum.")
|
||||
return self
|
||||
|
||||
|
||||
class DistributionListEntryInput(BaseModel):
|
||||
entry_key: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=80,
|
||||
pattern=r"^[A-Za-z0-9_.:-]+$",
|
||||
)
|
||||
kind: EntryKind
|
||||
mode: EntryMode = "include"
|
||||
source: SourceReferenceModel
|
||||
label: str | None = Field(default=None, max_length=300)
|
||||
purpose: str | None = Field(default=None, max_length=120)
|
||||
requested_channels: list[Channel] = Field(default_factory=list, max_length=4)
|
||||
effective_from: datetime | None = None
|
||||
effective_until: datetime | None = None
|
||||
configuration: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("requested_channels")
|
||||
@classmethod
|
||||
def unique_channels(cls, value: list[Channel]) -> list[Channel]:
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_effective_range(self) -> "DistributionListEntryInput":
|
||||
if (
|
||||
self.effective_from is not None
|
||||
and self.effective_until is not None
|
||||
and self.effective_until <= self.effective_from
|
||||
):
|
||||
raise ValueError("Entry effective until must be after effective from.")
|
||||
return self
|
||||
|
||||
|
||||
class DistributionListCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=300)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
scope_type: Literal["tenant", "group", "user"] = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=36)
|
||||
definition_kind: DefinitionKind = "static"
|
||||
parameters: list[ParameterDefinitionModel] = Field(default_factory=list, max_length=100)
|
||||
constraints: dict[str, Any] = Field(default_factory=dict)
|
||||
entries: list[DistributionListEntryInput] = Field(default_factory=list, max_length=500)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_scope(self) -> "DistributionListCreateRequest":
|
||||
if self.scope_type == "tenant" and self.scope_id is not None:
|
||||
raise ValueError("Tenant-scoped lists do not use a scope ID.")
|
||||
if self.scope_type != "tenant" and self.scope_id is None:
|
||||
raise ValueError("Group- and user-scoped lists require a scope ID.")
|
||||
return self
|
||||
|
||||
|
||||
class DistributionListUpdateRequest(DistributionListCreateRequest):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class DistributionListEntryResponse(BaseModel):
|
||||
id: str
|
||||
entry_key: str
|
||||
kind: EntryKind
|
||||
mode: EntryMode
|
||||
source: SourceReferenceModel
|
||||
label: str | None
|
||||
purpose: str | None
|
||||
requested_channels: list[Channel]
|
||||
effective_from: datetime | None
|
||||
effective_until: datetime | None
|
||||
order: int
|
||||
configuration: dict[str, Any]
|
||||
|
||||
|
||||
class DistributionListRevisionResponse(BaseModel):
|
||||
id: str
|
||||
revision: int
|
||||
definition_kind: DefinitionKind
|
||||
definition_hash: str
|
||||
parameters: list[ParameterDefinitionModel]
|
||||
constraints: dict[str, Any]
|
||||
source_fingerprints: list[dict[str, Any]]
|
||||
entries: list[DistributionListEntryResponse]
|
||||
created_by_account_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DistributionListResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
scope_type: Literal["tenant", "group", "user"]
|
||||
scope_id: str | None
|
||||
name: str
|
||||
description: str | None
|
||||
status: str
|
||||
current_revision: int
|
||||
resource_revision: int
|
||||
etag: str
|
||||
metadata: dict[str, Any]
|
||||
deleted_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
revision: DistributionListRevisionResponse
|
||||
|
||||
|
||||
class DistributionListListResponse(BaseModel):
|
||||
items: list[DistributionListResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ExpansionRequestModel(BaseModel):
|
||||
revision: int | None = Field(default=None, ge=1)
|
||||
effective_at: datetime | None = None
|
||||
purpose: str | None = Field(default=None, max_length=120)
|
||||
requested_channels: list[Channel] = Field(default_factory=list, max_length=4)
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
preview: bool = False
|
||||
freeze: bool = False
|
||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
max_entries: int = Field(default=500, ge=1, le=5000)
|
||||
max_results: int = Field(default=5000, ge=1, le=20_000)
|
||||
max_depth: int = Field(default=8, ge=1, le=20)
|
||||
max_provider_results: int = Field(default=2000, ge=1, le=10_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_freeze(self) -> "ExpansionRequestModel":
|
||||
if self.freeze and not self.idempotency_key:
|
||||
raise ValueError("Frozen expansions require an idempotency key.")
|
||||
return self
|
||||
|
||||
|
||||
class ExplanationResponse(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
severity: Literal["info", "warning", "error"]
|
||||
provider: str | None = None
|
||||
source: SourceReferenceModel | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChannelCandidateResponse(BaseModel):
|
||||
channel: Channel
|
||||
target: str
|
||||
target_key: str
|
||||
status: Outcome
|
||||
contact_point_id: str | None = None
|
||||
locale: str | None = None
|
||||
preferred: bool = False
|
||||
reason_code: str | None = None
|
||||
explanation: str | None = None
|
||||
source: SourceReferenceModel | None = None
|
||||
decision_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RecipientResponse(BaseModel):
|
||||
recipient_key: str
|
||||
display_name: str
|
||||
status: Outcome
|
||||
channels: list[ChannelCandidateResponse]
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
contact_id: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
source_entry_ids: list[str]
|
||||
explanations: list[ExplanationResponse]
|
||||
attributes: dict[str, Any]
|
||||
provenance: dict[str, Any]
|
||||
|
||||
|
||||
class ProviderEvidenceResponse(BaseModel):
|
||||
provider: str
|
||||
source: SourceReferenceModel
|
||||
actual_revision: str | None = None
|
||||
actual_fingerprint: str | None = None
|
||||
stale: bool
|
||||
generated_at: datetime | None = None
|
||||
details: dict[str, Any]
|
||||
|
||||
|
||||
class ExpansionResultResponse(BaseModel):
|
||||
list_id: str
|
||||
revision_id: str
|
||||
revision: int
|
||||
definition_hash: str
|
||||
recipients: list[RecipientResponse]
|
||||
excluded: list[RecipientResponse]
|
||||
diagnostics: list[ExplanationResponse]
|
||||
provider_evidence: list[ProviderEvidenceResponse]
|
||||
expansion_hash: str
|
||||
generated_at: datetime
|
||||
snapshot_id: str | None = None
|
||||
stale: bool
|
||||
truncated: bool
|
||||
|
||||
|
||||
class DistributionSnapshotResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
list_id: str
|
||||
revision_id: str
|
||||
revision: int
|
||||
expansion_hash: str
|
||||
generated_at: datetime
|
||||
effective_at: datetime
|
||||
recipient_count: int
|
||||
excluded_count: int
|
||||
stale: bool
|
||||
truncated: bool
|
||||
request: dict[str, Any]
|
||||
recipients: list[RecipientResponse]
|
||||
excluded: list[RecipientResponse]
|
||||
diagnostics: list[ExplanationResponse]
|
||||
provider_evidence: list[ProviderEvidenceResponse]
|
||||
provenance: dict[str, Any]
|
||||
|
||||
|
||||
class SnapshotListItemResponse(BaseModel):
|
||||
id: str
|
||||
list_id: str
|
||||
revision_id: str
|
||||
revision: int
|
||||
expansion_hash: str
|
||||
effective_at: datetime
|
||||
recipient_count: int
|
||||
excluded_count: int
|
||||
stale: bool
|
||||
truncated: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SnapshotListResponse(BaseModel):
|
||||
items: list[SnapshotListItemResponse]
|
||||
|
||||
|
||||
class ProviderOptionResponse(BaseModel):
|
||||
key: str
|
||||
kind: EntryKind
|
||||
label: str
|
||||
description: str | None = None
|
||||
provider: str
|
||||
source: SourceReferenceModel
|
||||
available: bool = True
|
||||
reason: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ProviderCatalogueResponse(BaseModel):
|
||||
items: list[ProviderOptionResponse]
|
||||
unavailable_providers: list[ExplanationResponse]
|
||||
|
||||
|
||||
class WriteDecisionResponse(BaseModel):
|
||||
list_id: str | None
|
||||
operation: str
|
||||
allowed: bool
|
||||
reason_code: str
|
||||
explanation: str
|
||||
read_only: bool
|
||||
required_scopes: list[str]
|
||||
provenance: dict[str, Any]
|
||||
|
||||
|
||||
class DeleteRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class ApiErrorResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
detail: Any
|
||||
|
||||
|
||||
__all__ = [name for name in globals() if name.endswith(("Model", "Request", "Response"))]
|
||||
@@ -0,0 +1,503 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.concurrency import claim_revision
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
DistributionListConflictError,
|
||||
DistributionListEntryRef,
|
||||
DistributionListNotFoundError,
|
||||
DistributionListSourceRef,
|
||||
DistributionParameterDefinition,
|
||||
DistributionSourceReference,
|
||||
)
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_dist_lists.backend.db.models import (
|
||||
DistributionList,
|
||||
DistributionListEntry,
|
||||
DistributionListRevision,
|
||||
)
|
||||
from govoplan_dist_lists.backend.schemas import (
|
||||
DistributionListCreateRequest,
|
||||
DistributionListEntryInput,
|
||||
DistributionListUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
def visible_lists_statement(
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
):
|
||||
statement = select(DistributionList).where(
|
||||
DistributionList.tenant_id == principal.tenant_id,
|
||||
)
|
||||
if not principal.has("dist_lists:list:admin"):
|
||||
statement = statement.where(
|
||||
or_(
|
||||
DistributionList.scope_type == "tenant",
|
||||
(
|
||||
(DistributionList.scope_type == "user")
|
||||
& (DistributionList.scope_id == principal.account_id)
|
||||
),
|
||||
(
|
||||
(DistributionList.scope_type == "group")
|
||||
& DistributionList.scope_id.in_(tuple(principal.group_ids) or ("",))
|
||||
),
|
||||
)
|
||||
)
|
||||
if not include_deleted:
|
||||
statement = statement.where(DistributionList.deleted_at.is_(None))
|
||||
return statement
|
||||
|
||||
|
||||
def list_distribution_lists(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
query: str = "",
|
||||
include_deleted: bool = False,
|
||||
limit: int = 200,
|
||||
) -> list[DistributionList]:
|
||||
statement = visible_lists_statement(
|
||||
principal,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
normalized_query = query.strip()
|
||||
if normalized_query:
|
||||
statement = statement.where(
|
||||
DistributionList.name.ilike(f"%{normalized_query}%")
|
||||
)
|
||||
return list(
|
||||
session.scalars(
|
||||
statement.order_by(DistributionList.name, DistributionList.id).limit(
|
||||
max(1, min(limit, 500))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_distribution_list(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
list_id: str,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
) -> DistributionList:
|
||||
item = session.scalar(
|
||||
visible_lists_statement(principal, include_deleted=include_deleted).where(
|
||||
DistributionList.id == list_id
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
raise DistributionListNotFoundError("Distribution list not found.")
|
||||
return item
|
||||
|
||||
|
||||
def get_distribution_list_revision(
|
||||
session: Session,
|
||||
distribution_list: DistributionList,
|
||||
*,
|
||||
revision: int | None = None,
|
||||
) -> DistributionListRevision:
|
||||
revision_number = revision or distribution_list.current_revision
|
||||
item = session.scalar(
|
||||
select(DistributionListRevision)
|
||||
.options(selectinload(DistributionListRevision.entries))
|
||||
.where(
|
||||
DistributionListRevision.distribution_list_id == distribution_list.id,
|
||||
DistributionListRevision.tenant_id == distribution_list.tenant_id,
|
||||
DistributionListRevision.revision == revision_number,
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
raise DistributionListNotFoundError("Distribution list revision not found.")
|
||||
return item
|
||||
|
||||
|
||||
def create_distribution_list(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
payload: DistributionListCreateRequest,
|
||||
) -> tuple[DistributionList, DistributionListRevision]:
|
||||
_ensure_requested_scope(
|
||||
principal,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
)
|
||||
_ensure_unique_name(
|
||||
session,
|
||||
principal,
|
||||
name=payload.name,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
)
|
||||
item = DistributionList(
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
name=payload.name.strip(),
|
||||
description=_text(payload.description),
|
||||
current_revision_id="pending",
|
||||
current_revision=1,
|
||||
resource_revision=1,
|
||||
created_by_account_id=principal.account_id,
|
||||
updated_by_account_id=principal.account_id,
|
||||
metadata_=dict(payload.metadata),
|
||||
)
|
||||
session.add(item)
|
||||
session.flush()
|
||||
revision = _create_revision(
|
||||
session,
|
||||
principal,
|
||||
distribution_list=item,
|
||||
revision_number=1,
|
||||
payload=payload,
|
||||
)
|
||||
item.current_revision_id = revision.id
|
||||
session.flush()
|
||||
return item, revision
|
||||
|
||||
|
||||
def update_distribution_list(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
distribution_list: DistributionList,
|
||||
payload: DistributionListUpdateRequest,
|
||||
) -> tuple[DistributionList, DistributionListRevision]:
|
||||
_ensure_mutable_scope(principal, distribution_list)
|
||||
_ensure_requested_scope(
|
||||
principal,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
)
|
||||
_ensure_unique_name(
|
||||
session,
|
||||
principal,
|
||||
name=payload.name,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
exclude_id=distribution_list.id,
|
||||
)
|
||||
next_resource_revision = claim_revision(
|
||||
session,
|
||||
model=DistributionList,
|
||||
filters=(
|
||||
DistributionList.id == distribution_list.id,
|
||||
DistributionList.tenant_id == distribution_list.tenant_id,
|
||||
DistributionList.deleted_at.is_(None),
|
||||
),
|
||||
revision_attribute="resource_revision",
|
||||
expected_revision=payload.base_revision,
|
||||
resource_type="distribution_list",
|
||||
resource_id=distribution_list.id,
|
||||
refresh_path=f"/api/v1/dist-lists/lists/{distribution_list.id}",
|
||||
)
|
||||
next_definition_revision = distribution_list.current_revision + 1
|
||||
distribution_list.resource_revision = next_resource_revision
|
||||
distribution_list.scope_type = payload.scope_type
|
||||
distribution_list.scope_id = payload.scope_id
|
||||
distribution_list.name = payload.name.strip()
|
||||
distribution_list.description = _text(payload.description)
|
||||
distribution_list.current_revision = next_definition_revision
|
||||
distribution_list.updated_by_account_id = principal.account_id
|
||||
distribution_list.metadata_ = dict(payload.metadata)
|
||||
revision = _create_revision(
|
||||
session,
|
||||
principal,
|
||||
distribution_list=distribution_list,
|
||||
revision_number=next_definition_revision,
|
||||
payload=payload,
|
||||
)
|
||||
distribution_list.current_revision_id = revision.id
|
||||
session.flush()
|
||||
return distribution_list, revision
|
||||
|
||||
|
||||
def delete_distribution_list(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
distribution_list: DistributionList,
|
||||
*,
|
||||
base_revision: int,
|
||||
) -> DistributionList:
|
||||
_ensure_mutable_scope(principal, distribution_list)
|
||||
next_revision = claim_revision(
|
||||
session,
|
||||
model=DistributionList,
|
||||
filters=(
|
||||
DistributionList.id == distribution_list.id,
|
||||
DistributionList.tenant_id == distribution_list.tenant_id,
|
||||
DistributionList.deleted_at.is_(None),
|
||||
),
|
||||
revision_attribute="resource_revision",
|
||||
expected_revision=base_revision,
|
||||
resource_type="distribution_list",
|
||||
resource_id=distribution_list.id,
|
||||
refresh_path="/api/v1/dist-lists/lists",
|
||||
)
|
||||
distribution_list.resource_revision = next_revision
|
||||
distribution_list.deleted_at = utc_now()
|
||||
distribution_list.status = "deleted"
|
||||
distribution_list.updated_by_account_id = principal.account_id
|
||||
session.flush()
|
||||
return distribution_list
|
||||
|
||||
|
||||
def source_ref(
|
||||
distribution_list: DistributionList,
|
||||
revision: DistributionListRevision,
|
||||
) -> DistributionListSourceRef:
|
||||
return DistributionListSourceRef(
|
||||
id=distribution_list.id,
|
||||
tenant_id=distribution_list.tenant_id,
|
||||
name=distribution_list.name,
|
||||
description=distribution_list.description,
|
||||
revision_id=revision.id,
|
||||
revision=revision.revision,
|
||||
definition_hash=revision.definition_hash,
|
||||
definition_kind=revision.definition_kind, # type: ignore[arg-type]
|
||||
status=distribution_list.status,
|
||||
entry_count=len(revision.entries),
|
||||
read_only=False,
|
||||
parameters=tuple(
|
||||
DistributionParameterDefinition(
|
||||
key=str(item["key"]),
|
||||
value_type=str(item["value_type"]), # type: ignore[arg-type]
|
||||
label=_text(item.get("label")),
|
||||
required=bool(item.get("required", False)),
|
||||
default=item.get("default"),
|
||||
allowed_values=tuple(item.get("allowed_values") or ()),
|
||||
minimum=_number(item.get("minimum")),
|
||||
maximum=_number(item.get("maximum")),
|
||||
pattern=_text(item.get("pattern")),
|
||||
description=_text(item.get("description")),
|
||||
)
|
||||
for item in revision.parameter_schema
|
||||
),
|
||||
updated_at=distribution_list.updated_at,
|
||||
provenance={
|
||||
"module": "dist_lists",
|
||||
"scope_type": distribution_list.scope_type,
|
||||
"scope_id": distribution_list.scope_id,
|
||||
"resource_revision": distribution_list.resource_revision,
|
||||
},
|
||||
metadata=dict(distribution_list.metadata_),
|
||||
)
|
||||
|
||||
|
||||
def entry_ref(entry: DistributionListEntry) -> DistributionListEntryRef:
|
||||
return DistributionListEntryRef(
|
||||
id=entry.id,
|
||||
kind=entry.kind, # type: ignore[arg-type]
|
||||
mode=entry.mode, # type: ignore[arg-type]
|
||||
source=DistributionSourceReference(
|
||||
provider=entry.source_provider,
|
||||
resource_type=entry.source_resource_type,
|
||||
resource_id=entry.source_resource_id,
|
||||
revision=entry.source_revision,
|
||||
fingerprint=entry.source_fingerprint,
|
||||
label=entry.source_label,
|
||||
metadata=dict(entry.source_metadata),
|
||||
),
|
||||
label=entry.label,
|
||||
purpose=entry.purpose,
|
||||
requested_channels=tuple(entry.requested_channels), # type: ignore[arg-type]
|
||||
effective_from=entry.effective_from,
|
||||
effective_until=entry.effective_until,
|
||||
order=entry.order_index,
|
||||
configuration=dict(entry.configuration),
|
||||
)
|
||||
|
||||
|
||||
def definition_hash(
|
||||
payload: DistributionListCreateRequest | DistributionListUpdateRequest,
|
||||
) -> str:
|
||||
definition = {
|
||||
"definition_kind": payload.definition_kind,
|
||||
"parameters": [item.model_dump(mode="json") for item in payload.parameters],
|
||||
"constraints": payload.constraints,
|
||||
"entries": [
|
||||
{
|
||||
**entry.model_dump(mode="json", exclude={"entry_key"}),
|
||||
"entry_key": entry.entry_key,
|
||||
}
|
||||
for entry in payload.entries
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(
|
||||
definition,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _create_revision(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
distribution_list: DistributionList,
|
||||
revision_number: int,
|
||||
payload: DistributionListCreateRequest | DistributionListUpdateRequest,
|
||||
) -> DistributionListRevision:
|
||||
_validate_entry_keys(payload.entries)
|
||||
revision = DistributionListRevision(
|
||||
tenant_id=principal.tenant_id,
|
||||
distribution_list_id=distribution_list.id,
|
||||
revision=revision_number,
|
||||
definition_kind=payload.definition_kind,
|
||||
definition_hash=definition_hash(payload),
|
||||
parameter_schema=[item.model_dump(mode="json") for item in payload.parameters],
|
||||
constraints=dict(payload.constraints),
|
||||
source_fingerprints=[
|
||||
{
|
||||
"provider": item.source.provider,
|
||||
"resource_type": item.source.resource_type,
|
||||
"resource_id": item.source.resource_id,
|
||||
"revision": item.source.revision,
|
||||
"fingerprint": item.source.fingerprint,
|
||||
}
|
||||
for item in payload.entries
|
||||
if item.source.revision or item.source.fingerprint
|
||||
],
|
||||
created_by_account_id=principal.account_id,
|
||||
)
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
for order, entry in enumerate(payload.entries):
|
||||
session.add(
|
||||
_entry_model(
|
||||
principal,
|
||||
revision=revision,
|
||||
payload=entry,
|
||||
order=order,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
session.refresh(revision, attribute_names=["entries"])
|
||||
return revision
|
||||
|
||||
|
||||
def _entry_model(
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
revision: DistributionListRevision,
|
||||
payload: DistributionListEntryInput,
|
||||
order: int,
|
||||
) -> DistributionListEntry:
|
||||
source = payload.source
|
||||
return DistributionListEntry(
|
||||
tenant_id=principal.tenant_id,
|
||||
revision_id=revision.id,
|
||||
entry_key=payload.entry_key or f"entry-{order + 1}",
|
||||
kind=payload.kind,
|
||||
mode=payload.mode,
|
||||
source_provider=source.provider,
|
||||
source_resource_type=source.resource_type,
|
||||
source_resource_id=source.resource_id,
|
||||
source_revision=source.revision,
|
||||
source_fingerprint=source.fingerprint,
|
||||
source_label=source.label,
|
||||
source_metadata=dict(source.metadata),
|
||||
label=payload.label,
|
||||
purpose=payload.purpose,
|
||||
requested_channels=list(payload.requested_channels),
|
||||
effective_from=payload.effective_from,
|
||||
effective_until=payload.effective_until,
|
||||
order_index=order,
|
||||
configuration=dict(payload.configuration),
|
||||
)
|
||||
|
||||
|
||||
def _validate_entry_keys(entries: Sequence[DistributionListEntryInput]) -> None:
|
||||
keys = [item.entry_key or f"entry-{index + 1}" for index, item in enumerate(entries)]
|
||||
if len(keys) != len(set(keys)):
|
||||
raise DistributionListConflictError("Entry keys must be unique within a revision.")
|
||||
|
||||
|
||||
def _ensure_unique_name(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
name: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
exclude_id: str | None = None,
|
||||
) -> None:
|
||||
statement = select(DistributionList.id).where(
|
||||
DistributionList.tenant_id == principal.tenant_id,
|
||||
DistributionList.scope_type == scope_type,
|
||||
DistributionList.scope_id.is_(None)
|
||||
if scope_id is None
|
||||
else DistributionList.scope_id == scope_id,
|
||||
DistributionList.name == name.strip(),
|
||||
DistributionList.deleted_at.is_(None),
|
||||
)
|
||||
if exclude_id:
|
||||
statement = statement.where(DistributionList.id != exclude_id)
|
||||
if session.scalar(statement) is not None:
|
||||
raise DistributionListConflictError(
|
||||
"A distribution list with this name already exists in the selected scope."
|
||||
)
|
||||
|
||||
|
||||
def _ensure_mutable_scope(
|
||||
principal: ApiPrincipal,
|
||||
distribution_list: DistributionList,
|
||||
) -> None:
|
||||
if principal.has("dist_lists:list:admin"):
|
||||
return
|
||||
if distribution_list.scope_type == "user" and distribution_list.scope_id != principal.account_id:
|
||||
raise DistributionListConflictError("This user-scoped distribution list is read-only.")
|
||||
if distribution_list.scope_type == "group" and distribution_list.scope_id not in principal.group_ids:
|
||||
raise DistributionListConflictError("This group-scoped distribution list is read-only.")
|
||||
|
||||
|
||||
def _ensure_requested_scope(
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> None:
|
||||
if principal.has("dist_lists:list:admin") or scope_type == "tenant":
|
||||
return
|
||||
if scope_type == "user" and scope_id == principal.account_id:
|
||||
return
|
||||
if scope_type == "group" and scope_id in principal.group_ids:
|
||||
return
|
||||
raise DistributionListConflictError(
|
||||
"The selected distribution-list scope is not writable by the current principal."
|
||||
)
|
||||
|
||||
|
||||
def _text(value: object | None) -> str | None:
|
||||
candidate = str(value).strip() if value is not None else ""
|
||||
return candidate or None
|
||||
|
||||
|
||||
def _number(value: object | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_distribution_list",
|
||||
"definition_hash",
|
||||
"delete_distribution_list",
|
||||
"entry_ref",
|
||||
"get_distribution_list",
|
||||
"get_distribution_list_revision",
|
||||
"list_distribution_lists",
|
||||
"source_ref",
|
||||
"update_distribution_list",
|
||||
"visible_lists_statement",
|
||||
]
|
||||
Reference in New Issue
Block a user