feat: harden campaign delivery and editing
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -74,6 +75,7 @@ from govoplan_campaign.backend.route_support import (
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -236,13 +238,25 @@ def send_unattempted_campaign_jobs(
|
||||
def send_single_campaign_job_endpoint(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignSendJobRequest | None = None,
|
||||
payload: CampaignSendJobRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_any_scope(
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:send_test",
|
||||
)
|
||||
),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignSendJobRequest()
|
||||
_require_permission(
|
||||
principal,
|
||||
(
|
||||
"campaigns:campaign:send_test"
|
||||
if payload.kind == "test"
|
||||
else "campaigns:campaign:send"
|
||||
),
|
||||
)
|
||||
_require_campaign_profile_use_if_needed(session, principal, campaign_id, None)
|
||||
try:
|
||||
result = send_single_campaign_job(
|
||||
@@ -250,17 +264,20 @@ def send_single_campaign_job_endpoint(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
job_id=job_id,
|
||||
kind=payload.kind,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_api_key_id=getattr(principal, "api_key_id", None),
|
||||
reason=payload.reason,
|
||||
action_context=payload.context,
|
||||
include_warnings=payload.include_warnings,
|
||||
dry_run=payload.dry_run,
|
||||
use_rate_limit=payload.use_rate_limit,
|
||||
enqueue_imap_task=payload.enqueue_imap_task,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.single_message_sent"
|
||||
if not payload.dry_run
|
||||
else "campaign.single_message_send_dry_run",
|
||||
action=f"campaign.message_{payload.kind}",
|
||||
object_type="campaign_job",
|
||||
object_id=job_id,
|
||||
details=result,
|
||||
@@ -272,8 +289,17 @@ def send_single_campaign_job_endpoint(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Unexpected single-message campaign action failure",
|
||||
extra={
|
||||
"campaign_id": campaign_id,
|
||||
"job_id": job_id,
|
||||
"action_kind": payload.kind,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="The message action failed because of an internal error.",
|
||||
) from exc
|
||||
|
||||
|
||||
|
||||
@@ -23,10 +23,13 @@ from govoplan_campaign.backend.change_tracking import (
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import postbox_integration
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
@@ -49,6 +52,27 @@ from govoplan_campaign.backend.services.job_queries import (
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
def _postbox_receipts_for_attempts(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
attempts: list[PostboxDeliveryAttempt],
|
||||
):
|
||||
integration = postbox_integration()
|
||||
if not integration.receipt_evidence_available:
|
||||
return None
|
||||
delivery_ids = [
|
||||
attempt.provider_delivery_id
|
||||
for attempt in attempts
|
||||
if attempt.provider_delivery_id
|
||||
]
|
||||
return integration.delivery_receipt_summaries(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
delivery_ids=delivery_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
|
||||
def list_jobs(
|
||||
campaign_id: str,
|
||||
@@ -373,12 +397,36 @@ def get_job_detail(
|
||||
),
|
||||
label="Postbox attempts for this campaign job",
|
||||
)
|
||||
message_actions = _job_attempt_rows(
|
||||
session.query(CampaignMessageAction)
|
||||
.filter(CampaignMessageAction.job_id == job.id)
|
||||
.order_by(CampaignMessageAction.created_at.asc()),
|
||||
label="Single-message actions for this campaign job",
|
||||
)
|
||||
action_ids = [action.id for action in message_actions]
|
||||
message_action_attempts = (
|
||||
_job_attempt_rows(
|
||||
session.query(CampaignMessageActionAttempt)
|
||||
.filter(CampaignMessageActionAttempt.action_id.in_(action_ids))
|
||||
.order_by(CampaignMessageActionAttempt.started_at.asc()),
|
||||
label="Single-message action attempts for this campaign job",
|
||||
)
|
||||
if action_ids
|
||||
else []
|
||||
)
|
||||
return CampaignJobDetailResponse(
|
||||
job=_job_detail_payload(job),
|
||||
attempts=_job_attempts_payload(
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts,
|
||||
message_actions,
|
||||
message_action_attempts,
|
||||
postbox_receipts=_postbox_receipts_for_attempts(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
attempts=postbox_attempts,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -428,9 +476,33 @@ def get_job_diagnostics(
|
||||
),
|
||||
label="Postbox diagnostics for this campaign job",
|
||||
)
|
||||
message_actions = _job_attempt_rows(
|
||||
session.query(CampaignMessageAction)
|
||||
.filter(CampaignMessageAction.job_id == job.id)
|
||||
.order_by(CampaignMessageAction.created_at.asc()),
|
||||
label="Single-message action diagnostics for this campaign job",
|
||||
)
|
||||
action_ids = [action.id for action in message_actions]
|
||||
message_action_attempts = (
|
||||
_job_attempt_rows(
|
||||
session.query(CampaignMessageActionAttempt)
|
||||
.filter(CampaignMessageActionAttempt.action_id.in_(action_ids))
|
||||
.order_by(CampaignMessageActionAttempt.started_at.asc()),
|
||||
label="Single-message action-attempt diagnostics for this campaign job",
|
||||
)
|
||||
if action_ids
|
||||
else []
|
||||
)
|
||||
return _job_diagnostics_payload(
|
||||
job,
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts,
|
||||
message_actions,
|
||||
message_action_attempts,
|
||||
postbox_receipts=_postbox_receipts_for_attempts(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
attempts=postbox_attempts,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,9 +25,11 @@ from govoplan_campaign.backend.reports.emailing import (
|
||||
send_campaign_report_email,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
MailDeliveryCommandError,
|
||||
MailProfileError,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
mail_integration,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,6 +43,24 @@ router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _enqueue_mail_command() -> None:
|
||||
try:
|
||||
from govoplan_core.celery_app import celery
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
if settings.celery_enabled:
|
||||
celery.send_task(
|
||||
"govoplan.mail.dispatch_outbox",
|
||||
args=[None, 25],
|
||||
queue="mail",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Mail delivery command is durable but immediate worker wake-up failed",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/summary")
|
||||
def campaign_summary(
|
||||
campaign_id: str,
|
||||
@@ -164,18 +184,22 @@ def email_campaign_report(
|
||||
attach_jobs_csv=payload.attach_jobs_csv,
|
||||
attach_report_json=payload.attach_report_json,
|
||||
dry_run=payload.dry_run,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
created_by_user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="report.email_sent"
|
||||
action="report.email_requested"
|
||||
if not payload.dry_run
|
||||
else "report.email_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result.as_dict(),
|
||||
details=result.audit_dict(),
|
||||
commit=True,
|
||||
)
|
||||
if not payload.dry_run:
|
||||
_enqueue_mail_command()
|
||||
return ReportEmailResponse(result=result.as_dict())
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
@@ -184,6 +208,7 @@ def email_campaign_report(
|
||||
except (
|
||||
CampaignReportEmailError,
|
||||
MailProfileError,
|
||||
MailDeliveryCommandError,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
) as exc:
|
||||
@@ -196,3 +221,37 @@ def email_campaign_report(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Campaign report email could not be completed.",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/report/email/{command_id}",
|
||||
response_model=ReportEmailResponse,
|
||||
)
|
||||
def campaign_report_email_status(
|
||||
campaign_id: str,
|
||||
command_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
try:
|
||||
result = mail_integration().delivery_command_summary(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
command_id=command_id,
|
||||
)
|
||||
except MailDeliveryCommandError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
if (
|
||||
result.get("source_module") != "campaigns"
|
||||
or result.get("source_resource_type") != "campaign"
|
||||
or result.get("source_resource_id") != campaign_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign report delivery not found",
|
||||
)
|
||||
return ReportEmailResponse(result=result)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
@@ -101,6 +101,7 @@ def list_versions(
|
||||
def get_version_detail(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
@@ -113,10 +114,12 @@ def get_version_detail(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
return CampaignVersionDetailResponse.model_validate(
|
||||
result = CampaignVersionDetailResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
response.headers["ETag"] = version.strong_etag
|
||||
return result
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
@@ -335,18 +338,23 @@ def update_version_detail(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
return _update_campaign_version_detail_response(
|
||||
result = _update_campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
version_id,
|
||||
payload,
|
||||
if_match=if_match,
|
||||
autosave=False,
|
||||
audit_action="campaign.version_updated",
|
||||
)
|
||||
response.headers["ETag"] = result.strong_etag
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -357,18 +365,23 @@ def autosave_version(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
return _update_campaign_version_detail_response(
|
||||
result = _update_campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
version_id,
|
||||
payload,
|
||||
if_match=if_match,
|
||||
autosave=True,
|
||||
audit_action="campaign.version_autosaved",
|
||||
)
|
||||
response.headers["ETag"] = result.strong_etag
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
Reference in New Issue
Block a user