266 lines
8.5 KiB
Python
266 lines
8.5 KiB
Python
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",
|
|
]
|