from __future__ import annotations import copy from dataclasses import dataclass from datetime import UTC, datetime from typing import Mapping from sqlalchemy.orm import Session from govoplan_core.core.approvals import ( ApprovalRequestCreateCommand, ApprovalRequestRef, ApprovalStepDefinition, ) from govoplan_campaign.backend.db.models import Campaign, CampaignVersion from govoplan_campaign.backend.integrations import ( ApprovalGateUnavailable, approvals_integration, ) from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot APPROVAL_GATE_KEY = "approval_gate" SUBJECT_MODULE = "campaigns" SUBJECT_TYPE = "campaign_execution" class CampaignApprovalGateError(ValueError): pass @dataclass(frozen=True, slots=True) class _TenantPrincipal: tenant_id: str account_id: str | None = None def campaign_approval_gate(version: CampaignVersion) -> dict[str, object] | None: state = version.editor_state if isinstance(version.editor_state, dict) else {} gate = state.get(APPROVAL_GATE_KEY) return dict(gate) if isinstance(gate, dict) else None def request_campaign_approval( session: Session, principal: object, *, campaign: Campaign, version: CampaignVersion, title: str, description: str | None, steps: tuple[ApprovalStepDefinition, ...], idempotency_key: str, template_id: str | None = None, template_revision: int | None = None, unique_actors_across_steps: bool = True, expires_at: datetime | None = None, policy_refs: tuple[str, ...] = (), ) -> ApprovalRequestRef: if campaign.tenant_id != str(getattr(principal, "tenant_id", "") or ""): raise CampaignApprovalGateError("Campaign approval tenant mismatch.") if version.campaign_id != campaign.id: raise CampaignApprovalGateError("Campaign approval version mismatch.") snapshot = ensure_execution_snapshot(session, version) digest = str(version.execution_snapshot_hash or "") if len(digest) != 64: raise CampaignApprovalGateError( "Build a valid Campaign execution snapshot before requesting approval." ) subject_version = _subject_version(version, snapshot.build_token) evidence_actors = _campaign_evidence_actors(campaign, version) try: request = approvals_integration().create_request( session, principal, command=ApprovalRequestCreateCommand( title=title, description=description, subject_module=SUBJECT_MODULE, subject_type=SUBJECT_TYPE, subject_id=version.id, subject_version=subject_version, subject_digest=digest, steps=steps, separation_of_duties=True, unique_actors_across_steps=unique_actors_across_steps, expires_at=expires_at, policy_refs=policy_refs, evidence_actors=evidence_actors, template_id=template_id, template_revision=template_revision, metadata={ "campaign_id": campaign.id, "campaign_version_number": version.version_number, "execution_snapshot_version": snapshot.snapshot_version, }, ), idempotency_key=idempotency_key, ) except ApprovalGateUnavailable as exc: raise CampaignApprovalGateError(str(exc)) from exc state = copy.deepcopy(version.editor_state or {}) state[APPROVAL_GATE_KEY] = { "request_id": request.id, "request_revision": request.revision, "subject_version": subject_version, "subject_digest": digest, "requested_at": datetime.now(UTC).isoformat(), "requested_by_user_id": _actor_id(principal), } version.editor_state = state session.add(version) session.flush() return request def assert_campaign_approval( session: Session, *, tenant_id: str, version: CampaignVersion, ) -> None: gate = campaign_approval_gate(version) if gate is None: return snapshot = ensure_execution_snapshot(session, version) digest = str(version.execution_snapshot_hash or "") subject_version = _subject_version(version, snapshot.build_token) if ( gate.get("subject_digest") != digest or gate.get("subject_version") != subject_version ): raise CampaignApprovalGateError( "Campaign execution changed after approval was requested. Request approval for the current build." ) request_id = str(gate.get("request_id") or "").strip() if not request_id: raise CampaignApprovalGateError( "Campaign approval gate has no request reference." ) try: check = approvals_integration().check_approved( session, _TenantPrincipal(tenant_id=tenant_id), request_id=request_id, subject_module=SUBJECT_MODULE, subject_type=SUBJECT_TYPE, subject_id=version.id, subject_version=subject_version, subject_digest=digest, ) except (ApprovalGateUnavailable, LookupError, ValueError) as exc: raise CampaignApprovalGateError(str(exc)) from exc if not check.approved: raise CampaignApprovalGateError( f"Campaign delivery requires Approval request {request_id}, which is {check.state}." ) def campaign_approval_status( session: Session, *, tenant_id: str, version: CampaignVersion, ) -> dict[str, object]: gate = campaign_approval_gate(version) integration = approvals_integration() if gate is None: return { "configured": False, "available": integration.available, "approved": False, "state": "not_required", "explanation": None, } try: assert_campaign_approval(session, tenant_id=tenant_id, version=version) except CampaignApprovalGateError as exc: return { **gate, "configured": True, "available": integration.available, "approved": False, "state": "unavailable" if not integration.available else "pending", "explanation": str(exc), } return { **gate, "configured": True, "available": True, "approved": True, "state": "approved", "explanation": None, } def clear_campaign_approval_gate(version: CampaignVersion) -> None: state = copy.deepcopy(version.editor_state or {}) state.pop(APPROVAL_GATE_KEY, None) version.editor_state = state def _campaign_evidence_actors( campaign: Campaign, version: CampaignVersion, ) -> Mapping[str, tuple[str, ...]]: validation = ( version.validation_summary if isinstance(version.validation_summary, dict) else {} ) build = version.build_summary if isinstance(version.build_summary, dict) else {} editor = version.editor_state if isinstance(version.editor_state, dict) else {} review = ( editor.get("review_send") if isinstance(editor.get("review_send"), dict) else {} ) review_decisions = ( review.get("issue_decisions") if isinstance(review.get("issue_decisions"), list) else [] ) values = { "author": (campaign.created_by_user_id,), "owner": (campaign.owner_user_id,), "validator": ( validation.get("validated_by_user_id"), version.locked_by_user_id, ), "builder": (build.get("built_by_user_id"),), "reviewer": ( review.get("updated_by_user_id"), *( item.get("actor_user_id") for item in review_decisions if isinstance(item, dict) ), ), } return { role: tuple(dict.fromkeys(str(item) for item in actors if item)) for role, actors in values.items() if any(actors) } def _subject_version(version: CampaignVersion, build_token: str | None) -> str: return str(build_token or f"campaign-version-{version.version_number}")[:120] def _actor_id(principal: object) -> str | None: for name in ("account_id", "user_id", "identity_id", "membership_id"): value = str(getattr(principal, name, "") or "").strip() if value: return value return None __all__ = [ "CampaignApprovalGateError", "assert_campaign_approval", "campaign_approval_gate", "campaign_approval_status", "clear_campaign_approval_gate", "request_campaign_approval", ]