"""Audited, independently editable delivery limits; never a delivery command.""" from __future__ import annotations from typing import Literal from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, Field, StrictInt from sqlalchemy.orm import Session from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings from govoplan_core.audit.logging import audit_from_principal from govoplan_core.auth import ApiPrincipal, require_any_scope from govoplan_core.core.configuration_control import ( ConfigurationControlError, configuration_value_digest, ensure_configuration_change_allowed, record_configuration_change_applied, ) from govoplan_core.db.session import get_session from govoplan_core.tenancy.scope import Tenant from govoplan_campaign.backend.delivery_policy import ( ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS, DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS, CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY, SYNCHRONOUS_SEND_MAX_SETTINGS_KEY, CampaignDeliveryPolicyError, effective_synchronous_send_policy, ) from govoplan_campaign.backend.route_support import _require_permission router = APIRouter(prefix="/campaigns/settings/delivery-policy", tags=["campaigns"]) Scope = Literal["system", "tenant"] class DeliveryPolicyUpdate(BaseModel): model_config = ConfigDict(extra="forbid") synchronous_send_max_recipients: StrictInt | None = Field(default=None, ge=0, le=500) expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$") def _state(session: Session, principal: ApiPrincipal, scope: Scope) -> dict: policy = effective_synchronous_send_policy(session, tenant_id=principal.tenant_id, apply_tenant_override=scope == "tenant") system_limit = policy.system_max_recipient_jobs # Resolve the parent without projecting the tenant's own override into it. parent_limit = min(policy.deployment_max_recipient_jobs, system_limit) if system_limit is not None else ( policy.deployment_max_recipient_jobs if policy.deployment_ceiling_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS ) own = system_limit if scope == "system" else policy.tenant_max_recipient_jobs system = session.get(SystemSettings, SYSTEM_SETTINGS_ID) tenant = session.get(Tenant, principal.tenant_id) if scope == "tenant" else None def stored_revision(row): return ((row.settings or {}).get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY) or {}).get("revision", 0) if row else 0 revision = configuration_value_digest({ "scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None, "own": own, "system": system_limit, "deployment": policy.deployment_max_recipient_jobs, "explicit_deployment": policy.deployment_ceiling_explicit, "system_revision": stored_revision(system), "tenant_revision": stored_revision(tenant), }) return { "scope": scope, "synchronous_send_max_recipients": own, "revision": revision, "max_configurable_recipients": policy.deployment_max_recipient_jobs if scope == "system" else parent_limit, "effective_max_recipients": parent_limit if scope == "system" else policy.max_recipient_jobs, "inherited_max_recipients": (policy.deployment_max_recipient_jobs if policy.deployment_ceiling_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS) if scope == "system" else parent_limit, "absolute_max_recipients": ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS, "deployment_ceiling_explicit": policy.deployment_ceiling_explicit, "deployment_max_recipients": policy.deployment_max_recipient_jobs, } def _scope_permission(principal: ApiPrincipal, scope: Scope, operation: str) -> None: _require_permission(principal, f"system:settings:{operation}" if scope == "system" else f"admin:policies:{operation}") @router.get("/{scope}") def read_delivery_policy( scope: Scope, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:settings:read", "admin:policies:read")), ): _scope_permission(principal, scope, "read") try: return _state(session, principal, scope) except CampaignDeliveryPolicyError as exc: raise HTTPException(422, detail=str(exc)) from exc @router.put("/{scope}") def update_delivery_policy( scope: Scope, payload: DeliveryPolicyUpdate, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:settings:write", "admin:policies:write")), ): _scope_permission(principal, scope, "write") try: # Always lock in the same order; history and system policy share one Core row. system = session.query(SystemSettings).filter(SystemSettings.id == SYSTEM_SETTINGS_ID).populate_existing().with_for_update().one_or_none() if system is None: system = get_system_settings(session) target = system if scope == "tenant": target = session.query(Tenant).filter(Tenant.id == principal.tenant_id).populate_existing().with_for_update().one_or_none() if target is None: raise HTTPException(404, detail="Tenant not found") before = _state(session, principal, scope) if payload.expected_revision != before["revision"]: raise HTTPException(409, detail="Campaign delivery policy changed. Reload the saved policy before retrying; your draft has not been saved.") value = payload.synchronous_send_max_recipients if value is not None and value > before["max_configurable_recipients"]: raise HTTPException(422, detail=f"This scope may configure at most {before['max_configurable_recipients']} recipient jobs; inherited or explicit deployment ceilings cannot be raised here.") key = f"campaign_delivery_policy.{scope}" after_value = {SYNCHRONOUS_SEND_MAX_SETTINGS_KEY: value} approval = ensure_configuration_change_allowed( session, key=key, value=after_value, actor_user_id=principal.user.id, actor_scopes=tuple(principal.scopes), target={"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None}, ) settings = dict(target.settings or {}) saved_policy = dict(settings.get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY) or {}) saved_policy["revision"] = int(saved_policy.get("revision") or 0) + 1 if value is None: saved_policy.pop(SYNCHRONOUS_SEND_MAX_SETTINGS_KEY, None) else: saved_policy[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY] = value settings[CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY] = saved_policy target.settings = settings session.flush() record_configuration_change_applied( session, key=key, before_value={SYNCHRONOUS_SEND_MAX_SETTINGS_KEY: before[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY]}, after_value=after_value, actor_user_id=principal.user.id, approval=approval, target={"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None}, audit_event="campaign.delivery_policy_updated", ) result = _state(session, principal, scope) audit_from_principal(session, principal, action="campaign.delivery_policy_updated", scope=scope, object_type="campaign_delivery_policy", object_id=scope if scope == "system" else principal.tenant_id, details={"scope": scope, "before": before[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY], "after": value, "effective_max_recipients": result["effective_max_recipients"]}, commit=False) session.commit() return result except (CampaignDeliveryPolicyError, ConfigurationControlError) as exc: session.rollback() raise HTTPException(422, detail=str(exc)) from exc except Exception: session.rollback() raise