Complete governed campaign lifecycle actions
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user