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
@@ -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,