Integrate Distribution Lists with Campaign recipients

This commit is contained in:
2026-08-02 11:53:35 +02:00
parent 4eeba62bbc
commit b38597f2be
15 changed files with 1552 additions and 3 deletions
@@ -43,6 +43,8 @@ _FILES_INTEGRATION = "files.campaign_attachments"
_MAIL_INTEGRATION = "mail.campaign_delivery"
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
@@ -219,6 +221,45 @@ CAMPAIGN_USER_DOCUMENTATION = (
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
related_modules=("addresses",),
),
_workflow_topic(
topic_id="campaigns.workflow.import-distribution-list",
title="Freeze a Distribution List into a campaign",
summary="Resolve a reusable audience, inspect its channel and policy decisions, and copy an immutable snapshot into the current campaign version.",
body="A Distribution List stays live and versioned in its owning module. Campaign freezes one exact expansion; later list or provider changes only produce a drift warning and never rewrite the saved Campaign recipients.",
order=33,
audience=("campaign_manager", "campaign_author"),
required_modules=("campaigns", "dist_lists"),
required_capabilities=(
_DISTRIBUTION_LIST_SOURCE_INTEGRATION,
_DISTRIBUTION_LIST_EXPAND_INTEGRATION,
),
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
),
route="/campaigns/{campaign_id}/recipients",
screen="Recipient data",
help_contexts=("campaign.recipients", "campaign.recipient-data"),
prerequisites=(
"A visible Distribution List can be expanded for campaign delivery.",
"The current Campaign version is editable.",
),
steps=(
"Open Recipient data and select Import Distribution List.",
"Choose the list, requested channels, and any declared parameters, then preview the expansion.",
"Review included and excluded recipients, stale provider evidence, diagnostics, and unresolved route choices.",
"Choose append or replace, freeze and import the expansion, inspect the copied rows, and save the Campaign version.",
"Use the drift warning for a deliberate refresh when the reusable list changes later.",
),
outcome="A Campaign-local recipient snapshot with immutable audience, provider, policy, and channel-decision evidence.",
verification="The saved recipient rows retain the list revision and snapshot reference, and later list changes do not alter them automatically.",
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
related_modules=("dist_lists",),
limitations=("Unsupported non-email output routes remain inactive until a compatible Campaign output integration is configured.",),
),
_workflow_topic(
topic_id="campaigns.workflow.use-managed-attachments",
title="Use managed files as campaign attachments",
+17
View File
@@ -36,6 +36,10 @@ from govoplan_core.core.modules import (
RoleTemplate,
)
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
from govoplan_core.core.distribution_lists import (
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
CAPABILITY_DISTRIBUTION_LIST_SOURCE,
)
from govoplan_core.core.operations import OperationalCheckProviderRegistration
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
@@ -363,6 +367,7 @@ manifest = ModuleManifest(
"mail",
"notifications",
"addresses",
"dist_lists",
"postbox",
"approvals",
"reporting",
@@ -415,6 +420,18 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_DISTRIBUTION_LIST_SOURCE,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_DISTRIBUTION_LIST_EXPAND,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_DELIVERY,
version_min="0.1.1",
@@ -21,6 +21,10 @@ from govoplan_campaign.backend.schemas import (
CampaignRecipientAddressSourceSnapshotResponse,
CampaignRecipientSnapshotExcludedItem,
CampaignRecipientSnapshotItem,
CampaignDistributionListExpansionRequest,
CampaignDistributionListExpansionResponse,
CampaignDistributionListSource,
CampaignDistributionListSourcesResponse,
RecipientImportMappingProfileListResponse,
RecipientImportMappingProfilePayload,
RecipientImportMappingProfileResponse,
@@ -63,6 +67,11 @@ from govoplan_campaign.backend.integrations import (
postbox_integration,
)
from govoplan_core.db.session import get_session
from govoplan_core.core.distribution_lists import (
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
CAPABILITY_DISTRIBUTION_LIST_SOURCE,
DistributionExpansionRequest,
)
from govoplan_campaign.backend.reports.campaigns import (
CampaignReportError,
generate_campaign_report,
@@ -809,6 +818,144 @@ def snapshot_campaign_recipient_address_source(
)
@router.get(
"/{campaign_id}/recipient-distribution-lists",
response_model=CampaignDistributionListSourcesResponse,
)
def list_campaign_recipient_distribution_lists(
campaign_id: str,
query: str = Query(default="", min_length=0, max_length=200),
limit: int = Query(default=100, ge=1, le=250),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
source_capability = _registry_capability(CAPABILITY_DISTRIBUTION_LIST_SOURCE)
expand_capability = _registry_capability(CAPABILITY_DISTRIBUTION_LIST_EXPAND)
if source_capability is None or not hasattr(source_capability, "list_sources"):
return CampaignDistributionListSourcesResponse(available=False)
sources = getattr(source_capability, "list_sources")(
session,
principal,
query=query,
limit=limit,
)
return CampaignDistributionListSourcesResponse(
available=True,
expand_available=bool(
expand_capability is not None and hasattr(expand_capability, "expand")
),
sources=[
CampaignDistributionListSource.model_validate(
_capability_payload(source)
)
for source in sources
],
)
@router.post(
"/{campaign_id}/recipient-distribution-lists/preview",
response_model=CampaignDistributionListExpansionResponse,
)
def preview_campaign_recipient_distribution_list(
campaign_id: str,
payload: CampaignDistributionListExpansionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
return _expand_campaign_distribution_list(
session,
principal,
payload=payload,
freeze=False,
)
@router.post(
"/{campaign_id}/recipient-distribution-lists/snapshot",
response_model=CampaignDistributionListExpansionResponse,
)
def snapshot_campaign_recipient_distribution_list(
campaign_id: str,
payload: CampaignDistributionListExpansionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
if not payload.idempotency_key:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Freezing a distribution list requires an idempotency key",
)
result = _expand_campaign_distribution_list(
session,
principal,
payload=payload,
freeze=True,
)
audit_from_principal(
session,
principal,
action="campaign.distribution_list_snapshot_frozen",
object_type="campaign",
object_id=campaign_id,
details={
"list_id": result.source.id,
"list_revision": result.source.revision,
"snapshot_id": result.snapshot_id,
"expansion_hash": result.expansion_hash,
"recipient_count": len(result.recipients),
"excluded_count": len(result.excluded),
"stale": result.stale,
"truncated": result.truncated,
},
commit=True,
)
return result
def _expand_campaign_distribution_list(
session: Session,
principal: ApiPrincipal,
*,
payload: CampaignDistributionListExpansionRequest,
freeze: bool,
) -> CampaignDistributionListExpansionResponse:
capability = _registry_capability(CAPABILITY_DISTRIBUTION_LIST_EXPAND)
if capability is None or not hasattr(capability, "expand"):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Distribution-list expansion capability is not available",
)
request = DistributionExpansionRequest(
list_id=payload.list_id,
revision=payload.revision,
effective_at=payload.effective_at,
purpose=payload.purpose,
requested_channels=tuple(payload.requested_channels),
parameters=payload.parameters,
preview=not freeze,
freeze=freeze,
idempotency_key=payload.idempotency_key if freeze else None,
)
try:
result = getattr(capability, "expand")(
session,
principal,
request=request,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return CampaignDistributionListExpansionResponse.model_validate(
_capability_payload(result)
)
@router.get("/{campaign_id}", response_model=CampaignResponse)
def get_campaign(
campaign_id: str,
@@ -1109,6 +1109,11 @@
"additionalProperties": true,
"default": {}
},
"distribution_source": {
"type": "object",
"description": "Immutable Distribution List recipient, route-decision, and source evidence captured for this Campaign version.",
"additionalProperties": true
},
"last_sent": {
"type": "string",
"format": "date-time"
@@ -1195,7 +1200,8 @@
"csv",
"xlsx",
"text",
"addresses"
"addresses",
"distribution_list"
]
},
"source_id": {
+126
View File
@@ -438,6 +438,132 @@ class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionListParameter(BaseModel):
key: str
value_type: str
label: str | None = None
required: bool = False
default: Any = None
allowed_values: list[Any] = Field(default_factory=list)
minimum: float | None = None
maximum: float | None = None
pattern: str | None = None
description: str | None = None
class CampaignDistributionListSource(BaseModel):
id: str
tenant_id: str
name: str
revision_id: str
revision: int
definition_hash: str
definition_kind: str = "static"
description: str | None = None
status: str = "active"
entry_count: int = 0
read_only: bool = False
stale: bool = False
parameters: list[CampaignDistributionListParameter] = Field(default_factory=list)
updated_at: datetime | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionListSourcesResponse(BaseModel):
available: bool = False
expand_available: bool = False
sources: list[CampaignDistributionListSource] = Field(default_factory=list)
class CampaignDistributionListExpansionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
list_id: str = Field(min_length=1, max_length=36)
revision: int | None = Field(default=None, ge=1)
effective_at: datetime | None = None
purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120)
requested_channels: list[Literal["email", "postal", "internal_mail", "portal"]] = Field(
default_factory=list,
max_length=4,
)
parameters: dict[str, Any] = Field(default_factory=dict)
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
class CampaignDistributionSourceReference(BaseModel):
provider: str
resource_type: str
resource_id: str
revision: str | None = None
fingerprint: str | None = None
label: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionExplanation(BaseModel):
code: str
message: str
severity: str
provider: str | None = None
source: CampaignDistributionSourceReference | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionChannelCandidate(BaseModel):
channel: str
target: str
target_key: str
status: str
contact_point_id: str | None = None
locale: str | None = None
preferred: bool = False
reason_code: str | None = None
explanation: str | None = None
source: CampaignDistributionSourceReference | None = None
decision_provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionRecipient(BaseModel):
recipient_key: str
display_name: str
status: str
channels: list[CampaignDistributionChannelCandidate] = Field(default_factory=list)
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] = Field(default_factory=list)
explanations: list[CampaignDistributionExplanation] = Field(default_factory=list)
attributes: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionProviderEvidence(BaseModel):
provider: str
source: CampaignDistributionSourceReference
actual_revision: str | None = None
actual_fingerprint: str | None = None
stale: bool = False
generated_at: datetime | None = None
details: dict[str, Any] = Field(default_factory=dict)
class CampaignDistributionListExpansionResponse(BaseModel):
source: CampaignDistributionListSource
request: dict[str, Any] = Field(default_factory=dict)
recipients: list[CampaignDistributionRecipient] = Field(default_factory=list)
excluded: list[CampaignDistributionRecipient] = Field(default_factory=list)
diagnostics: list[CampaignDistributionExplanation] = Field(default_factory=list)
provider_evidence: list[CampaignDistributionProviderEvidence] = Field(default_factory=list)
expansion_hash: str
generated_at: datetime | None = None
snapshot_id: str | None = None
stale: bool = False
truncated: bool = False
class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]]
page: int = 1