Implement governed distribution lists
This commit is contained in:
+30
-28
@@ -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
@@ -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
@@ -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."""
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
"""distribution lists baseline
|
||||||
|
|
||||||
|
Revision ID: e7c3a9d1b5f2
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-07-31 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "e7c3a9d1b5f2"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "4f2a9c8e7b6d"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"dist_lists_lists",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=300), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("current_revision_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("current_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_lists")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_dist_lists_lists_tenant_id"), "dist_lists_lists", ["tenant_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_lists_scope_type"), "dist_lists_lists", ["scope_type"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_lists_scope_id"), "dist_lists_lists", ["scope_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_lists_status"), "dist_lists_lists", ["status"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_lists_created_by_account_id"), "dist_lists_lists", ["created_by_account_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_lists_updated_by_account_id"), "dist_lists_lists", ["updated_by_account_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_lists_deleted_at"), "dist_lists_lists", ["deleted_at"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_dist_lists_lists_tenant_status",
|
||||||
|
"dist_lists_lists",
|
||||||
|
["tenant_id", "status", "updated_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_dist_lists_lists_active_tenant_name",
|
||||||
|
"dist_lists_lists",
|
||||||
|
["tenant_id", "name"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text("deleted_at IS NULL AND scope_type = 'tenant'"),
|
||||||
|
postgresql_where=sa.text("deleted_at IS NULL AND scope_type = 'tenant'"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_dist_lists_lists_active_scoped_name",
|
||||||
|
"dist_lists_lists",
|
||||||
|
["tenant_id", "scope_type", "scope_id", "name"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text(
|
||||||
|
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
|
||||||
|
),
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"deleted_at IS NULL AND scope_type IN ('group', 'user')"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"dist_lists_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("distribution_list_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("definition_kind", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("definition_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("parameter_schema", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("constraints", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["distribution_list_id"],
|
||||||
|
["dist_lists_lists.id"],
|
||||||
|
name=op.f("fk_dist_lists_revisions_distribution_list_id_dist_lists_lists"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_revisions")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"distribution_list_id",
|
||||||
|
"revision",
|
||||||
|
name="uq_dist_lists_revision_number",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_dist_lists_revisions_tenant_id"), "dist_lists_revisions", ["tenant_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_revisions_distribution_list_id"), "dist_lists_revisions", ["distribution_list_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_revisions_definition_kind"), "dist_lists_revisions", ["definition_kind"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_revisions_created_by_account_id"), "dist_lists_revisions", ["created_by_account_id"])
|
||||||
|
op.create_index("ix_dist_lists_revisions_tenant_list", "dist_lists_revisions", ["tenant_id", "distribution_list_id"])
|
||||||
|
op.create_index("ix_dist_lists_revisions_hash", "dist_lists_revisions", ["tenant_id", "definition_hash"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"dist_lists_entries",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("revision_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("entry_key", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("source_provider", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("source_resource_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("source_resource_id", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("source_revision", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("source_fingerprint", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("source_label", sa.String(length=300), nullable=True),
|
||||||
|
sa.Column("source_metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=300), nullable=True),
|
||||||
|
sa.Column("purpose", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("requested_channels", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("effective_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("configuration", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["revision_id"],
|
||||||
|
["dist_lists_revisions.id"],
|
||||||
|
name=op.f("fk_dist_lists_entries_revision_id_dist_lists_revisions"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_entries")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"revision_id",
|
||||||
|
"entry_key",
|
||||||
|
name="uq_dist_lists_entry_key_revision",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_dist_lists_entries_tenant_id"), "dist_lists_entries", ["tenant_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_entries_revision_id"), "dist_lists_entries", ["revision_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_entries_kind"), "dist_lists_entries", ["kind"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_entries_mode"), "dist_lists_entries", ["mode"])
|
||||||
|
op.create_index("ix_dist_lists_entries_revision_order", "dist_lists_entries", ["revision_id", "order_index"])
|
||||||
|
op.create_index("ix_dist_lists_entries_source", "dist_lists_entries", ["tenant_id", "kind", "source_resource_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"dist_lists_snapshots",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("distribution_list_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("revision_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("revision_number", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("request", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("expansion_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("effective_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("recipient_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("excluded_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("recipients", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("excluded", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("diagnostics", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("provider_evidence", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("stale", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("truncated", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["distribution_list_id"],
|
||||||
|
["dist_lists_lists.id"],
|
||||||
|
name=op.f("fk_dist_lists_snapshots_distribution_list_id_dist_lists_lists"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["revision_id"],
|
||||||
|
["dist_lists_revisions.id"],
|
||||||
|
name=op.f("fk_dist_lists_snapshots_revision_id_dist_lists_revisions"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_dist_lists_snapshots")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_dist_lists_snapshot_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_dist_lists_snapshots_tenant_id"), "dist_lists_snapshots", ["tenant_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_snapshots_distribution_list_id"), "dist_lists_snapshots", ["distribution_list_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_snapshots_revision_id"), "dist_lists_snapshots", ["revision_id"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_snapshots_idempotency_key"), "dist_lists_snapshots", ["idempotency_key"])
|
||||||
|
op.create_index(op.f("ix_dist_lists_snapshots_created_by_account_id"), "dist_lists_snapshots", ["created_by_account_id"])
|
||||||
|
op.create_index("ix_dist_lists_snapshots_tenant_list", "dist_lists_snapshots", ["tenant_id", "distribution_list_id", "created_at"])
|
||||||
|
op.create_index("ix_dist_lists_snapshots_hash", "dist_lists_snapshots", ["tenant_id", "expansion_hash"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("dist_lists_snapshots")
|
||||||
|
op.drop_table("dist_lists_entries")
|
||||||
|
op.drop_table("dist_lists_revisions")
|
||||||
|
op.drop_table("dist_lists_lists")
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
|
from govoplan_core.core.concurrency import (
|
||||||
|
ConcurrencyError,
|
||||||
|
MissingPreconditionError,
|
||||||
|
RevisionConflictError,
|
||||||
|
assert_revision_precondition,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.distribution_lists import (
|
||||||
|
DistributionExpansionLimits,
|
||||||
|
DistributionExpansionRequest,
|
||||||
|
DistributionListConflictError,
|
||||||
|
DistributionListNotFoundError,
|
||||||
|
DistributionListUnavailableError,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_dist_lists.backend.capabilities import SqlDistributionListCapabilities
|
||||||
|
from govoplan_dist_lists.backend.catalogue import provider_catalogue
|
||||||
|
from govoplan_dist_lists.backend.db.models import DistributionListSnapshot
|
||||||
|
from govoplan_dist_lists.backend.expansion import snapshot_ref
|
||||||
|
from govoplan_dist_lists.backend.schemas import (
|
||||||
|
DeleteRequest,
|
||||||
|
DistributionListCreateRequest,
|
||||||
|
DistributionListEntryResponse,
|
||||||
|
DistributionListListResponse,
|
||||||
|
DistributionListResponse,
|
||||||
|
DistributionListRevisionResponse,
|
||||||
|
DistributionSnapshotResponse,
|
||||||
|
DistributionListUpdateRequest,
|
||||||
|
ExpansionRequestModel,
|
||||||
|
ExpansionResultResponse,
|
||||||
|
ProviderCatalogueResponse,
|
||||||
|
SnapshotListItemResponse,
|
||||||
|
SnapshotListResponse,
|
||||||
|
SourceReferenceModel,
|
||||||
|
WriteDecisionResponse,
|
||||||
|
)
|
||||||
|
from govoplan_dist_lists.backend.service import (
|
||||||
|
create_distribution_list,
|
||||||
|
delete_distribution_list,
|
||||||
|
get_distribution_list,
|
||||||
|
get_distribution_list_revision,
|
||||||
|
list_distribution_lists,
|
||||||
|
update_distribution_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dist-lists", tags=["distribution-lists"])
|
||||||
|
|
||||||
|
READ_SCOPE = "dist_lists:list:read"
|
||||||
|
WRITE_SCOPE = "dist_lists:list:write"
|
||||||
|
ADMIN_SCOPE = "dist_lists:list:admin"
|
||||||
|
|
||||||
|
|
||||||
|
def _require(principal: ApiPrincipal, *scopes: str) -> None:
|
||||||
|
if not any(principal.has(scope) for scope in scopes):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Requires one of: {', '.join(scopes)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lists", response_model=DistributionListListResponse)
|
||||||
|
def api_list_distribution_lists(
|
||||||
|
query: str = Query(default="", max_length=200),
|
||||||
|
include_deleted: bool = False,
|
||||||
|
limit: int = Query(default=200, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DistributionListListResponse:
|
||||||
|
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
items = list_distribution_lists(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=query,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return DistributionListListResponse(
|
||||||
|
items=[
|
||||||
|
_list_response(
|
||||||
|
item,
|
||||||
|
get_distribution_list_revision(session, item),
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
],
|
||||||
|
total=len(items),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/lists",
|
||||||
|
response_model=DistributionListResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_create_distribution_list(
|
||||||
|
payload: DistributionListCreateRequest,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DistributionListResponse:
|
||||||
|
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
item, revision = create_distribution_list(session, principal, payload)
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="dist_lists.list.created",
|
||||||
|
item_id=item.id,
|
||||||
|
details={
|
||||||
|
"name": item.name,
|
||||||
|
"definition_hash": revision.definition_hash,
|
||||||
|
"definition_kind": revision.definition_kind,
|
||||||
|
"entry_count": len(revision.entries),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_event(session, principal, item.id, "dist_lists.list.created.v1")
|
||||||
|
session.commit()
|
||||||
|
except (DistributionListConflictError, IntegrityError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
session.refresh(item)
|
||||||
|
response.headers["ETag"] = item.strong_etag
|
||||||
|
return _list_response(item, revision)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lists/{list_id}", response_model=DistributionListResponse)
|
||||||
|
def api_get_distribution_list(
|
||||||
|
list_id: str,
|
||||||
|
response: Response,
|
||||||
|
revision: int | None = Query(default=None, ge=1),
|
||||||
|
include_deleted: bool = False,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DistributionListResponse:
|
||||||
|
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
item = get_distribution_list(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
list_id,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
)
|
||||||
|
item_revision = get_distribution_list_revision(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
revision=revision,
|
||||||
|
)
|
||||||
|
except DistributionListNotFoundError as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
response.headers["ETag"] = item.strong_etag
|
||||||
|
return _list_response(item, item_revision)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/lists/{list_id}", response_model=DistributionListResponse)
|
||||||
|
def api_update_distribution_list(
|
||||||
|
list_id: str,
|
||||||
|
payload: DistributionListUpdateRequest,
|
||||||
|
response: Response,
|
||||||
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DistributionListResponse:
|
||||||
|
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
item = get_distribution_list(session, principal, list_id)
|
||||||
|
assert_revision_precondition(
|
||||||
|
if_match,
|
||||||
|
resource_type="distribution_list",
|
||||||
|
resource_id=item.id,
|
||||||
|
submitted_base_revision=payload.base_revision,
|
||||||
|
)
|
||||||
|
item, revision = update_distribution_list(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
item,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="dist_lists.list.updated",
|
||||||
|
item_id=item.id,
|
||||||
|
details={
|
||||||
|
"definition_hash": revision.definition_hash,
|
||||||
|
"definition_revision": revision.revision,
|
||||||
|
"resource_revision": item.resource_revision,
|
||||||
|
"entry_count": len(revision.entries),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_event(session, principal, item.id, "dist_lists.list.updated.v1")
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
ConcurrencyError,
|
||||||
|
DistributionListConflictError,
|
||||||
|
DistributionListNotFoundError,
|
||||||
|
IntegrityError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
session.refresh(item)
|
||||||
|
response.headers["ETag"] = item.strong_etag
|
||||||
|
return _list_response(item, revision)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/lists/{list_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def api_delete_distribution_list(
|
||||||
|
list_id: str,
|
||||||
|
payload: DeleteRequest,
|
||||||
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> Response:
|
||||||
|
_require(principal, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
item = get_distribution_list(session, principal, list_id)
|
||||||
|
assert_revision_precondition(
|
||||||
|
if_match,
|
||||||
|
resource_type="distribution_list",
|
||||||
|
resource_id=item.id,
|
||||||
|
submitted_base_revision=payload.base_revision,
|
||||||
|
)
|
||||||
|
delete_distribution_list(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
item,
|
||||||
|
base_revision=payload.base_revision,
|
||||||
|
)
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="dist_lists.list.deleted",
|
||||||
|
item_id=item.id,
|
||||||
|
details={"resource_revision": item.resource_revision},
|
||||||
|
)
|
||||||
|
_event(session, principal, item.id, "dist_lists.list.deleted.v1")
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
ConcurrencyError,
|
||||||
|
DistributionListConflictError,
|
||||||
|
DistributionListNotFoundError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lists/{list_id}/expand", response_model=ExpansionResultResponse)
|
||||||
|
def api_expand_distribution_list(
|
||||||
|
list_id: str,
|
||||||
|
payload: ExpansionRequestModel,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ExpansionResultResponse:
|
||||||
|
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
request = DistributionExpansionRequest(
|
||||||
|
list_id=list_id,
|
||||||
|
revision=payload.revision,
|
||||||
|
effective_at=payload.effective_at,
|
||||||
|
purpose=payload.purpose,
|
||||||
|
requested_channels=tuple(payload.requested_channels),
|
||||||
|
parameters=dict(payload.parameters),
|
||||||
|
preview=payload.preview,
|
||||||
|
freeze=payload.freeze,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
limits=DistributionExpansionLimits(
|
||||||
|
max_entries=payload.max_entries,
|
||||||
|
max_results=payload.max_results,
|
||||||
|
max_depth=payload.max_depth,
|
||||||
|
max_provider_results=payload.max_provider_results,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = SqlDistributionListCapabilities(get_registry()).expand(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
if payload.freeze:
|
||||||
|
_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="dist_lists.snapshot.created",
|
||||||
|
item_id=result.snapshot_id or list_id,
|
||||||
|
details={
|
||||||
|
"list_id": list_id,
|
||||||
|
"definition_hash": result.source.definition_hash,
|
||||||
|
"expansion_hash": result.expansion_hash,
|
||||||
|
"recipient_count": len(result.recipients),
|
||||||
|
"excluded_count": len(result.excluded),
|
||||||
|
"stale": result.stale,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_event(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
result.snapshot_id or list_id,
|
||||||
|
"dist_lists.snapshot.created.v1",
|
||||||
|
resource_type="distribution_list_snapshot",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
DistributionListConflictError,
|
||||||
|
DistributionListNotFoundError,
|
||||||
|
DistributionListUnavailableError,
|
||||||
|
IntegrityError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return _expansion_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lists/{list_id}/snapshots", response_model=SnapshotListResponse)
|
||||||
|
def api_list_distribution_list_snapshots(
|
||||||
|
list_id: str,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> SnapshotListResponse:
|
||||||
|
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
get_distribution_list(session, principal, list_id)
|
||||||
|
rows = list(
|
||||||
|
session.scalars(
|
||||||
|
select(DistributionListSnapshot)
|
||||||
|
.where(
|
||||||
|
DistributionListSnapshot.tenant_id == principal.tenant_id,
|
||||||
|
DistributionListSnapshot.distribution_list_id == list_id,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
DistributionListSnapshot.created_at.desc(),
|
||||||
|
DistributionListSnapshot.id.desc(),
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SnapshotListResponse(
|
||||||
|
items=[
|
||||||
|
SnapshotListItemResponse(
|
||||||
|
id=item.id,
|
||||||
|
list_id=item.distribution_list_id,
|
||||||
|
revision_id=item.revision_id,
|
||||||
|
revision=item.revision_number,
|
||||||
|
expansion_hash=item.expansion_hash,
|
||||||
|
effective_at=item.effective_at,
|
||||||
|
recipient_count=item.recipient_count,
|
||||||
|
excluded_count=item.excluded_count,
|
||||||
|
stale=item.stale,
|
||||||
|
truncated=item.truncated,
|
||||||
|
created_at=item.created_at,
|
||||||
|
)
|
||||||
|
for item in rows
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/snapshots/{snapshot_id}",
|
||||||
|
response_model=DistributionSnapshotResponse,
|
||||||
|
)
|
||||||
|
def api_get_distribution_list_snapshot(
|
||||||
|
snapshot_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DistributionSnapshotResponse:
|
||||||
|
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
row = session.scalar(
|
||||||
|
select(DistributionListSnapshot).where(
|
||||||
|
DistributionListSnapshot.id == snapshot_id,
|
||||||
|
DistributionListSnapshot.tenant_id == principal.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Distribution-list snapshot not found.")
|
||||||
|
return DistributionSnapshotResponse.model_validate(asdict(snapshot_ref(row)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers", response_model=ProviderCatalogueResponse)
|
||||||
|
def api_distribution_list_providers(
|
||||||
|
query: str = Query(default="", max_length=200),
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ProviderCatalogueResponse:
|
||||||
|
_require(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
|
||||||
|
return provider_catalogue(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=get_registry(),
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/write-decision", response_model=WriteDecisionResponse)
|
||||||
|
def api_distribution_list_write_decision(
|
||||||
|
operation: str = Query(default="create", max_length=50),
|
||||||
|
list_id: str | None = Query(default=None, max_length=36),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> WriteDecisionResponse:
|
||||||
|
decision = SqlDistributionListCapabilities(get_registry()).explain_write(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
list_id=list_id,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
return WriteDecisionResponse.model_validate(asdict(decision))
|
||||||
|
|
||||||
|
|
||||||
|
def _list_response(item, revision) -> DistributionListResponse:
|
||||||
|
return DistributionListResponse(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
scope_type=item.scope_type,
|
||||||
|
scope_id=item.scope_id,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
status=item.status,
|
||||||
|
current_revision=item.current_revision,
|
||||||
|
resource_revision=item.resource_revision,
|
||||||
|
etag=item.strong_etag,
|
||||||
|
metadata=dict(item.metadata_),
|
||||||
|
deleted_at=item.deleted_at,
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
revision=DistributionListRevisionResponse(
|
||||||
|
id=revision.id,
|
||||||
|
revision=revision.revision,
|
||||||
|
definition_kind=revision.definition_kind,
|
||||||
|
definition_hash=revision.definition_hash,
|
||||||
|
parameters=list(revision.parameter_schema),
|
||||||
|
constraints=dict(revision.constraints),
|
||||||
|
source_fingerprints=list(revision.source_fingerprints),
|
||||||
|
entries=[
|
||||||
|
DistributionListEntryResponse(
|
||||||
|
id=entry.id,
|
||||||
|
entry_key=entry.entry_key,
|
||||||
|
kind=entry.kind,
|
||||||
|
mode=entry.mode,
|
||||||
|
source=SourceReferenceModel(
|
||||||
|
provider=entry.source_provider,
|
||||||
|
resource_type=entry.source_resource_type,
|
||||||
|
resource_id=entry.source_resource_id,
|
||||||
|
revision=entry.source_revision,
|
||||||
|
fingerprint=entry.source_fingerprint,
|
||||||
|
label=entry.source_label,
|
||||||
|
metadata=dict(entry.source_metadata),
|
||||||
|
),
|
||||||
|
label=entry.label,
|
||||||
|
purpose=entry.purpose,
|
||||||
|
requested_channels=list(entry.requested_channels),
|
||||||
|
effective_from=entry.effective_from,
|
||||||
|
effective_until=entry.effective_until,
|
||||||
|
order=entry.order_index,
|
||||||
|
configuration=dict(entry.configuration),
|
||||||
|
)
|
||||||
|
for entry in revision.entries
|
||||||
|
],
|
||||||
|
created_by_account_id=revision.created_by_account_id,
|
||||||
|
created_at=revision.created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _expansion_response(result) -> ExpansionResultResponse:
|
||||||
|
return ExpansionResultResponse.model_validate(
|
||||||
|
{
|
||||||
|
"list_id": result.source.id,
|
||||||
|
"revision_id": result.source.revision_id,
|
||||||
|
"revision": result.source.revision,
|
||||||
|
"definition_hash": result.source.definition_hash,
|
||||||
|
"recipients": [asdict(item) for item in result.recipients],
|
||||||
|
"excluded": [asdict(item) for item in result.excluded],
|
||||||
|
"diagnostics": [asdict(item) for item in result.diagnostics],
|
||||||
|
"provider_evidence": [asdict(item) for item in result.provider_evidence],
|
||||||
|
"expansion_hash": result.expansion_hash,
|
||||||
|
"generated_at": result.generated_at,
|
||||||
|
"snapshot_id": result.snapshot_id,
|
||||||
|
"stale": result.stale,
|
||||||
|
"truncated": result.truncated,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
item_id: str,
|
||||||
|
details: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action=action,
|
||||||
|
object_type="distribution_list",
|
||||||
|
object_id=item_id,
|
||||||
|
details=details,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
resource_id: str,
|
||||||
|
event_type: str,
|
||||||
|
*,
|
||||||
|
resource_type: str = "distribution_list",
|
||||||
|
) -> None:
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=event_type,
|
||||||
|
module_id="dist_lists",
|
||||||
|
actor=EventActorRef(type="account", id=principal.account_id),
|
||||||
|
tenant=EventTenantRef(id=principal.tenant_id),
|
||||||
|
resource=EventObjectRef(type=resource_type, id=resource_id),
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _error(exc: Exception) -> HTTPException:
|
||||||
|
if isinstance(exc, MissingPreconditionError):
|
||||||
|
return HTTPException(status_code=428, detail=exc.as_dict())
|
||||||
|
if isinstance(exc, RevisionConflictError):
|
||||||
|
return HTTPException(status_code=412, detail=exc.as_dict())
|
||||||
|
if isinstance(exc, ConcurrencyError):
|
||||||
|
return HTTPException(status_code=409, detail=str(exc))
|
||||||
|
if isinstance(exc, DistributionListNotFoundError):
|
||||||
|
return HTTPException(status_code=404, detail=str(exc))
|
||||||
|
if isinstance(exc, DistributionListUnavailableError):
|
||||||
|
return HTTPException(status_code=503, detail=str(exc))
|
||||||
|
if isinstance(exc, IntegrityError):
|
||||||
|
return HTTPException(status_code=409, detail="Distribution-list data conflicts with an existing record.")
|
||||||
|
return HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
DefinitionKind = Literal["static", "parameterized", "dynamic", "template"]
|
||||||
|
EntryMode = Literal["include", "exclude", "override"]
|
||||||
|
EntryKind = Literal[
|
||||||
|
"address_contact",
|
||||||
|
"address_list",
|
||||||
|
"address_email",
|
||||||
|
"raw_email",
|
||||||
|
"raw_postal_address",
|
||||||
|
"internal_mail",
|
||||||
|
"portal",
|
||||||
|
"idm_identity",
|
||||||
|
"idm_group",
|
||||||
|
"organization_unit",
|
||||||
|
"function",
|
||||||
|
"effective_function_incumbent",
|
||||||
|
"dataflow_result",
|
||||||
|
"distribution_list",
|
||||||
|
]
|
||||||
|
Channel = Literal["email", "postal", "internal_mail", "portal"]
|
||||||
|
Outcome = Literal[
|
||||||
|
"usable",
|
||||||
|
"unresolved",
|
||||||
|
"invalid",
|
||||||
|
"suppressed",
|
||||||
|
"ambiguous",
|
||||||
|
"duplicate",
|
||||||
|
"policy_blocked",
|
||||||
|
"provider_unavailable",
|
||||||
|
"stale",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceReferenceModel(BaseModel):
|
||||||
|
provider: str = Field(min_length=1, max_length=80)
|
||||||
|
resource_type: str = Field(min_length=1, max_length=80)
|
||||||
|
resource_id: str = Field(min_length=1, max_length=500)
|
||||||
|
revision: str | None = Field(default=None, max_length=500)
|
||||||
|
fingerprint: str | None = Field(default=None, max_length=255)
|
||||||
|
label: str | None = Field(default=None, max_length=300)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ParameterDefinitionModel(BaseModel):
|
||||||
|
key: str = Field(min_length=1, max_length=80, pattern=r"^[A-Za-z_][A-Za-z0-9_.-]*$")
|
||||||
|
value_type: Literal[
|
||||||
|
"string",
|
||||||
|
"integer",
|
||||||
|
"number",
|
||||||
|
"boolean",
|
||||||
|
"date",
|
||||||
|
"datetime",
|
||||||
|
"string_list",
|
||||||
|
]
|
||||||
|
label: str | None = Field(default=None, max_length=200)
|
||||||
|
required: bool = False
|
||||||
|
default: Any = None
|
||||||
|
allowed_values: list[Any] = Field(default_factory=list, max_length=200)
|
||||||
|
minimum: float | None = None
|
||||||
|
maximum: float | None = None
|
||||||
|
pattern: str | None = Field(default=None, max_length=500)
|
||||||
|
description: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_bounds(self) -> "ParameterDefinitionModel":
|
||||||
|
if (
|
||||||
|
self.minimum is not None
|
||||||
|
and self.maximum is not None
|
||||||
|
and self.minimum > self.maximum
|
||||||
|
):
|
||||||
|
raise ValueError("Parameter minimum cannot exceed maximum.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListEntryInput(BaseModel):
|
||||||
|
entry_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
max_length=80,
|
||||||
|
pattern=r"^[A-Za-z0-9_.:-]+$",
|
||||||
|
)
|
||||||
|
kind: EntryKind
|
||||||
|
mode: EntryMode = "include"
|
||||||
|
source: SourceReferenceModel
|
||||||
|
label: str | None = Field(default=None, max_length=300)
|
||||||
|
purpose: str | None = Field(default=None, max_length=120)
|
||||||
|
requested_channels: list[Channel] = Field(default_factory=list, max_length=4)
|
||||||
|
effective_from: datetime | None = None
|
||||||
|
effective_until: datetime | None = None
|
||||||
|
configuration: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@field_validator("requested_channels")
|
||||||
|
@classmethod
|
||||||
|
def unique_channels(cls, value: list[Channel]) -> list[Channel]:
|
||||||
|
return list(dict.fromkeys(value))
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_effective_range(self) -> "DistributionListEntryInput":
|
||||||
|
if (
|
||||||
|
self.effective_from is not None
|
||||||
|
and self.effective_until is not None
|
||||||
|
and self.effective_until <= self.effective_from
|
||||||
|
):
|
||||||
|
raise ValueError("Entry effective until must be after effective from.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=300)
|
||||||
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
|
scope_type: Literal["tenant", "group", "user"] = "tenant"
|
||||||
|
scope_id: str | None = Field(default=None, max_length=36)
|
||||||
|
definition_kind: DefinitionKind = "static"
|
||||||
|
parameters: list[ParameterDefinitionModel] = Field(default_factory=list, max_length=100)
|
||||||
|
constraints: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
entries: list[DistributionListEntryInput] = Field(default_factory=list, max_length=500)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_scope(self) -> "DistributionListCreateRequest":
|
||||||
|
if self.scope_type == "tenant" and self.scope_id is not None:
|
||||||
|
raise ValueError("Tenant-scoped lists do not use a scope ID.")
|
||||||
|
if self.scope_type != "tenant" and self.scope_id is None:
|
||||||
|
raise ValueError("Group- and user-scoped lists require a scope ID.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListUpdateRequest(DistributionListCreateRequest):
|
||||||
|
base_revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListEntryResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
entry_key: str
|
||||||
|
kind: EntryKind
|
||||||
|
mode: EntryMode
|
||||||
|
source: SourceReferenceModel
|
||||||
|
label: str | None
|
||||||
|
purpose: str | None
|
||||||
|
requested_channels: list[Channel]
|
||||||
|
effective_from: datetime | None
|
||||||
|
effective_until: datetime | None
|
||||||
|
order: int
|
||||||
|
configuration: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListRevisionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
revision: int
|
||||||
|
definition_kind: DefinitionKind
|
||||||
|
definition_hash: str
|
||||||
|
parameters: list[ParameterDefinitionModel]
|
||||||
|
constraints: dict[str, Any]
|
||||||
|
source_fingerprints: list[dict[str, Any]]
|
||||||
|
entries: list[DistributionListEntryResponse]
|
||||||
|
created_by_account_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
scope_type: Literal["tenant", "group", "user"]
|
||||||
|
scope_id: str | None
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
status: str
|
||||||
|
current_revision: int
|
||||||
|
resource_revision: int
|
||||||
|
etag: str
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
deleted_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
revision: DistributionListRevisionResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionListListResponse(BaseModel):
|
||||||
|
items: list[DistributionListResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class ExpansionRequestModel(BaseModel):
|
||||||
|
revision: int | None = Field(default=None, ge=1)
|
||||||
|
effective_at: datetime | None = None
|
||||||
|
purpose: str | None = Field(default=None, max_length=120)
|
||||||
|
requested_channels: list[Channel] = Field(default_factory=list, max_length=4)
|
||||||
|
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
preview: bool = False
|
||||||
|
freeze: bool = False
|
||||||
|
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
max_entries: int = Field(default=500, ge=1, le=5000)
|
||||||
|
max_results: int = Field(default=5000, ge=1, le=20_000)
|
||||||
|
max_depth: int = Field(default=8, ge=1, le=20)
|
||||||
|
max_provider_results: int = Field(default=2000, ge=1, le=10_000)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_freeze(self) -> "ExpansionRequestModel":
|
||||||
|
if self.freeze and not self.idempotency_key:
|
||||||
|
raise ValueError("Frozen expansions require an idempotency key.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ExplanationResponse(BaseModel):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
severity: Literal["info", "warning", "error"]
|
||||||
|
provider: str | None = None
|
||||||
|
source: SourceReferenceModel | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelCandidateResponse(BaseModel):
|
||||||
|
channel: Channel
|
||||||
|
target: str
|
||||||
|
target_key: str
|
||||||
|
status: Outcome
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
locale: str | None = None
|
||||||
|
preferred: bool = False
|
||||||
|
reason_code: str | None = None
|
||||||
|
explanation: str | None = None
|
||||||
|
source: SourceReferenceModel | None = None
|
||||||
|
decision_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipientResponse(BaseModel):
|
||||||
|
recipient_key: str
|
||||||
|
display_name: str
|
||||||
|
status: Outcome
|
||||||
|
channels: list[ChannelCandidateResponse]
|
||||||
|
identity_id: str | None = None
|
||||||
|
account_id: str | None = None
|
||||||
|
contact_id: str | None = None
|
||||||
|
organization_unit_id: str | None = None
|
||||||
|
function_id: str | None = None
|
||||||
|
source_entry_ids: list[str]
|
||||||
|
explanations: list[ExplanationResponse]
|
||||||
|
attributes: dict[str, Any]
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderEvidenceResponse(BaseModel):
|
||||||
|
provider: str
|
||||||
|
source: SourceReferenceModel
|
||||||
|
actual_revision: str | None = None
|
||||||
|
actual_fingerprint: str | None = None
|
||||||
|
stale: bool
|
||||||
|
generated_at: datetime | None = None
|
||||||
|
details: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ExpansionResultResponse(BaseModel):
|
||||||
|
list_id: str
|
||||||
|
revision_id: str
|
||||||
|
revision: int
|
||||||
|
definition_hash: str
|
||||||
|
recipients: list[RecipientResponse]
|
||||||
|
excluded: list[RecipientResponse]
|
||||||
|
diagnostics: list[ExplanationResponse]
|
||||||
|
provider_evidence: list[ProviderEvidenceResponse]
|
||||||
|
expansion_hash: str
|
||||||
|
generated_at: datetime
|
||||||
|
snapshot_id: str | None = None
|
||||||
|
stale: bool
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionSnapshotResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
list_id: str
|
||||||
|
revision_id: str
|
||||||
|
revision: int
|
||||||
|
expansion_hash: str
|
||||||
|
generated_at: datetime
|
||||||
|
effective_at: datetime
|
||||||
|
recipient_count: int
|
||||||
|
excluded_count: int
|
||||||
|
stale: bool
|
||||||
|
truncated: bool
|
||||||
|
request: dict[str, Any]
|
||||||
|
recipients: list[RecipientResponse]
|
||||||
|
excluded: list[RecipientResponse]
|
||||||
|
diagnostics: list[ExplanationResponse]
|
||||||
|
provider_evidence: list[ProviderEvidenceResponse]
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotListItemResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
list_id: str
|
||||||
|
revision_id: str
|
||||||
|
revision: int
|
||||||
|
expansion_hash: str
|
||||||
|
effective_at: datetime
|
||||||
|
recipient_count: int
|
||||||
|
excluded_count: int
|
||||||
|
stale: bool
|
||||||
|
truncated: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotListResponse(BaseModel):
|
||||||
|
items: list[SnapshotListItemResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderOptionResponse(BaseModel):
|
||||||
|
key: str
|
||||||
|
kind: EntryKind
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
provider: str
|
||||||
|
source: SourceReferenceModel
|
||||||
|
available: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderCatalogueResponse(BaseModel):
|
||||||
|
items: list[ProviderOptionResponse]
|
||||||
|
unavailable_providers: list[ExplanationResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class WriteDecisionResponse(BaseModel):
|
||||||
|
list_id: str | None
|
||||||
|
operation: str
|
||||||
|
allowed: bool
|
||||||
|
reason_code: str
|
||||||
|
explanation: str
|
||||||
|
read_only: bool
|
||||||
|
required_scopes: list[str]
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteRequest(BaseModel):
|
||||||
|
base_revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiErrorResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
detail: Any
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [name for name in globals() if name.endswith(("Model", "Request", "Response"))]
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.concurrency import claim_revision
|
||||||
|
from govoplan_core.core.distribution_lists import (
|
||||||
|
DistributionListConflictError,
|
||||||
|
DistributionListEntryRef,
|
||||||
|
DistributionListNotFoundError,
|
||||||
|
DistributionListSourceRef,
|
||||||
|
DistributionParameterDefinition,
|
||||||
|
DistributionSourceReference,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
from govoplan_dist_lists.backend.db.models import (
|
||||||
|
DistributionList,
|
||||||
|
DistributionListEntry,
|
||||||
|
DistributionListRevision,
|
||||||
|
)
|
||||||
|
from govoplan_dist_lists.backend.schemas import (
|
||||||
|
DistributionListCreateRequest,
|
||||||
|
DistributionListEntryInput,
|
||||||
|
DistributionListUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def visible_lists_statement(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
):
|
||||||
|
statement = select(DistributionList).where(
|
||||||
|
DistributionList.tenant_id == principal.tenant_id,
|
||||||
|
)
|
||||||
|
if not principal.has("dist_lists:list:admin"):
|
||||||
|
statement = statement.where(
|
||||||
|
or_(
|
||||||
|
DistributionList.scope_type == "tenant",
|
||||||
|
(
|
||||||
|
(DistributionList.scope_type == "user")
|
||||||
|
& (DistributionList.scope_id == principal.account_id)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(DistributionList.scope_type == "group")
|
||||||
|
& DistributionList.scope_id.in_(tuple(principal.group_ids) or ("",))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not include_deleted:
|
||||||
|
statement = statement.where(DistributionList.deleted_at.is_(None))
|
||||||
|
return statement
|
||||||
|
|
||||||
|
|
||||||
|
def list_distribution_lists(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
include_deleted: bool = False,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[DistributionList]:
|
||||||
|
statement = visible_lists_statement(
|
||||||
|
principal,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
)
|
||||||
|
normalized_query = query.strip()
|
||||||
|
if normalized_query:
|
||||||
|
statement = statement.where(
|
||||||
|
DistributionList.name.ilike(f"%{normalized_query}%")
|
||||||
|
)
|
||||||
|
return list(
|
||||||
|
session.scalars(
|
||||||
|
statement.order_by(DistributionList.name, DistributionList.id).limit(
|
||||||
|
max(1, min(limit, 500))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_distribution_list(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
list_id: str,
|
||||||
|
*,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
) -> DistributionList:
|
||||||
|
item = session.scalar(
|
||||||
|
visible_lists_statement(principal, include_deleted=include_deleted).where(
|
||||||
|
DistributionList.id == list_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise DistributionListNotFoundError("Distribution list not found.")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def get_distribution_list_revision(
|
||||||
|
session: Session,
|
||||||
|
distribution_list: DistributionList,
|
||||||
|
*,
|
||||||
|
revision: int | None = None,
|
||||||
|
) -> DistributionListRevision:
|
||||||
|
revision_number = revision or distribution_list.current_revision
|
||||||
|
item = session.scalar(
|
||||||
|
select(DistributionListRevision)
|
||||||
|
.options(selectinload(DistributionListRevision.entries))
|
||||||
|
.where(
|
||||||
|
DistributionListRevision.distribution_list_id == distribution_list.id,
|
||||||
|
DistributionListRevision.tenant_id == distribution_list.tenant_id,
|
||||||
|
DistributionListRevision.revision == revision_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise DistributionListNotFoundError("Distribution list revision not found.")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def create_distribution_list(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
payload: DistributionListCreateRequest,
|
||||||
|
) -> tuple[DistributionList, DistributionListRevision]:
|
||||||
|
_ensure_requested_scope(
|
||||||
|
principal,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
)
|
||||||
|
_ensure_unique_name(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
name=payload.name,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
)
|
||||||
|
item = DistributionList(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
name=payload.name.strip(),
|
||||||
|
description=_text(payload.description),
|
||||||
|
current_revision_id="pending",
|
||||||
|
current_revision=1,
|
||||||
|
resource_revision=1,
|
||||||
|
created_by_account_id=principal.account_id,
|
||||||
|
updated_by_account_id=principal.account_id,
|
||||||
|
metadata_=dict(payload.metadata),
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
session.flush()
|
||||||
|
revision = _create_revision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
distribution_list=item,
|
||||||
|
revision_number=1,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
item.current_revision_id = revision.id
|
||||||
|
session.flush()
|
||||||
|
return item, revision
|
||||||
|
|
||||||
|
|
||||||
|
def update_distribution_list(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
distribution_list: DistributionList,
|
||||||
|
payload: DistributionListUpdateRequest,
|
||||||
|
) -> tuple[DistributionList, DistributionListRevision]:
|
||||||
|
_ensure_mutable_scope(principal, distribution_list)
|
||||||
|
_ensure_requested_scope(
|
||||||
|
principal,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
)
|
||||||
|
_ensure_unique_name(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
name=payload.name,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
exclude_id=distribution_list.id,
|
||||||
|
)
|
||||||
|
next_resource_revision = claim_revision(
|
||||||
|
session,
|
||||||
|
model=DistributionList,
|
||||||
|
filters=(
|
||||||
|
DistributionList.id == distribution_list.id,
|
||||||
|
DistributionList.tenant_id == distribution_list.tenant_id,
|
||||||
|
DistributionList.deleted_at.is_(None),
|
||||||
|
),
|
||||||
|
revision_attribute="resource_revision",
|
||||||
|
expected_revision=payload.base_revision,
|
||||||
|
resource_type="distribution_list",
|
||||||
|
resource_id=distribution_list.id,
|
||||||
|
refresh_path=f"/api/v1/dist-lists/lists/{distribution_list.id}",
|
||||||
|
)
|
||||||
|
next_definition_revision = distribution_list.current_revision + 1
|
||||||
|
distribution_list.resource_revision = next_resource_revision
|
||||||
|
distribution_list.scope_type = payload.scope_type
|
||||||
|
distribution_list.scope_id = payload.scope_id
|
||||||
|
distribution_list.name = payload.name.strip()
|
||||||
|
distribution_list.description = _text(payload.description)
|
||||||
|
distribution_list.current_revision = next_definition_revision
|
||||||
|
distribution_list.updated_by_account_id = principal.account_id
|
||||||
|
distribution_list.metadata_ = dict(payload.metadata)
|
||||||
|
revision = _create_revision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
distribution_list=distribution_list,
|
||||||
|
revision_number=next_definition_revision,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
distribution_list.current_revision_id = revision.id
|
||||||
|
session.flush()
|
||||||
|
return distribution_list, revision
|
||||||
|
|
||||||
|
|
||||||
|
def delete_distribution_list(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
distribution_list: DistributionList,
|
||||||
|
*,
|
||||||
|
base_revision: int,
|
||||||
|
) -> DistributionList:
|
||||||
|
_ensure_mutable_scope(principal, distribution_list)
|
||||||
|
next_revision = claim_revision(
|
||||||
|
session,
|
||||||
|
model=DistributionList,
|
||||||
|
filters=(
|
||||||
|
DistributionList.id == distribution_list.id,
|
||||||
|
DistributionList.tenant_id == distribution_list.tenant_id,
|
||||||
|
DistributionList.deleted_at.is_(None),
|
||||||
|
),
|
||||||
|
revision_attribute="resource_revision",
|
||||||
|
expected_revision=base_revision,
|
||||||
|
resource_type="distribution_list",
|
||||||
|
resource_id=distribution_list.id,
|
||||||
|
refresh_path="/api/v1/dist-lists/lists",
|
||||||
|
)
|
||||||
|
distribution_list.resource_revision = next_revision
|
||||||
|
distribution_list.deleted_at = utc_now()
|
||||||
|
distribution_list.status = "deleted"
|
||||||
|
distribution_list.updated_by_account_id = principal.account_id
|
||||||
|
session.flush()
|
||||||
|
return distribution_list
|
||||||
|
|
||||||
|
|
||||||
|
def source_ref(
|
||||||
|
distribution_list: DistributionList,
|
||||||
|
revision: DistributionListRevision,
|
||||||
|
) -> DistributionListSourceRef:
|
||||||
|
return DistributionListSourceRef(
|
||||||
|
id=distribution_list.id,
|
||||||
|
tenant_id=distribution_list.tenant_id,
|
||||||
|
name=distribution_list.name,
|
||||||
|
description=distribution_list.description,
|
||||||
|
revision_id=revision.id,
|
||||||
|
revision=revision.revision,
|
||||||
|
definition_hash=revision.definition_hash,
|
||||||
|
definition_kind=revision.definition_kind, # type: ignore[arg-type]
|
||||||
|
status=distribution_list.status,
|
||||||
|
entry_count=len(revision.entries),
|
||||||
|
read_only=False,
|
||||||
|
parameters=tuple(
|
||||||
|
DistributionParameterDefinition(
|
||||||
|
key=str(item["key"]),
|
||||||
|
value_type=str(item["value_type"]), # type: ignore[arg-type]
|
||||||
|
label=_text(item.get("label")),
|
||||||
|
required=bool(item.get("required", False)),
|
||||||
|
default=item.get("default"),
|
||||||
|
allowed_values=tuple(item.get("allowed_values") or ()),
|
||||||
|
minimum=_number(item.get("minimum")),
|
||||||
|
maximum=_number(item.get("maximum")),
|
||||||
|
pattern=_text(item.get("pattern")),
|
||||||
|
description=_text(item.get("description")),
|
||||||
|
)
|
||||||
|
for item in revision.parameter_schema
|
||||||
|
),
|
||||||
|
updated_at=distribution_list.updated_at,
|
||||||
|
provenance={
|
||||||
|
"module": "dist_lists",
|
||||||
|
"scope_type": distribution_list.scope_type,
|
||||||
|
"scope_id": distribution_list.scope_id,
|
||||||
|
"resource_revision": distribution_list.resource_revision,
|
||||||
|
},
|
||||||
|
metadata=dict(distribution_list.metadata_),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_ref(entry: DistributionListEntry) -> DistributionListEntryRef:
|
||||||
|
return DistributionListEntryRef(
|
||||||
|
id=entry.id,
|
||||||
|
kind=entry.kind, # type: ignore[arg-type]
|
||||||
|
mode=entry.mode, # type: ignore[arg-type]
|
||||||
|
source=DistributionSourceReference(
|
||||||
|
provider=entry.source_provider,
|
||||||
|
resource_type=entry.source_resource_type,
|
||||||
|
resource_id=entry.source_resource_id,
|
||||||
|
revision=entry.source_revision,
|
||||||
|
fingerprint=entry.source_fingerprint,
|
||||||
|
label=entry.source_label,
|
||||||
|
metadata=dict(entry.source_metadata),
|
||||||
|
),
|
||||||
|
label=entry.label,
|
||||||
|
purpose=entry.purpose,
|
||||||
|
requested_channels=tuple(entry.requested_channels), # type: ignore[arg-type]
|
||||||
|
effective_from=entry.effective_from,
|
||||||
|
effective_until=entry.effective_until,
|
||||||
|
order=entry.order_index,
|
||||||
|
configuration=dict(entry.configuration),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def definition_hash(
|
||||||
|
payload: DistributionListCreateRequest | DistributionListUpdateRequest,
|
||||||
|
) -> str:
|
||||||
|
definition = {
|
||||||
|
"definition_kind": payload.definition_kind,
|
||||||
|
"parameters": [item.model_dump(mode="json") for item in payload.parameters],
|
||||||
|
"constraints": payload.constraints,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
**entry.model_dump(mode="json", exclude={"entry_key"}),
|
||||||
|
"entry_key": entry.entry_key,
|
||||||
|
}
|
||||||
|
for entry in payload.entries
|
||||||
|
],
|
||||||
|
}
|
||||||
|
encoded = json.dumps(
|
||||||
|
definition,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_revision(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
distribution_list: DistributionList,
|
||||||
|
revision_number: int,
|
||||||
|
payload: DistributionListCreateRequest | DistributionListUpdateRequest,
|
||||||
|
) -> DistributionListRevision:
|
||||||
|
_validate_entry_keys(payload.entries)
|
||||||
|
revision = DistributionListRevision(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
distribution_list_id=distribution_list.id,
|
||||||
|
revision=revision_number,
|
||||||
|
definition_kind=payload.definition_kind,
|
||||||
|
definition_hash=definition_hash(payload),
|
||||||
|
parameter_schema=[item.model_dump(mode="json") for item in payload.parameters],
|
||||||
|
constraints=dict(payload.constraints),
|
||||||
|
source_fingerprints=[
|
||||||
|
{
|
||||||
|
"provider": item.source.provider,
|
||||||
|
"resource_type": item.source.resource_type,
|
||||||
|
"resource_id": item.source.resource_id,
|
||||||
|
"revision": item.source.revision,
|
||||||
|
"fingerprint": item.source.fingerprint,
|
||||||
|
}
|
||||||
|
for item in payload.entries
|
||||||
|
if item.source.revision or item.source.fingerprint
|
||||||
|
],
|
||||||
|
created_by_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
session.add(revision)
|
||||||
|
session.flush()
|
||||||
|
for order, entry in enumerate(payload.entries):
|
||||||
|
session.add(
|
||||||
|
_entry_model(
|
||||||
|
principal,
|
||||||
|
revision=revision,
|
||||||
|
payload=entry,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
session.refresh(revision, attribute_names=["entries"])
|
||||||
|
return revision
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_model(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
revision: DistributionListRevision,
|
||||||
|
payload: DistributionListEntryInput,
|
||||||
|
order: int,
|
||||||
|
) -> DistributionListEntry:
|
||||||
|
source = payload.source
|
||||||
|
return DistributionListEntry(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
revision_id=revision.id,
|
||||||
|
entry_key=payload.entry_key or f"entry-{order + 1}",
|
||||||
|
kind=payload.kind,
|
||||||
|
mode=payload.mode,
|
||||||
|
source_provider=source.provider,
|
||||||
|
source_resource_type=source.resource_type,
|
||||||
|
source_resource_id=source.resource_id,
|
||||||
|
source_revision=source.revision,
|
||||||
|
source_fingerprint=source.fingerprint,
|
||||||
|
source_label=source.label,
|
||||||
|
source_metadata=dict(source.metadata),
|
||||||
|
label=payload.label,
|
||||||
|
purpose=payload.purpose,
|
||||||
|
requested_channels=list(payload.requested_channels),
|
||||||
|
effective_from=payload.effective_from,
|
||||||
|
effective_until=payload.effective_until,
|
||||||
|
order_index=order,
|
||||||
|
configuration=dict(payload.configuration),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_entry_keys(entries: Sequence[DistributionListEntryInput]) -> None:
|
||||||
|
keys = [item.entry_key or f"entry-{index + 1}" for index, item in enumerate(entries)]
|
||||||
|
if len(keys) != len(set(keys)):
|
||||||
|
raise DistributionListConflictError("Entry keys must be unique within a revision.")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_unique_name(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
exclude_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
statement = select(DistributionList.id).where(
|
||||||
|
DistributionList.tenant_id == principal.tenant_id,
|
||||||
|
DistributionList.scope_type == scope_type,
|
||||||
|
DistributionList.scope_id.is_(None)
|
||||||
|
if scope_id is None
|
||||||
|
else DistributionList.scope_id == scope_id,
|
||||||
|
DistributionList.name == name.strip(),
|
||||||
|
DistributionList.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if exclude_id:
|
||||||
|
statement = statement.where(DistributionList.id != exclude_id)
|
||||||
|
if session.scalar(statement) is not None:
|
||||||
|
raise DistributionListConflictError(
|
||||||
|
"A distribution list with this name already exists in the selected scope."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_mutable_scope(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
distribution_list: DistributionList,
|
||||||
|
) -> None:
|
||||||
|
if principal.has("dist_lists:list:admin"):
|
||||||
|
return
|
||||||
|
if distribution_list.scope_type == "user" and distribution_list.scope_id != principal.account_id:
|
||||||
|
raise DistributionListConflictError("This user-scoped distribution list is read-only.")
|
||||||
|
if distribution_list.scope_type == "group" and distribution_list.scope_id not in principal.group_ids:
|
||||||
|
raise DistributionListConflictError("This group-scoped distribution list is read-only.")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_requested_scope(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
if principal.has("dist_lists:list:admin") or scope_type == "tenant":
|
||||||
|
return
|
||||||
|
if scope_type == "user" and scope_id == principal.account_id:
|
||||||
|
return
|
||||||
|
if scope_type == "group" and scope_id in principal.group_ids:
|
||||||
|
return
|
||||||
|
raise DistributionListConflictError(
|
||||||
|
"The selected distribution-list scope is not writable by the current principal."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object | None) -> str | None:
|
||||||
|
candidate = str(value).strip() if value is not None else ""
|
||||||
|
return candidate or None
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: object | None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_distribution_list",
|
||||||
|
"definition_hash",
|
||||||
|
"delete_distribution_list",
|
||||||
|
"entry_ref",
|
||||||
|
"get_distribution_list",
|
||||||
|
"get_distribution_list_revision",
|
||||||
|
"list_distribution_lists",
|
||||||
|
"source_ref",
|
||||||
|
"update_distribution_list",
|
||||||
|
"visible_lists_statement",
|
||||||
|
]
|
||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference path="../../../govoplan-core/webui/src/vite-env.d.ts" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user