Integrate Distribution Lists with Campaign recipients
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user