Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user