Add distribution audience contracts and module wiring
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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_")]
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user