Implement governed distribution lists

This commit is contained in:
2026-07-31 21:00:06 +02:00
parent 5d74f061b0
commit 8c54a5d6c9
26 changed files with 7280 additions and 40 deletions
+30 -28
View File
@@ -17,13 +17,13 @@ Goal: define mixed recipient entries without implementing every provider.
Tasks: Tasks:
- define distribution-list, entry, expansion-plan, and expansion-result DTOs - [x] define distribution-list, entry, expansion-plan, and expansion-result DTOs
- define source reference formats for address contacts, IDM identities/typed - [x] define source reference formats for address contacts, IDM identities/typed
groups, organization units/functions, effective incumbents, Dataflow results, groups, organization units/functions, effective incumbents, Dataflow results,
raw targets, and nested-list entries raw targets, and nested-list entries
- define policy/read-only explanation payloads - [x] define policy/read-only explanation payloads
- define snapshot evidence shape - [x] define snapshot evidence shape
- add cycle detection and expansion-limit semantics - [x] add cycle detection and expansion-limit semantics
## Milestone 2: Local Distribution Lists ## Milestone 2: Local Distribution Lists
@@ -31,12 +31,12 @@ Goal: implement local, manually maintained distribution lists.
Tasks: Tasks:
- add database tables and migrations - [x] add database tables and migrations
- add CRUD routes and permissions - [x] add CRUD routes and permissions
- add local raw email/postal entries - [x] add local raw email/postal entries
- add references to address contacts and address lists via capabilities - [x] add references to address contacts and address lists via capabilities
- expose `dist_lists.source` and `dist_lists.expand` - [x] expose `dist_lists.source` and `dist_lists.expand`
- add tests for expansion and snapshots - [x] add tests for expansion and snapshots
## Milestone 3: Provider Integrations ## Milestone 3: Provider Integrations
@@ -44,12 +44,14 @@ Goal: allow distribution lists to resolve through installed modules.
Tasks: Tasks:
- integrate with `addresses.lookup` and address-list source capability - [x] integrate with `addresses.lookup` and address-list source capability
- integrate with IDM identity/group/effective-assignment resolution - [x] integrate with IDM identity and effective function-assignment resolution
- integrate with Organizations unit/function resolution - [ ] resolve typed IDM groups once `govoplan-idm#7` exposes the provider contract;
- integrate with Datasources, Connectors, and pinned Dataflow results until then the selector and expansion return an explicit unavailable outcome
- apply Addresses preference/consent facts and explainable Policy decisions - [x] integrate with Organizations unit/function resolution
- preserve module independence when optional providers are absent - [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 ## Milestone 4: UI
@@ -57,11 +59,11 @@ Goal: make distribution lists practical for administrators and operators.
Tasks: Tasks:
- add list management UI - [x] add list management UI
- add mixed entry editor with provider-specific search - [x] add mixed entry editor with provider-specific search
- show disabled/read-only entries with hover explanations - [x] show disabled/read-only entries with explanations
- show expansion preview and stale-source warnings - [x] show expansion preview and stale-source warnings
- expose picker components to Campaign, Templates, Reporting, and Workflow - [x] expose picker components to Campaign, Templates, Reporting, and Workflow
consumers consumers
## Milestone 5: AdreMa Dynamic Audiences ## Milestone 5: AdreMa Dynamic Audiences
@@ -71,12 +73,12 @@ definitions.
Tasks: Tasks:
- add immutable static, parameterized, and dynamic definition revisions - [x] add immutable static, parameterized, and dynamic definition revisions
- support include/exclude/manual-override sets with bounded policy constraints - [x] support include/exclude/manual-override sets with bounded policy constraints
- pin Datasource states, Connector revisions, and Dataflow definitions - [x] pin Datasource states, Connector revisions, and Dataflow definitions
- provide preview, counts, samples, and `why included/excluded` diagnostics - [x] provide preview, counts, samples, and `why included/excluded` diagnostics
- freeze exact expansion and channel-decision evidence - [x] freeze exact expansion and channel-decision evidence
- validate with a synthetic AdreMa golden flow - [x] validate with a synthetic AdreMa golden flow
## Milestone 6: Campaign, Reporting, And Workflow Integration ## Milestone 6: Campaign, Reporting, And Workflow Integration
+3 -3
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-dist-lists" name = "govoplan-dist-lists"
version = "0.1.8" version = "0.1.14"
description = "GovOPlaN operational distribution lists and Verteiler module seed." description = "GovOPlaN operational distribution lists and governed audience expansion."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.14",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -0,0 +1,173 @@
from __future__ import annotations
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.distribution_lists import (
DistributionExpansionRequest,
DistributionExpansionResult,
DistributionListSourceRef,
DistributionSnapshotRef,
DistributionWriteDecision,
)
from govoplan_dist_lists.backend.expansion import (
expand_distribution_list,
get_snapshot_ref,
)
from govoplan_dist_lists.backend.service import (
get_distribution_list,
get_distribution_list_revision,
list_distribution_lists,
source_ref,
)
READ_SCOPE = "dist_lists:list:read"
WRITE_SCOPE = "dist_lists:list:write"
ADMIN_SCOPE = "dist_lists:list:admin"
class SqlDistributionListCapabilities:
def __init__(self, registry: object | None = None) -> None:
self.registry = registry
def list_sources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> tuple[DistributionListSourceRef, ...]:
typed_session, typed_principal = _contracts(session, principal)
return tuple(
source_ref(
item,
get_distribution_list_revision(typed_session, item),
)
for item in list_distribution_lists(
typed_session,
typed_principal,
query=query,
limit=limit,
)
)
def get_source(
self,
session: object,
principal: object,
*,
list_id: str,
revision: int | None = None,
) -> DistributionListSourceRef | None:
typed_session, typed_principal = _contracts(session, principal)
try:
item = get_distribution_list(typed_session, typed_principal, list_id)
item_revision = get_distribution_list_revision(
typed_session,
item,
revision=revision,
)
except ValueError:
return None
return source_ref(item, item_revision)
def expand(
self,
session: object,
principal: object,
*,
request: DistributionExpansionRequest,
) -> DistributionExpansionResult:
typed_session, typed_principal = _contracts(session, principal)
return expand_distribution_list(
typed_session,
typed_principal,
registry=self.registry,
request=request,
)
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_id: str,
) -> DistributionSnapshotRef | None:
typed_session, typed_principal = _contracts(session, principal)
return get_snapshot_ref(typed_session, typed_principal, snapshot_id)
def explain_write(
self,
session: object,
principal: object,
*,
list_id: str | None,
operation: str,
) -> DistributionWriteDecision:
typed_session, typed_principal = _contracts(session, principal)
required_scope = ADMIN_SCOPE if operation == "admin" else WRITE_SCOPE
if not typed_principal.has(required_scope) and not typed_principal.has(ADMIN_SCOPE):
return DistributionWriteDecision(
list_id=list_id,
operation=operation,
allowed=False,
reason_code="permission_missing",
explanation=f"This operation requires {required_scope}.",
required_scopes=(required_scope,),
)
if list_id is None:
return DistributionWriteDecision(
list_id=None,
operation=operation,
allowed=True,
reason_code="allowed",
explanation="The current principal may create distribution lists.",
required_scopes=(required_scope,),
)
try:
item = get_distribution_list(typed_session, typed_principal, list_id)
except ValueError as exc:
return DistributionWriteDecision(
list_id=list_id,
operation=operation,
allowed=False,
reason_code="not_visible",
explanation=str(exc),
read_only=True,
required_scopes=(required_scope,),
)
scope_allowed = (
typed_principal.has(ADMIN_SCOPE)
or item.scope_type == "tenant"
or (item.scope_type == "user" and item.scope_id == typed_principal.account_id)
or (item.scope_type == "group" and item.scope_id in typed_principal.group_ids)
)
return DistributionWriteDecision(
list_id=list_id,
operation=operation,
allowed=scope_allowed,
reason_code="allowed" if scope_allowed else "scope_read_only",
explanation=(
"The distribution list can be changed."
if scope_allowed
else "The distribution list is inherited from a read-only scope."
),
read_only=not scope_allowed,
required_scopes=(required_scope,),
provenance={"scope_type": item.scope_type, "scope_id": item.scope_id},
)
def capability(context: object | None = None) -> SqlDistributionListCapabilities:
return SqlDistributionListCapabilities(getattr(context, "registry", None))
def _contracts(session: object, principal: object):
if not hasattr(session, "scalar") or not hasattr(session, "scalars"):
raise TypeError("Distribution Lists requires a SQLAlchemy session.")
if not isinstance(principal, ApiPrincipal):
raise TypeError("Distribution Lists requires an API principal.")
return session, principal
__all__ = ["SqlDistributionListCapabilities", "capability"]
@@ -0,0 +1,329 @@
from __future__ import annotations
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.dataflows import dataflow_dataset_output
from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_SEARCH,
IdentitySearchProvider,
)
from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY,
OrganizationDirectory,
)
from govoplan_dist_lists.backend.schemas import (
ExplanationResponse,
ProviderCatalogueResponse,
ProviderOptionResponse,
SourceReferenceModel,
)
from govoplan_dist_lists.backend.service import (
get_distribution_list_revision,
list_distribution_lists,
source_ref,
)
ADDRESSES_LOOKUP = "addresses.lookup"
ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
def provider_catalogue(
session,
principal: ApiPrincipal,
*,
registry: object | None,
query: str = "",
limit: int = 50,
) -> ProviderCatalogueResponse:
bounded_limit = max(1, min(limit, 100))
items: list[ProviderOptionResponse] = []
unavailable: list[ExplanationResponse] = []
items.extend(_local_options(session, principal, query=query, limit=bounded_limit))
addresses = _capability(registry, ADDRESSES_RECIPIENT_SOURCE)
if addresses is not None and hasattr(addresses, "list_sources"):
try:
for source in addresses.list_sources(session, principal)[:bounded_limit]:
label = str(getattr(source, "source_label", "Address source"))
if query and query.casefold() not in label.casefold():
continue
source_id = str(getattr(source, "source_id"))
source_kind = str(getattr(source, "source_kind", "address_list"))
items.append(
ProviderOptionResponse(
key=f"addresses:{source_id}",
kind=(
"address_list"
if "list" in source_id or source_kind == "address_list"
else "address_contact"
),
label=label,
description=f"{int(getattr(source, 'recipient_count', 0))} recipients",
provider="addresses",
source=SourceReferenceModel(
provider="addresses",
resource_type=source_kind,
resource_id=source_id,
revision=str(getattr(source, "source_revision", "")) or None,
label=label,
metadata=dict(getattr(source, "provenance", {}) or {}),
),
)
)
except (LookupError, PermissionError, ValueError) as exc:
unavailable.append(_unavailable("addresses", str(exc)))
else:
unavailable.append(
_unavailable("addresses", "Addresses recipient sources are not installed or enabled.")
)
address_lookup = _capability(registry, ADDRESSES_LOOKUP)
if address_lookup is not None and hasattr(address_lookup, "lookup"):
try:
for candidate in address_lookup.lookup(
session,
principal,
query=query,
limit=bounded_limit,
):
email = str(getattr(candidate, "email", "") or "").strip()
if not email:
continue
contact_id = str(getattr(candidate, "contact_id"))
label = str(getattr(candidate, "display_name", "") or contact_id)
revision = str(getattr(candidate, "source_revision", "") or "")
items.append(
ProviderOptionResponse(
key=f"address-email:{contact_id}:{email.casefold()}",
kind="address_email",
label=label,
description=email,
provider="addresses",
source=SourceReferenceModel(
provider="addresses",
resource_type="contact",
resource_id=contact_id,
revision=revision or None,
label=label,
metadata={
"address_book_id": str(
getattr(candidate, "address_book_id", "")
),
"email": email,
"email_label": getattr(
candidate,
"email_label",
None,
),
"provenance": dict(
getattr(candidate, "provenance", {}) or {}
),
},
),
)
)
except (LookupError, PermissionError, ValueError) as exc:
unavailable.append(_unavailable("addresses", str(exc)))
else:
unavailable.append(
_unavailable(
"addresses",
"Address contact search is not installed or enabled.",
severity="info",
)
)
identity_search = _typed_capability(
registry,
CAPABILITY_IDENTITY_SEARCH,
IdentitySearchProvider,
)
if identity_search is not None:
for identity in identity_search.search_identities(
query or None,
include_inactive=False,
limit=bounded_limit,
):
items.append(
ProviderOptionResponse(
key=f"identity:{identity.id}",
kind="idm_identity",
label=identity.display_name or identity.id,
description=identity.source,
provider="identity",
source=SourceReferenceModel(
provider="identity",
resource_type="identity",
resource_id=identity.id,
label=identity.display_name,
metadata={"status": identity.status},
),
)
)
else:
unavailable.append(
_unavailable("identity", "Identity search is not installed or enabled.")
)
unavailable.append(
_unavailable(
"idm",
"Typed-group selection is unavailable until an IDM group-directory capability is installed.",
severity="info",
)
)
organizations = _typed_capability(
registry,
CAPABILITY_ORGANIZATION_DIRECTORY,
OrganizationDirectory,
)
if organizations is not None:
units = organizations.organization_units_for_tenant(principal.tenant_id)
for unit in units:
if query and query.casefold() not in unit.name.casefold():
unit_matches = False
else:
unit_matches = True
if unit.status == "active" and unit_matches:
items.append(
ProviderOptionResponse(
key=f"organization-unit:{unit.id}",
kind="organization_unit",
label=unit.name,
provider="organizations",
source=SourceReferenceModel(
provider="organizations",
resource_type="organization_unit",
resource_id=unit.id,
label=unit.name,
),
)
)
for function in organizations.functions_for_organization_unit(unit.id):
if function.status != "active":
continue
label = f"{function.name} ({unit.name})"
if query and query.casefold() not in label.casefold():
continue
items.append(
ProviderOptionResponse(
key=f"function:{function.id}",
kind="effective_function_incumbent",
label=label,
provider="organizations",
source=SourceReferenceModel(
provider="organizations",
resource_type="function",
resource_id=function.id,
label=label,
metadata={"organization_unit_id": unit.id},
),
)
)
if len(items) >= bounded_limit * 4:
break
else:
unavailable.append(
_unavailable("organizations", "Organization lookup is not installed or enabled.")
)
dataflow = dataflow_dataset_output(registry)
if dataflow is not None:
for output in dataflow.list_outputs(
session,
principal,
query=query,
limit=bounded_limit,
):
items.append(
ProviderOptionResponse(
key=f"dataflow:{output.pipeline_ref}:{output.revision}",
kind="dataflow_result",
label=output.name,
description=output.description,
provider="dataflow",
source=SourceReferenceModel(
provider="dataflow",
resource_type="pipeline_output",
resource_id=output.pipeline_ref,
revision=str(output.revision),
fingerprint=output.definition_hash,
label=output.name,
metadata=dict(output.provenance),
),
)
)
else:
unavailable.append(
_unavailable("dataflow", "Dataflow dataset output is not installed or enabled.")
)
items.sort(key=lambda item: (item.provider, item.label.casefold(), item.key))
return ProviderCatalogueResponse(
items=items[: bounded_limit * 5],
unavailable_providers=unavailable,
)
def _local_options(session, principal, *, query: str, limit: int):
options: list[ProviderOptionResponse] = []
for item in list_distribution_lists(
session,
principal,
query=query,
limit=limit,
):
revision = get_distribution_list_revision(session, item)
source = source_ref(item, revision)
options.append(
ProviderOptionResponse(
key=f"distribution-list:{item.id}",
kind="distribution_list",
label=item.name,
description=item.description,
provider="dist_lists",
source=SourceReferenceModel(
provider="dist_lists",
resource_type="distribution_list",
resource_id=item.id,
revision=str(revision.revision),
fingerprint=revision.definition_hash,
label=item.name,
metadata=dict(source.provenance),
),
)
)
return options
def _unavailable(
provider: str,
message: str,
*,
severity: str = "warning",
) -> ExplanationResponse:
return ExplanationResponse(
code="provider.unavailable",
message=message,
severity=severity,
provider=provider,
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _typed_capability(registry: object | None, name: str, protocol):
capability = _capability(registry, name)
return capability if isinstance(capability, protocol) else None
__all__ = ["provider_catalogue"]
@@ -0,0 +1,13 @@
from govoplan_dist_lists.backend.db.models import (
DistributionList,
DistributionListEntry,
DistributionListRevision,
DistributionListSnapshot,
)
__all__ = [
"DistributionList",
"DistributionListEntry",
"DistributionListRevision",
"DistributionListSnapshot",
]
@@ -0,0 +1,313 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.core.concurrency import strong_resource_etag
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class DistributionList(Base, TimestampMixin):
__tablename__ = "dist_lists_lists"
__table_args__ = (
Index(
"ix_dist_lists_lists_tenant_status",
"tenant_id",
"status",
"updated_at",
),
Index(
"uq_dist_lists_lists_active_tenant_name",
"tenant_id",
"name",
unique=True,
sqlite_where=text(
"deleted_at IS NULL AND scope_type = 'tenant'"
),
postgresql_where=text(
"deleted_at IS NULL AND scope_type = 'tenant'"
),
),
Index(
"uq_dist_lists_lists_active_scoped_name",
"tenant_id",
"scope_type",
"scope_id",
"name",
unique=True,
sqlite_where=text(
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
),
postgresql_where=text(
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
),
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
scope_type: Mapped[str] = mapped_column(
String(20), default="tenant", nullable=False, index=True
)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(30), default="active", nullable=False, index=True
)
current_revision_id: Mapped[str] = mapped_column(String(36), nullable=False)
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_by_account_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
updated_by_account_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
revisions: Mapped[list["DistributionListRevision"]] = relationship(
back_populates="distribution_list",
cascade="all, delete-orphan",
order_by="DistributionListRevision.revision",
)
snapshots: Mapped[list["DistributionListSnapshot"]] = relationship(
back_populates="distribution_list",
cascade="all, delete-orphan",
order_by="DistributionListSnapshot.created_at",
)
@property
def strong_etag(self) -> str:
return strong_resource_etag(
"distribution_list",
self.id,
self.resource_revision,
)
class DistributionListRevision(Base, TimestampMixin):
__tablename__ = "dist_lists_revisions"
__table_args__ = (
UniqueConstraint(
"distribution_list_id",
"revision",
name="uq_dist_lists_revision_number",
),
Index(
"ix_dist_lists_revisions_tenant_list",
"tenant_id",
"distribution_list_id",
),
Index(
"ix_dist_lists_revisions_hash",
"tenant_id",
"definition_hash",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
distribution_list_id: Mapped[str] = mapped_column(
ForeignKey("dist_lists_lists.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
definition_kind: Mapped[str] = mapped_column(
String(30), default="static", nullable=False, index=True
)
definition_hash: Mapped[str] = mapped_column(String(64), nullable=False)
parameter_schema: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
constraints: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
created_by_account_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
distribution_list: Mapped[DistributionList] = relationship(
back_populates="revisions"
)
entries: Mapped[list["DistributionListEntry"]] = relationship(
back_populates="revision",
cascade="all, delete-orphan",
order_by="DistributionListEntry.order_index",
)
snapshots: Mapped[list["DistributionListSnapshot"]] = relationship(
back_populates="revision"
)
class DistributionListEntry(Base, TimestampMixin):
__tablename__ = "dist_lists_entries"
__table_args__ = (
UniqueConstraint(
"revision_id",
"entry_key",
name="uq_dist_lists_entry_key_revision",
),
Index(
"ix_dist_lists_entries_revision_order",
"revision_id",
"order_index",
),
Index(
"ix_dist_lists_entries_source",
"tenant_id",
"kind",
"source_resource_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
revision_id: Mapped[str] = mapped_column(
ForeignKey("dist_lists_revisions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
entry_key: Mapped[str] = mapped_column(String(80), nullable=False)
kind: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
mode: Mapped[str] = mapped_column(
String(20), default="include", nullable=False, index=True
)
source_provider: Mapped[str] = mapped_column(String(80), nullable=False)
source_resource_type: Mapped[str] = mapped_column(String(80), nullable=False)
source_resource_id: Mapped[str] = mapped_column(String(500), nullable=False)
source_revision: Mapped[str | None] = mapped_column(String(500), nullable=True)
source_fingerprint: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_label: Mapped[str | None] = mapped_column(String(300), nullable=True)
source_metadata: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
label: Mapped[str | None] = mapped_column(String(300), nullable=True)
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True)
requested_channels: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
effective_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
effective_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
configuration: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
revision: Mapped[DistributionListRevision] = relationship(back_populates="entries")
class DistributionListSnapshot(Base, TimestampMixin):
__tablename__ = "dist_lists_snapshots"
__table_args__ = (
Index(
"ix_dist_lists_snapshots_tenant_list",
"tenant_id",
"distribution_list_id",
"created_at",
),
Index(
"ix_dist_lists_snapshots_hash",
"tenant_id",
"expansion_hash",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_dist_lists_snapshot_idempotency",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
distribution_list_id: Mapped[str] = mapped_column(
ForeignKey("dist_lists_lists.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
revision_id: Mapped[str] = mapped_column(
ForeignKey("dist_lists_revisions.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
revision_number: Mapped[int] = mapped_column(Integer, nullable=False)
idempotency_key: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
request_: Mapped[dict[str, Any]] = mapped_column(
"request", JSON, default=dict, nullable=False
)
expansion_hash: Mapped[str] = mapped_column(String(64), nullable=False)
effective_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
recipient_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
excluded_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
recipients: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
excluded: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
provider_evidence: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
stale: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_by_account_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
distribution_list: Mapped[DistributionList] = relationship(
back_populates="snapshots"
)
revision: Mapped[DistributionListRevision] = relationship(
back_populates="snapshots"
)
__all__ = [
"DistributionList",
"DistributionListEntry",
"DistributionListRevision",
"DistributionListSnapshot",
"new_uuid",
]
File diff suppressed because it is too large Load Diff
+193 -7
View File
@@ -1,11 +1,48 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from pathlib import Path
from govoplan_core.core.modules import DocumentationTopic, ModuleInterfaceProvider, ModuleManifest, PermissionDefinition, RoleTemplate
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_ID = "dist_lists"
MODULE_NAME = "Distribution Lists" MODULE_NAME = "Distribution Lists"
MODULE_VERSION = "0.1.8" MODULE_VERSION = "0.1.14"
READ_SCOPE = "dist_lists:list:read" READ_SCOPE = "dist_lists:list:read"
WRITE_SCOPE = "dist_lists:list:write" 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( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
@@ -69,8 +136,15 @@ manifest = ModuleManifest(
"addresses", "addresses",
"access", "access",
"identity", "identity",
"idm",
"organizations", "organizations",
"datasources",
"connectors",
"dataflow",
"policy",
"campaigns", "campaigns",
"templates",
"reporting",
"mail", "mail",
"postbox", "postbox",
"notifications", "notifications",
@@ -79,14 +153,126 @@ manifest = ModuleManifest(
"workflow_engine", "workflow_engine",
"tasks", "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=( provides_interfaces=(
ModuleInterfaceProvider(name="dist_lists.source", version=MODULE_VERSION), ModuleInterfaceProvider(
ModuleInterfaceProvider(name="dist_lists.expand", version=MODULE_VERSION), name=CAPABILITY_DISTRIBUTION_LIST_SOURCE,
ModuleInterfaceProvider(name="dist_lists.writer", version=MODULE_VERSION), version=MODULE_VERSION,
),
ModuleInterfaceProvider(
name=CAPABILITY_DISTRIBUTION_LIST_EXPAND,
version=MODULE_VERSION,
),
ModuleInterfaceProvider(
name=CAPABILITY_DISTRIBUTION_LIST_WRITER,
version=MODULE_VERSION,
),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, 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, documentation=DOCUMENTATION,
) )
@@ -0,0 +1 @@
"""Distribution Lists migrations."""
@@ -0,0 +1 @@
"""Distribution Lists Alembic revisions."""
@@ -0,0 +1,207 @@
"""distribution lists baseline
Revision ID: e7c3a9d1b5f2
Revises: None
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e7c3a9d1b5f2"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"dist_lists_lists",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=36), nullable=True),
sa.Column("name", sa.String(length=300), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("current_revision_id", sa.String(length=36), nullable=False),
sa.Column("current_revision", sa.Integer(), nullable=False),
sa.Column("resource_revision", sa.Integer(), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_lists")),
)
op.create_index(op.f("ix_dist_lists_lists_tenant_id"), "dist_lists_lists", ["tenant_id"])
op.create_index(op.f("ix_dist_lists_lists_scope_type"), "dist_lists_lists", ["scope_type"])
op.create_index(op.f("ix_dist_lists_lists_scope_id"), "dist_lists_lists", ["scope_id"])
op.create_index(op.f("ix_dist_lists_lists_status"), "dist_lists_lists", ["status"])
op.create_index(op.f("ix_dist_lists_lists_created_by_account_id"), "dist_lists_lists", ["created_by_account_id"])
op.create_index(op.f("ix_dist_lists_lists_updated_by_account_id"), "dist_lists_lists", ["updated_by_account_id"])
op.create_index(op.f("ix_dist_lists_lists_deleted_at"), "dist_lists_lists", ["deleted_at"])
op.create_index(
"ix_dist_lists_lists_tenant_status",
"dist_lists_lists",
["tenant_id", "status", "updated_at"],
)
op.create_index(
"uq_dist_lists_lists_active_tenant_name",
"dist_lists_lists",
["tenant_id", "name"],
unique=True,
sqlite_where=sa.text("deleted_at IS NULL AND scope_type = 'tenant'"),
postgresql_where=sa.text("deleted_at IS NULL AND scope_type = 'tenant'"),
)
op.create_index(
"uq_dist_lists_lists_active_scoped_name",
"dist_lists_lists",
["tenant_id", "scope_type", "scope_id", "name"],
unique=True,
sqlite_where=sa.text(
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
),
postgresql_where=sa.text(
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
),
)
op.create_table(
"dist_lists_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("distribution_list_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("definition_kind", sa.String(length=30), nullable=False),
sa.Column("definition_hash", sa.String(length=64), nullable=False),
sa.Column("parameter_schema", sa.JSON(), nullable=False),
sa.Column("constraints", sa.JSON(), nullable=False),
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["distribution_list_id"],
["dist_lists_lists.id"],
name=op.f("fk_dist_lists_revisions_distribution_list_id_dist_lists_lists"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_revisions")),
sa.UniqueConstraint(
"distribution_list_id",
"revision",
name="uq_dist_lists_revision_number",
),
)
op.create_index(op.f("ix_dist_lists_revisions_tenant_id"), "dist_lists_revisions", ["tenant_id"])
op.create_index(op.f("ix_dist_lists_revisions_distribution_list_id"), "dist_lists_revisions", ["distribution_list_id"])
op.create_index(op.f("ix_dist_lists_revisions_definition_kind"), "dist_lists_revisions", ["definition_kind"])
op.create_index(op.f("ix_dist_lists_revisions_created_by_account_id"), "dist_lists_revisions", ["created_by_account_id"])
op.create_index("ix_dist_lists_revisions_tenant_list", "dist_lists_revisions", ["tenant_id", "distribution_list_id"])
op.create_index("ix_dist_lists_revisions_hash", "dist_lists_revisions", ["tenant_id", "definition_hash"])
op.create_table(
"dist_lists_entries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("revision_id", sa.String(length=36), nullable=False),
sa.Column("entry_key", sa.String(length=80), nullable=False),
sa.Column("kind", sa.String(length=50), nullable=False),
sa.Column("mode", sa.String(length=20), nullable=False),
sa.Column("source_provider", sa.String(length=80), nullable=False),
sa.Column("source_resource_type", sa.String(length=80), nullable=False),
sa.Column("source_resource_id", sa.String(length=500), nullable=False),
sa.Column("source_revision", sa.String(length=500), nullable=True),
sa.Column("source_fingerprint", sa.String(length=255), nullable=True),
sa.Column("source_label", sa.String(length=300), nullable=True),
sa.Column("source_metadata", sa.JSON(), nullable=False),
sa.Column("label", sa.String(length=300), nullable=True),
sa.Column("purpose", sa.String(length=120), nullable=True),
sa.Column("requested_channels", sa.JSON(), nullable=False),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("effective_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column("configuration", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["revision_id"],
["dist_lists_revisions.id"],
name=op.f("fk_dist_lists_entries_revision_id_dist_lists_revisions"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_entries")),
sa.UniqueConstraint(
"revision_id",
"entry_key",
name="uq_dist_lists_entry_key_revision",
),
)
op.create_index(op.f("ix_dist_lists_entries_tenant_id"), "dist_lists_entries", ["tenant_id"])
op.create_index(op.f("ix_dist_lists_entries_revision_id"), "dist_lists_entries", ["revision_id"])
op.create_index(op.f("ix_dist_lists_entries_kind"), "dist_lists_entries", ["kind"])
op.create_index(op.f("ix_dist_lists_entries_mode"), "dist_lists_entries", ["mode"])
op.create_index("ix_dist_lists_entries_revision_order", "dist_lists_entries", ["revision_id", "order_index"])
op.create_index("ix_dist_lists_entries_source", "dist_lists_entries", ["tenant_id", "kind", "source_resource_id"])
op.create_table(
"dist_lists_snapshots",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("distribution_list_id", sa.String(length=36), nullable=False),
sa.Column("revision_id", sa.String(length=36), nullable=False),
sa.Column("revision_number", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
sa.Column("request", sa.JSON(), nullable=False),
sa.Column("expansion_hash", sa.String(length=64), nullable=False),
sa.Column("effective_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("recipient_count", sa.Integer(), nullable=False),
sa.Column("excluded_count", sa.Integer(), nullable=False),
sa.Column("recipients", sa.JSON(), nullable=False),
sa.Column("excluded", sa.JSON(), nullable=False),
sa.Column("diagnostics", sa.JSON(), nullable=False),
sa.Column("provider_evidence", sa.JSON(), nullable=False),
sa.Column("stale", sa.Boolean(), nullable=False),
sa.Column("truncated", sa.Boolean(), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["distribution_list_id"],
["dist_lists_lists.id"],
name=op.f("fk_dist_lists_snapshots_distribution_list_id_dist_lists_lists"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["revision_id"],
["dist_lists_revisions.id"],
name=op.f("fk_dist_lists_snapshots_revision_id_dist_lists_revisions"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_snapshots")),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_dist_lists_snapshot_idempotency",
),
)
op.create_index(op.f("ix_dist_lists_snapshots_tenant_id"), "dist_lists_snapshots", ["tenant_id"])
op.create_index(op.f("ix_dist_lists_snapshots_distribution_list_id"), "dist_lists_snapshots", ["distribution_list_id"])
op.create_index(op.f("ix_dist_lists_snapshots_revision_id"), "dist_lists_snapshots", ["revision_id"])
op.create_index(op.f("ix_dist_lists_snapshots_idempotency_key"), "dist_lists_snapshots", ["idempotency_key"])
op.create_index(op.f("ix_dist_lists_snapshots_created_by_account_id"), "dist_lists_snapshots", ["created_by_account_id"])
op.create_index("ix_dist_lists_snapshots_tenant_list", "dist_lists_snapshots", ["tenant_id", "distribution_list_id", "created_at"])
op.create_index("ix_dist_lists_snapshots_hash", "dist_lists_snapshots", ["tenant_id", "expansion_hash"])
def downgrade() -> None:
op.drop_table("dist_lists_snapshots")
op.drop_table("dist_lists_entries")
op.drop_table("dist_lists_revisions")
op.drop_table("dist_lists_lists")
+558
View File
@@ -0,0 +1,558 @@
from __future__ import annotations
from dataclasses import asdict
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.distribution_lists import (
DistributionExpansionLimits,
DistributionExpansionRequest,
DistributionListConflictError,
DistributionListNotFoundError,
DistributionListUnavailableError,
)
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
from govoplan_dist_lists.backend.capabilities import SqlDistributionListCapabilities
from govoplan_dist_lists.backend.catalogue import provider_catalogue
from govoplan_dist_lists.backend.db.models import DistributionListSnapshot
from govoplan_dist_lists.backend.expansion import snapshot_ref
from govoplan_dist_lists.backend.schemas import (
DeleteRequest,
DistributionListCreateRequest,
DistributionListEntryResponse,
DistributionListListResponse,
DistributionListResponse,
DistributionListRevisionResponse,
DistributionSnapshotResponse,
DistributionListUpdateRequest,
ExpansionRequestModel,
ExpansionResultResponse,
ProviderCatalogueResponse,
SnapshotListItemResponse,
SnapshotListResponse,
SourceReferenceModel,
WriteDecisionResponse,
)
from govoplan_dist_lists.backend.service import (
create_distribution_list,
delete_distribution_list,
get_distribution_list,
get_distribution_list_revision,
list_distribution_lists,
update_distribution_list,
)
router = APIRouter(prefix="/dist-lists", tags=["distribution-lists"])
READ_SCOPE = "dist_lists:list:read"
WRITE_SCOPE = "dist_lists:list:write"
ADMIN_SCOPE = "dist_lists:list:admin"
def _require(principal: ApiPrincipal, *scopes: str) -> None:
if not any(principal.has(scope) for scope in scopes):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of: {', '.join(scopes)}",
)
@router.get("/lists", response_model=DistributionListListResponse)
def api_list_distribution_lists(
query: str = Query(default="", max_length=200),
include_deleted: bool = False,
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DistributionListListResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
items = list_distribution_lists(
session,
principal,
query=query,
include_deleted=include_deleted,
limit=limit,
)
return DistributionListListResponse(
items=[
_list_response(
item,
get_distribution_list_revision(session, item),
)
for item in items
],
total=len(items),
)
@router.post(
"/lists",
response_model=DistributionListResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_distribution_list(
payload: DistributionListCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DistributionListResponse:
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item, revision = create_distribution_list(session, principal, payload)
_audit(
session,
principal,
action="dist_lists.list.created",
item_id=item.id,
details={
"name": item.name,
"definition_hash": revision.definition_hash,
"definition_kind": revision.definition_kind,
"entry_count": len(revision.entries),
},
)
_event(session, principal, item.id, "dist_lists.list.created.v1")
session.commit()
except (DistributionListConflictError, IntegrityError) as exc:
session.rollback()
raise _error(exc) from exc
session.refresh(item)
response.headers["ETag"] = item.strong_etag
return _list_response(item, revision)
@router.get("/lists/{list_id}", response_model=DistributionListResponse)
def api_get_distribution_list(
list_id: str,
response: Response,
revision: int | None = Query(default=None, ge=1),
include_deleted: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DistributionListResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
try:
item = get_distribution_list(
session,
principal,
list_id,
include_deleted=include_deleted,
)
item_revision = get_distribution_list_revision(
session,
item,
revision=revision,
)
except DistributionListNotFoundError as exc:
raise _error(exc) from exc
response.headers["ETag"] = item.strong_etag
return _list_response(item, item_revision)
@router.put("/lists/{list_id}", response_model=DistributionListResponse)
def api_update_distribution_list(
list_id: str,
payload: DistributionListUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DistributionListResponse:
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item = get_distribution_list(session, principal, list_id)
assert_revision_precondition(
if_match,
resource_type="distribution_list",
resource_id=item.id,
submitted_base_revision=payload.base_revision,
)
item, revision = update_distribution_list(
session,
principal,
item,
payload,
)
_audit(
session,
principal,
action="dist_lists.list.updated",
item_id=item.id,
details={
"definition_hash": revision.definition_hash,
"definition_revision": revision.revision,
"resource_revision": item.resource_revision,
"entry_count": len(revision.entries),
},
)
_event(session, principal, item.id, "dist_lists.list.updated.v1")
session.commit()
except (
ConcurrencyError,
DistributionListConflictError,
DistributionListNotFoundError,
IntegrityError,
) as exc:
session.rollback()
raise _error(exc) from exc
session.refresh(item)
response.headers["ETag"] = item.strong_etag
return _list_response(item, revision)
@router.delete("/lists/{list_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_distribution_list(
list_id: str,
payload: DeleteRequest,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> Response:
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item = get_distribution_list(session, principal, list_id)
assert_revision_precondition(
if_match,
resource_type="distribution_list",
resource_id=item.id,
submitted_base_revision=payload.base_revision,
)
delete_distribution_list(
session,
principal,
item,
base_revision=payload.base_revision,
)
_audit(
session,
principal,
action="dist_lists.list.deleted",
item_id=item.id,
details={"resource_revision": item.resource_revision},
)
_event(session, principal, item.id, "dist_lists.list.deleted.v1")
session.commit()
except (
ConcurrencyError,
DistributionListConflictError,
DistributionListNotFoundError,
) as exc:
session.rollback()
raise _error(exc) from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/lists/{list_id}/expand", response_model=ExpansionResultResponse)
def api_expand_distribution_list(
list_id: str,
payload: ExpansionRequestModel,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ExpansionResultResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
request = DistributionExpansionRequest(
list_id=list_id,
revision=payload.revision,
effective_at=payload.effective_at,
purpose=payload.purpose,
requested_channels=tuple(payload.requested_channels),
parameters=dict(payload.parameters),
preview=payload.preview,
freeze=payload.freeze,
idempotency_key=payload.idempotency_key,
limits=DistributionExpansionLimits(
max_entries=payload.max_entries,
max_results=payload.max_results,
max_depth=payload.max_depth,
max_provider_results=payload.max_provider_results,
),
)
try:
result = SqlDistributionListCapabilities(get_registry()).expand(
session,
principal,
request=request,
)
if payload.freeze:
_audit(
session,
principal,
action="dist_lists.snapshot.created",
item_id=result.snapshot_id or list_id,
details={
"list_id": list_id,
"definition_hash": result.source.definition_hash,
"expansion_hash": result.expansion_hash,
"recipient_count": len(result.recipients),
"excluded_count": len(result.excluded),
"stale": result.stale,
},
)
_event(
session,
principal,
result.snapshot_id or list_id,
"dist_lists.snapshot.created.v1",
resource_type="distribution_list_snapshot",
)
session.commit()
except (
DistributionListConflictError,
DistributionListNotFoundError,
DistributionListUnavailableError,
IntegrityError,
) as exc:
session.rollback()
raise _error(exc) from exc
return _expansion_response(result)
@router.get("/lists/{list_id}/snapshots", response_model=SnapshotListResponse)
def api_list_distribution_list_snapshots(
list_id: str,
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SnapshotListResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
get_distribution_list(session, principal, list_id)
rows = list(
session.scalars(
select(DistributionListSnapshot)
.where(
DistributionListSnapshot.tenant_id == principal.tenant_id,
DistributionListSnapshot.distribution_list_id == list_id,
)
.order_by(
DistributionListSnapshot.created_at.desc(),
DistributionListSnapshot.id.desc(),
)
.limit(limit)
)
)
return SnapshotListResponse(
items=[
SnapshotListItemResponse(
id=item.id,
list_id=item.distribution_list_id,
revision_id=item.revision_id,
revision=item.revision_number,
expansion_hash=item.expansion_hash,
effective_at=item.effective_at,
recipient_count=item.recipient_count,
excluded_count=item.excluded_count,
stale=item.stale,
truncated=item.truncated,
created_at=item.created_at,
)
for item in rows
]
)
@router.get(
"/snapshots/{snapshot_id}",
response_model=DistributionSnapshotResponse,
)
def api_get_distribution_list_snapshot(
snapshot_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DistributionSnapshotResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
row = session.scalar(
select(DistributionListSnapshot).where(
DistributionListSnapshot.id == snapshot_id,
DistributionListSnapshot.tenant_id == principal.tenant_id,
)
)
if row is None:
raise HTTPException(status_code=404, detail="Distribution-list snapshot not found.")
return DistributionSnapshotResponse.model_validate(asdict(snapshot_ref(row)))
@router.get("/providers", response_model=ProviderCatalogueResponse)
def api_distribution_list_providers(
query: str = Query(default="", max_length=200),
limit: int = Query(default=50, ge=1, le=100),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ProviderCatalogueResponse:
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
return provider_catalogue(
session,
principal,
registry=get_registry(),
query=query,
limit=limit,
)
@router.get("/write-decision", response_model=WriteDecisionResponse)
def api_distribution_list_write_decision(
operation: str = Query(default="create", max_length=50),
list_id: str | None = Query(default=None, max_length=36),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WriteDecisionResponse:
decision = SqlDistributionListCapabilities(get_registry()).explain_write(
session,
principal,
list_id=list_id,
operation=operation,
)
return WriteDecisionResponse.model_validate(asdict(decision))
def _list_response(item, revision) -> DistributionListResponse:
return DistributionListResponse(
id=item.id,
tenant_id=item.tenant_id,
scope_type=item.scope_type,
scope_id=item.scope_id,
name=item.name,
description=item.description,
status=item.status,
current_revision=item.current_revision,
resource_revision=item.resource_revision,
etag=item.strong_etag,
metadata=dict(item.metadata_),
deleted_at=item.deleted_at,
created_at=item.created_at,
updated_at=item.updated_at,
revision=DistributionListRevisionResponse(
id=revision.id,
revision=revision.revision,
definition_kind=revision.definition_kind,
definition_hash=revision.definition_hash,
parameters=list(revision.parameter_schema),
constraints=dict(revision.constraints),
source_fingerprints=list(revision.source_fingerprints),
entries=[
DistributionListEntryResponse(
id=entry.id,
entry_key=entry.entry_key,
kind=entry.kind,
mode=entry.mode,
source=SourceReferenceModel(
provider=entry.source_provider,
resource_type=entry.source_resource_type,
resource_id=entry.source_resource_id,
revision=entry.source_revision,
fingerprint=entry.source_fingerprint,
label=entry.source_label,
metadata=dict(entry.source_metadata),
),
label=entry.label,
purpose=entry.purpose,
requested_channels=list(entry.requested_channels),
effective_from=entry.effective_from,
effective_until=entry.effective_until,
order=entry.order_index,
configuration=dict(entry.configuration),
)
for entry in revision.entries
],
created_by_account_id=revision.created_by_account_id,
created_at=revision.created_at,
),
)
def _expansion_response(result) -> ExpansionResultResponse:
return ExpansionResultResponse.model_validate(
{
"list_id": result.source.id,
"revision_id": result.source.revision_id,
"revision": result.source.revision,
"definition_hash": result.source.definition_hash,
"recipients": [asdict(item) for item in result.recipients],
"excluded": [asdict(item) for item in result.excluded],
"diagnostics": [asdict(item) for item in result.diagnostics],
"provider_evidence": [asdict(item) for item in result.provider_evidence],
"expansion_hash": result.expansion_hash,
"generated_at": result.generated_at,
"snapshot_id": result.snapshot_id,
"stale": result.stale,
"truncated": result.truncated,
}
)
def _audit(
session: Session,
principal: ApiPrincipal,
*,
action: str,
item_id: str,
details: dict[str, object],
) -> None:
audit_from_principal(
session,
principal,
action=action,
object_type="distribution_list",
object_id=item_id,
details=details,
commit=False,
)
def _event(
session: Session,
principal: ApiPrincipal,
resource_id: str,
event_type: str,
*,
resource_type: str = "distribution_list",
) -> None:
emit_platform_event(
session,
PlatformEvent(
type=event_type,
module_id="dist_lists",
actor=EventActorRef(type="account", id=principal.account_id),
tenant=EventTenantRef(id=principal.tenant_id),
resource=EventObjectRef(type=resource_type, id=resource_id),
classification="internal",
),
)
def _error(exc: Exception) -> HTTPException:
if isinstance(exc, MissingPreconditionError):
return HTTPException(status_code=428, detail=exc.as_dict())
if isinstance(exc, RevisionConflictError):
return HTTPException(status_code=412, detail=exc.as_dict())
if isinstance(exc, ConcurrencyError):
return HTTPException(status_code=409, detail=str(exc))
if isinstance(exc, DistributionListNotFoundError):
return HTTPException(status_code=404, detail=str(exc))
if isinstance(exc, DistributionListUnavailableError):
return HTTPException(status_code=503, detail=str(exc))
if isinstance(exc, IntegrityError):
return HTTPException(status_code=409, detail="Distribution-list data conflicts with an existing record.")
return HTTPException(status_code=422, detail=str(exc))
__all__ = ["router"]
+353
View File
@@ -0,0 +1,353 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
DefinitionKind = Literal["static", "parameterized", "dynamic", "template"]
EntryMode = Literal["include", "exclude", "override"]
EntryKind = Literal[
"address_contact",
"address_list",
"address_email",
"raw_email",
"raw_postal_address",
"internal_mail",
"portal",
"idm_identity",
"idm_group",
"organization_unit",
"function",
"effective_function_incumbent",
"dataflow_result",
"distribution_list",
]
Channel = Literal["email", "postal", "internal_mail", "portal"]
Outcome = Literal[
"usable",
"unresolved",
"invalid",
"suppressed",
"ambiguous",
"duplicate",
"policy_blocked",
"provider_unavailable",
"stale",
]
class SourceReferenceModel(BaseModel):
provider: str = Field(min_length=1, max_length=80)
resource_type: str = Field(min_length=1, max_length=80)
resource_id: str = Field(min_length=1, max_length=500)
revision: str | None = Field(default=None, max_length=500)
fingerprint: str | None = Field(default=None, max_length=255)
label: str | None = Field(default=None, max_length=300)
metadata: dict[str, Any] = Field(default_factory=dict)
class ParameterDefinitionModel(BaseModel):
key: str = Field(min_length=1, max_length=80, pattern=r"^[A-Za-z_][A-Za-z0-9_.-]*$")
value_type: Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"string_list",
]
label: str | None = Field(default=None, max_length=200)
required: bool = False
default: Any = None
allowed_values: list[Any] = Field(default_factory=list, max_length=200)
minimum: float | None = None
maximum: float | None = None
pattern: str | None = Field(default=None, max_length=500)
description: str | None = Field(default=None, max_length=2000)
@model_validator(mode="after")
def validate_bounds(self) -> "ParameterDefinitionModel":
if (
self.minimum is not None
and self.maximum is not None
and self.minimum > self.maximum
):
raise ValueError("Parameter minimum cannot exceed maximum.")
return self
class DistributionListEntryInput(BaseModel):
entry_key: str | None = Field(
default=None,
min_length=1,
max_length=80,
pattern=r"^[A-Za-z0-9_.:-]+$",
)
kind: EntryKind
mode: EntryMode = "include"
source: SourceReferenceModel
label: str | None = Field(default=None, max_length=300)
purpose: str | None = Field(default=None, max_length=120)
requested_channels: list[Channel] = Field(default_factory=list, max_length=4)
effective_from: datetime | None = None
effective_until: datetime | None = None
configuration: dict[str, Any] = Field(default_factory=dict)
@field_validator("requested_channels")
@classmethod
def unique_channels(cls, value: list[Channel]) -> list[Channel]:
return list(dict.fromkeys(value))
@model_validator(mode="after")
def validate_effective_range(self) -> "DistributionListEntryInput":
if (
self.effective_from is not None
and self.effective_until is not None
and self.effective_until <= self.effective_from
):
raise ValueError("Entry effective until must be after effective from.")
return self
class DistributionListCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4000)
scope_type: Literal["tenant", "group", "user"] = "tenant"
scope_id: str | None = Field(default=None, max_length=36)
definition_kind: DefinitionKind = "static"
parameters: list[ParameterDefinitionModel] = Field(default_factory=list, max_length=100)
constraints: dict[str, Any] = Field(default_factory=dict)
entries: list[DistributionListEntryInput] = Field(default_factory=list, max_length=500)
metadata: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_scope(self) -> "DistributionListCreateRequest":
if self.scope_type == "tenant" and self.scope_id is not None:
raise ValueError("Tenant-scoped lists do not use a scope ID.")
if self.scope_type != "tenant" and self.scope_id is None:
raise ValueError("Group- and user-scoped lists require a scope ID.")
return self
class DistributionListUpdateRequest(DistributionListCreateRequest):
base_revision: int = Field(ge=1)
class DistributionListEntryResponse(BaseModel):
id: str
entry_key: str
kind: EntryKind
mode: EntryMode
source: SourceReferenceModel
label: str | None
purpose: str | None
requested_channels: list[Channel]
effective_from: datetime | None
effective_until: datetime | None
order: int
configuration: dict[str, Any]
class DistributionListRevisionResponse(BaseModel):
id: str
revision: int
definition_kind: DefinitionKind
definition_hash: str
parameters: list[ParameterDefinitionModel]
constraints: dict[str, Any]
source_fingerprints: list[dict[str, Any]]
entries: list[DistributionListEntryResponse]
created_by_account_id: str | None
created_at: datetime
class DistributionListResponse(BaseModel):
id: str
tenant_id: str
scope_type: Literal["tenant", "group", "user"]
scope_id: str | None
name: str
description: str | None
status: str
current_revision: int
resource_revision: int
etag: str
metadata: dict[str, Any]
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
revision: DistributionListRevisionResponse
class DistributionListListResponse(BaseModel):
items: list[DistributionListResponse]
total: int
class ExpansionRequestModel(BaseModel):
revision: int | None = Field(default=None, ge=1)
effective_at: datetime | None = None
purpose: str | None = Field(default=None, max_length=120)
requested_channels: list[Channel] = Field(default_factory=list, max_length=4)
parameters: dict[str, Any] = Field(default_factory=dict)
preview: bool = False
freeze: bool = False
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
max_entries: int = Field(default=500, ge=1, le=5000)
max_results: int = Field(default=5000, ge=1, le=20_000)
max_depth: int = Field(default=8, ge=1, le=20)
max_provider_results: int = Field(default=2000, ge=1, le=10_000)
@model_validator(mode="after")
def validate_freeze(self) -> "ExpansionRequestModel":
if self.freeze and not self.idempotency_key:
raise ValueError("Frozen expansions require an idempotency key.")
return self
class ExplanationResponse(BaseModel):
code: str
message: str
severity: Literal["info", "warning", "error"]
provider: str | None = None
source: SourceReferenceModel | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ChannelCandidateResponse(BaseModel):
channel: Channel
target: str
target_key: str
status: Outcome
contact_point_id: str | None = None
locale: str | None = None
preferred: bool = False
reason_code: str | None = None
explanation: str | None = None
source: SourceReferenceModel | None = None
decision_provenance: dict[str, Any] = Field(default_factory=dict)
class RecipientResponse(BaseModel):
recipient_key: str
display_name: str
status: Outcome
channels: list[ChannelCandidateResponse]
identity_id: str | None = None
account_id: str | None = None
contact_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
source_entry_ids: list[str]
explanations: list[ExplanationResponse]
attributes: dict[str, Any]
provenance: dict[str, Any]
class ProviderEvidenceResponse(BaseModel):
provider: str
source: SourceReferenceModel
actual_revision: str | None = None
actual_fingerprint: str | None = None
stale: bool
generated_at: datetime | None = None
details: dict[str, Any]
class ExpansionResultResponse(BaseModel):
list_id: str
revision_id: str
revision: int
definition_hash: str
recipients: list[RecipientResponse]
excluded: list[RecipientResponse]
diagnostics: list[ExplanationResponse]
provider_evidence: list[ProviderEvidenceResponse]
expansion_hash: str
generated_at: datetime
snapshot_id: str | None = None
stale: bool
truncated: bool
class DistributionSnapshotResponse(BaseModel):
id: str
tenant_id: str
list_id: str
revision_id: str
revision: int
expansion_hash: str
generated_at: datetime
effective_at: datetime
recipient_count: int
excluded_count: int
stale: bool
truncated: bool
request: dict[str, Any]
recipients: list[RecipientResponse]
excluded: list[RecipientResponse]
diagnostics: list[ExplanationResponse]
provider_evidence: list[ProviderEvidenceResponse]
provenance: dict[str, Any]
class SnapshotListItemResponse(BaseModel):
id: str
list_id: str
revision_id: str
revision: int
expansion_hash: str
effective_at: datetime
recipient_count: int
excluded_count: int
stale: bool
truncated: bool
created_at: datetime
class SnapshotListResponse(BaseModel):
items: list[SnapshotListItemResponse]
class ProviderOptionResponse(BaseModel):
key: str
kind: EntryKind
label: str
description: str | None = None
provider: str
source: SourceReferenceModel
available: bool = True
reason: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ProviderCatalogueResponse(BaseModel):
items: list[ProviderOptionResponse]
unavailable_providers: list[ExplanationResponse]
class WriteDecisionResponse(BaseModel):
list_id: str | None
operation: str
allowed: bool
reason_code: str
explanation: str
read_only: bool
required_scopes: list[str]
provenance: dict[str, Any]
class DeleteRequest(BaseModel):
base_revision: int = Field(ge=1)
class ApiErrorResponse(BaseModel):
model_config = ConfigDict(extra="allow")
detail: Any
__all__ = [name for name in globals() if name.endswith(("Model", "Request", "Response"))]
+503
View File
@@ -0,0 +1,503 @@
from __future__ import annotations
from collections.abc import Sequence
import hashlib
import json
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.concurrency import claim_revision
from govoplan_core.core.distribution_lists import (
DistributionListConflictError,
DistributionListEntryRef,
DistributionListNotFoundError,
DistributionListSourceRef,
DistributionParameterDefinition,
DistributionSourceReference,
)
from govoplan_core.security.time import utc_now
from govoplan_dist_lists.backend.db.models import (
DistributionList,
DistributionListEntry,
DistributionListRevision,
)
from govoplan_dist_lists.backend.schemas import (
DistributionListCreateRequest,
DistributionListEntryInput,
DistributionListUpdateRequest,
)
def visible_lists_statement(
principal: ApiPrincipal,
*,
include_deleted: bool = False,
):
statement = select(DistributionList).where(
DistributionList.tenant_id == principal.tenant_id,
)
if not principal.has("dist_lists:list:admin"):
statement = statement.where(
or_(
DistributionList.scope_type == "tenant",
(
(DistributionList.scope_type == "user")
& (DistributionList.scope_id == principal.account_id)
),
(
(DistributionList.scope_type == "group")
& DistributionList.scope_id.in_(tuple(principal.group_ids) or ("",))
),
)
)
if not include_deleted:
statement = statement.where(DistributionList.deleted_at.is_(None))
return statement
def list_distribution_lists(
session: Session,
principal: ApiPrincipal,
*,
query: str = "",
include_deleted: bool = False,
limit: int = 200,
) -> list[DistributionList]:
statement = visible_lists_statement(
principal,
include_deleted=include_deleted,
)
normalized_query = query.strip()
if normalized_query:
statement = statement.where(
DistributionList.name.ilike(f"%{normalized_query}%")
)
return list(
session.scalars(
statement.order_by(DistributionList.name, DistributionList.id).limit(
max(1, min(limit, 500))
)
)
)
def get_distribution_list(
session: Session,
principal: ApiPrincipal,
list_id: str,
*,
include_deleted: bool = False,
) -> DistributionList:
item = session.scalar(
visible_lists_statement(principal, include_deleted=include_deleted).where(
DistributionList.id == list_id
)
)
if item is None:
raise DistributionListNotFoundError("Distribution list not found.")
return item
def get_distribution_list_revision(
session: Session,
distribution_list: DistributionList,
*,
revision: int | None = None,
) -> DistributionListRevision:
revision_number = revision or distribution_list.current_revision
item = session.scalar(
select(DistributionListRevision)
.options(selectinload(DistributionListRevision.entries))
.where(
DistributionListRevision.distribution_list_id == distribution_list.id,
DistributionListRevision.tenant_id == distribution_list.tenant_id,
DistributionListRevision.revision == revision_number,
)
)
if item is None:
raise DistributionListNotFoundError("Distribution list revision not found.")
return item
def create_distribution_list(
session: Session,
principal: ApiPrincipal,
payload: DistributionListCreateRequest,
) -> tuple[DistributionList, DistributionListRevision]:
_ensure_requested_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
_ensure_unique_name(
session,
principal,
name=payload.name,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
item = DistributionList(
tenant_id=principal.tenant_id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
name=payload.name.strip(),
description=_text(payload.description),
current_revision_id="pending",
current_revision=1,
resource_revision=1,
created_by_account_id=principal.account_id,
updated_by_account_id=principal.account_id,
metadata_=dict(payload.metadata),
)
session.add(item)
session.flush()
revision = _create_revision(
session,
principal,
distribution_list=item,
revision_number=1,
payload=payload,
)
item.current_revision_id = revision.id
session.flush()
return item, revision
def update_distribution_list(
session: Session,
principal: ApiPrincipal,
distribution_list: DistributionList,
payload: DistributionListUpdateRequest,
) -> tuple[DistributionList, DistributionListRevision]:
_ensure_mutable_scope(principal, distribution_list)
_ensure_requested_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
_ensure_unique_name(
session,
principal,
name=payload.name,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
exclude_id=distribution_list.id,
)
next_resource_revision = claim_revision(
session,
model=DistributionList,
filters=(
DistributionList.id == distribution_list.id,
DistributionList.tenant_id == distribution_list.tenant_id,
DistributionList.deleted_at.is_(None),
),
revision_attribute="resource_revision",
expected_revision=payload.base_revision,
resource_type="distribution_list",
resource_id=distribution_list.id,
refresh_path=f"/api/v1/dist-lists/lists/{distribution_list.id}",
)
next_definition_revision = distribution_list.current_revision + 1
distribution_list.resource_revision = next_resource_revision
distribution_list.scope_type = payload.scope_type
distribution_list.scope_id = payload.scope_id
distribution_list.name = payload.name.strip()
distribution_list.description = _text(payload.description)
distribution_list.current_revision = next_definition_revision
distribution_list.updated_by_account_id = principal.account_id
distribution_list.metadata_ = dict(payload.metadata)
revision = _create_revision(
session,
principal,
distribution_list=distribution_list,
revision_number=next_definition_revision,
payload=payload,
)
distribution_list.current_revision_id = revision.id
session.flush()
return distribution_list, revision
def delete_distribution_list(
session: Session,
principal: ApiPrincipal,
distribution_list: DistributionList,
*,
base_revision: int,
) -> DistributionList:
_ensure_mutable_scope(principal, distribution_list)
next_revision = claim_revision(
session,
model=DistributionList,
filters=(
DistributionList.id == distribution_list.id,
DistributionList.tenant_id == distribution_list.tenant_id,
DistributionList.deleted_at.is_(None),
),
revision_attribute="resource_revision",
expected_revision=base_revision,
resource_type="distribution_list",
resource_id=distribution_list.id,
refresh_path="/api/v1/dist-lists/lists",
)
distribution_list.resource_revision = next_revision
distribution_list.deleted_at = utc_now()
distribution_list.status = "deleted"
distribution_list.updated_by_account_id = principal.account_id
session.flush()
return distribution_list
def source_ref(
distribution_list: DistributionList,
revision: DistributionListRevision,
) -> DistributionListSourceRef:
return DistributionListSourceRef(
id=distribution_list.id,
tenant_id=distribution_list.tenant_id,
name=distribution_list.name,
description=distribution_list.description,
revision_id=revision.id,
revision=revision.revision,
definition_hash=revision.definition_hash,
definition_kind=revision.definition_kind, # type: ignore[arg-type]
status=distribution_list.status,
entry_count=len(revision.entries),
read_only=False,
parameters=tuple(
DistributionParameterDefinition(
key=str(item["key"]),
value_type=str(item["value_type"]), # type: ignore[arg-type]
label=_text(item.get("label")),
required=bool(item.get("required", False)),
default=item.get("default"),
allowed_values=tuple(item.get("allowed_values") or ()),
minimum=_number(item.get("minimum")),
maximum=_number(item.get("maximum")),
pattern=_text(item.get("pattern")),
description=_text(item.get("description")),
)
for item in revision.parameter_schema
),
updated_at=distribution_list.updated_at,
provenance={
"module": "dist_lists",
"scope_type": distribution_list.scope_type,
"scope_id": distribution_list.scope_id,
"resource_revision": distribution_list.resource_revision,
},
metadata=dict(distribution_list.metadata_),
)
def entry_ref(entry: DistributionListEntry) -> DistributionListEntryRef:
return DistributionListEntryRef(
id=entry.id,
kind=entry.kind, # type: ignore[arg-type]
mode=entry.mode, # type: ignore[arg-type]
source=DistributionSourceReference(
provider=entry.source_provider,
resource_type=entry.source_resource_type,
resource_id=entry.source_resource_id,
revision=entry.source_revision,
fingerprint=entry.source_fingerprint,
label=entry.source_label,
metadata=dict(entry.source_metadata),
),
label=entry.label,
purpose=entry.purpose,
requested_channels=tuple(entry.requested_channels), # type: ignore[arg-type]
effective_from=entry.effective_from,
effective_until=entry.effective_until,
order=entry.order_index,
configuration=dict(entry.configuration),
)
def definition_hash(
payload: DistributionListCreateRequest | DistributionListUpdateRequest,
) -> str:
definition = {
"definition_kind": payload.definition_kind,
"parameters": [item.model_dump(mode="json") for item in payload.parameters],
"constraints": payload.constraints,
"entries": [
{
**entry.model_dump(mode="json", exclude={"entry_key"}),
"entry_key": entry.entry_key,
}
for entry in payload.entries
],
}
encoded = json.dumps(
definition,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _create_revision(
session: Session,
principal: ApiPrincipal,
*,
distribution_list: DistributionList,
revision_number: int,
payload: DistributionListCreateRequest | DistributionListUpdateRequest,
) -> DistributionListRevision:
_validate_entry_keys(payload.entries)
revision = DistributionListRevision(
tenant_id=principal.tenant_id,
distribution_list_id=distribution_list.id,
revision=revision_number,
definition_kind=payload.definition_kind,
definition_hash=definition_hash(payload),
parameter_schema=[item.model_dump(mode="json") for item in payload.parameters],
constraints=dict(payload.constraints),
source_fingerprints=[
{
"provider": item.source.provider,
"resource_type": item.source.resource_type,
"resource_id": item.source.resource_id,
"revision": item.source.revision,
"fingerprint": item.source.fingerprint,
}
for item in payload.entries
if item.source.revision or item.source.fingerprint
],
created_by_account_id=principal.account_id,
)
session.add(revision)
session.flush()
for order, entry in enumerate(payload.entries):
session.add(
_entry_model(
principal,
revision=revision,
payload=entry,
order=order,
)
)
session.flush()
session.refresh(revision, attribute_names=["entries"])
return revision
def _entry_model(
principal: ApiPrincipal,
*,
revision: DistributionListRevision,
payload: DistributionListEntryInput,
order: int,
) -> DistributionListEntry:
source = payload.source
return DistributionListEntry(
tenant_id=principal.tenant_id,
revision_id=revision.id,
entry_key=payload.entry_key or f"entry-{order + 1}",
kind=payload.kind,
mode=payload.mode,
source_provider=source.provider,
source_resource_type=source.resource_type,
source_resource_id=source.resource_id,
source_revision=source.revision,
source_fingerprint=source.fingerprint,
source_label=source.label,
source_metadata=dict(source.metadata),
label=payload.label,
purpose=payload.purpose,
requested_channels=list(payload.requested_channels),
effective_from=payload.effective_from,
effective_until=payload.effective_until,
order_index=order,
configuration=dict(payload.configuration),
)
def _validate_entry_keys(entries: Sequence[DistributionListEntryInput]) -> None:
keys = [item.entry_key or f"entry-{index + 1}" for index, item in enumerate(entries)]
if len(keys) != len(set(keys)):
raise DistributionListConflictError("Entry keys must be unique within a revision.")
def _ensure_unique_name(
session: Session,
principal: ApiPrincipal,
*,
name: str,
scope_type: str,
scope_id: str | None,
exclude_id: str | None = None,
) -> None:
statement = select(DistributionList.id).where(
DistributionList.tenant_id == principal.tenant_id,
DistributionList.scope_type == scope_type,
DistributionList.scope_id.is_(None)
if scope_id is None
else DistributionList.scope_id == scope_id,
DistributionList.name == name.strip(),
DistributionList.deleted_at.is_(None),
)
if exclude_id:
statement = statement.where(DistributionList.id != exclude_id)
if session.scalar(statement) is not None:
raise DistributionListConflictError(
"A distribution list with this name already exists in the selected scope."
)
def _ensure_mutable_scope(
principal: ApiPrincipal,
distribution_list: DistributionList,
) -> None:
if principal.has("dist_lists:list:admin"):
return
if distribution_list.scope_type == "user" and distribution_list.scope_id != principal.account_id:
raise DistributionListConflictError("This user-scoped distribution list is read-only.")
if distribution_list.scope_type == "group" and distribution_list.scope_id not in principal.group_ids:
raise DistributionListConflictError("This group-scoped distribution list is read-only.")
def _ensure_requested_scope(
principal: ApiPrincipal,
*,
scope_type: str,
scope_id: str | None,
) -> None:
if principal.has("dist_lists:list:admin") or scope_type == "tenant":
return
if scope_type == "user" and scope_id == principal.account_id:
return
if scope_type == "group" and scope_id in principal.group_ids:
return
raise DistributionListConflictError(
"The selected distribution-list scope is not writable by the current principal."
)
def _text(value: object | None) -> str | None:
candidate = str(value).strip() if value is not None else ""
return candidate or None
def _number(value: object | None) -> float | None:
if value is None:
return None
return float(value)
__all__ = [
"create_distribution_list",
"definition_hash",
"delete_distribution_list",
"entry_ref",
"get_distribution_list",
"get_distribution_list_revision",
"list_distribution_lists",
"source_ref",
"update_distribution_list",
"visible_lists_statement",
]
+4 -2
View File
@@ -17,8 +17,10 @@ class DistributionListsManifestTests(unittest.TestCase):
self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("dist_lists.expand", {interface.name for interface in manifest.provides_interfaces}) 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.assertIn("dist_lists:list:read", {permission.scope for permission in manifest.permissions})
self.assertIsNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec) 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__": if __name__ == "__main__":
+48
View File
@@ -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()
+511
View File
@@ -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()
+32
View File
@@ -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
}
}
}
+309
View File
@@ -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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
source_fingerprints: Array<Record<string, unknown>>;
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<string, unknown>;
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<string, unknown>;
entries: DistributionListEntryInput[];
metadata: Record<string, unknown>;
};
export type DistributionExplanation = {
code: string;
message: string;
severity: "info" | "warning" | "error";
provider?: string | null;
source?: SourceReference | null;
provenance: Record<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
provenance: Record<string, unknown>;
};
export type ProviderEvidence = {
provider: string;
source: SourceReference;
actual_revision?: string | null;
actual_fingerprint?: string | null;
stale: boolean;
generated_at?: string | null;
details: Record<string, unknown>;
};
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<string, unknown>;
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<string, unknown>;
};
export type ProviderCatalogue = {
items: ProviderOption[];
unavailable_providers: DistributionExplanation[];
};
export async function listDistributionLists(
settings: ApiSettings,
query = ""
): Promise<DistributionList[]> {
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<DistributionList> {
return apiFetch(settings, "/api/v1/dist-lists/lists", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateDistributionList(
settings: ApiSettings,
item: DistributionList,
payload: DistributionListPayload
): Promise<DistributionList> {
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<void> {
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<ExpansionResult> {
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<DistributionSnapshot[]> {
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<ProviderCatalogue> {
return apiFetch(
settings,
apiPath("/api/v1/dist-lists/providers", { query, limit }),
{ signal }
);
}
@@ -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<string, DistributionList>());
if (selected) cache.current.set(selected.id, selected);
const selectedOption = useMemo<SearchableSelectOption | null>(() => {
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 (
<FormField label={label}>
<SearchableSelect
value={value}
selectedOption={selectedOption}
loadOptions={loadOptions}
onChange={(nextValue) =>
onChange(nextValue, cache.current.get(nextValue) ?? null)}
placeholder={placeholder}
searchPlaceholder="Search by list name"
emptyText="No matching distribution lists."
disabled={disabled}
required={required}
/>
</FormField>
);
}
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}`
};
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export { default } from "./module";
export * from "./module";
export * from "./api/distLists";
export { default as DistributionListPicker } from "./components/DistributionListPicker";
export type { DistributionListPickerProps } from "./components/DistributionListPicker";
export { default as DistributionListsPage } from "./features/distributionLists/DistributionListsPage";
+47
View File
@@ -0,0 +1,47 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/dist-lists.css";
const DistributionListsPage = lazy(
() => import("./features/distributionLists/DistributionListsPage")
);
const readScopes = [
"dist_lists:list:read",
"dist_lists:list:write",
"dist_lists:list:admin"
];
export const distributionListsModule: PlatformWebModule = {
id: "dist_lists",
label: "Distribution Lists",
version: "0.1.14",
optionalDependencies: [
"addresses",
"identity",
"idm",
"organizations",
"dataflow",
"policy"
],
navItems: [
{
to: "/distribution-lists",
label: "Distribution Lists",
iconName: "list-tree",
anyOf: readScopes,
order: 74
}
],
routes: [
{
path: "/distribution-lists",
anyOf: readScopes,
order: 74,
render: ({ settings, auth }) =>
createElement(DistributionListsPage, { settings, auth })
}
]
};
export default distributionListsModule;
+552
View File
@@ -0,0 +1,552 @@
.dist-lists-page {
position: relative;
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.dist-lists-page *,
.dist-lists-page *::before,
.dist-lists-page *::after {
box-sizing: border-box;
}
.dist-lists-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.dist-lists-sidebar,
.dist-lists-workspace,
.dist-lists-list-frame,
.dist-lists-content-frame,
.dist-lists-content {
min-width: 0;
min-height: 0;
}
.dist-lists-sidebar {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: var(--border-line);
background: var(--panel-soft);
}
.dist-lists-sidebar-toolbar,
.dist-lists-workspace-toolbar,
.dist-lists-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.dist-lists-sidebar-toolbar {
min-height: 52px;
padding: 8px 10px 8px 14px;
}
.dist-lists-sidebar-toolbar > span,
.dist-lists-toolbar-actions,
.dist-lists-row-actions,
.dist-lists-preview-actions,
.dist-lists-explanation-summary {
display: flex;
align-items: center;
gap: 7px;
}
.dist-lists-search {
padding: 9px;
border-bottom: var(--border-line);
background: var(--panel);
}
.dist-lists-search input {
width: 100%;
min-height: 34px;
padding: 7px 9px;
}
.dist-lists-list-frame {
flex: 1 1 auto;
overflow: hidden;
}
.dist-lists-list {
height: 100%;
overflow: auto;
padding: 6px;
}
.dist-lists-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 56px;
padding: 8px 9px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
text-align: left;
}
.dist-lists-list > button:hover,
.dist-lists-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.dist-lists-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.dist-lists-list > button > span:first-child {
min-width: 0;
}
.dist-lists-list strong,
.dist-lists-list small,
.dist-lists-current-title strong,
.dist-lists-current-title small,
.dist-lists-source-cell strong,
.dist-lists-source-cell small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dist-lists-list strong,
.dist-lists-current-title strong,
.dist-lists-source-cell strong {
color: var(--text-strong);
font-size: 13px;
}
.dist-lists-list small,
.dist-lists-current-title small,
.dist-lists-source-cell small {
margin-top: 3px;
color: var(--muted);
font-size: 11px;
}
.dist-lists-workspace {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.dist-lists-workspace-toolbar {
min-height: 58px;
padding: 8px 10px 8px 14px;
}
.dist-lists-current-title {
display: block;
min-width: 0;
flex: 1 1 auto;
}
.dist-lists-toolbar-actions {
flex: 0 0 auto;
}
.dist-lists-toolbar-actions .btn {
white-space: nowrap;
}
.dist-lists-alerts {
flex: 0 0 auto;
padding: 0 12px;
}
.dist-lists-alerts:empty {
display: none;
}
.dist-lists-alerts .alert {
margin: 10px 0 0;
}
.dist-lists-content-frame {
flex: 1 1 auto;
overflow: hidden;
}
.dist-lists-content {
height: 100%;
overflow: auto;
padding: 14px;
}
.dist-lists-empty,
.dist-lists-inline-empty {
display: grid;
place-items: center;
min-height: 110px;
padding: 18px;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.dist-lists-definition-fields {
display: grid;
grid-template-columns: minmax(220px, 1.4fr) minmax(160px, 0.7fr) minmax(160px, 0.7fr);
gap: 12px;
margin-bottom: 14px;
}
.dist-lists-definition-fields .form-field:last-child {
grid-column: 1 / -1;
}
.dist-lists-definition-fields input,
.dist-lists-definition-fields select,
.dist-lists-definition-fields textarea,
.dist-lists-dialog-form input,
.dist-lists-dialog-form select,
.dist-lists-dialog-form textarea,
.dist-lists-preview-inputs input,
.dist-lists-parameter-row input,
.dist-lists-parameter-row select {
width: 100%;
}
.dist-lists-definition-fields textarea {
resize: vertical;
}
.dist-lists-section {
min-width: 0;
margin-bottom: 14px;
border: var(--border-line);
background: var(--panel);
}
.dist-lists-entry-section {
min-height: 260px;
}
.dist-lists-section-heading {
min-height: 44px;
padding: 7px 10px;
}
.dist-lists-section-heading > span {
min-width: 0;
}
.dist-lists-section-heading strong,
.dist-lists-section-heading small {
display: block;
}
.dist-lists-section-heading strong {
color: var(--text-strong);
font-size: 13px;
}
.dist-lists-section-heading small {
margin-top: 2px;
color: var(--muted);
font-size: 11px;
}
.dist-lists-parameter-list {
padding: 8px;
}
.dist-lists-parameter-row {
display: grid;
grid-template-columns: minmax(130px, 1fr) minmax(150px, 1.2fr) minmax(120px, 0.8fr) minmax(130px, 1fr) auto 36px;
align-items: center;
gap: 7px;
padding: 7px;
border-bottom: var(--border-line);
}
.dist-lists-parameter-row:last-child {
border-bottom: 0;
}
.dist-lists-grid {
min-width: 0;
}
.dist-lists-row-actions {
justify-content: flex-end;
}
.dist-lists-preview-controls {
display: flex;
align-items: end;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: var(--border-line);
}
.dist-lists-preview-inputs {
display: grid;
grid-template-columns: repeat(3, minmax(160px, 1fr));
gap: 10px;
flex: 1 1 auto;
}
.dist-lists-preview-actions {
flex: 0 0 auto;
padding-bottom: 1px;
}
.dist-lists-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.dist-lists-metrics > span {
min-height: 62px;
padding: 9px 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
}
.dist-lists-metrics small,
.dist-lists-metrics strong {
display: block;
}
.dist-lists-metrics small {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.dist-lists-metrics strong {
margin-top: 6px;
color: var(--text-strong);
font-size: 15px;
text-transform: capitalize;
}
.dist-lists-content > .alert {
margin: 8px 0;
}
.dist-lists-result-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 14px 0 8px;
}
.dist-lists-result-heading code,
.dist-lists-explanation-summary code {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.dist-lists-candidates {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dist-lists-dialog-form {
display: grid;
gap: 12px;
}
.dist-lists-entry-dialog {
width: min(780px, calc(100vw - 32px));
max-width: 780px;
}
.dist-lists-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.dist-lists-provider-notices {
display: grid;
max-height: 150px;
overflow: auto;
}
.dist-lists-provider-notices .alert {
margin: 4px 0;
}
.dist-lists-channel-fieldset {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0;
padding: 10px;
border: var(--border-line);
}
.dist-lists-channel-fieldset legend {
padding: 0 5px;
color: var(--muted);
font-size: 11px;
}
.dist-lists-explanation-dialog {
width: min(760px, calc(100vw - 32px));
max-width: 760px;
}
.dist-lists-explanation-content {
display: grid;
gap: 9px;
}
.dist-lists-explanation-content h3 {
margin: 8px 0 0;
color: var(--text-strong);
font-size: 13px;
}
.dist-lists-decision {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px 10px;
padding: 10px;
border: var(--border-line);
border-left: 3px solid var(--accent);
background: var(--panel-soft);
}
.dist-lists-decision span strong,
.dist-lists-decision span small {
display: block;
}
.dist-lists-decision span small {
margin-top: 3px;
color: var(--muted);
}
.dist-lists-decision p,
.dist-lists-decision pre {
grid-column: 1 / -1;
margin: 0;
}
.dist-lists-explanation-content pre {
max-height: 210px;
margin: 0;
padding: 9px;
overflow: auto;
border: var(--border-line);
background: var(--panel-soft);
color: var(--text);
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 1040px) {
.dist-lists-shell {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
}
.dist-lists-workspace-toolbar {
align-items: flex-start;
}
.dist-lists-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.dist-lists-definition-fields,
.dist-lists-preview-inputs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dist-lists-parameter-row {
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
}
}
@media (max-width: 760px) {
.dist-lists-page {
height: auto;
min-height: calc(100vh - 115px);
overflow: auto;
}
.dist-lists-shell {
display: flex;
min-height: calc(100vh - 115px);
overflow: visible;
flex-direction: column;
}
.dist-lists-sidebar {
min-height: 230px;
max-height: 36vh;
border-right: 0;
border-bottom: var(--border-line);
}
.dist-lists-workspace {
min-height: 560px;
}
.dist-lists-workspace-toolbar,
.dist-lists-preview-controls {
align-items: stretch;
flex-direction: column;
}
.dist-lists-toolbar-actions,
.dist-lists-preview-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.dist-lists-definition-fields,
.dist-lists-preview-inputs,
.dist-lists-form-grid,
.dist-lists-metrics,
.dist-lists-channel-fieldset,
.dist-lists-parameter-row {
grid-template-columns: minmax(0, 1fr);
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference path="../../../govoplan-core/webui/src/vite-env.d.ts" />
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
}
},
"include": ["src"]
}