Files
govoplan-campaign/src/govoplan_campaign/backend/routes/delivery_settings.py
T
zemion c51fc180fb
Module Package Release / publish-packages (push) Successful in 12s
Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
2026-09-08 01:32:26 +02:00

139 lines
7.8 KiB
Python

"""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