139 lines
7.8 KiB
Python
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
|