Complete governed campaign lifecycle actions

This commit is contained in:
2026-08-04 01:04:39 +02:00
parent df5a93d6a3
commit 8f5231147d
13 changed files with 1324 additions and 79 deletions
+272 -42
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import copy
import dataclasses
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import (
@@ -12,6 +13,9 @@ from govoplan_campaign.backend.schemas import (
CampaignUpdateRequest,
CampaignCreateResponse,
CampaignCreateMinimalRequest,
CampaignCopyRequest,
CampaignLifecycleMutationRequest,
CampaignLifecyclePolicyResponse,
CampaignAddressLookupCandidate,
CampaignAddressLookupResponse,
CampaignCalendarCatalogResponse,
@@ -56,13 +60,16 @@ from govoplan_campaign.backend.change_tracking import (
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignVersion,
RecipientImportMappingProfile,
)
from govoplan_campaign.backend.campaign.postbox_targets import (
delivery_catalog_payload,
)
from govoplan_campaign.backend.campaign.lifecycle import (
assert_lifecycle_state_token,
campaign_lifecycle_policy,
)
from govoplan_campaign.backend.integrations import (
calendar_integration,
PostboxDeliveryUnavailable,
@@ -120,6 +127,93 @@ CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
def _lifecycle_policy_for_mutation(
session: Session,
*,
campaign_id: str,
principal: ApiPrincipal,
expected_state_token: str,
action: str,
version_id: str | None = None,
) -> tuple[Campaign, dict[str, object]]:
_get_campaign_for_principal(session, campaign_id, principal, write=True)
campaign = (
session.query(Campaign)
.filter(
Campaign.id == campaign_id,
Campaign.tenant_id == principal.tenant_id,
)
.with_for_update()
.one()
)
policy = campaign_lifecycle_policy(
session,
campaign=campaign,
principal=principal,
version_id=version_id,
)
try:
assert_lifecycle_state_token(
str(policy["state_token"]),
expected_state_token,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
decision = policy["actions"][action] # type: ignore[index]
if not decision["allowed"]: # type: ignore[index]
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=decision["reason"], # type: ignore[index]
)
return campaign, policy
def _campaign_copy_external_id(
session: Session,
*,
tenant_id: str,
source_external_id: str,
requested: str | None,
) -> str:
if requested is not None:
candidate = requested.strip()
exists = (
session.query(Campaign.id)
.filter(
Campaign.tenant_id == tenant_id,
Campaign.external_id == candidate,
)
.first()
)
if exists is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Campaign ID already exists for this tenant",
)
return candidate
stem = f"{source_external_id[:240]}-copy"
for suffix in ("", *(f"-{number}" for number in range(2, 10_000))):
candidate = f"{stem[:255 - len(suffix)]}{suffix}"
exists = (
session.query(Campaign.id)
.filter(
Campaign.tenant_id == tenant_id,
Campaign.external_id == candidate,
)
.first()
)
if exists is None:
return candidate
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="No available campaign copy identifier could be generated.",
)
@router.post("", response_model=CampaignCreateResponse)
def create_campaign(
payload: CampaignCreateRequest,
@@ -1524,18 +1618,180 @@ def update_campaign_metadata_endpoint(
return CampaignResponse.model_validate(campaign)
@router.post("/{campaign_id}/archive", response_model=CampaignResponse)
def archive_campaign(
@router.get(
"/{campaign_id}/lifecycle-policy",
response_model=CampaignLifecyclePolicyResponse,
)
def get_campaign_lifecycle_policy(
campaign_id: str,
version_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal)
return campaign_lifecycle_policy(
session,
campaign=campaign,
principal=principal,
version_id=version_id,
)
@router.post("/{campaign_id}/copies", response_model=CampaignCreateResponse)
def copy_campaign(
campaign_id: str,
payload: CampaignCopyRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")),
):
source_campaign, _policy = _lifecycle_policy_for_mutation(
session,
campaign_id=campaign_id,
principal=principal,
expected_state_token=payload.expected_state_token,
action="copy_campaign",
version_id=payload.source_version_id,
)
_require_permission(principal, "campaigns:recipient:read")
source_version = (
session.query(CampaignVersion)
.filter(
CampaignVersion.id == payload.source_version_id,
CampaignVersion.campaign_id == source_campaign.id,
)
.one_or_none()
)
if source_version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign version not found",
)
external_id = _campaign_copy_external_id(
session,
tenant_id=principal.tenant_id,
source_external_id=source_campaign.external_id,
requested=payload.external_id,
)
name = (payload.name or f"{source_campaign.name} (copy)").strip()
raw_json = copy.deepcopy(source_version.raw_json)
campaign_metadata = raw_json.get("campaign")
if not isinstance(campaign_metadata, dict):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The selected source version has no valid campaign metadata.",
)
campaign_metadata["id"] = external_id
campaign_metadata["name"] = name
campaign_metadata["mode"] = "draft"
_require_mail_profile_use_if_needed(principal, raw_json)
try:
campaign, version = create_campaign_version_from_json(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
raw_json=raw_json,
source_filename=None,
source_base_path=source_version.source_base_path,
commit=False,
)
audit_from_principal(
session,
principal,
action="campaign.copied",
object_type="campaign",
object_id=campaign.id,
details={
"source_campaign_id": source_campaign.id,
"source_version_id": source_version.id,
"destination_version_id": version.id,
"copied_evidence": False,
},
commit=True,
)
except HTTPException:
session.rollback()
raise
except Exception:
session.rollback()
raise
session.refresh(campaign)
session.refresh(version)
return CampaignCreateResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
),
)
@router.post(
"/{campaign_id}/versions/{version_id}/archive",
response_model=CampaignVersionResponse,
)
def archive_campaign_version(
campaign_id: str,
version_id: str,
payload: CampaignLifecycleMutationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
if campaign.status in {"queued", "sending", "outcome_unknown"}:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Active or uncertain delivery must be resolved before archiving.",
campaign, _policy = _lifecycle_policy_for_mutation(
session,
campaign_id=campaign_id,
principal=principal,
expected_state_token=payload.expected_state_token,
action="archive_version",
version_id=version_id,
)
version = (
session.query(CampaignVersion)
.filter(
CampaignVersion.id == version_id,
CampaignVersion.campaign_id == campaign.id,
)
.with_for_update()
.one()
)
version.archived_at = datetime.now(UTC)
version.archived_by_user_id = principal.user.id
session.add(version)
audit_from_principal(
session,
principal,
action="campaign.version_archived",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign.id,
"version_number": version.version_number,
"retained_evidence": True,
},
commit=True,
)
session.refresh(version)
return CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
@router.post("/{campaign_id}/archive", response_model=CampaignResponse)
def archive_campaign(
campaign_id: str,
payload: CampaignLifecycleMutationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")),
):
campaign, _policy = _lifecycle_policy_for_mutation(
session,
campaign_id=campaign_id,
principal=principal,
expected_state_token=payload.expected_state_token,
action="archive_campaign",
)
campaign.status = "archived"
session.add(campaign)
audit_from_principal(
@@ -1554,43 +1810,17 @@ def archive_campaign(
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_draft_campaign(
campaign_id: str,
payload: CampaignLifecycleMutationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
if campaign.status != "draft":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Only untouched draft campaigns can be deleted.",
)
if (
session.query(CampaignJob.id)
.filter(CampaignJob.campaign_id == campaign.id)
.first()
is not None
):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Campaigns with built or delivery jobs must be archived instead of deleted.",
)
protected_version = (
session.query(CampaignVersion.id)
.filter(
CampaignVersion.campaign_id == campaign.id,
or_(
CampaignVersion.locked_at.is_not(None),
CampaignVersion.user_lock_state.is_not(None),
CampaignVersion.published_at.is_not(None),
CampaignVersion.execution_snapshot_at.is_not(None),
),
)
.first()
campaign, _policy = _lifecycle_policy_for_mutation(
session,
campaign_id=campaign_id,
principal=principal,
expected_state_token=payload.expected_state_token,
action="delete_campaign",
)
if protected_version is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Audit-relevant campaign versions must be archived instead of deleted.",
)
campaign.status = "deleted"
session.add(campaign)
audit_from_principal(