diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 6dbb125..55db05e 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -17,13 +17,13 @@ Goal: define mixed recipient entries without implementing every provider. Tasks: -- define distribution-list, entry, expansion-plan, and expansion-result DTOs -- define source reference formats for address contacts, IDM identities/typed +- [x] define distribution-list, entry, expansion-plan, and expansion-result DTOs +- [x] define source reference formats for address contacts, IDM identities/typed groups, organization units/functions, effective incumbents, Dataflow results, raw targets, and nested-list entries -- define policy/read-only explanation payloads -- define snapshot evidence shape -- add cycle detection and expansion-limit semantics +- [x] define policy/read-only explanation payloads +- [x] define snapshot evidence shape +- [x] add cycle detection and expansion-limit semantics ## Milestone 2: Local Distribution Lists @@ -31,12 +31,12 @@ Goal: implement local, manually maintained distribution lists. Tasks: -- add database tables and migrations -- add CRUD routes and permissions -- add local raw email/postal entries -- add references to address contacts and address lists via capabilities -- expose `dist_lists.source` and `dist_lists.expand` -- add tests for expansion and snapshots +- [x] add database tables and migrations +- [x] add CRUD routes and permissions +- [x] add local raw email/postal entries +- [x] add references to address contacts and address lists via capabilities +- [x] expose `dist_lists.source` and `dist_lists.expand` +- [x] add tests for expansion and snapshots ## Milestone 3: Provider Integrations @@ -44,12 +44,14 @@ Goal: allow distribution lists to resolve through installed modules. Tasks: -- integrate with `addresses.lookup` and address-list source capability -- integrate with IDM identity/group/effective-assignment resolution -- integrate with Organizations unit/function resolution -- integrate with Datasources, Connectors, and pinned Dataflow results -- apply Addresses preference/consent facts and explainable Policy decisions -- preserve module independence when optional providers are absent +- [x] integrate with `addresses.lookup` and address-list source capability +- [x] integrate with IDM identity and effective function-assignment resolution +- [ ] resolve typed IDM groups once `govoplan-idm#7` exposes the provider contract; + until then the selector and expansion return an explicit unavailable outcome +- [x] integrate with Organizations unit/function resolution +- [x] consume governed Datasource/Connector states through pinned Dataflow outputs +- [x] apply Addresses preference/consent facts and explainable Policy decisions +- [x] preserve module independence when optional providers are absent ## Milestone 4: UI @@ -57,11 +59,11 @@ Goal: make distribution lists practical for administrators and operators. Tasks: -- add list management UI -- add mixed entry editor with provider-specific search -- show disabled/read-only entries with hover explanations -- show expansion preview and stale-source warnings -- expose picker components to Campaign, Templates, Reporting, and Workflow +- [x] add list management UI +- [x] add mixed entry editor with provider-specific search +- [x] show disabled/read-only entries with explanations +- [x] show expansion preview and stale-source warnings +- [x] expose picker components to Campaign, Templates, Reporting, and Workflow consumers ## Milestone 5: AdreMa Dynamic Audiences @@ -71,12 +73,12 @@ definitions. Tasks: -- add immutable static, parameterized, and dynamic definition revisions -- support include/exclude/manual-override sets with bounded policy constraints -- pin Datasource states, Connector revisions, and Dataflow definitions -- provide preview, counts, samples, and `why included/excluded` diagnostics -- freeze exact expansion and channel-decision evidence -- validate with a synthetic AdreMa golden flow +- [x] add immutable static, parameterized, and dynamic definition revisions +- [x] support include/exclude/manual-override sets with bounded policy constraints +- [x] pin Datasource states, Connector revisions, and Dataflow definitions +- [x] provide preview, counts, samples, and `why included/excluded` diagnostics +- [x] freeze exact expansion and channel-decision evidence +- [x] validate with a synthetic AdreMa golden flow ## Milestone 6: Campaign, Reporting, And Workflow Integration diff --git a/pyproject.toml b/pyproject.toml index 3640820..3c821e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-dist-lists" -version = "0.1.8" -description = "GovOPlaN operational distribution lists and Verteiler module seed." +version = "0.1.14" +description = "GovOPlaN operational distribution lists and governed audience expansion." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.8", + "govoplan-core>=0.1.14", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_dist_lists/backend/capabilities.py b/src/govoplan_dist_lists/backend/capabilities.py new file mode 100644 index 0000000..f2bdf9d --- /dev/null +++ b/src/govoplan_dist_lists/backend/capabilities.py @@ -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"] diff --git a/src/govoplan_dist_lists/backend/catalogue.py b/src/govoplan_dist_lists/backend/catalogue.py new file mode 100644 index 0000000..578ecf2 --- /dev/null +++ b/src/govoplan_dist_lists/backend/catalogue.py @@ -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"] diff --git a/src/govoplan_dist_lists/backend/db/__init__.py b/src/govoplan_dist_lists/backend/db/__init__.py new file mode 100644 index 0000000..1979ebd --- /dev/null +++ b/src/govoplan_dist_lists/backend/db/__init__.py @@ -0,0 +1,13 @@ +from govoplan_dist_lists.backend.db.models import ( + DistributionList, + DistributionListEntry, + DistributionListRevision, + DistributionListSnapshot, +) + +__all__ = [ + "DistributionList", + "DistributionListEntry", + "DistributionListRevision", + "DistributionListSnapshot", +] diff --git a/src/govoplan_dist_lists/backend/db/models.py b/src/govoplan_dist_lists/backend/db/models.py new file mode 100644 index 0000000..fc8eb49 --- /dev/null +++ b/src/govoplan_dist_lists/backend/db/models.py @@ -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", +] diff --git a/src/govoplan_dist_lists/backend/expansion.py b/src/govoplan_dist_lists/backend/expansion.py new file mode 100644 index 0000000..24affe3 --- /dev/null +++ b/src/govoplan_dist_lists/backend/expansion.py @@ -0,0 +1,1724 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import asdict, dataclass, field, replace +from datetime import UTC, date, datetime +from email.utils import parseaddr +import hashlib +import json +import re + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.dataflows import ( + DataflowDatasetRequest, + dataflow_dataset_output, +) +from govoplan_core.core.distribution_lists import ( + CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, + CAPABILITY_RECIPIENT_CHANNEL_FACTS, + DistributionChannelCandidate, + DistributionChannelPolicyProvider, + DistributionChannelPolicyRequest, + DistributionExpansionRequest, + DistributionExpansionResult, + DistributionExplanation, + DistributionListConflictError, + DistributionListEntryRef, + DistributionListNotFoundError, + DistributionListUnavailableError, + DistributionProviderEvidence, + DistributionRecipientRef, + DistributionSnapshotRef, + DistributionSourceReference, + RecipientChannelFactsProvider, + RecipientChannelFactsRequest, +) +from govoplan_core.core.identity import ( + CAPABILITY_IDENTITY_DIRECTORY, + IdentityDirectory, +) +from govoplan_core.core.idm import ( + CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + IdmFunctionAssignmentDirectory, +) +from govoplan_core.core.organizations import ( + CAPABILITY_ORGANIZATION_DIRECTORY, + OrganizationDirectory, +) +from govoplan_core.security.time import utc_now +from govoplan_dist_lists.backend.db.models import ( + DistributionList, + DistributionListRevision, + DistributionListSnapshot, +) +from govoplan_dist_lists.backend.service import ( + entry_ref, + get_distribution_list, + get_distribution_list_revision, + source_ref, +) + + +ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source" +ADDRESSES_LOOKUP = "addresses.lookup" + + +@dataclass(slots=True) +class _ExpansionContext: + session: Session + principal: ApiPrincipal + registry: object | None + request: DistributionExpansionRequest + effective_at: datetime + diagnostics: list[DistributionExplanation] = field(default_factory=list) + evidence: list[DistributionProviderEvidence] = field(default_factory=list) + visited_entries: int = 0 + resolved_candidates: int = 0 + candidate_truncated: bool = False + policy_unavailable_reported: bool = False + channel_facts_unavailable_reported: bool = False + + +def expand_distribution_list( + session: Session, + principal: ApiPrincipal, + *, + registry: object | None, + request: DistributionExpansionRequest, +) -> DistributionExpansionResult: + distribution_list = get_distribution_list(session, principal, request.list_id) + revision = get_distribution_list_revision( + session, + distribution_list, + revision=request.revision, + ) + if revision.definition_kind == "template": + raise DistributionListUnavailableError( + "Distribution-list templates cannot be expanded directly." + ) + effective_at = _aware(request.effective_at or utc_now()) + parameters = _resolve_parameters(revision.parameter_schema, request.parameters) + context = _ExpansionContext( + session=session, + principal=principal, + registry=registry, + request=replace(request, parameters=parameters, effective_at=effective_at), + effective_at=effective_at, + ) + recipients, excluded = _expand_revision( + context, + distribution_list, + revision, + parameters=parameters, + stack=(), + ) + recipients, duplicate_rows = _deduplicate_recipients( + recipients, + duplicate_rule=str(revision.constraints.get("duplicate_rule") or "first"), + ) + excluded.extend(duplicate_rows) + recipients = [ + _apply_channel_decisions(context, distribution_list, revision, item) + for item in recipients + ] + channel_excluded = [item for item in recipients if item.status != "usable"] + recipients = [item for item in recipients if item.status == "usable"] + excluded.extend(channel_excluded) + + result_truncated = len(recipients) > request.limits.max_results + truncated = context.candidate_truncated or result_truncated + if result_truncated: + context.diagnostics.append( + _explanation( + "expansion.result_limit", + f"Expansion was limited to {request.limits.max_results} recipients.", + severity="warning", + ) + ) + recipients = recipients[: request.limits.max_results] + stale = any(item.stale for item in context.evidence) + generated_at = utc_now() + expansion_hash = _expansion_hash( + distribution_list, + revision, + request=context.request, + recipients=recipients, + excluded=excluded, + evidence=context.evidence, + ) + result = DistributionExpansionResult( + source=source_ref(distribution_list, revision), + request=context.request, + recipients=tuple(recipients), + excluded=tuple(excluded), + diagnostics=tuple(_unique_explanations(context.diagnostics)), + provider_evidence=tuple(_unique_evidence(context.evidence)), + expansion_hash=expansion_hash, + generated_at=generated_at, + stale=stale, + truncated=truncated, + ) + if request.freeze: + snapshot = _freeze_expansion( + session, + principal, + result, + revision=revision, + ) + result = replace(result, snapshot_id=snapshot.id) + return result + + +def get_snapshot_ref( + session: Session, + principal: ApiPrincipal, + snapshot_id: str, +) -> DistributionSnapshotRef | None: + row = session.scalar( + select(DistributionListSnapshot).where( + DistributionListSnapshot.id == snapshot_id, + DistributionListSnapshot.tenant_id == principal.tenant_id, + ) + ) + if row is None: + return None + return snapshot_ref(row) + + +def snapshot_ref(row: DistributionListSnapshot) -> DistributionSnapshotRef: + return DistributionSnapshotRef( + id=row.id, + tenant_id=row.tenant_id, + list_id=row.distribution_list_id, + revision_id=row.revision_id, + revision=row.revision_number, + expansion_hash=row.expansion_hash, + generated_at=row.created_at, + effective_at=row.effective_at, + recipient_count=row.recipient_count, + excluded_count=row.excluded_count, + stale=row.stale, + truncated=row.truncated, + request=dict(row.request_), + recipients=tuple(_recipient_from_json(item) for item in row.recipients), + excluded=tuple(_recipient_from_json(item) for item in row.excluded), + diagnostics=tuple(_explanation_from_json(item) for item in row.diagnostics), + provider_evidence=tuple( + _evidence_from_json(item) for item in row.provider_evidence + ), + provenance=dict(row.provenance), + ) + + +def _expand_revision( + context: _ExpansionContext, + distribution_list: DistributionList, + revision: DistributionListRevision, + *, + parameters: Mapping[str, object], + stack: tuple[str, ...], +) -> tuple[list[DistributionRecipientRef], list[DistributionRecipientRef]]: + if distribution_list.id in stack: + cycle = " -> ".join((*stack, distribution_list.id)) + explanation = _explanation( + "expansion.nested_cycle", + f"Nested distribution-list cycle detected: {cycle}.", + severity="error", + provider="dist_lists", + ) + context.diagnostics.append(explanation) + return [], [_placeholder_recipient(distribution_list.id, explanation)] + if len(stack) >= context.request.limits.max_depth: + explanation = _explanation( + "expansion.depth_limit", + f"Nested lists are limited to {context.request.limits.max_depth} levels.", + severity="error", + provider="dist_lists", + ) + context.diagnostics.append(explanation) + return [], [_placeholder_recipient(distribution_list.id, explanation)] + + included: list[DistributionRecipientRef] = [] + excluded: list[DistributionRecipientRef] = [] + exclusion_keys: set[str] = set() + overrides: list[DistributionRecipientRef] = [] + for model in revision.entries: + context.visited_entries += 1 + if context.visited_entries > context.request.limits.max_entries: + context.diagnostics.append( + _explanation( + "expansion.entry_limit", + f"Expansion exceeded the {context.request.limits.max_entries}-entry limit.", + severity="error", + ) + ) + break + entry = _substitute_entry(entry_ref(model), parameters) + if not _entry_is_effective(entry, context.effective_at): + context.diagnostics.append( + _explanation( + "entry.not_effective", + f"{entry.label or entry.source.label or entry.id} is outside its effective period.", + severity="info", + provider=entry.source.provider, + source=entry.source, + ) + ) + continue + rows = _expand_entry( + context, + entry, + parameters=parameters, + stack=(*stack, distribution_list.id), + ) + if entry.mode == "exclude": + exclusion_keys.update(_recipient_match_keys(row) for row in rows) + excluded.extend( + replace( + row, + status="suppressed", + explanations=( + *row.explanations, + _explanation( + "entry.explicit_exclusion", + "Recipient was removed by an explicit exclusion entry.", + provider="dist_lists", + source=entry.source, + ), + ), + ) + for row in rows + ) + elif entry.mode == "override": + overrides.extend(rows) + else: + included.extend(rows) + + kept: list[DistributionRecipientRef] = [] + for row in included: + if _recipient_match_keys(row) in exclusion_keys: + excluded.append( + replace( + row, + status="suppressed", + explanations=( + *row.explanations, + _explanation( + "entry.excluded", + "Recipient matched an exclusion entry.", + provider="dist_lists", + ), + ), + ) + ) + else: + kept.append(row) + for row in overrides: + override_reason = _text(row.provenance.get("override_reason")) + if not override_reason: + excluded.append( + replace( + row, + status="policy_blocked", + explanations=( + *row.explanations, + _explanation( + "override.reason_required", + "Manual overrides require a recorded reason.", + severity="error", + provider="dist_lists", + ), + ), + ) + ) + continue + key = _recipient_match_keys(row) + kept = [item for item in kept if _recipient_match_keys(item) != key] + kept.append(row) + return kept, excluded + + +def _expand_entry( + context: _ExpansionContext, + entry: DistributionListEntryRef, + *, + parameters: Mapping[str, object], + stack: tuple[str, ...], +) -> list[DistributionRecipientRef]: + candidate_limit = ( + context.request.limits.max_results + + context.request.limits.max_provider_results + ) + if context.resolved_candidates >= candidate_limit: + if not context.candidate_truncated: + context.candidate_truncated = True + context.diagnostics.append( + _explanation( + "expansion.candidate_limit", + f"Expansion candidate processing was limited to {candidate_limit} rows.", + severity="warning", + provider=entry.source.provider, + source=entry.source, + ) + ) + return [] + if entry.kind == "raw_email": + return _bounded_candidates(context, entry, [_raw_email_recipient(entry)]) + if entry.kind == "raw_postal_address": + return _bounded_candidates(context, entry, [_raw_postal_recipient(entry)]) + if entry.kind in {"internal_mail", "portal"}: + return _bounded_candidates(context, entry, [_raw_digital_recipient(entry)]) + if entry.kind == "distribution_list": + return _nested_list_recipients(context, entry, parameters=parameters, stack=stack) + if entry.kind in {"address_list", "address_contact", "address_email"}: + return _bounded_candidates(context, entry, _address_recipients(context, entry)) + if entry.kind == "idm_identity": + return _bounded_candidates(context, entry, _identity_recipients(context, entry)) + if entry.kind == "idm_group": + return _bounded_candidates( + context, + entry, + [_provider_unavailable(entry, "IDM typed-group expansion is unavailable.")], + ) + if entry.kind in {"organization_unit", "function", "effective_function_incumbent"}: + return _bounded_candidates( + context, + entry, + _organization_recipients(context, entry), + ) + if entry.kind == "dataflow_result": + return _bounded_candidates( + context, + entry, + _dataflow_recipients(context, entry, parameters=parameters), + ) + return _bounded_candidates( + context, + entry, + [_provider_unavailable(entry, f"Unsupported entry kind: {entry.kind}.")], + ) + + +def _bounded_candidates( + context: _ExpansionContext, + entry: DistributionListEntryRef, + rows: Sequence[DistributionRecipientRef], +) -> list[DistributionRecipientRef]: + candidate_limit = ( + context.request.limits.max_results + + context.request.limits.max_provider_results + ) + remaining = max(0, candidate_limit - context.resolved_candidates) + accepted = list(rows[:remaining]) + context.resolved_candidates += len(accepted) + if len(rows) > len(accepted) and not context.candidate_truncated: + context.candidate_truncated = True + context.diagnostics.append( + _explanation( + "expansion.candidate_limit", + f"Expansion candidate processing was limited to {candidate_limit} rows.", + severity="warning", + provider=entry.source.provider, + source=entry.source, + ) + ) + return accepted + + +def _raw_email_recipient(entry: DistributionListEntryRef) -> DistributionRecipientRef: + display, address = parseaddr(entry.source.resource_id) + valid = bool(address and "@" in address and len(address) <= 320) + source = entry.source + channel = DistributionChannelCandidate( + channel="email", + target=address or entry.source.resource_id, + target_key=f"email:{(address or entry.source.resource_id).casefold()}", + status="usable" if valid else "invalid", + reason_code=None if valid else "email.invalid", + explanation=None if valid else "The email address is invalid.", + source=source, + ) + return DistributionRecipientRef( + recipient_key=channel.target_key, + display_name=entry.label or display or address or entry.source.resource_id, + status=channel.status, + channels=(channel,), + source_entry_ids=(entry.id,), + explanations=( + () + if valid + else ( + _explanation( + "email.invalid", + "The email address is invalid.", + severity="error", + provider=source.provider, + source=source, + ), + ) + ), + provenance=_entry_provenance(entry), + ) + + +def _raw_postal_recipient(entry: DistributionListEntryRef) -> DistributionRecipientRef: + target = _text(entry.configuration.get("formatted_address")) or entry.source.resource_id + valid = bool(target.strip()) + channel = DistributionChannelCandidate( + channel="postal", + target=target, + target_key=f"postal:{_normalized_target(target)}", + status="usable" if valid else "invalid", + reason_code=None if valid else "postal.invalid", + explanation=None if valid else "The postal address is empty.", + source=entry.source, + ) + return DistributionRecipientRef( + recipient_key=channel.target_key, + display_name=entry.label or entry.source.label or target, + status=channel.status, + channels=(channel,), + source_entry_ids=(entry.id,), + explanations=(), + attributes=dict(entry.configuration), + provenance=_entry_provenance(entry), + ) + + +def _raw_digital_recipient(entry: DistributionListEntryRef) -> DistributionRecipientRef: + channel_name = "portal" if entry.kind == "portal" else "internal_mail" + target = entry.source.resource_id.strip() + channel = DistributionChannelCandidate( + channel=channel_name, + target=target, + target_key=f"{channel_name}:{target.casefold()}", + status="usable" if target else "invalid", + source=entry.source, + ) + return DistributionRecipientRef( + recipient_key=channel.target_key, + display_name=entry.label or entry.source.label or target, + status=channel.status, + channels=(channel,), + account_id=target if channel_name == "internal_mail" else None, + source_entry_ids=(entry.id,), + provenance=_entry_provenance(entry), + ) + + +def _nested_list_recipients( + context: _ExpansionContext, + entry: DistributionListEntryRef, + *, + parameters: Mapping[str, object], + stack: tuple[str, ...], +) -> list[DistributionRecipientRef]: + try: + nested = get_distribution_list( + context.session, + context.principal, + entry.source.resource_id, + ) + nested_revision = get_distribution_list_revision( + context.session, + nested, + revision=int(entry.source.revision) if entry.source.revision else None, + ) + mapped_parameters = entry.configuration.get("parameters") + nested_parameters = _resolve_parameters( + nested_revision.parameter_schema, + mapped_parameters if isinstance(mapped_parameters, Mapping) else parameters, + ) + included, excluded = _expand_revision( + context, + nested, + nested_revision, + parameters=nested_parameters, + stack=stack, + ) + context.diagnostics.extend( + explanation + for row in excluded + for explanation in row.explanations + if explanation.severity == "error" + ) + return [ + replace( + row, + source_entry_ids=tuple(dict.fromkeys((*row.source_entry_ids, entry.id))), + provenance={ + **dict(row.provenance), + "nested_distribution_list_id": nested.id, + "nested_revision": nested_revision.revision, + }, + ) + for row in included + ] + except (DistributionListConflictError, ValueError) as exc: + return [_unresolved(entry, "nested_list.unavailable", str(exc))] + except DistributionListNotFoundError as exc: + return [_unresolved(entry, "nested_list.not_found", str(exc))] + + +def _address_recipients( + context: _ExpansionContext, + entry: DistributionListEntryRef, +) -> list[DistributionRecipientRef]: + if entry.kind == "address_list": + provider = _capability(context.registry, ADDRESSES_RECIPIENT_SOURCE) + if provider is None or not hasattr(provider, "snapshot"): + return [_provider_unavailable(entry, "Addresses recipient sources are unavailable.")] + try: + snapshot = provider.snapshot( + context.session, + context.principal, + source_id=entry.source.resource_id, + ) + except (LookupError, PermissionError, ValueError) as exc: + return [_unresolved(entry, "addresses.source_failed", str(exc))] + actual_revision = _text(getattr(snapshot, "source_revision", None)) + stale = _is_stale(entry.source, actual_revision=actual_revision) + context.evidence.append( + DistributionProviderEvidence( + provider="addresses", + source=entry.source, + actual_revision=actual_revision, + stale=stale, + generated_at=_parse_datetime(getattr(snapshot, "generated_at", None)), + details=dict(getattr(snapshot, "provenance", {}) or {}), + ) + ) + rows = list(getattr(snapshot, "recipients", ()))[: context.request.limits.max_provider_results] + if len(getattr(snapshot, "recipients", ())) > len(rows): + context.diagnostics.append( + _explanation( + "provider.result_limit", + "Addresses results were truncated by the provider limit.", + provider="addresses", + source=entry.source, + ) + ) + return [_address_snapshot_recipient(entry, row, stale=stale) for row in rows] + + provider = _capability(context.registry, ADDRESSES_LOOKUP) + if provider is None or not hasattr(provider, "lookup"): + return [_provider_unavailable(entry, "Addresses lookup is unavailable.")] + try: + candidates = provider.lookup( + context.session, + context.principal, + query=entry.source.resource_id, + limit=100, + ) + except (LookupError, PermissionError, ValueError) as exc: + return [_unresolved(entry, "addresses.lookup_failed", str(exc))] + exact = [ + item + for item in candidates + if str(getattr(item, "contact_id", "")) == entry.source.resource_id + ] + if entry.kind == "address_email": + expected = _text( + entry.configuration.get("email") + or entry.source.metadata.get("email") + ) + exact = [item for item in exact if expected is None or getattr(item, "email", None) == expected] + if not exact: + return [_unresolved(entry, "addresses.contact_not_found", "Address contact could not be resolved.")] + return [_address_lookup_recipient(entry, item) for item in exact] + + +def _address_snapshot_recipient( + entry: DistributionListEntryRef, + item: object, + *, + stale: bool, +) -> DistributionRecipientRef: + contact_id = str(getattr(item, "contact_id", "")) or None + email = str(getattr(item, "email", "")) + source = DistributionSourceReference( + provider="addresses", + resource_type="contact", + resource_id=contact_id or email, + revision=entry.source.revision, + fingerprint=entry.source.fingerprint, + label=str(getattr(item, "display_name", "")) or None, + metadata=dict(getattr(item, "provenance", {}) or {}), + ) + channel = DistributionChannelCandidate( + channel="email", + target=email, + target_key=f"email:{email.casefold()}", + status="stale" if stale else "usable", + reason_code="source.stale" if stale else None, + explanation="The source revision changed." if stale else None, + source=source, + ) + return DistributionRecipientRef( + recipient_key=f"contact:{contact_id}" if contact_id else channel.target_key, + display_name=str(getattr(item, "display_name", "")) or email, + status=channel.status, + channels=(channel,), + contact_id=contact_id, + source_entry_ids=(entry.id,), + attributes=dict(getattr(item, "fields", {}) or {}), + provenance={ + **_entry_provenance(entry), + **dict(getattr(item, "provenance", {}) or {}), + }, + ) + + +def _address_lookup_recipient( + entry: DistributionListEntryRef, + item: object, +) -> DistributionRecipientRef: + contact_id = str(getattr(item, "contact_id")) + email = _text(getattr(item, "email", None)) + channels = ( + ( + DistributionChannelCandidate( + channel="email", + target=email, + target_key=f"email:{email.casefold()}", + contact_point_id=None, + source=entry.source, + ), + ) + if email + else () + ) + return DistributionRecipientRef( + recipient_key=f"contact:{contact_id}", + display_name=str(getattr(item, "display_name", "")) or contact_id, + status="usable" if channels else "unresolved", + channels=channels, + contact_id=contact_id, + source_entry_ids=(entry.id,), + attributes={ + "organization": getattr(item, "organization", None), + "role_title": getattr(item, "role_title", None), + "tags": tuple(getattr(item, "tags", ()) or ()), + }, + provenance={ + **_entry_provenance(entry), + **dict(getattr(item, "provenance", {}) or {}), + }, + ) + + +def _identity_recipients( + context: _ExpansionContext, + entry: DistributionListEntryRef, +) -> list[DistributionRecipientRef]: + provider = _typed_capability( + context.registry, + CAPABILITY_IDENTITY_DIRECTORY, + IdentityDirectory, + ) + if provider is None: + return [_provider_unavailable(entry, "Identity directory is unavailable.")] + identity = provider.get_identity(entry.source.resource_id) + if identity is None or identity.status != "active": + return [_unresolved(entry, "identity.not_active", "Identity is missing or inactive.")] + account_id = identity.primary_account_id or next(iter(identity.account_ids), None) + if account_id is None: + return [_unresolved(entry, "identity.no_account", "Identity has no linked account.")] + channel = DistributionChannelCandidate( + channel="internal_mail", + target=account_id, + target_key=f"internal_mail:{account_id}", + source=entry.source, + ) + return [ + DistributionRecipientRef( + recipient_key=f"identity:{identity.id}", + display_name=identity.display_name or identity.id, + status="usable", + channels=(channel,), + identity_id=identity.id, + account_id=account_id, + source_entry_ids=(entry.id,), + provenance=_entry_provenance(entry), + ) + ] + + +def _organization_recipients( + context: _ExpansionContext, + entry: DistributionListEntryRef, +) -> list[DistributionRecipientRef]: + organizations = _typed_capability( + context.registry, + CAPABILITY_ORGANIZATION_DIRECTORY, + OrganizationDirectory, + ) + incumbencies = _typed_capability( + context.registry, + CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + IdmFunctionAssignmentDirectory, + ) + identities = _typed_capability( + context.registry, + CAPABILITY_IDENTITY_DIRECTORY, + IdentityDirectory, + ) + if organizations is None or incumbencies is None or identities is None: + return [ + _provider_unavailable( + entry, + "Organizations, IDM incumbency, and Identity capabilities are required.", + ) + ] + if entry.kind == "organization_unit": + unit = organizations.get_organization_unit(entry.source.resource_id) + if unit is None or unit.tenant_id != context.principal.tenant_id: + return [_unresolved(entry, "organization.unit_not_found", "Organization unit not found.")] + functions = organizations.functions_for_organization_unit( + unit.id, + include_subunits=bool(entry.configuration.get("include_subunits", False)), + ) + function_ids = [item.id for item in functions if item.status == "active"] + else: + function = organizations.get_function(entry.source.resource_id) + if function is None or function.tenant_id != context.principal.tenant_id: + return [_unresolved(entry, "organization.function_not_found", "Organization function not found.")] + function_ids = [function.id] + results: list[DistributionRecipientRef] = [] + batch = incumbencies.organization_function_incumbencies( + function_ids, + tenant_id=context.principal.tenant_id, + effective_at=context.effective_at, + ) + for function_id in function_ids: + incumbency = batch.get(function_id) + if incumbency is None or incumbency.vacant: + context.diagnostics.append( + _explanation( + "function.vacant", + f"Function {function_id} has no effective incumbent.", + provider="idm", + source=entry.source, + ) + ) + continue + identity_ids = [assignment.identity_id for assignment in incumbency.assignments] + account_by_identity = { + assignment.identity_id: assignment.account_id + for assignment in incumbency.assignments + if assignment.account_id + } + for identity in identities.identities_for_accounts( + [item for item in account_by_identity.values() if item] + ): + account_id = account_by_identity.get(identity.id) or identity.primary_account_id + if account_id is None: + continue + results.append( + DistributionRecipientRef( + recipient_key=f"identity:{identity.id}", + display_name=identity.display_name or identity.id, + status="usable", + channels=( + DistributionChannelCandidate( + channel="internal_mail", + target=account_id, + target_key=f"internal_mail:{account_id}", + source=entry.source, + ), + ), + identity_id=identity.id, + account_id=account_id, + function_id=function_id, + source_entry_ids=(entry.id,), + provenance=_entry_provenance(entry), + ) + ) + missing_ids = set(identity_ids) - {item.identity_id for item in results if item.function_id == function_id} + for identity_id in sorted(missing_ids): + context.diagnostics.append( + _explanation( + "identity.account_unresolved", + f"Incumbent identity {identity_id} has no resolvable account.", + provider="identity", + source=entry.source, + ) + ) + return results or [_unresolved(entry, "function.no_recipients", "No effective function recipient could be resolved.")] + + +def _dataflow_recipients( + context: _ExpansionContext, + entry: DistributionListEntryRef, + *, + parameters: Mapping[str, object], +) -> list[DistributionRecipientRef]: + provider = dataflow_dataset_output(context.registry) + if provider is None: + return [_provider_unavailable(entry, "Dataflow dataset output is unavailable.")] + try: + revision = int(entry.source.revision or entry.configuration.get("revision") or 0) + except (TypeError, ValueError): + revision = 0 + if revision < 1: + return [_unresolved(entry, "dataflow.revision_required", "A pinned Dataflow revision is required.")] + configured_parameters = entry.configuration.get("parameters") + request_parameters = ( + dict(configured_parameters) + if isinstance(configured_parameters, Mapping) + else dict(parameters) + ) + expected_source_fingerprints = entry.configuration.get( + "expected_source_fingerprints" + ) + expected_fingerprints = ( + tuple( + dict(item) + for item in expected_source_fingerprints + if isinstance(item, Mapping) + ) + if isinstance(expected_source_fingerprints, Sequence) + and not isinstance(expected_source_fingerprints, (str, bytes)) + else () + ) + try: + result = provider.read_output( + context.session, + context.principal, + request=DataflowDatasetRequest( + pipeline_ref=entry.source.resource_id, + revision=revision, + parameters=request_parameters, + row_limit=context.request.limits.max_provider_results, + expected_definition_hash=entry.source.fingerprint, + expected_source_fingerprints=expected_fingerprints, + ), + ) + except (LookupError, PermissionError, ValueError) as exc: + return [_unresolved(entry, "dataflow.output_failed", str(exc))] + stale = _is_stale( + entry.source, + actual_revision=str(result.revision), + actual_fingerprint=result.definition_hash, + ) + context.evidence.append( + DistributionProviderEvidence( + provider="dataflow", + source=entry.source, + actual_revision=str(result.revision), + actual_fingerprint=result.definition_hash, + stale=stale, + generated_at=result.generated_at, + details={ + "output_hash": result.output_hash, + "run_ref": result.run_ref, + "source_fingerprints": [dict(item) for item in result.source_fingerprints], + }, + ) + ) + return [ + _dataflow_row_recipient(entry, row, index=index, stale=stale) + for index, row in enumerate(result.rows) + ] + + +def _dataflow_row_recipient( + entry: DistributionListEntryRef, + row: Mapping[str, object], + *, + index: int, + stale: bool, +) -> DistributionRecipientRef: + fields = entry.configuration.get("field_map") + field_map = dict(fields) if isinstance(fields, Mapping) else {} + + def value(name: str, default: str) -> str | None: + key = str(field_map.get(name) or default) + return _text(row.get(key)) + + def raw_value(name: str, default: str) -> object: + key = str(field_map.get(name) or default) + return row.get(key) + + recipient_key = value("recipient_key", "recipient_key") or f"dataflow:{entry.id}:{index}" + display_name = value("display_name", "display_name") or recipient_key + explicit_status = value("status", "distribution_status") + if explicit_status not in { + "usable", + "unresolved", + "invalid", + "suppressed", + "ambiguous", + "duplicate", + "policy_blocked", + "provider_unavailable", + "stale", + }: + explicit_status = None + reason_code = value("reason_code", "exclusion_reason") + explanation_text = value("explanation", "exclusion_explanation") or reason_code + selected_channel = value("selected_channel", "selected_channel") + generic_contact_point_id = value("contact_point_id", "contact_point_id") + locale = value("locale", "locale") + channels: list[DistributionChannelCandidate] = [] + for channel, default_field in ( + ("email", "email"), + ("postal", "postal_address"), + ("internal_mail", "account_id"), + ("portal", "portal_target"), + ): + target = value(channel, default_field) + if target: + candidate_status = ( + "stale" + if stale and explicit_status in {None, "usable"} + else explicit_status or "usable" + ) + contact_point_id = value( + f"{channel}_contact_point_id", + f"{default_field}_contact_point_id", + ) + if contact_point_id is None and selected_channel == channel: + contact_point_id = generic_contact_point_id + channels.append( + DistributionChannelCandidate( + channel=channel, # type: ignore[arg-type] + target=target, + target_key=f"{channel}:{_normalized_target(target)}", + status=candidate_status, # type: ignore[arg-type] + contact_point_id=contact_point_id, + locale=locale, + preferred=bool( + raw_value(f"{channel}_preferred", f"{channel}_preferred") + or selected_channel == channel + ), + reason_code=( + "source.stale" + if candidate_status == "stale" + else reason_code + ), + explanation=explanation_text, + source=entry.source, + decision_provenance={ + "dataflow_policy_decision": value( + "policy_decision", + "policy_decision", + ), + }, + ) + ) + status = ( + explicit_status + or ("stale" if stale else ("usable" if channels else "unresolved")) + ) + if status == "usable" and not channels: + status = "unresolved" + explanations = ( + ( + _explanation( + reason_code or f"dataflow.{status}", + explanation_text or f"Dataflow classified this recipient as {status}.", + severity="info" if status in {"usable", "duplicate"} else "warning", + provider="dataflow", + source=entry.source, + ), + ) + if status != "usable" or explanation_text + else () + ) + return DistributionRecipientRef( + recipient_key=recipient_key, + display_name=display_name, + status=status, # type: ignore[arg-type] + channels=tuple(channels), + identity_id=value("identity_id", "identity_id"), + account_id=value("account_id", "account_id"), + contact_id=value("contact_id", "contact_id"), + organization_unit_id=value("organization_unit_id", "organization_unit_id"), + function_id=value("function_id", "function_id"), + source_entry_ids=(entry.id,), + explanations=explanations, + attributes=dict(row), + provenance={ + **_entry_provenance(entry), + "dataflow_row_index": index, + "override_reason": _text(entry.configuration.get("override_reason")), + }, + ) + + +def _apply_channel_decisions( + context: _ExpansionContext, + distribution_list: DistributionList, + revision: DistributionListRevision, + recipient: DistributionRecipientRef, +) -> DistributionRecipientRef: + channels = list(recipient.channels) + facts_applied = False + facts_provider = _typed_capability( + context.registry, + CAPABILITY_RECIPIENT_CHANNEL_FACTS, + RecipientChannelFactsProvider, + ) + if facts_provider is not None and recipient.source_entry_ids: + source = channels[0].source if channels else None + if source is not None: + facts = facts_provider.resolve_channel_facts( + context.session, + context.principal, + request=RecipientChannelFactsRequest( + tenant_id=context.principal.tenant_id, + source=source, + recipient_key=recipient.recipient_key, + effective_at=context.effective_at, + purpose=context.request.purpose, + requested_channels=context.request.requested_channels, + context={"list_id": distribution_list.id}, + ), + ) + channels = list(facts.candidates) + facts_applied = True + recipient = replace( + recipient, + explanations=(*recipient.explanations, *facts.explanations), + provenance={ + **dict(recipient.provenance), + "channel_facts": dict(facts.provenance), + }, + ) + + raw_requested_by_entry = recipient.provenance.get("requested_channels", ()) + requested_by_entry = ( + {str(item) for item in raw_requested_by_entry if str(item)} + if isinstance(raw_requested_by_entry, (list, tuple, set, frozenset)) + else set() + ) + requested_by_caller = set(context.request.requested_channels) + requested_channels = ( + requested_by_entry.intersection(requested_by_caller) + if requested_by_entry and requested_by_caller + else requested_by_entry or requested_by_caller + ) + if requested_channels: + channels = [ + item + if item.channel in requested_channels + or item.status not in {"usable", "stale"} + else replace( + item, + status="suppressed", + reason_code="channel.not_requested", + explanation="This channel was not requested for the expansion.", + ) + for item in channels + ] + + eligible = [item for item in channels if item.status in {"usable", "stale"}] + if not facts_applied and len(eligible) > 1: + default_channel = str(revision.constraints.get("default_channel") or "email") + preferred = next( + (item for item in eligible if item.channel == default_channel), + eligible[0], + ) + channels = [ + item + if item.target_key == preferred.target_key + or item.status not in {"usable", "stale"} + else replace( + item, + status="suppressed", + reason_code="channel.default_not_selected", + explanation=( + f"The configured {default_channel} channel was selected " + "because no preference facts are available." + ), + ) + for item in channels + ] + if not context.channel_facts_unavailable_reported: + context.diagnostics.append( + _explanation( + "channel_facts.unavailable", + "Channel preference facts are unavailable; only the configured default channel is used.", + severity="info", + provider="addresses", + ) + ) + context.channel_facts_unavailable_reported = True + + policy = _typed_capability( + context.registry, + CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, + DistributionChannelPolicyProvider, + ) + governed: list[DistributionChannelCandidate] = [] + for candidate in channels: + if candidate.status not in {"usable", "stale"}: + governed.append(candidate) + continue + if policy is None: + governed.append(candidate) + continue + decision = policy.resolve_distribution_channel( + context.session, + context.principal, + request=DistributionChannelPolicyRequest( + tenant_id=context.principal.tenant_id, + list_id=distribution_list.id, + purpose=context.request.purpose, + effective_at=context.effective_at, + recipient=recipient, + candidate=candidate, + context={"definition_hash": revision.definition_hash}, + ), + ) + governed.append( + replace( + candidate, + status=candidate.status if decision.allowed else "policy_blocked", + reason_code=decision.reason_code, + explanation=decision.explanation, + decision_provenance={ + "source_path": [dict(item) for item in decision.source_path], + "requirements": list(decision.requirements), + "details": dict(decision.details), + }, + ) + ) + if policy is None and not context.policy_unavailable_reported: + context.diagnostics.append( + _explanation( + "policy.unavailable", + "Distribution-channel Policy is unavailable; provider facts and configured defaults apply.", + severity="info", + provider="policy", + ) + ) + context.policy_unavailable_reported = True + status = "usable" if any(item.status == "usable" for item in governed) else _recipient_outcome(governed, recipient.status) + return replace(recipient, channels=tuple(governed), status=status) + + +def _deduplicate_recipients( + recipients: Sequence[DistributionRecipientRef], + *, + duplicate_rule: str, +) -> tuple[list[DistributionRecipientRef], list[DistributionRecipientRef]]: + grouped: dict[str, DistributionRecipientRef] = {} + duplicates: list[DistributionRecipientRef] = [] + for recipient in recipients: + key = _recipient_match_keys(recipient) + previous = grouped.get(key) + if previous is None: + grouped[key] = recipient + continue + if duplicate_rule == "group": + grouped[key] = replace( + previous, + channels=tuple( + { + item.target_key: item + for item in (*previous.channels, *recipient.channels) + }.values() + ), + source_entry_ids=tuple( + dict.fromkeys((*previous.source_entry_ids, *recipient.source_entry_ids)) + ), + provenance={ + **dict(previous.provenance), + "grouped_duplicate_count": int( + previous.provenance.get("grouped_duplicate_count", 1) + ) + + 1, + }, + ) + continue + duplicates.append( + replace( + recipient, + status="duplicate", + channels=tuple( + replace( + item, + status="duplicate", + reason_code="recipient.duplicate", + explanation="An earlier recipient resolved to the same target.", + ) + for item in recipient.channels + ), + explanations=( + *recipient.explanations, + _explanation( + "recipient.duplicate", + "An earlier recipient resolved to the same target.", + severity="info", + ), + ), + ) + ) + return list(grouped.values()), duplicates + + +def _freeze_expansion( + session: Session, + principal: ApiPrincipal, + result: DistributionExpansionResult, + *, + revision: DistributionListRevision, +) -> DistributionListSnapshot: + idempotency_key = result.request.idempotency_key + assert idempotency_key is not None + existing = session.scalar( + select(DistributionListSnapshot).where( + DistributionListSnapshot.tenant_id == principal.tenant_id, + DistributionListSnapshot.idempotency_key == idempotency_key, + ) + ) + request_json = _json_value(asdict(result.request)) + if existing is not None: + if existing.expansion_hash != result.expansion_hash or existing.request_ != request_json: + raise DistributionListConflictError( + "The snapshot idempotency key was already used for a different expansion." + ) + return existing + snapshot = DistributionListSnapshot( + tenant_id=principal.tenant_id, + distribution_list_id=result.source.id, + revision_id=revision.id, + revision_number=revision.revision, + idempotency_key=idempotency_key, + request_=request_json, + expansion_hash=result.expansion_hash, + effective_at=result.request.effective_at or utc_now(), + recipient_count=len(result.recipients), + excluded_count=len(result.excluded), + recipients=[_json_value(asdict(item)) for item in result.recipients], + excluded=[_json_value(asdict(item)) for item in result.excluded], + diagnostics=[_json_value(asdict(item)) for item in result.diagnostics], + provider_evidence=[ + _json_value(asdict(item)) for item in result.provider_evidence + ], + stale=result.stale, + truncated=result.truncated, + created_by_account_id=principal.account_id, + provenance={ + "module": "dist_lists", + "definition_hash": result.source.definition_hash, + "resource_revision": result.source.provenance.get("resource_revision"), + }, + ) + session.add(snapshot) + session.flush() + return snapshot + + +def _resolve_parameters( + schema: Sequence[Mapping[str, object]], + supplied: Mapping[str, object], +) -> dict[str, object]: + definitions = {str(item.get("key")): item for item in schema} + unknown = sorted(set(supplied) - set(definitions)) + if unknown: + raise DistributionListConflictError( + f"Unknown distribution-list parameters: {', '.join(unknown)}." + ) + result: dict[str, object] = {} + for key, definition in definitions.items(): + value = supplied.get(key, definition.get("default")) + if value is None: + if bool(definition.get("required")): + raise DistributionListConflictError(f"Parameter {key!r} is required.") + continue + value = _coerce_parameter(key, value, str(definition.get("value_type"))) + allowed = definition.get("allowed_values") + if isinstance(allowed, Sequence) and not isinstance(allowed, (str, bytes)) and allowed and value not in allowed: + raise DistributionListConflictError(f"Parameter {key!r} has a disallowed value.") + if isinstance(value, (int, float)) and not isinstance(value, bool): + minimum = definition.get("minimum") + maximum = definition.get("maximum") + if minimum is not None and value < float(minimum): + raise DistributionListConflictError(f"Parameter {key!r} is below its minimum.") + if maximum is not None and value > float(maximum): + raise DistributionListConflictError(f"Parameter {key!r} exceeds its maximum.") + pattern = _text(definition.get("pattern")) + if pattern and isinstance(value, str) and re.fullmatch(pattern, value) is None: + raise DistributionListConflictError(f"Parameter {key!r} does not match its pattern.") + result[key] = value + return result + + +def _coerce_parameter(key: str, value: object, value_type: str) -> object: + try: + if value_type == "string": + return str(value) + if value_type == "integer" and not isinstance(value, bool): + return int(value) + if value_type == "number" and not isinstance(value, bool): + return float(value) + if value_type == "boolean" and isinstance(value, bool): + return value + if value_type == "string_list" and isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [str(item) for item in value] + if value_type == "date": + return date.fromisoformat(str(value)).isoformat() + if value_type == "datetime": + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).isoformat() + except (TypeError, ValueError) as exc: + raise DistributionListConflictError( + f"Parameter {key!r} is not a valid {value_type}." + ) from exc + raise DistributionListConflictError(f"Parameter {key!r} is not a valid {value_type}.") + + +def _substitute_entry( + entry: DistributionListEntryRef, + parameters: Mapping[str, object], +) -> DistributionListEntryRef: + return replace( + entry, + source=replace( + entry.source, + resource_id=str(_substitute_value(entry.source.resource_id, parameters)), + metadata=_substitute_value(entry.source.metadata, parameters), + ), + label=_substitute_value(entry.label, parameters), + purpose=_substitute_value(entry.purpose, parameters), + configuration=_substitute_value(entry.configuration, parameters), + ) + + +def _substitute_value(value: object, parameters: Mapping[str, object]): + if isinstance(value, str): + exact = re.fullmatch(r"\$\{([A-Za-z_][A-Za-z0-9_.-]*)\}", value) + if exact: + return parameters.get(exact.group(1), value) + return re.sub( + r"\$\{([A-Za-z_][A-Za-z0-9_.-]*)\}", + lambda match: str(parameters.get(match.group(1), match.group(0))), + value, + ) + if isinstance(value, Mapping): + return {str(key): _substitute_value(item, parameters) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [_substitute_value(item, parameters) for item in value] + return value + + +def _entry_is_effective(entry: DistributionListEntryRef, effective_at: datetime) -> bool: + if entry.effective_from and _aware(entry.effective_from) > effective_at: + return False + return not (entry.effective_until and _aware(entry.effective_until) <= effective_at) + + +def _provider_unavailable( + entry: DistributionListEntryRef, + message: str, +) -> DistributionRecipientRef: + return _unresolved(entry, "provider.unavailable", message, status="provider_unavailable") + + +def _unresolved( + entry: DistributionListEntryRef, + code: str, + message: str, + *, + status: str = "unresolved", +) -> DistributionRecipientRef: + explanation = _explanation( + code, + message, + severity="error" if status == "invalid" else "warning", + provider=entry.source.provider, + source=entry.source, + ) + return DistributionRecipientRef( + recipient_key=f"{status}:{entry.id}", + display_name=entry.label or entry.source.label or entry.source.resource_id, + status=status, # type: ignore[arg-type] + source_entry_ids=(entry.id,), + explanations=(explanation,), + provenance=_entry_provenance(entry), + ) + + +def _placeholder_recipient( + key: str, + explanation: DistributionExplanation, +) -> DistributionRecipientRef: + return DistributionRecipientRef( + recipient_key=f"invalid:{key}", + display_name=key, + status="invalid", + explanations=(explanation,), + ) + + +def _entry_provenance(entry: DistributionListEntryRef) -> dict[str, object]: + return { + "entry_kind": entry.kind, + "entry_mode": entry.mode, + "source_provider": entry.source.provider, + "source_resource_type": entry.source.resource_type, + "source_resource_id": entry.source.resource_id, + "source_revision": entry.source.revision, + "source_fingerprint": entry.source.fingerprint, + "requested_channels": list(entry.requested_channels), + "purpose": entry.purpose, + "override_reason": _text(entry.configuration.get("override_reason")), + } + + +def _recipient_match_keys(recipient: DistributionRecipientRef) -> str: + channel_keys = sorted(item.target_key for item in recipient.channels) + return channel_keys[0] if channel_keys else recipient.recipient_key + + +def _recipient_outcome( + channels: Sequence[DistributionChannelCandidate], + fallback: str, +): + priority = ( + "policy_blocked", + "suppressed", + "invalid", + "ambiguous", + "stale", + "unresolved", + "provider_unavailable", + "duplicate", + ) + statuses = {item.status for item in channels} + return next((item for item in priority if item in statuses), fallback) + + +def _is_stale( + source: DistributionSourceReference, + *, + actual_revision: str | None = None, + actual_fingerprint: str | None = None, +) -> bool: + return bool( + (source.revision and actual_revision and source.revision != actual_revision) + or ( + source.fingerprint + and actual_fingerprint + and source.fingerprint != actual_fingerprint + ) + ) + + +def _expansion_hash( + distribution_list: DistributionList, + revision: DistributionListRevision, + *, + request: DistributionExpansionRequest, + recipients: Sequence[DistributionRecipientRef], + excluded: Sequence[DistributionRecipientRef], + evidence: Sequence[DistributionProviderEvidence], +) -> str: + request_payload = asdict(request) + for control_field in ("preview", "freeze", "idempotency_key"): + request_payload.pop(control_field, None) + payload = { + "list_id": distribution_list.id, + "revision": revision.revision, + "definition_hash": revision.definition_hash, + "request": _json_value(request_payload), + "recipients": [_json_value(asdict(item)) for item in recipients], + "excluded": [_json_value(asdict(item)) for item in excluded], + "provider_evidence": [ + _stable_provider_evidence(item) for item in evidence + ], + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _stable_provider_evidence(item: DistributionProviderEvidence) -> object: + payload = asdict(item) + payload.pop("generated_at", None) + details = dict(payload.get("details") or {}) + details.pop("run_ref", None) + payload["details"] = details + return _json_value(payload) + + +def _json_value(value): + if isinstance(value, datetime): + return _aware(value).isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, Mapping): + return {str(key): _json_value(item) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [_json_value(item) for item in value] + return value + + +def _recipient_from_json(value: Mapping[str, object]) -> DistributionRecipientRef: + return DistributionRecipientRef( + recipient_key=str(value["recipient_key"]), + display_name=str(value["display_name"]), + status=str(value["status"]), # type: ignore[arg-type] + channels=tuple(_channel_from_json(item) for item in value.get("channels", ())), # type: ignore[arg-type] + identity_id=_text(value.get("identity_id")), + account_id=_text(value.get("account_id")), + contact_id=_text(value.get("contact_id")), + organization_unit_id=_text(value.get("organization_unit_id")), + function_id=_text(value.get("function_id")), + source_entry_ids=tuple(str(item) for item in value.get("source_entry_ids", ())), # type: ignore[arg-type] + explanations=tuple(_explanation_from_json(item) for item in value.get("explanations", ())), # type: ignore[arg-type] + attributes=dict(value.get("attributes", {}) or {}), # type: ignore[arg-type] + provenance=dict(value.get("provenance", {}) or {}), # type: ignore[arg-type] + ) + + +def _channel_from_json(value: Mapping[str, object]) -> DistributionChannelCandidate: + source = value.get("source") + return DistributionChannelCandidate( + channel=str(value["channel"]), # type: ignore[arg-type] + target=str(value["target"]), + target_key=str(value["target_key"]), + status=str(value.get("status") or "usable"), # type: ignore[arg-type] + contact_point_id=_text(value.get("contact_point_id")), + locale=_text(value.get("locale")), + preferred=bool(value.get("preferred")), + reason_code=_text(value.get("reason_code")), + explanation=_text(value.get("explanation")), + source=_source_from_json(source) if isinstance(source, Mapping) else None, + decision_provenance=dict(value.get("decision_provenance", {}) or {}), # type: ignore[arg-type] + ) + + +def _explanation_from_json(value: Mapping[str, object]) -> DistributionExplanation: + source = value.get("source") + return DistributionExplanation( + code=str(value["code"]), + message=str(value["message"]), + severity=str(value.get("severity") or "warning"), # type: ignore[arg-type] + provider=_text(value.get("provider")), + source=_source_from_json(source) if isinstance(source, Mapping) else None, + provenance=dict(value.get("provenance", {}) or {}), # type: ignore[arg-type] + ) + + +def _evidence_from_json(value: Mapping[str, object]) -> DistributionProviderEvidence: + return DistributionProviderEvidence( + provider=str(value["provider"]), + source=_source_from_json(value["source"]), # type: ignore[arg-type] + actual_revision=_text(value.get("actual_revision")), + actual_fingerprint=_text(value.get("actual_fingerprint")), + stale=bool(value.get("stale")), + generated_at=_parse_datetime(value.get("generated_at")), + details=dict(value.get("details", {}) or {}), # type: ignore[arg-type] + ) + + +def _source_from_json(value: Mapping[str, object]) -> DistributionSourceReference: + return DistributionSourceReference( + provider=str(value["provider"]), + resource_type=str(value["resource_type"]), + resource_id=str(value["resource_id"]), + revision=_text(value.get("revision")), + fingerprint=_text(value.get("fingerprint")), + label=_text(value.get("label")), + metadata=dict(value.get("metadata", {}) or {}), # type: ignore[arg-type] + ) + + +def _unique_explanations( + items: Sequence[DistributionExplanation], +) -> list[DistributionExplanation]: + seen: set[tuple[str, str, str | None]] = set() + result: list[DistributionExplanation] = [] + for item in items: + key = (item.code, item.message, item.provider) + if key not in seen: + seen.add(key) + result.append(item) + return result + + +def _unique_evidence( + items: Sequence[DistributionProviderEvidence], +) -> list[DistributionProviderEvidence]: + seen: set[tuple[str, str, str | None, str | None]] = set() + result: list[DistributionProviderEvidence] = [] + for item in items: + key = ( + item.provider, + item.source.resource_id, + item.actual_revision, + item.actual_fingerprint, + ) + if key not in seen: + seen.add(key) + result.append(item) + return result + + +def _explanation( + code: str, + message: str, + *, + severity: str = "warning", + provider: str | None = None, + source: DistributionSourceReference | None = None, +) -> DistributionExplanation: + return DistributionExplanation( + code=code, + message=message, + severity=severity, # type: ignore[arg-type] + provider=provider, + source=source, + ) + + +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 + + +def _normalized_target(value: str) -> str: + return " ".join(value.casefold().split()) + + +def _aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def _parse_datetime(value: object | None) -> datetime | None: + if isinstance(value, datetime): + return _aware(value) + if value: + try: + return _aware(datetime.fromisoformat(str(value).replace("Z", "+00:00"))) + except ValueError: + return None + return None + + +def _text(value: object | None) -> str | None: + candidate = str(value).strip() if value is not None else "" + return candidate or None + + +__all__ = [ + "expand_distribution_list", + "get_snapshot_ref", + "snapshot_ref", +] diff --git a/src/govoplan_dist_lists/backend/manifest.py b/src/govoplan_dist_lists/backend/manifest.py index 474b417..4a3b3c4 100644 --- a/src/govoplan_dist_lists/backend/manifest.py +++ b/src/govoplan_dist_lists/backend/manifest.py @@ -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, ) diff --git a/src/govoplan_dist_lists/backend/migrations/__init__.py b/src/govoplan_dist_lists/backend/migrations/__init__.py new file mode 100644 index 0000000..380e756 --- /dev/null +++ b/src/govoplan_dist_lists/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Distribution Lists migrations.""" diff --git a/src/govoplan_dist_lists/backend/migrations/versions/__init__.py b/src/govoplan_dist_lists/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..a7ce58f --- /dev/null +++ b/src/govoplan_dist_lists/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Distribution Lists Alembic revisions.""" diff --git a/src/govoplan_dist_lists/backend/migrations/versions/e7c3a9d1b5f2_distribution_lists_baseline.py b/src/govoplan_dist_lists/backend/migrations/versions/e7c3a9d1b5f2_distribution_lists_baseline.py new file mode 100644 index 0000000..c3881ce --- /dev/null +++ b/src/govoplan_dist_lists/backend/migrations/versions/e7c3a9d1b5f2_distribution_lists_baseline.py @@ -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") diff --git a/src/govoplan_dist_lists/backend/router.py b/src/govoplan_dist_lists/backend/router.py new file mode 100644 index 0000000..de1d5b3 --- /dev/null +++ b/src/govoplan_dist_lists/backend/router.py @@ -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"] diff --git a/src/govoplan_dist_lists/backend/schemas.py b/src/govoplan_dist_lists/backend/schemas.py new file mode 100644 index 0000000..c03f61c --- /dev/null +++ b/src/govoplan_dist_lists/backend/schemas.py @@ -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"))] diff --git a/src/govoplan_dist_lists/backend/service.py b/src/govoplan_dist_lists/backend/service.py new file mode 100644 index 0000000..8f7185a --- /dev/null +++ b/src/govoplan_dist_lists/backend/service.py @@ -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", +] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index c6fc135..7329200 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -17,8 +17,10 @@ class DistributionListsManifestTests(unittest.TestCase): self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("dist_lists.expand", {interface.name for interface in manifest.provides_interfaces}) self.assertIn("dist_lists:list:read", {permission.scope for permission in manifest.permissions}) - self.assertIsNone(manifest.route_factory) - self.assertIsNone(manifest.migration_spec) + self.assertIsNotNone(manifest.route_factory) + self.assertIsNotNone(manifest.migration_spec) + self.assertIn("dist_lists.expand", manifest.capability_factories) + self.assertEqual("@govoplan/dist-lists-webui", manifest.frontend.package_name) if __name__ == "__main__": diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..efcb9d6 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.db.migrations import migrate_database +from govoplan_dist_lists.backend.manifest import get_manifest + + +class DistributionListMigrationTests(unittest.TestCase): + def test_baseline_creates_tables_and_head(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-dist-lists-migration-") as directory: + url = f"sqlite:///{Path(directory) / 'dist-lists.db'}" + migrate_database( + database_url=url, + enabled_modules=("dist_lists",), + manifest_factories=(get_manifest,), + ) + engine = create_engine(url) + try: + with engine.connect() as connection: + self.assertIn( + "e7c3a9d1b5f2", + set(MigrationContext.configure(connection).get_current_heads()), + ) + self.assertEqual( + { + "dist_lists_entries", + "dist_lists_lists", + "dist_lists_revisions", + "dist_lists_snapshots", + }, + { + name + for name in inspect(connection).get_table_names() + if name.startswith("dist_lists_") + }, + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..c99a2be --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,511 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import unittest + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.concurrency import RevisionConflictError +from govoplan_core.core.dataflows import ( + DataflowDatasetDescriptor, + DataflowDatasetResult, +) +from govoplan_core.core.distribution_lists import ( + DistributionChannelPolicyDecision, + DistributionExpansionLimits, + DistributionExpansionRequest, + DistributionListConflictError, +) +from govoplan_core.db.base import Base +from govoplan_core.db.session import configure_database, reset_database +from govoplan_dist_lists.backend.db.models import ( + DistributionList, + DistributionListEntry, + DistributionListRevision, + DistributionListSnapshot, +) +from govoplan_dist_lists.backend.capabilities import SqlDistributionListCapabilities +from govoplan_dist_lists.backend.expansion import expand_distribution_list +from govoplan_dist_lists.backend.schemas import ( + DistributionListCreateRequest, + DistributionListUpdateRequest, +) +from govoplan_dist_lists.backend.service import ( + create_distribution_list, + get_distribution_list, + update_distribution_list, +) + + +def principal( + tenant_id: str = "tenant-1", + *, + account_id: str = "account-1", + group_ids: frozenset[str] = frozenset(), + admin: bool = True, +) -> ApiPrincipal: + scopes = { + "dist_lists:list:read", + "dist_lists:list:write", + } + if admin: + scopes.add("dist_lists:list:admin") + return ApiPrincipal( + principal=PrincipalRef( + account_id=account_id, + membership_id="membership-1", + tenant_id=tenant_id, + identity_id="identity-1", + scopes=frozenset(scopes), + group_ids=group_ids, + ), + account=object(), + user=object(), + ) + + +def raw_payload(name: str, email: str) -> DistributionListCreateRequest: + return DistributionListCreateRequest.model_validate( + { + "name": name, + "constraints": {"default_channel": "email"}, + "entries": [ + { + "entry_key": "primary-email", + "kind": "raw_email", + "source": { + "provider": "local", + "resource_type": "email", + "resource_id": email, + }, + } + ], + } + ) + + +class _Dataflow: + def list_outputs(self, session, principal, *, query="", limit=100): + del session, principal, query, limit + return ( + DataflowDatasetDescriptor( + pipeline_ref="pipeline-1", + name="Audience", + revision=3, + definition_hash="definition-a", + status="active", + ), + ) + + def read_output(self, session, principal, *, request): + del session, principal + self.request = request + return DataflowDatasetResult( + pipeline_ref=request.pipeline_ref, + revision=request.revision, + definition_hash="definition-a", + rows=( + { + "recipient_key": "person-1", + "display_name": "Ada Example", + "email": "ada@example.test", + "postal_address": "Example Street 1", + "identity_id": "identity-a", + }, + { + "recipient_key": "person-2", + "display_name": "Blocked Example", + "email": "blocked@example.test", + "identity_id": "identity-b", + }, + { + "recipient_key": "person-3", + "display_name": "Suppressed Example", + "postal_address": "Example Street 3", + "identity_id": "identity-c", + "selected_channel": "postal", + "contact_point_id": "contact-postal-c", + "distribution_status": "suppressed", + "exclusion_reason": "preference.suppressed", + "policy_decision": "preference.suppressed", + }, + ), + total_rows=2, + truncated=False, + output_hash="output-a", + executor_version="test", + source_fingerprints=({"source": "fixture", "fingerprint": "input-a"},), + generated_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + + +class _Policy: + def resolve_distribution_channel(self, session, principal, *, request): + del session, principal + allowed = "blocked" not in request.candidate.target + return DistributionChannelPolicyDecision( + allowed=allowed, + reason_code="allowed" if allowed else "policy.suppressed", + explanation="Allowed by test Policy." if allowed else "Suppressed by test Policy.", + source_path=({"scope_type": "tenant", "scope_id": request.tenant_id},), + ) + + +class _Registry: + def __init__(self, capabilities=None) -> None: + self.capabilities = dict(capabilities or {}) + + def has_capability(self, name: str) -> bool: + return name in self.capabilities + + def capability(self, name: str): + return self.capabilities.get(name) + + +class DistributionListServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.database = configure_database("sqlite:///:memory:") + Base.metadata.create_all( + self.database.engine, + tables=[ + DistributionList.__table__, + DistributionListRevision.__table__, + DistributionListEntry.__table__, + DistributionListSnapshot.__table__, + ], + ) + + def tearDown(self) -> None: + reset_database(dispose=True) + + def test_local_list_freezes_reproducible_snapshot_and_preserves_history(self) -> None: + effective_at = datetime(2026, 1, 1, tzinfo=UTC) + with self.database.session() as session: + item, revision = create_distribution_list( + session, + principal(), + raw_payload("Monthly recipients", "first@example.test"), + ) + session.flush() + first = expand_distribution_list( + session, + principal(), + registry=_Registry(), + request=DistributionExpansionRequest( + list_id=item.id, + effective_at=effective_at, + freeze=True, + idempotency_key="monthly-2026-01", + ), + ) + session.commit() + + self.assertEqual(1, len(first.recipients)) + self.assertIsNotNone(first.snapshot_id) + self.assertEqual("first@example.test", first.recipients[0].channels[0].target) + preview = expand_distribution_list( + session, + principal(), + registry=_Registry(), + request=DistributionExpansionRequest( + list_id=item.id, + effective_at=effective_at, + preview=True, + ), + ) + self.assertEqual(first.expansion_hash, preview.expansion_hash) + + update = DistributionListUpdateRequest.model_validate( + { + **raw_payload("Monthly recipients", "second@example.test").model_dump(mode="json"), + "base_revision": 1, + } + ) + item, current = update_distribution_list( + session, + principal(), + item, + update, + ) + session.commit() + + snapshot = session.get(DistributionListSnapshot, first.snapshot_id) + self.assertEqual(1, snapshot.revision_number) + self.assertEqual("first@example.test", snapshot.recipients[0]["channels"][0]["target"]) + self.assertEqual(2, current.revision) + self.assertEqual(2, item.resource_revision) + self.assertNotEqual(revision.definition_hash, current.definition_hash) + + def test_stale_resource_revision_is_rejected(self) -> None: + with self.database.session() as session: + item, _ = create_distribution_list( + session, + principal(), + raw_payload("OCC", "one@example.test"), + ) + session.flush() + payload = DistributionListUpdateRequest.model_validate( + { + **raw_payload("OCC", "two@example.test").model_dump(mode="json"), + "base_revision": 1, + } + ) + update_distribution_list(session, principal(), item, payload) + session.flush() + with self.assertRaises(RevisionConflictError): + update_distribution_list(session, principal(), item, payload) + + def test_expansion_applies_an_aggregate_candidate_budget(self) -> None: + with self.database.session() as session: + item, _ = create_distribution_list( + session, + principal(), + DistributionListCreateRequest.model_validate( + { + "name": "Bounded", + "entries": [ + { + "entry_key": f"email-{index}", + "kind": "raw_email", + "source": { + "provider": "local", + "resource_type": "email", + "resource_id": f"person-{index}@example.test", + }, + } + for index in range(4) + ], + } + ), + ) + result = expand_distribution_list( + session, + principal(), + registry=_Registry(), + request=DistributionExpansionRequest( + list_id=item.id, + limits=DistributionExpansionLimits( + max_entries=10, + max_results=2, + max_provider_results=1, + ), + ), + ) + self.assertTrue(result.truncated) + self.assertEqual(2, len(result.recipients)) + self.assertIn( + "expansion.candidate_limit", + {diagnostic.code for diagnostic in result.diagnostics}, + ) + + def test_non_admin_cannot_claim_another_user_or_group_scope(self) -> None: + actor = principal(group_ids=frozenset({"group-own"}), admin=False) + with self.database.session() as session: + own_user, _ = create_distribution_list( + session, + actor, + DistributionListCreateRequest( + name="Mine", + scope_type="user", + scope_id=actor.account_id, + ), + ) + own_group, _ = create_distribution_list( + session, + actor, + DistributionListCreateRequest( + name="Ours", + scope_type="group", + scope_id="group-own", + ), + ) + self.assertEqual(actor.account_id, own_user.scope_id) + self.assertEqual("group-own", own_group.scope_id) + + for scope_type, scope_id in ( + ("user", "account-other"), + ("group", "group-other"), + ): + with self.assertRaises(DistributionListConflictError): + create_distribution_list( + session, + actor, + DistributionListCreateRequest( + name=f"Blocked {scope_type}", + scope_type=scope_type, + scope_id=scope_id, + ), + ) + + other_user, _ = create_distribution_list( + session, + principal(account_id="admin"), + DistributionListCreateRequest( + name="Delegated", + scope_type="user", + scope_id="account-other", + ), + ) + self.assertEqual( + "account-other", + get_distribution_list( + session, + principal(account_id="admin"), + other_user.id, + ).scope_id, + ) + decision = SqlDistributionListCapabilities().explain_write( + session, + principal(account_id="admin"), + list_id=other_user.id, + operation="update", + ) + self.assertTrue(decision.allowed) + + def test_tenant_isolation_and_nested_cycle_diagnostic(self) -> None: + with self.database.session() as session: + first, _ = create_distribution_list( + session, + principal(), + DistributionListCreateRequest(name="First"), + ) + second, _ = create_distribution_list( + session, + principal(), + DistributionListCreateRequest.model_validate( + { + "name": "Second", + "entries": [_nested_entry(first.id, "first")], + } + ), + ) + first_update = DistributionListUpdateRequest.model_validate( + { + "name": "First", + "base_revision": 1, + "entries": [_nested_entry(second.id, "second")], + } + ) + update_distribution_list(session, principal(), first, first_update) + session.flush() + + result = expand_distribution_list( + session, + principal(), + registry=_Registry(), + request=DistributionExpansionRequest(list_id=first.id), + ) + self.assertIn("expansion.nested_cycle", {item.code for item in result.diagnostics}) + with self.assertRaises(ValueError): + get_distribution_list(session, principal("tenant-2"), first.id) + + def test_dataflow_parameters_policy_and_missing_providers_are_explained(self) -> None: + dataflow = _Dataflow() + registry = _Registry( + { + "dataflow.dataset_output": dataflow, + "policy.distribution_channels": _Policy(), + } + ) + with self.database.session() as session: + item, _ = create_distribution_list( + session, + principal(), + DistributionListCreateRequest.model_validate( + { + "name": "Dynamic", + "definition_kind": "parameterized", + "parameters": [ + { + "key": "region", + "value_type": "string", + "required": True, + "allowed_values": ["north", "south"], + } + ], + "entries": [ + { + "entry_key": "flow", + "kind": "dataflow_result", + "source": { + "provider": "dataflow", + "resource_type": "pipeline_output", + "resource_id": "pipeline-1", + "revision": "3", + "fingerprint": "definition-a", + }, + }, + { + "entry_key": "group", + "kind": "idm_group", + "source": { + "provider": "idm", + "resource_type": "group", + "resource_id": "group-1", + }, + }, + ], + } + ), + ) + session.flush() + result = expand_distribution_list( + session, + principal(), + registry=registry, + request=DistributionExpansionRequest( + list_id=item.id, + parameters={"region": "north"}, + effective_at=datetime(2026, 1, 1, tzinfo=UTC), + ), + ) + + self.assertEqual({"person-1"}, {item.recipient_key for item in result.recipients}) + self.assertIn("policy.suppressed", {item.channels[0].reason_code for item in result.excluded if item.channels}) + explicitly_suppressed = next( + row for row in result.excluded if row.recipient_key == "person-3" + ) + self.assertEqual("suppressed", explicitly_suppressed.status) + self.assertEqual( + "contact-postal-c", + explicitly_suppressed.channels[0].contact_point_id, + ) + self.assertEqual( + "preference.suppressed", + explicitly_suppressed.explanations[0].code, + ) + self.assertIn("provider_unavailable", {item.status for item in result.excluded}) + self.assertEqual("north", dataflow.request.parameters["region"]) + + postal = expand_distribution_list( + session, + principal(), + registry=registry, + request=DistributionExpansionRequest( + list_id=item.id, + parameters={"region": "north"}, + requested_channels=("postal",), + effective_at=datetime(2026, 1, 1, tzinfo=UTC), + ), + ) + ada = next(row for row in postal.recipients if row.recipient_key == "person-1") + by_channel = {candidate.channel: candidate for candidate in ada.channels} + self.assertEqual("suppressed", by_channel["email"].status) + self.assertEqual("channel.not_requested", by_channel["email"].reason_code) + self.assertEqual("usable", by_channel["postal"].status) + + +def _nested_entry(list_id: str, key: str) -> dict[str, object]: + return { + "entry_key": key, + "kind": "distribution_list", + "source": { + "provider": "dist_lists", + "resource_type": "distribution_list", + "resource_id": list_id, + }, + } + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..bc7fb8e --- /dev/null +++ b/webui/package.json @@ -0,0 +1,32 @@ +{ + "name": "@govoplan/dist-lists-webui", + "version": "0.1.14", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/dist-lists.css": "./src/styles/dist-lists.css" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9", + "typescript": "^5.7.2" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/distLists.ts b/webui/src/api/distLists.ts new file mode 100644 index 0000000..7c1c0cc --- /dev/null +++ b/webui/src/api/distLists.ts @@ -0,0 +1,309 @@ +import { + apiFetch, + apiPath, + type ApiSettings +} from "@govoplan/core-webui"; + +export type DefinitionKind = "static" | "parameterized" | "dynamic" | "template"; +export type EntryMode = "include" | "exclude" | "override"; +export type EntryKind = + | "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"; +export type DistributionChannel = "email" | "postal" | "internal_mail" | "portal"; +export type DistributionOutcome = + | "usable" + | "unresolved" + | "invalid" + | "suppressed" + | "ambiguous" + | "duplicate" + | "policy_blocked" + | "provider_unavailable" + | "stale"; + +export type SourceReference = { + provider: string; + resource_type: string; + resource_id: string; + revision?: string | null; + fingerprint?: string | null; + label?: string | null; + metadata: Record; +}; + +export type DistributionParameter = { + key: string; + value_type: "string" | "integer" | "number" | "boolean" | "date" | "datetime" | "string_list"; + label?: string | null; + required: boolean; + default?: unknown; + allowed_values: unknown[]; + minimum?: number | null; + maximum?: number | null; + pattern?: string | null; + description?: string | null; +}; + +export type DistributionListEntryInput = { + entry_key?: string | null; + kind: EntryKind; + mode: EntryMode; + source: SourceReference; + label?: string | null; + purpose?: string | null; + requested_channels: DistributionChannel[]; + effective_from?: string | null; + effective_until?: string | null; + configuration: Record; +}; + +export type DistributionListEntry = DistributionListEntryInput & { + id: string; + entry_key: string; + order: number; +}; + +export type DistributionListRevision = { + id: string; + revision: number; + definition_kind: DefinitionKind; + definition_hash: string; + parameters: DistributionParameter[]; + constraints: Record; + source_fingerprints: Array>; + entries: DistributionListEntry[]; + created_by_account_id?: string | null; + created_at: string; +}; + +export type DistributionList = { + id: string; + tenant_id: string; + scope_type: "tenant" | "group" | "user"; + scope_id?: string | null; + name: string; + description?: string | null; + status: string; + current_revision: number; + resource_revision: number; + etag: string; + metadata: Record; + deleted_at?: string | null; + created_at: string; + updated_at: string; + revision: DistributionListRevision; +}; + +export type DistributionListPayload = { + name: string; + description?: string | null; + scope_type: "tenant" | "group" | "user"; + scope_id?: string | null; + definition_kind: DefinitionKind; + parameters: DistributionParameter[]; + constraints: Record; + entries: DistributionListEntryInput[]; + metadata: Record; +}; + +export type DistributionExplanation = { + code: string; + message: string; + severity: "info" | "warning" | "error"; + provider?: string | null; + source?: SourceReference | null; + provenance: Record; +}; + +export type DistributionChannelCandidate = { + channel: DistributionChannel; + target: string; + target_key: string; + status: DistributionOutcome; + contact_point_id?: string | null; + locale?: string | null; + preferred: boolean; + reason_code?: string | null; + explanation?: string | null; + source?: SourceReference | null; + decision_provenance: Record; +}; + +export type DistributionRecipient = { + recipient_key: string; + display_name: string; + status: DistributionOutcome; + channels: DistributionChannelCandidate[]; + identity_id?: string | null; + account_id?: string | null; + contact_id?: string | null; + organization_unit_id?: string | null; + function_id?: string | null; + source_entry_ids: string[]; + explanations: DistributionExplanation[]; + attributes: Record; + provenance: Record; +}; + +export type ProviderEvidence = { + provider: string; + source: SourceReference; + actual_revision?: string | null; + actual_fingerprint?: string | null; + stale: boolean; + generated_at?: string | null; + details: Record; +}; + +export type ExpansionResult = { + list_id: string; + revision_id: string; + revision: number; + definition_hash: string; + recipients: DistributionRecipient[]; + excluded: DistributionRecipient[]; + diagnostics: DistributionExplanation[]; + provider_evidence: ProviderEvidence[]; + expansion_hash: string; + generated_at: string; + snapshot_id?: string | null; + stale: boolean; + truncated: boolean; +}; + +export type ExpansionPayload = { + revision?: number | null; + effective_at?: string | null; + purpose?: string | null; + requested_channels?: DistributionChannel[]; + parameters?: Record; + preview?: boolean; + freeze?: boolean; + idempotency_key?: string | null; + max_entries?: number; + max_results?: number; + max_depth?: number; + max_provider_results?: number; +}; + +export type DistributionSnapshot = { + id: string; + list_id: string; + revision_id: string; + revision: number; + expansion_hash: string; + effective_at: string; + recipient_count: number; + excluded_count: number; + stale: boolean; + truncated: boolean; + created_at: string; +}; + +export type ProviderOption = { + key: string; + kind: EntryKind; + label: string; + description?: string | null; + provider: string; + source: SourceReference; + available: boolean; + reason?: string | null; + metadata: Record; +}; + +export type ProviderCatalogue = { + items: ProviderOption[]; + unavailable_providers: DistributionExplanation[]; +}; + +export async function listDistributionLists( + settings: ApiSettings, + query = "" +): Promise { + const result = await apiFetch<{ items: DistributionList[] }>( + settings, + apiPath("/api/v1/dist-lists/lists", { query, limit: 500 }) + ); + return result.items; +} + +export function createDistributionList( + settings: ApiSettings, + payload: DistributionListPayload +): Promise { + return apiFetch(settings, "/api/v1/dist-lists/lists", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateDistributionList( + settings: ApiSettings, + item: DistributionList, + payload: DistributionListPayload +): Promise { + return apiFetch(settings, `/api/v1/dist-lists/lists/${encodeURIComponent(item.id)}`, { + method: "PUT", + headers: { "If-Match": item.etag }, + body: JSON.stringify({ ...payload, base_revision: item.resource_revision }) + }); +} + +export function deleteDistributionList( + settings: ApiSettings, + item: DistributionList +): Promise { + return apiFetch(settings, `/api/v1/dist-lists/lists/${encodeURIComponent(item.id)}`, { + method: "DELETE", + headers: { "If-Match": item.etag }, + body: JSON.stringify({ base_revision: item.resource_revision }) + }); +} + +export function expandDistributionList( + settings: ApiSettings, + listId: string, + payload: ExpansionPayload +): Promise { + return apiFetch(settings, `/api/v1/dist-lists/lists/${encodeURIComponent(listId)}/expand`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function listDistributionSnapshots( + settings: ApiSettings, + listId: string +): Promise { + const result = await apiFetch<{ items: DistributionSnapshot[] }>( + settings, + `/api/v1/dist-lists/lists/${encodeURIComponent(listId)}/snapshots` + ); + return result.items; +} + +export function listProviderOptions( + settings: ApiSettings, + query = "", + limit = 50, + signal?: AbortSignal +): Promise { + return apiFetch( + settings, + apiPath("/api/v1/dist-lists/providers", { query, limit }), + { signal } + ); +} diff --git a/webui/src/components/DistributionListPicker.tsx b/webui/src/components/DistributionListPicker.tsx new file mode 100644 index 0000000..857c038 --- /dev/null +++ b/webui/src/components/DistributionListPicker.tsx @@ -0,0 +1,74 @@ +import { useCallback, useMemo, useRef } from "react"; +import { + FormField, + SearchableSelect, + type ApiSettings, + type SearchableSelectOption +} from "@govoplan/core-webui"; +import { + listDistributionLists, + type DistributionList +} from "../api/distLists"; + +export type DistributionListPickerProps = { + settings: ApiSettings; + value: string; + onChange: (listId: string, item: DistributionList | null) => void; + label?: string; + placeholder?: string; + disabled?: boolean; + required?: boolean; + selected?: DistributionList | null; +}; + +export default function DistributionListPicker({ + settings, + value, + onChange, + label = "Distribution list", + placeholder = "Search distribution lists", + disabled = false, + required = false, + selected = null +}: DistributionListPickerProps) { + const cache = useRef(new Map()); + if (selected) cache.current.set(selected.id, selected); + + const selectedOption = useMemo(() => { + const item = selected ?? cache.current.get(value); + if (!item) return value ? { value, label: value } : null; + return optionFor(item); + }, [selected, value]); + + const loadOptions = useCallback(async (query: string) => { + const items = await listDistributionLists(settings, query); + for (const item of items) cache.current.set(item.id, item); + return items.map(optionFor); + }, [settings]); + + return ( + + + onChange(nextValue, cache.current.get(nextValue) ?? null)} + placeholder={placeholder} + searchPlaceholder="Search by list name" + emptyText="No matching distribution lists." + disabled={disabled} + required={required} + /> + + ); +} + +function optionFor(item: DistributionList): SearchableSelectOption { + return { + value: item.id, + label: item.name, + description: `${item.revision.entries.length} entries · revision ${item.current_revision}`, + searchText: `${item.description ?? ""} ${item.scope_type}` + }; +} diff --git a/webui/src/features/distributionLists/DistributionListsPage.tsx b/webui/src/features/distributionLists/DistributionListsPage.tsx new file mode 100644 index 0000000..fbab567 --- /dev/null +++ b/webui/src/features/distributionLists/DistributionListsPage.tsx @@ -0,0 +1,1264 @@ +import { + Eye, + Pencil, + Plus, + RefreshCw, + Save, + Snowflake, + Trash2 +} from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from "react"; +import { + ApiError, + Button, + ConfirmDialog, + DataGrid, + DataGridRowActions, + Dialog, + DismissibleAlert, + FormField, + IconButton, + LoadingFrame, + SearchableSelect, + SegmentedControl, + StatusBadge, + ToggleSwitch, + formatDateTime, + hasScope, + useUnsavedChanges, + useUnsavedDraftGuard, + type ApiSettings, + type AuthInfo, + type DataGridColumn, + type SearchableSelectOption +} from "@govoplan/core-webui"; +import { + createDistributionList, + deleteDistributionList, + expandDistributionList, + listDistributionLists, + listDistributionSnapshots, + listProviderOptions, + updateDistributionList, + type DefinitionKind, + type DistributionChannel, + type DistributionList, + type DistributionListEntryInput, + type DistributionListPayload, + type DistributionOutcome, + type DistributionParameter, + type DistributionRecipient, + type DistributionSnapshot, + type EntryKind, + type EntryMode, + type ExpansionResult, + type ProviderCatalogue, + type ProviderOption, + type SourceReference +} from "../../api/distLists"; + +type Props = { + settings: ApiSettings; + auth: AuthInfo; +}; + +type WorkspaceView = "definition" | "preview" | "snapshots"; +type PreviewView = "included" | "excluded"; +type DirectSourceMode = "email" | "postal" | "internal_mail" | "portal"; +type SourceMode = "provider" | DirectSourceMode; + +type EntryDraft = DistributionListEntryInput & { clientId: string }; +type ListDraft = Omit & { entries: EntryDraft[] }; + +type EntryEditorState = { + index: number | null; + insertAt: number | null; + sourceMode: SourceMode; + providerKey: string; + providerOption: ProviderOption | null; + target: string; + label: string; + mode: EntryMode; + purpose: string; + requestedChannels: DistributionChannel[]; + overrideReason: string; + effectiveFrom: string; + effectiveUntil: string; + configuration: Record; +}; + +let clientSequence = 0; + +export default function DistributionListsPage({ settings, auth }: Props) { + const [items, setItems] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [draft, setDraft] = useState(emptyDraft()); + const [savedDraftKey, setSavedDraftKey] = useState(""); + const [view, setView] = useState("definition"); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const [createName, setCreateName] = useState(""); + const [createKind, setCreateKind] = useState("static"); + const [deleteOpen, setDeleteOpen] = useState(false); + const [entryEditor, setEntryEditor] = useState(null); + const [providerUnavailable, setProviderUnavailable] = useState([]); + const providerCache = useRef(new Map()); + const [preview, setPreview] = useState(null); + const [previewView, setPreviewView] = useState("included"); + const [previewPurpose, setPreviewPurpose] = useState(""); + const [parameterValues, setParameterValues] = useState>({}); + const [explanationTarget, setExplanationTarget] = useState(null); + const [snapshots, setSnapshots] = useState([]); + const { requestDiscard } = useUnsavedChanges(); + + const selected = items.find((item) => item.id === selectedId) ?? null; + const canWrite = hasScope(auth, "dist_lists:list:write") + || hasScope(auth, "dist_lists:list:admin"); + const dirty = Boolean(selected && draftKey(draft) !== savedDraftKey); + + const applyItem = useCallback((item: DistributionList | null) => { + const next = item ? draftFromItem(item) : emptyDraft(); + setDraft(next); + setSavedDraftKey(item ? draftKey(next) : ""); + setPreview(null); + setSnapshots([]); + setParameterValues(parameterDefaults(next.parameters)); + setPreviewPurpose(""); + }, []); + + const reload = useCallback(async (preferredId?: string) => { + setLoading(true); + setError(""); + try { + const nextItems = await listDistributionLists(settings); + setItems(nextItems); + const nextId = preferredId && nextItems.some((item) => item.id === preferredId) + ? preferredId + : nextItems.some((item) => item.id === selectedId) + ? selectedId + : nextItems[0]?.id ?? ""; + setSelectedId(nextId); + applyItem(nextItems.find((item) => item.id === nextId) ?? null); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setLoading(false); + } + }, [applyItem, selectedId, settings]); + + useEffect(() => { + void reload(); + }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); + + const visibleItems = useMemo(() => { + const needle = search.trim().toLocaleLowerCase(); + if (!needle) return items; + return items.filter((item) => + `${item.name} ${item.description ?? ""} ${item.revision.definition_kind}` + .toLocaleLowerCase() + .includes(needle)); + }, [items, search]); + + const selectItem = (item: DistributionList) => { + if (item.id === selectedId) return; + requestDiscard(() => { + setSelectedId(item.id); + applyItem(item); + setError(""); + setSuccess(""); + }); + }; + + const createItem = async () => { + if (!createName.trim()) return; + setBusy(true); + setError(""); + try { + const created = await createDistributionList(settings, { + ...emptyPayload(), + name: createName.trim(), + definition_kind: createKind + }); + setCreateOpen(false); + setCreateName(""); + setCreateKind("static"); + setSuccess(`Created ${created.name}.`); + await reload(created.id); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setBusy(false); + } + }; + + const saveItem = async () => { + if (!selected || !draft.name.trim()) return false; + setBusy(true); + setError(""); + try { + const updated = await updateDistributionList(settings, selected, payloadFromDraft(draft)); + setSuccess(`Saved revision ${updated.current_revision}.`); + await reload(updated.id); + return true; + } catch (caught) { + setError(errorMessage(caught)); + return false; + } finally { + setBusy(false); + } + }; + + useUnsavedDraftGuard({ + dirty, + onSave: saveItem, + onDiscard: () => applyItem(selected) + }); + + const removeItem = async () => { + if (!selected) return; + setBusy(true); + setError(""); + try { + await deleteDistributionList(settings, selected); + setDeleteOpen(false); + setSuccess(`Deleted ${selected.name}.`); + await reload(); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setBusy(false); + } + }; + + const runPreview = async (freeze: boolean) => { + if (!selected || dirty) return; + setBusy(true); + setError(""); + try { + const result = await expandDistributionList(settings, selected.id, { + revision: selected.current_revision, + effective_at: new Date().toISOString(), + purpose: previewPurpose.trim() || null, + parameters: normalizeParameterValues(selected.revision.parameters, parameterValues), + preview: !freeze, + freeze, + idempotency_key: freeze + ? `ui:${selected.id}:${selected.current_revision}:${crypto.randomUUID()}` + : null, + max_entries: 500, + max_results: 5000, + max_provider_results: 2000 + }); + setPreview(result); + setPreviewView("included"); + setSuccess(freeze + ? `Frozen snapshot ${result.snapshot_id ?? "created"}.` + : `Expanded ${result.recipients.length} usable recipients.`); + if (freeze) setSnapshots(await listDistributionSnapshots(settings, selected.id)); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setBusy(false); + } + }; + + const openSnapshots = async () => { + setView("snapshots"); + if (!selected) return; + setBusy(true); + try { + setSnapshots(await listDistributionSnapshots(settings, selected.id)); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setBusy(false); + } + }; + + const entryColumns = useMemo[]>(() => [ + { + id: "mode", + header: "Mode", + width: 105, + value: (entry) => entry.mode, + render: (entry) => + }, + { + id: "source", + header: "Source", + width: "1.4fr", + minWidth: 190, + resizable: true, + sortable: true, + filterable: true, + value: (entry) => entry.label || entry.source.label || entry.source.resource_id, + render: (entry) => ( + + {entry.label || entry.source.label || entry.source.resource_id} + {entry.source.provider} · {entry.kind} + + ) + }, + { + id: "channels", + header: "Channels", + width: 180, + value: (entry) => entry.requested_channels.join(", "), + render: (entry) => entry.requested_channels.length + ? entry.requested_channels.join(", ") + : "Provider/default" + }, + { + id: "purpose", + header: "Purpose", + width: "1fr", + minWidth: 140, + resizable: true, + filterable: true, + value: (entry) => entry.purpose ?? "", + render: (entry) => entry.purpose || "All purposes" + }, + { + id: "actions", + header: "Actions", + width: 205, + sticky: "end", + render: (entry, index) => ( +
+ } + variant="ghost" + disabled={!canWrite} + onClick={() => setEntryEditor(editorFromEntry(entry, index))} + /> + setEntryEditor(emptyEntryEditor(index + 1))} + onRemove={() => removeEntry(index)} + onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined} + onMoveDown={index < draft.entries.length - 1 ? () => moveEntry(index, index + 1) : undefined} + /> +
+ ) + } + ], [canWrite, draft.entries.length]); + + const previewRows = previewView === "included" + ? preview?.recipients ?? [] + : preview?.excluded ?? []; + const previewColumns = useMemo[]>(() => [ + { + id: "name", + header: "Recipient", + width: "1.2fr", + minWidth: 180, + resizable: true, + sortable: true, + filterable: true, + value: (row) => row.display_name + }, + { + id: "targets", + header: "Delivery candidates", + width: "1.4fr", + minWidth: 210, + resizable: true, + filterable: true, + value: (row) => row.channels.map((channel) => `${channel.channel}:${channel.target}`).join(" "), + render: (row) => ( + + {row.channels.length + ? row.channels.map((channel) => `${channel.channel}: ${channel.target}`).join(", ") + : "No usable candidate"} + + ) + }, + { + id: "status", + header: "Outcome", + width: 150, + sortable: true, + filterable: true, + value: (row) => row.status, + render: (row) => + }, + { + id: "reason", + header: "Reason", + width: "1fr", + minWidth: 190, + resizable: true, + value: (row) => recipientReason(row), + render: (row) => recipientReason(row) + }, + { + id: "actions", + header: "Details", + width: 90, + sticky: "end", + render: (row) => ( + } + variant="ghost" + onClick={() => setExplanationTarget(row)} + /> + ) + } + ], []); + + const snapshotColumns = useMemo[]>(() => [ + { id: "created", header: "Created", width: 190, sortable: true, value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) }, + { id: "revision", header: "Revision", width: 110, sortable: true, value: (row) => row.revision }, + { id: "recipients", header: "Recipients", width: 130, sortable: true, value: (row) => row.recipient_count }, + { id: "excluded", header: "Excluded", width: 120, sortable: true, value: (row) => row.excluded_count }, + { id: "state", header: "State", width: 150, render: (row) => }, + { id: "hash", header: "Expansion hash", width: "1fr", minWidth: 240, resizable: true, value: (row) => row.expansion_hash, render: (row) => {row.expansion_hash.slice(0, 20)}… } + ], []); + + function removeEntry(index: number) { + setDraft((current) => ({ ...current, entries: current.entries.filter((_, itemIndex) => itemIndex !== index) })); + } + + function moveEntry(index: number, target: number) { + setDraft((current) => { + const entries = [...current.entries]; + const [entry] = entries.splice(index, 1); + if (!entry) return current; + entries.splice(target, 0, entry); + return { ...current, entries }; + }); + } + + async function loadProviders(query: string, options: { limit: number; signal: AbortSignal }): Promise { + const catalogue = await listProviderOptions(settings, query, options.limit, options.signal); + setProviderUnavailable(catalogue.unavailable_providers); + for (const item of catalogue.items) providerCache.current.set(item.key, item); + return catalogue.items.map(providerSearchOption); + } + + function saveEntryEditor() { + if (!entryEditor) return; + const entry = entryFromEditor(entryEditor); + if (!entry) return; + setDraft((current) => { + const entries = [...current.entries]; + if (entryEditor.index !== null && entryEditor.index < entries.length) { + entries[entryEditor.index] = entry; + } else if (entryEditor.insertAt !== null) { + entries.splice(Math.min(entryEditor.insertAt, entries.length), 0, entry); + } else { + entries.push(entry); + } + return { ...current, entries }; + }); + setEntryEditor(null); + } + + return ( +
+
+ + +
+
+ + {selected?.name ?? "No distribution list selected"} + {selected ? Revision {selected.current_revision} · {selected.scope_type} scope : null} + + + + ariaLabel="Distribution-list workspace" + value={view} + onChange={(next) => next === "snapshots" ? void openSnapshots() : setView(next)} + options={[ + { id: "definition", label: "Definition" }, + { id: "preview", label: "Preview" }, + { id: "snapshots", label: "Snapshots" } + ]} + /> + + } + variant="danger" + disabled={!selected || !canWrite} + onClick={() => setDeleteOpen(true)} + /> + +
+ +
+ {error ? {error} : null} + {success ? {success} : null} +
+ + + {!selected ? ( +
Create or select a distribution list.
+ ) : view === "definition" ? ( + setEntryEditor(emptyEntryEditor(null))} + /> + ) : view === "preview" ? ( +
+
+
+ + setPreviewPurpose(event.target.value)} placeholder="Optional purpose" /> + + {selected.revision.parameters.map((parameter) => ( + setParameterValues((current) => ({ ...current, [parameter.key]: value }))} + /> + ))} +
+
+ + +
+
+ {preview ? ( + <> +
+ + + + +
+ {preview.diagnostics.map((diagnostic, index) => ( + + {diagnostic.code}: {diagnostic.message} + + ))} +
+ + value={previewView} + onChange={setPreviewView} + ariaLabel="Expansion result" + options={[ + { id: "included", label: `Included (${preview.recipients.length})` }, + { id: "excluded", label: `Excluded (${preview.excluded.length})` } + ]} + /> + #{preview.expansion_hash.slice(0, 12)} +
+ `${previewView}:${row.recipient_key}`} + emptyText={previewView === "included" ? "No included recipients." : "No excluded recipients."} + className="dist-lists-grid" + /> + + ) : ( +
Expand the saved revision to inspect recipients and decisions.
+ )} +
+ ) : ( +
+
+ + Frozen snapshots + Immutable recipient evidence held by consumers + + } variant="ghost" onClick={() => void openSnapshots()} /> +
+ row.id} + emptyText="No frozen snapshots." + className="dist-lists-grid" + /> +
+ )} +
+
+
+ + !busy && setCreateOpen(false)} + closeDisabled={busy} + footer={( + <> + + + + )} + > +
+ setCreateName(event.target.value)} /> + + + +
+
+ + providerCache.current.get(key) ?? null} + onChange={setEntryEditor} + onClose={() => setEntryEditor(null)} + onSave={saveEntryEditor} + /> + + setExplanationTarget(null)} /> + + void removeItem()} + onCancel={() => setDeleteOpen(false)} + /> +
+ ); +} + +function DefinitionEditor({ + draft, + canWrite, + entryColumns, + onChange, + onAddEntry +}: { + draft: ListDraft; + canWrite: boolean; + entryColumns: DataGridColumn[]; + onChange: (draft: ListDraft) => void; + onAddEntry: () => void; +}) { + return ( +
+
+ + onChange({ ...draft, name: event.target.value })} /> + + + + + + + + +