feat: add campaign copying scheduling and residual handling

This commit is contained in:
2026-08-07 14:54:04 +02:00
parent 696f8f6385
commit c2efd6b7bd
35 changed files with 3359 additions and 39 deletions
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignSchedule,
CampaignShare,
CampaignVersion,
)
@@ -19,7 +20,7 @@ from govoplan_core.auth import ApiPrincipal, has_scope
POLICY_ID = "campaign.lifecycle"
POLICY_VERSION = "1"
POLICY_VERSION = "2"
_ACTIVE_QUEUE_STATES = {"queued", "sending"}
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
@@ -104,6 +105,12 @@ def campaign_lifecycle_policy(
.order_by(CampaignShare.id.asc())
.all()
)
schedules = (
session.query(CampaignSchedule)
.filter(CampaignSchedule.campaign_id == campaign.id)
.order_by(CampaignSchedule.id.asc())
.all()
)
selected_version = next(
(version for version in versions if version.id == version_id),
None,
@@ -116,6 +123,10 @@ def campaign_lifecycle_policy(
"id": campaign.id,
"status": campaign.status,
"current_version_id": campaign.current_version_id,
"settings_sha256": _canonical_hash(campaign.settings or {}),
"mail_profile_policy_sha256": _canonical_hash(
campaign.mail_profile_policy or {}
),
"updated_at": _timestamp(campaign.updated_at),
},
"versions": [
@@ -129,6 +140,7 @@ def campaign_lifecycle_policy(
"published_at": _timestamp(version.published_at),
"execution_snapshot_at": _timestamp(version.execution_snapshot_at),
"archived_at": _timestamp(version.archived_at),
"configuration_sha256": _canonical_hash(version.raw_json or {}),
"updated_at": _timestamp(version.updated_at),
}
for version in versions
@@ -145,13 +157,34 @@ def campaign_lifecycle_policy(
}
for job in jobs
],
"active_share_ids": [share.id for share in shares],
"active_shares": [
{
"id": share.id,
"target_type": share.target_type,
"target_id": share.target_id,
"permission": share.permission,
"updated_at": _timestamp(share.updated_at),
}
for share in shares
],
"schedules": [
{
"id": schedule.id,
"active": schedule.active,
"resource_revision": schedule.resource_revision,
"next_fire_at": _timestamp(schedule.next_fire_at),
"occurrence_count": schedule.occurrence_count,
"updated_at": _timestamp(schedule.updated_at),
}
for schedule in schedules
],
"selected_version_id": version_id,
}
token = _canonical_hash(snapshot)
active_delivery = any(_active_delivery(job) for job in jobs)
protected_versions = any(_protected_version(version) for version in versions)
active_schedules = any(schedule.active for schedule in schedules)
archive = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:archive"):
@@ -163,6 +196,11 @@ def campaign_lifecycle_policy(
False,
"Active or uncertain delivery must be resolved before archiving.",
)
elif active_schedules:
archive = LifecycleDecision(
False,
"Pause active Campaign schedules before archiving.",
)
delete = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:delete"):
@@ -184,12 +222,15 @@ def campaign_lifecycle_policy(
False,
"Revoke active campaign shares before deleting the untouched draft.",
)
elif schedules:
delete = LifecycleDecision(
False,
"Campaigns with schedule evidence must be archived instead of deleted.",
)
copy = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:copy"):
copy = LifecycleDecision(False, "Missing campaign copy permission.")
elif not has_scope(principal, "campaigns:recipient:read"):
copy = LifecycleDecision(False, "Recipient read permission is required to copy a campaign.")
elif version_id is not None and selected_version is None:
copy = LifecycleDecision(False, "The selected source version does not exist.")
@@ -220,9 +261,10 @@ def campaign_lifecycle_policy(
"campaign_state",
"retained_evidence",
"active_delivery",
"scheduled_automation",
"optimistic_concurrency",
),
"evidence_retention": "Versions, delivery outcomes, reports, and audit records are never deleted by archival.",
"evidence_retention": "Versions, schedule occurrences, delivery outcomes, reports, and audit records are never deleted by archival.",
},
}