Implement governed distribution lists

This commit is contained in:
2026-07-31 21:00:06 +02:00
parent 5d74f061b0
commit 8c54a5d6c9
26 changed files with 7280 additions and 40 deletions
@@ -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
+193 -7
View File
@@ -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."""
@@ -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")
+558
View File
@@ -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"]
+353
View File
@@ -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"))]
+503
View File
@@ -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",
]