feat: govern autonomous campaign delivery schedules

This commit is contained in:
2026-08-20 21:10:11 +02:00
parent c846c249b8
commit 2a00d910df
14 changed files with 1251 additions and 65 deletions
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.scheduling import (
campaign_schedule_source_snapshot,
canonical_configuration_hash,
validate_autonomous_schedule_source,
)
from govoplan_campaign.backend.db.models import (
CampaignSchedule,
@@ -80,6 +81,10 @@ def create_campaign_schedule(
_require_permission(principal, "campaigns:recipient:read")
if payload.include_shares:
_require_permission(principal, "campaigns:campaign:share")
if payload.delivery_mode == "autonomous":
_require_permission(principal, "campaigns:campaign:queue")
_require_permission(principal, "campaigns:campaign:send")
_require_permission(principal, "mail:profile:use")
source_version = (
session.query(CampaignVersion)
.filter(
@@ -129,12 +134,27 @@ def create_campaign_schedule(
for item in source_shares
],
)
autonomous_evidence: dict[str, object] | None = None
if payload.delivery_mode == "autonomous":
try:
autonomous_evidence = validate_autonomous_schedule_source(
session,
campaign=campaign,
version=source_version,
)
except (RuntimeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
snapshot["autonomous_delivery"] = autonomous_evidence
schedule = CampaignSchedule(
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
source_version_id=source_version.id,
created_by_user_id=principal.user.id,
name=payload.name.strip(),
delivery_mode=payload.delivery_mode,
recurrence_kind=payload.recurrence_kind,
interval_count=payload.interval_count,
timezone=payload.timezone,
@@ -151,6 +171,11 @@ def create_campaign_schedule(
},
source_snapshot=snapshot,
source_snapshot_hash=canonical_configuration_hash(snapshot),
approved_execution_snapshot_hash=(
str(autonomous_evidence["execution_snapshot_hash"])
if autonomous_evidence is not None
else None
),
source_base_path=source_version.source_base_path,
)
session.add(schedule)
@@ -169,7 +194,19 @@ def create_campaign_schedule(
"starts_at": schedule.starts_at.isoformat(),
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
"max_occurrences": schedule.max_occurrences,
"delivery_mode": schedule.delivery_mode,
"delivery_started": False,
"autonomous_delivery_opted_in": (
schedule.delivery_mode == "autonomous"
),
"approved_execution_snapshot_hash": (
schedule.approved_execution_snapshot_hash
),
"approval_request_id": (
autonomous_evidence.get("approval_request_id")
if autonomous_evidence is not None
else None
),
},
commit=True,
)
@@ -206,6 +243,23 @@ def set_campaign_schedule_state(
status_code=status.HTTP_409_CONFLICT,
detail="A completed campaign schedule cannot be resumed.",
)
if payload.active:
unresolved = (
session.query(CampaignScheduleOccurrence.id)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule.id,
CampaignScheduleOccurrence.status == "uncertain",
)
.first()
)
if unresolved is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"Reconcile the autonomous delivery outcome in Mail before "
"resuming this schedule."
),
)
schedule.active = payload.active
schedule.last_error = None if payload.active else schedule.last_error
schedule.resource_revision += 1