from __future__ import annotations import hashlib import hmac import json from dataclasses import dataclass from datetime import datetime from typing import Any from sqlalchemy.orm import Session from govoplan_campaign.backend.db.models import ( Campaign, CampaignJob, CampaignSchedule, CampaignShare, CampaignVersion, ) from govoplan_core.auth import ApiPrincipal, has_scope POLICY_ID = "campaign.lifecycle" POLICY_VERSION = "2" _ACTIVE_QUEUE_STATES = {"queued", "sending"} _ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"} _ACTIVE_POSTBOX_STATES = {"pending", "delivering", "outcome_unknown"} _ACTIVE_PRINT_STATES = {"ready", "accepting"} _ACTIVE_IMAP_STATES = {"pending", "appending", "outcome_unknown"} @dataclass(frozen=True) class LifecycleDecision: allowed: bool reason: str | None = None def as_dict(self) -> dict[str, Any]: return {"allowed": self.allowed, "reason": self.reason} def _timestamp(value: datetime | None) -> str | None: return value.isoformat() if value is not None else None def _canonical_hash(value: object) -> str: return hashlib.sha256( json.dumps( value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), ).encode("utf-8") ).hexdigest() def _protected_version(version: CampaignVersion) -> bool: return any( value is not None for value in ( version.locked_at, version.user_lock_state, version.published_at, version.execution_snapshot_at, ) ) def _active_delivery(job: CampaignJob) -> bool: return any( ( job.queue_status in _ACTIVE_QUEUE_STATES, job.send_status in _ACTIVE_SEND_STATES, job.postbox_status in _ACTIVE_POSTBOX_STATES, job.print_status in _ACTIVE_PRINT_STATES, job.imap_status in _ACTIVE_IMAP_STATES, ) ) def campaign_lifecycle_policy( session: Session, *, campaign: Campaign, principal: ApiPrincipal, version_id: str | None = None, ) -> dict[str, Any]: versions = ( session.query(CampaignVersion) .filter(CampaignVersion.campaign_id == campaign.id) .order_by(CampaignVersion.version_number.asc()) .all() ) jobs = ( session.query(CampaignJob) .filter(CampaignJob.campaign_id == campaign.id) .order_by(CampaignJob.id.asc()) .all() ) shares = ( session.query(CampaignShare) .filter( CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None), ) .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, ) snapshot = { "policy_id": POLICY_ID, "policy_version": POLICY_VERSION, "campaign": { "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": [ { "id": version.id, "version_number": version.version_number, "edit_revision": version.edit_revision, "workflow_state": version.workflow_state, "locked_at": _timestamp(version.locked_at), "user_lock_state": version.user_lock_state, "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 ], "jobs": [ { "id": job.id, "queue_status": job.queue_status, "send_status": job.send_status, "postbox_status": job.postbox_status, "print_status": job.print_status, "imap_status": job.imap_status, "updated_at": _timestamp(job.updated_at), } for job in jobs ], "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"): archive = LifecycleDecision(False, "Missing campaign archive permission.") elif campaign.status in {"archived", "deleted"}: archive = LifecycleDecision(False, "The campaign is already archived or deleted.") elif active_delivery: archive = LifecycleDecision( 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"): delete = LifecycleDecision(False, "Missing campaign delete permission.") elif campaign.status != "draft": delete = LifecycleDecision(False, "Only untouched draft campaigns can be deleted.") elif jobs: delete = LifecycleDecision( False, "Campaigns with built or delivery jobs must be archived instead of deleted.", ) elif protected_versions: delete = LifecycleDecision( False, "Audit-relevant campaign versions must be archived instead of deleted.", ) elif shares: delete = LifecycleDecision( 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 version_id is not None and selected_version is None: copy = LifecycleDecision(False, "The selected source version does not exist.") archive_version = LifecycleDecision(True) if not has_scope(principal, "campaigns:campaign:archive"): archive_version = LifecycleDecision(False, "Missing campaign archive permission.") elif version_id is None or selected_version is None: archive_version = LifecycleDecision(False, "Select a historical campaign version.") elif selected_version.id == campaign.current_version_id: archive_version = LifecycleDecision(False, "The current campaign version cannot be archived.") elif selected_version.archived_at is not None: archive_version = LifecycleDecision(False, "The historical version is already archived.") return { "policy_id": POLICY_ID, "policy_version": POLICY_VERSION, "state_token": token, "actions": { "archive_campaign": archive.as_dict(), "delete_campaign": delete.as_dict(), "copy_campaign": copy.as_dict(), "archive_version": archive_version.as_dict(), }, "provenance": { "source": "built_in", "rules": ( "permission", "campaign_state", "retained_evidence", "active_delivery", "scheduled_automation", "optimistic_concurrency", ), "evidence_retention": "Versions, schedule occurrences, delivery outcomes, reports, and audit records are never deleted by archival.", }, } def assert_lifecycle_state_token(actual: str, expected: str) -> None: if not hmac.compare_digest(actual, expected): raise ValueError( "Campaign state changed after this action was prepared. Reload and review the lifecycle decision again." )