diff --git a/src/govoplan_core/core/dataflows.py b/src/govoplan_core/core/dataflows.py index 3619d76..3a126ab 100644 --- a/src/govoplan_core/core/dataflows.py +++ b/src/govoplan_core/core/dataflows.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from datetime import datetime from typing import Protocol, runtime_checkable @@ -12,6 +12,7 @@ from govoplan_core.core.events import PlatformEvent CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle" CAPABILITY_DATAFLOW_RUN_WORKER = "dataflow.runWorker" CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher" +CAPABILITY_DATAFLOW_DATASET_OUTPUT = "dataflow.dataset_output" class DataflowRunError(ValueError): @@ -30,6 +31,66 @@ class DataflowRunUnavailableError(DataflowRunError): pass +@dataclass(frozen=True, slots=True) +class DataflowDatasetDescriptor: + pipeline_ref: str + name: str + revision: int + definition_hash: str + status: str + description: str | None = None + updated_at: datetime | None = None + parameters: Mapping[str, object] = field(default_factory=dict) + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DataflowDatasetRequest: + pipeline_ref: str + revision: int + parameters: Mapping[str, object] = field(default_factory=dict) + row_limit: int = 500 + expected_definition_hash: str | None = None + expected_source_fingerprints: tuple[Mapping[str, object], ...] = () + + +@dataclass(frozen=True, slots=True) +class DataflowDatasetResult: + pipeline_ref: str + revision: int + definition_hash: str + rows: tuple[Mapping[str, object], ...] + total_rows: int + truncated: bool + output_hash: str + executor_version: str + run_ref: str | None = None + source_fingerprints: tuple[Mapping[str, object], ...] = () + diagnostics: tuple[Mapping[str, object], ...] = () + generated_at: datetime | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + + +@runtime_checkable +class DataflowDatasetOutputProvider(Protocol): + def list_outputs( + self, + session: object, + principal: object, + *, + query: str = "", + limit: int = 100, + ) -> Sequence[DataflowDatasetDescriptor]: ... + + def read_output( + self, + session: object, + principal: object, + *, + request: DataflowDatasetRequest, + ) -> DataflowDatasetResult: ... + + @dataclass(frozen=True, slots=True) class DataflowPublicationTarget: target_datasource_ref: str | None = None @@ -184,6 +245,13 @@ def dataflow_trigger_dispatcher( ) +def dataflow_dataset_output( + registry: object | None, +) -> DataflowDatasetOutputProvider | None: + capability = _capability(registry, CAPABILITY_DATAFLOW_DATASET_OUTPUT) + return capability if isinstance(capability, DataflowDatasetOutputProvider) else None + + def _capability(registry: object | None, name: str) -> object | None: if ( registry is None @@ -199,6 +267,11 @@ __all__ = [ "CAPABILITY_DATAFLOW_RUN_LIFECYCLE", "CAPABILITY_DATAFLOW_RUN_WORKER", "CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER", + "CAPABILITY_DATAFLOW_DATASET_OUTPUT", + "DataflowDatasetDescriptor", + "DataflowDatasetOutputProvider", + "DataflowDatasetRequest", + "DataflowDatasetResult", "DataflowPublicationTarget", "DataflowRunConflictError", "DataflowRunDescriptor", @@ -212,4 +285,5 @@ __all__ = [ "dataflow_run_lifecycle", "dataflow_run_worker", "dataflow_trigger_dispatcher", + "dataflow_dataset_output", ] diff --git a/src/govoplan_core/core/distribution_lists.py b/src/govoplan_core/core/distribution_lists.py new file mode 100644 index 0000000..754a92f --- /dev/null +++ b/src/govoplan_core/core/distribution_lists.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, Protocol, runtime_checkable + + +CAPABILITY_DISTRIBUTION_LIST_SOURCE = "dist_lists.source" +CAPABILITY_DISTRIBUTION_LIST_EXPAND = "dist_lists.expand" +CAPABILITY_DISTRIBUTION_LIST_WRITER = "dist_lists.writer" +CAPABILITY_RECIPIENT_CHANNEL_FACTS = "addresses.channel_facts" +CAPABILITY_POLICY_DISTRIBUTION_CHANNELS = "policy.distribution_channels" + +DistributionDefinitionKind = Literal["static", "parameterized", "dynamic", "template"] +DistributionEntryMode = Literal["include", "exclude", "override"] +DistributionEntryKind = 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", +] +DistributionChannel = Literal["email", "postal", "internal_mail", "portal"] +DistributionOutcome = Literal[ + "usable", + "unresolved", + "invalid", + "suppressed", + "ambiguous", + "duplicate", + "policy_blocked", + "provider_unavailable", + "stale", +] +DistributionExplanationSeverity = Literal["info", "warning", "error"] + + +class DistributionListError(ValueError): + """Stable base error for provider-neutral distribution-list operations.""" + + +class DistributionListNotFoundError(DistributionListError): + pass + + +class DistributionListConflictError(DistributionListError): + pass + + +class DistributionListUnavailableError(DistributionListError): + pass + + +@dataclass(frozen=True, slots=True) +class DistributionSourceReference: + provider: str + resource_type: str + resource_id: str + revision: str | None = None + fingerprint: str | None = None + label: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionExplanation: + code: str + message: str + severity: DistributionExplanationSeverity = "warning" + provider: str | None = None + source: DistributionSourceReference | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionParameterDefinition: + key: str + value_type: Literal[ + "string", + "integer", + "number", + "boolean", + "date", + "datetime", + "string_list", + ] + label: str | None = None + required: bool = False + default: object | None = None + allowed_values: tuple[object, ...] = () + minimum: float | None = None + maximum: float | None = None + pattern: str | None = None + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class DistributionListEntryRef: + id: str + kind: DistributionEntryKind + mode: DistributionEntryMode + source: DistributionSourceReference + label: str | None = None + purpose: str | None = None + requested_channels: tuple[DistributionChannel, ...] = () + effective_from: datetime | None = None + effective_until: datetime | None = None + order: int = 0 + configuration: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionListSourceRef: + id: str + tenant_id: str + name: str + revision_id: str + revision: int + definition_hash: str + definition_kind: DistributionDefinitionKind = "static" + description: str | None = None + status: str = "active" + entry_count: int = 0 + read_only: bool = False + stale: bool = False + parameters: tuple[DistributionParameterDefinition, ...] = () + updated_at: datetime | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionExpansionLimits: + max_entries: int = 500 + max_results: int = 5_000 + max_depth: int = 8 + max_provider_results: int = 2_000 + + +@dataclass(frozen=True, slots=True) +class DistributionExpansionRequest: + list_id: str + revision: int | None = None + effective_at: datetime | None = None + purpose: str | None = None + requested_channels: tuple[DistributionChannel, ...] = () + parameters: Mapping[str, object] = field(default_factory=dict) + preview: bool = False + freeze: bool = False + idempotency_key: str | None = None + limits: DistributionExpansionLimits = field( + default_factory=DistributionExpansionLimits + ) + + +@dataclass(frozen=True, slots=True) +class DistributionChannelCandidate: + channel: DistributionChannel + target: str + target_key: str + status: DistributionOutcome = "usable" + contact_point_id: str | None = None + locale: str | None = None + preferred: bool = False + reason_code: str | None = None + explanation: str | None = None + source: DistributionSourceReference | None = None + decision_provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionRecipientRef: + recipient_key: str + display_name: str + status: DistributionOutcome + channels: tuple[DistributionChannelCandidate, ...] = () + 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: tuple[str, ...] = () + explanations: tuple[DistributionExplanation, ...] = () + attributes: Mapping[str, object] = field(default_factory=dict) + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionProviderEvidence: + provider: str + source: DistributionSourceReference + actual_revision: str | None = None + actual_fingerprint: str | None = None + stale: bool = False + generated_at: datetime | None = None + details: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionExpansionResult: + source: DistributionListSourceRef + request: DistributionExpansionRequest + recipients: tuple[DistributionRecipientRef, ...] + excluded: tuple[DistributionRecipientRef, ...] = () + diagnostics: tuple[DistributionExplanation, ...] = () + provider_evidence: tuple[DistributionProviderEvidence, ...] = () + expansion_hash: str = "" + generated_at: datetime | None = None + snapshot_id: str | None = None + stale: bool = False + truncated: bool = False + + +@dataclass(frozen=True, slots=True) +class DistributionSnapshotRef: + 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: Mapping[str, object] = field(default_factory=dict) + recipients: tuple[DistributionRecipientRef, ...] = () + excluded: tuple[DistributionRecipientRef, ...] = () + diagnostics: tuple[DistributionExplanation, ...] = () + provider_evidence: tuple[DistributionProviderEvidence, ...] = () + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionWriteDecision: + list_id: str | None + operation: str + allowed: bool + reason_code: str + explanation: str + read_only: bool = False + required_scopes: tuple[str, ...] = () + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RecipientChannelFactsRequest: + tenant_id: str + source: DistributionSourceReference + recipient_key: str + effective_at: datetime + purpose: str | None = None + requested_channels: tuple[DistributionChannel, ...] = () + context: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RecipientChannelFacts: + candidates: tuple[DistributionChannelCandidate, ...] + explanations: tuple[DistributionExplanation, ...] = () + source_revision: str | None = None + source_fingerprint: str | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionChannelPolicyRequest: + tenant_id: str + list_id: str + purpose: str | None + effective_at: datetime + recipient: DistributionRecipientRef + candidate: DistributionChannelCandidate + context: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DistributionChannelPolicyDecision: + allowed: bool + reason_code: str + explanation: str + source_path: tuple[Mapping[str, object], ...] = () + requirements: tuple[str, ...] = () + details: Mapping[str, object] = field(default_factory=dict) + + +@runtime_checkable +class DistributionListSourceProvider(Protocol): + def list_sources( + self, + session: object, + principal: object, + *, + query: str = "", + limit: int = 100, + ) -> Sequence[DistributionListSourceRef]: ... + + def get_source( + self, + session: object, + principal: object, + *, + list_id: str, + revision: int | None = None, + ) -> DistributionListSourceRef | None: ... + + +@runtime_checkable +class DistributionListExpansionProvider(Protocol): + def expand( + self, + session: object, + principal: object, + *, + request: DistributionExpansionRequest, + ) -> DistributionExpansionResult: ... + + def get_snapshot( + self, + session: object, + principal: object, + *, + snapshot_id: str, + ) -> DistributionSnapshotRef | None: ... + + +@runtime_checkable +class DistributionListWriter(Protocol): + def explain_write( + self, + session: object, + principal: object, + *, + list_id: str | None, + operation: str, + ) -> DistributionWriteDecision: ... + + +@runtime_checkable +class RecipientChannelFactsProvider(Protocol): + def resolve_channel_facts( + self, + session: object, + principal: object, + *, + request: RecipientChannelFactsRequest, + ) -> RecipientChannelFacts: ... + + +@runtime_checkable +class DistributionChannelPolicyProvider(Protocol): + def resolve_distribution_channel( + self, + session: object, + principal: object, + *, + request: DistributionChannelPolicyRequest, + ) -> DistributionChannelPolicyDecision: ... + + +def distribution_list_source_provider( + registry: object | None, +) -> DistributionListSourceProvider | None: + capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_SOURCE) + return capability if isinstance(capability, DistributionListSourceProvider) else None + + +def distribution_list_expansion_provider( + registry: object | None, +) -> DistributionListExpansionProvider | None: + capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_EXPAND) + return ( + capability + if isinstance(capability, DistributionListExpansionProvider) + else None + ) + + +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) + + +__all__ = [name for name in globals() if name.startswith("CAPABILITY_") or name.startswith("Distribution") or name.startswith("Recipient") or name.startswith("distribution_")] diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index 1df203c..b05fac0 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -49,7 +49,7 @@ class Settings(BaseSettings): default=( "tenancy,organizations,identity,idm,access,admin,dashboard,policy," "audit,campaigns,files,mail,calendar,poll,scheduling,connectors," - "datasources,dataflow,workflow_engine,workflow,views,search,risk_compliance," + "datasources,dataflow,dist_lists,workflow_engine,workflow,views,search,risk_compliance," "postbox,notifications,docs,ops" ), alias="ENABLED_MODULES", diff --git a/tests/test_distribution_list_contract.py b/tests/test_distribution_list_contract.py new file mode 100644 index 0000000..64f9aac --- /dev/null +++ b/tests/test_distribution_list_contract.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.distribution_lists import ( + CAPABILITY_DISTRIBUTION_LIST_EXPAND, + DistributionExpansionLimits, + DistributionExpansionRequest, + DistributionListEntryRef, + DistributionSourceReference, +) + + +class DistributionListContractTests(unittest.TestCase): + def test_mixed_entry_and_bounded_request_are_provider_neutral(self) -> None: + source = DistributionSourceReference( + provider="addresses", + resource_type="address_list", + resource_id="list-1", + revision="4", + fingerprint="sha256:abc", + ) + entry = DistributionListEntryRef( + id="entry-1", + kind="address_list", + mode="include", + source=source, + requested_channels=("email",), + ) + request = DistributionExpansionRequest( + list_id="distribution-1", + limits=DistributionExpansionLimits(max_results=25), + ) + + self.assertEqual("address_list", entry.kind) + self.assertEqual(25, request.limits.max_results) + self.assertEqual("dist_lists.expand", CAPABILITY_DISTRIBUTION_LIST_EXPAND) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package-lock.json b/webui/package-lock.json index be0ab35..a1dc35d 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -17,6 +17,7 @@ "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", "@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui", "@govoplan/datasources-webui": "file:../../govoplan-datasources/webui", + "@govoplan/dist-lists-webui": "file:../../govoplan-dist-lists/webui", "@govoplan/docs-webui": "file:../../govoplan-docs/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui", @@ -220,6 +221,23 @@ } } }, + "../../govoplan-dist-lists/webui": { + "name": "@govoplan/dist-lists-webui", + "version": "0.1.14", + "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 + } + } + }, "../../govoplan-docs/webui": { "name": "@govoplan/docs-webui", "version": "0.1.10", @@ -1273,6 +1291,10 @@ "resolved": "../../govoplan-datasources/webui", "link": true }, + "node_modules/@govoplan/dist-lists-webui": { + "resolved": "../../govoplan-dist-lists/webui", + "link": true + }, "node_modules/@govoplan/docs-webui": { "resolved": "../../govoplan-docs/webui", "link": true diff --git a/webui/package.json b/webui/package.json index 0e7c31a..3ff8c5b 100644 --- a/webui/package.json +++ b/webui/package.json @@ -56,6 +56,7 @@ "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", "@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui", "@govoplan/datasources-webui": "file:../../govoplan-datasources/webui", + "@govoplan/dist-lists-webui": "file:../../govoplan-dist-lists/webui", "@govoplan/docs-webui": "file:../../govoplan-docs/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui", diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index f3519d4..d5700c0 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -11,6 +11,7 @@ const packageByModule = { dashboard: "@govoplan/dashboard-webui", dataflow: "@govoplan/dataflow-webui", datasources: "@govoplan/datasources-webui", + dist_lists: "@govoplan/dist-lists-webui", docs: "@govoplan/docs-webui", files: "@govoplan/files-webui", idm: "@govoplan/idm-webui", @@ -41,6 +42,8 @@ const cases = [ { name: "dataflow-only", modules: ["dataflow"] }, { name: "datasources-only", modules: ["datasources"] }, { name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] }, + { name: "distribution-lists-only", modules: ["dist_lists"] }, + { name: "distribution-lists-with-providers", modules: ["addresses", "policy", "organizations", "idm", "dataflow", "dist_lists"] }, { name: "workflow-only", modules: ["workflow"] }, { name: "workflow-with-dataflow", modules: ["datasources", "dataflow", "workflow"] }, { name: "views-only", modules: ["views"] }, @@ -61,7 +64,7 @@ const cases = [ { name: "search-only", modules: ["search"] }, { name: "risk-compliance-only", modules: ["risk_compliance"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] }, - { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] } + { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] } ]; const npmExec = process.env.npm_execpath; diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index effbc85..edf961e 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -1,4 +1,4 @@ -import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react"; +import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, ListTree, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react"; import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules"; import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types"; import { @@ -70,6 +70,7 @@ const iconByName: Record = { form: Form, inbox: Inbox, "layout-template": LayoutTemplate, + "list-tree": ListTree, mail: Mail, notifications: Bell, organizations: Building2,