feat(campaign): bound synchronous delivery

This commit is contained in:
2026-07-22 08:21:42 +02:00
parent a8c0750dd7
commit 7e1660344d
10 changed files with 689 additions and 45 deletions

View File

@@ -44,6 +44,7 @@ from govoplan_campaign.backend.schemas import (
CampaignSendJobRequest,
CampaignSendUnattemptedRequest,
CampaignResolveOutcomeRequest,
CampaignDeliveryOptionsResponse,
CampaignListResponse,
CampaignResponse,
CampaignVersionDetailResponse,
@@ -65,7 +66,7 @@ from govoplan_campaign.backend.schemas import (
SendCampaignNowRequest,
SendCampaignNowResponse,
)
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.core.change_sequence import (
@@ -148,6 +149,7 @@ from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, r
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
from govoplan_campaign.backend.sending.jobs import (
QueueingError,
SynchronousSendRejected,
cancel_campaign_jobs,
enqueue_pending_imap_appends,
pause_campaign_jobs,
@@ -158,6 +160,7 @@ from govoplan_campaign.backend.sending.jobs import (
resume_campaign_jobs,
send_campaign_now,
send_single_campaign_job,
synchronous_send_options,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
@@ -3019,6 +3022,30 @@ def revoke_campaign_share(
# Queue / delivery control -------------------------------------------------
@router.get("/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse)
def campaign_delivery_options(
campaign_id: str,
version_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("campaigns:campaign:send", "campaigns:campaign:queue")
),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
try:
return CampaignDeliveryOptionsResponse(
**synchronous_send_options(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
def queue_campaign(
campaign_id: str,
@@ -3264,12 +3291,7 @@ def send_campaign_now_endpoint(
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
"""Validate/build/queue and synchronously send every eligible job.
No server-side job-count bound is currently enforced. Ordinary batches
must use the queue/Celery flow; this endpoint is for deliberately small
interactive runs.
"""
"""Preflight and synchronously send a policy-bounded built execution."""
payload = payload or SendCampaignNowRequest()
try:
@@ -3320,6 +3342,20 @@ def send_campaign_now_endpoint(
commit=True,
)
return SendCampaignNowResponse(result=result)
except SynchronousSendRejected as exc:
audit_from_principal(
session,
principal,
action="campaign.send_now_rejected",
object_type="campaign",
object_id=campaign_id,
details={
**exc.audit_details(),
"version_id": payload.version_id,
},
commit=True,
)
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except HTTPException:
raise
except (CampaignPersistenceError, QueueingError) as exc: