feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -4,7 +4,7 @@ import copy
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
@@ -29,6 +29,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignOwnerUpdateRequest,
|
||||
CampaignAddressLookupCandidate,
|
||||
CampaignAddressLookupResponse,
|
||||
CampaignPostboxCatalogResponse,
|
||||
CampaignRecipientAddressSource,
|
||||
CampaignRecipientAddressSourcesResponse,
|
||||
CampaignRecipientAddressSourceSnapshotRequest,
|
||||
@@ -98,12 +99,21 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignVersionWorkflowState,
|
||||
ImapAppendAttempt,
|
||||
JobImapStatus,
|
||||
JobPostboxStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
RecipientImportMappingProfile,
|
||||
PostboxDeliveryAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.postbox_targets import (
|
||||
delivery_catalog_payload,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
PostboxDeliveryUnavailable,
|
||||
postbox_integration,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||
from govoplan_campaign.backend.report_privacy_policy import CampaignReportPrivacyPolicyError
|
||||
@@ -719,14 +729,38 @@ def create_minimal_campaign_endpoint(
|
||||
|
||||
@router.get("", response_model=CampaignListResponse)
|
||||
def list_campaigns(
|
||||
limit: int = 200,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
campaigns = _campaign_query_for_principal(session, principal).order_by(Campaign.updated_at.desc()).all()
|
||||
campaigns = (
|
||||
_campaign_query_for_principal(session, principal)
|
||||
.order_by(Campaign.updated_at.desc())
|
||||
.limit(max(1, min(limit, 500)))
|
||||
.all()
|
||||
)
|
||||
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
|
||||
|
||||
|
||||
def _bounded_query_rows(query, *, limit: int, label: str):
|
||||
rows = query.limit(limit + 1).all()
|
||||
if len(rows) > limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail=(
|
||||
f"{label} exceeds the maximum response size of {limit} rows. "
|
||||
"Narrow the request or use a paginated/delta endpoint."
|
||||
),
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _job_attempt_rows(query, *, label: str):
|
||||
return _bounded_query_rows(query, limit=1000, label=label)
|
||||
|
||||
|
||||
_CAMPAIGN_LIST_DELTA_COLLECTIONS = (CAMPAIGNS_COLLECTION,)
|
||||
_CAMPAIGN_FULL_CURSOR_PREFIX = "full:campaigns:"
|
||||
_CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS = (
|
||||
CAMPAIGNS_COLLECTION,
|
||||
CAMPAIGN_VERSIONS_COLLECTION,
|
||||
@@ -786,14 +820,89 @@ def _campaign_entry_matches_principal(session: Session, principal: ApiPrincipal,
|
||||
return False
|
||||
|
||||
|
||||
def _campaign_list_full_delta_response(session: Session, principal: ApiPrincipal) -> CampaignDeltaResponse:
|
||||
campaigns = _campaign_query_for_principal(session, principal).order_by(Campaign.updated_at.desc()).all()
|
||||
def _campaign_full_cursor(
|
||||
*,
|
||||
page: int,
|
||||
snapshot_sequence: int,
|
||||
) -> str:
|
||||
return (
|
||||
f"{_CAMPAIGN_FULL_CURSOR_PREFIX}"
|
||||
f"{int(page)}:{int(snapshot_sequence)}"
|
||||
)
|
||||
|
||||
|
||||
def _decode_campaign_full_cursor(
|
||||
value: str | None,
|
||||
) -> tuple[int, int] | None:
|
||||
if not value or not value.startswith(_CAMPAIGN_FULL_CURSOR_PREFIX):
|
||||
return None
|
||||
parts = value[len(_CAMPAIGN_FULL_CURSOR_PREFIX):].split(":", 1)
|
||||
if len(parts) != 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid campaign full snapshot cursor",
|
||||
)
|
||||
try:
|
||||
page, snapshot_sequence = (int(item) for item in parts)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid campaign full snapshot cursor",
|
||||
) from exc
|
||||
if page < 1 or snapshot_sequence < 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid campaign full snapshot cursor",
|
||||
)
|
||||
return page, snapshot_sequence
|
||||
|
||||
|
||||
def _campaign_list_full_delta_response(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
cursor: tuple[int, int] | None = None,
|
||||
limit: int = 500,
|
||||
) -> CampaignDeltaResponse:
|
||||
page = cursor[0] if cursor is not None else 1
|
||||
snapshot_sequence = (
|
||||
cursor[1]
|
||||
if cursor is not None
|
||||
else decode_sequence_watermark(
|
||||
_campaign_delta_watermark(
|
||||
session,
|
||||
principal.tenant_id,
|
||||
_CAMPAIGN_LIST_DELTA_COLLECTIONS,
|
||||
),
|
||||
)
|
||||
)
|
||||
query = _campaign_query_for_principal(session, principal)
|
||||
total = query.order_by(None).count()
|
||||
pages = max(1, (total + limit - 1) // limit)
|
||||
campaigns = (
|
||||
query.order_by(Campaign.updated_at.desc(), Campaign.id.asc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
has_more = page < pages
|
||||
return CampaignDeltaResponse(
|
||||
campaigns=[CampaignResponse.model_validate(item) for item in campaigns],
|
||||
deleted=[],
|
||||
watermark=_campaign_delta_watermark(session, principal.tenant_id, _CAMPAIGN_LIST_DELTA_COLLECTIONS),
|
||||
has_more=False,
|
||||
watermark=(
|
||||
_campaign_full_cursor(
|
||||
page=page + 1,
|
||||
snapshot_sequence=snapshot_sequence,
|
||||
)
|
||||
if has_more
|
||||
else encode_sequence_watermark(snapshot_sequence)
|
||||
),
|
||||
has_more=has_more,
|
||||
full=True,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=limit,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@@ -809,7 +918,11 @@ def _campaign_list_delta_response(session: Session, principal: ApiPrincipal, *,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collections=_CAMPAIGN_LIST_DELTA_COLLECTIONS,
|
||||
):
|
||||
return _campaign_list_full_delta_response(session, principal)
|
||||
return _campaign_list_full_delta_response(
|
||||
session,
|
||||
principal,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
entries_plus_one = sequence_entries_since(
|
||||
session,
|
||||
@@ -858,6 +971,10 @@ def _campaign_list_delta_response(session: Session, principal: ApiPrincipal, *,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=False,
|
||||
total=len(visible_campaigns),
|
||||
page=1,
|
||||
page_size=limit,
|
||||
pages=1,
|
||||
)
|
||||
|
||||
|
||||
@@ -868,24 +985,35 @@ def list_campaigns_delta(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
if since is None:
|
||||
return _campaign_list_full_delta_response(session, principal)
|
||||
full_cursor = _decode_campaign_full_cursor(since)
|
||||
if since is None or full_cursor is not None:
|
||||
return _campaign_list_full_delta_response(
|
||||
session,
|
||||
principal,
|
||||
cursor=full_cursor,
|
||||
limit=limit,
|
||||
)
|
||||
return _campaign_list_delta_response(session, principal, since=since, limit=limit)
|
||||
|
||||
|
||||
@router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse)
|
||||
def list_recipient_import_mapping_profiles(
|
||||
limit: int = Query(default=200, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
|
||||
):
|
||||
profiles = (
|
||||
profiles = _bounded_query_rows(
|
||||
session.query(RecipientImportMappingProfile)
|
||||
.filter(
|
||||
RecipientImportMappingProfile.tenant_id == principal.tenant_id,
|
||||
RecipientImportMappingProfile.owner_user_id == principal.user.id,
|
||||
)
|
||||
.order_by(RecipientImportMappingProfile.updated_at.desc(), RecipientImportMappingProfile.name.asc())
|
||||
.all()
|
||||
.order_by(
|
||||
RecipientImportMappingProfile.updated_at.desc(),
|
||||
RecipientImportMappingProfile.name.asc(),
|
||||
),
|
||||
limit=limit,
|
||||
label="Recipient import mapping profile list",
|
||||
)
|
||||
return RecipientImportMappingProfileListResponse(
|
||||
profiles=[RecipientImportMappingProfileResponse.model_validate(profile) for profile in profiles]
|
||||
@@ -978,15 +1106,17 @@ def delete_recipient_import_mapping_profile(
|
||||
|
||||
@router.get("/aggregate-reports", response_model=AggregateReportCampaignList)
|
||||
def list_aggregate_campaign_reports(
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
|
||||
):
|
||||
"""List only the business metadata needed to select an aggregate report."""
|
||||
|
||||
campaigns = (
|
||||
campaigns = _bounded_query_rows(
|
||||
_campaign_query_for_principal(session, principal)
|
||||
.order_by(Campaign.updated_at.desc())
|
||||
.all()
|
||||
.order_by(Campaign.updated_at.desc(), Campaign.id.asc()),
|
||||
limit=limit,
|
||||
label="Aggregate report campaign list",
|
||||
)
|
||||
return AggregateReportCampaignList(
|
||||
campaigns=[aggregate_report_campaign_item(campaign) for campaign in campaigns]
|
||||
@@ -1058,6 +1188,38 @@ def list_campaign_recipient_address_sources(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/postbox-catalog",
|
||||
response_model=CampaignPostboxCatalogResponse,
|
||||
)
|
||||
def campaign_postbox_catalog(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("campaigns:recipient:write")
|
||||
),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
integration = postbox_integration()
|
||||
if not integration.available:
|
||||
return CampaignPostboxCatalogResponse(available=False)
|
||||
try:
|
||||
payload = delivery_catalog_payload(
|
||||
integration.delivery_catalog(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
)
|
||||
)
|
||||
except PostboxDeliveryUnavailable:
|
||||
return CampaignPostboxCatalogResponse(available=False)
|
||||
return CampaignPostboxCatalogResponse(
|
||||
available=True,
|
||||
postboxes=payload.get("postboxes", []),
|
||||
templates=payload.get("templates", []),
|
||||
organization_units=payload.get("organization_units", []),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/recipient-address-sources/snapshot", response_model=CampaignRecipientAddressSourceSnapshotResponse)
|
||||
def snapshot_campaign_recipient_address_source(
|
||||
campaign_id: str,
|
||||
@@ -1112,11 +1274,12 @@ def _campaign_workspace_response(
|
||||
|
||||
versions: list[CampaignVersion] = []
|
||||
if include_versions or include_current_version:
|
||||
versions = (
|
||||
versions = _bounded_query_rows(
|
||||
session.query(CampaignVersion)
|
||||
.filter(CampaignVersion.campaign_id == campaign.id)
|
||||
.order_by(CampaignVersion.version_number.desc())
|
||||
.all()
|
||||
.order_by(CampaignVersion.version_number.desc()),
|
||||
limit=1000,
|
||||
label="Campaign version history",
|
||||
)
|
||||
|
||||
selected_version_id = version_id or campaign.current_version_id or (versions[0].id if versions else None)
|
||||
@@ -1588,16 +1751,18 @@ def delete_draft_campaign(
|
||||
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
|
||||
def list_versions(
|
||||
campaign_id: str,
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
versions = (
|
||||
versions = _bounded_query_rows(
|
||||
session.query(CampaignVersion)
|
||||
.filter(CampaignVersion.campaign_id == campaign.id)
|
||||
.order_by(CampaignVersion.version_number.desc())
|
||||
.all()
|
||||
.order_by(CampaignVersion.version_number.desc()),
|
||||
limit=limit,
|
||||
label="Campaign version history",
|
||||
)
|
||||
return [
|
||||
CampaignVersionResponse.model_validate(
|
||||
@@ -2118,14 +2283,19 @@ def _job_summary_payload(
|
||||
"validation_status": job.validation_status,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"delivery_channel_policy": getattr(job, "delivery_channel_policy", "mail"),
|
||||
"postbox_status": getattr(job, "postbox_status", "not_requested"),
|
||||
"imap_status": job.imap_status,
|
||||
"eml_size_bytes": job.eml_size_bytes,
|
||||
"eml_sha256": job.eml_sha256,
|
||||
"attempt_count": job.attempt_count,
|
||||
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
|
||||
"postbox_target_count": len(getattr(job, "resolved_postbox_targets", None) or []),
|
||||
"last_error": public_delivery_result_message(
|
||||
last_error=job.last_error,
|
||||
send_status=job.send_status,
|
||||
imap_status=job.imap_status,
|
||||
postbox_status=getattr(job, "postbox_status", "not_requested"),
|
||||
),
|
||||
"queued_at": job.queued_at,
|
||||
"outcome_unknown_at": job.outcome_unknown_at,
|
||||
@@ -2151,12 +2321,14 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
|
||||
"issues": job.issues_snapshot or [],
|
||||
"attachments": public_campaign_payload(job.resolved_attachments or []),
|
||||
"resolved_recipients": job.resolved_recipients or {},
|
||||
"resolved_postbox_targets": getattr(job, "resolved_postbox_targets", None) or [],
|
||||
}
|
||||
|
||||
|
||||
def _job_attempts_payload(
|
||||
send_attempts: list[SendAttempt],
|
||||
imap_attempts: list[ImapAppendAttempt],
|
||||
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
|
||||
*,
|
||||
include_diagnostics: bool = False,
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
@@ -2191,13 +2363,43 @@ def _job_attempts_payload(
|
||||
payload["claim_token"] = attempt.claim_token
|
||||
payload["error_message"] = attempt.error_message
|
||||
imap_payloads.append(payload)
|
||||
return {"smtp": smtp_payloads, "imap": imap_payloads}
|
||||
postbox_payloads: list[dict[str, object]] = []
|
||||
for attempt in postbox_attempts:
|
||||
payload = {
|
||||
"id": attempt.id,
|
||||
"target_index": attempt.target_index,
|
||||
"attempt_number": attempt.attempt_number,
|
||||
"status": attempt.status,
|
||||
"postbox_id": attempt.postbox_id,
|
||||
"address": attempt.address,
|
||||
"holder_count": attempt.holder_count,
|
||||
"vacant": attempt.vacant,
|
||||
"duplicate": attempt.duplicate,
|
||||
"target": attempt.target_snapshot or {},
|
||||
"started_at": attempt.started_at,
|
||||
"finished_at": attempt.finished_at,
|
||||
"error_code": attempt.error_code,
|
||||
}
|
||||
if include_diagnostics:
|
||||
payload["idempotency_key"] = attempt.idempotency_key
|
||||
payload["provider_delivery_id"] = attempt.provider_delivery_id
|
||||
payload["provider_message_id"] = attempt.provider_message_id
|
||||
payload["evidence"] = attempt.evidence or {}
|
||||
payload["error_type"] = attempt.error_type
|
||||
payload["error_message"] = attempt.error_message
|
||||
postbox_payloads.append(payload)
|
||||
return {
|
||||
"smtp": smtp_payloads,
|
||||
"imap": imap_payloads,
|
||||
"postbox": postbox_payloads,
|
||||
}
|
||||
|
||||
|
||||
def _job_diagnostics_payload(
|
||||
job: CampaignJob,
|
||||
send_attempts: list[SendAttempt],
|
||||
imap_attempts: list[ImapAppendAttempt],
|
||||
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
|
||||
) -> CampaignJobDiagnosticsResponse:
|
||||
return CampaignJobDiagnosticsResponse(
|
||||
job_id=job.id,
|
||||
@@ -2219,6 +2421,7 @@ def _job_diagnostics_payload(
|
||||
attempts=_job_attempts_payload(
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts,
|
||||
include_diagnostics=True,
|
||||
),
|
||||
)
|
||||
@@ -2307,7 +2510,14 @@ def _review_metadata_counts(review_rows: list[tuple[object, int, str, str]], rev
|
||||
|
||||
def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[str, int]]:
|
||||
result: dict[str, dict[str, int]] = {}
|
||||
for field_name in ("build_status", "validation_status", "queue_status", "send_status", "imap_status"):
|
||||
for field_name in (
|
||||
"build_status",
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
"send_status",
|
||||
"postbox_status",
|
||||
"imap_status",
|
||||
):
|
||||
column = getattr(CampaignJob, field_name)
|
||||
rows = session.query(column, func.count(CampaignJob.id)).filter(*filters).group_by(column).all()
|
||||
result[field_name.removesuffix("_status")] = {str(value or "unknown"): int(count) for value, count in rows}
|
||||
@@ -2321,6 +2531,7 @@ CAMPAIGN_JOB_GRID_SORT_COLUMNS = {
|
||||
"validation": CampaignJob.validation_status,
|
||||
"queue": CampaignJob.queue_status,
|
||||
"send": CampaignJob.send_status,
|
||||
"postbox": CampaignJob.postbox_status,
|
||||
"imap": CampaignJob.imap_status,
|
||||
"attempts": CampaignJob.attempt_count,
|
||||
"updated": CampaignJob.updated_at,
|
||||
@@ -2329,6 +2540,10 @@ CAMPAIGN_JOB_GRID_LIST_FILTERS = {
|
||||
"validation": (CampaignJob.validation_status, {item.value for item in JobValidationStatus}),
|
||||
"queue": (CampaignJob.queue_status, {item.value for item in JobQueueStatus}),
|
||||
"send": (CampaignJob.send_status, {item.value for item in JobSendStatus}),
|
||||
"postbox": (
|
||||
CampaignJob.postbox_status,
|
||||
{item.value for item in JobPostboxStatus},
|
||||
),
|
||||
"imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}),
|
||||
}
|
||||
|
||||
@@ -2643,13 +2858,14 @@ class CampaignJobsQuery:
|
||||
validation_status: list[str] | None = Query(default=None),
|
||||
imap_status: list[str] | None = Query(default=None),
|
||||
query_text: str | None = Query(default=None, alias="q", max_length=200),
|
||||
sort_by: Literal["number", "recipient", "subject", "validation", "queue", "send", "imap", "attempts", "updated"] = Query(default="number"),
|
||||
sort_by: Literal["number", "recipient", "subject", "validation", "queue", "send", "postbox", "imap", "attempts", "updated"] = Query(default="number"),
|
||||
sort_direction: Literal["asc", "desc"] = Query(default="asc"),
|
||||
filter_recipient: str | None = Query(default=None, max_length=500),
|
||||
filter_subject: str | None = Query(default=None, max_length=1000),
|
||||
filter_validation: str | None = Query(default=None, max_length=1000),
|
||||
filter_queue: str | None = Query(default=None, max_length=1000),
|
||||
filter_send: str | None = Query(default=None, max_length=1000),
|
||||
filter_postbox: str | None = Query(default=None, max_length=1000),
|
||||
filter_imap: str | None = Query(default=None, max_length=1000),
|
||||
filter_attempts: str | None = Query(default=None, max_length=100),
|
||||
filter_evidence: str | None = Query(default=None, max_length=500),
|
||||
@@ -2672,6 +2888,7 @@ class CampaignJobsQuery:
|
||||
"validation": filter_validation,
|
||||
"queue": filter_queue,
|
||||
"send": filter_send,
|
||||
"postbox": filter_postbox,
|
||||
"imap": filter_imap,
|
||||
"attempts": filter_attempts,
|
||||
"evidence": filter_evidence,
|
||||
@@ -2966,21 +3183,34 @@ def get_job_detail(
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
|
||||
send_attempts = (
|
||||
send_attempts = _job_attempt_rows(
|
||||
session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id == job.id)
|
||||
.order_by(SendAttempt.attempt_number.asc())
|
||||
.all()
|
||||
.order_by(SendAttempt.attempt_number.asc()),
|
||||
label="SMTP attempts for this campaign job",
|
||||
)
|
||||
imap_attempts = (
|
||||
imap_attempts = _job_attempt_rows(
|
||||
session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id == job.id)
|
||||
.order_by(ImapAppendAttempt.attempt_number.asc())
|
||||
.all()
|
||||
.order_by(ImapAppendAttempt.attempt_number.asc()),
|
||||
label="IMAP attempts for this campaign job",
|
||||
)
|
||||
postbox_attempts = _job_attempt_rows(
|
||||
session.query(PostboxDeliveryAttempt)
|
||||
.filter(PostboxDeliveryAttempt.job_id == job.id)
|
||||
.order_by(
|
||||
PostboxDeliveryAttempt.target_index.asc(),
|
||||
PostboxDeliveryAttempt.attempt_number.asc(),
|
||||
),
|
||||
label="Postbox attempts for this campaign job",
|
||||
)
|
||||
return CampaignJobDetailResponse(
|
||||
job=_job_detail_payload(job),
|
||||
attempts=_job_attempts_payload(send_attempts, imap_attempts),
|
||||
attempts=_job_attempts_payload(
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -3002,19 +3232,33 @@ def get_job_diagnostics(
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
|
||||
send_attempts = (
|
||||
send_attempts = _job_attempt_rows(
|
||||
session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id == job.id)
|
||||
.order_by(SendAttempt.attempt_number.asc())
|
||||
.all()
|
||||
.order_by(SendAttempt.attempt_number.asc()),
|
||||
label="SMTP diagnostics for this campaign job",
|
||||
)
|
||||
imap_attempts = (
|
||||
imap_attempts = _job_attempt_rows(
|
||||
session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id == job.id)
|
||||
.order_by(ImapAppendAttempt.attempt_number.asc())
|
||||
.all()
|
||||
.order_by(ImapAppendAttempt.attempt_number.asc()),
|
||||
label="IMAP diagnostics for this campaign job",
|
||||
)
|
||||
postbox_attempts = _job_attempt_rows(
|
||||
session.query(PostboxDeliveryAttempt)
|
||||
.filter(PostboxDeliveryAttempt.job_id == job.id)
|
||||
.order_by(
|
||||
PostboxDeliveryAttempt.target_index.asc(),
|
||||
PostboxDeliveryAttempt.attempt_number.asc(),
|
||||
),
|
||||
label="Postbox diagnostics for this campaign job",
|
||||
)
|
||||
return _job_diagnostics_payload(
|
||||
job,
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts,
|
||||
)
|
||||
return _job_diagnostics_payload(job, send_attempts, imap_attempts)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/summary")
|
||||
@@ -3156,6 +3400,7 @@ def email_campaign_report(
|
||||
@router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse)
|
||||
def list_campaign_share_targets(
|
||||
campaign_id: str,
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
@@ -3163,6 +3408,14 @@ def list_campaign_share_targets(
|
||||
directory = _access_directory()
|
||||
users = [user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active"]
|
||||
groups = [group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active"]
|
||||
if len(users) > limit or len(groups) > limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail=(
|
||||
f"Campaign share targets exceed the maximum response size of {limit} "
|
||||
"users or groups. Use a searchable directory selector."
|
||||
),
|
||||
)
|
||||
return CampaignShareTargetsResponse(
|
||||
users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users],
|
||||
groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) for item in groups],
|
||||
@@ -3172,17 +3425,35 @@ def list_campaign_share_targets(
|
||||
@router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse)
|
||||
def list_campaign_shares(
|
||||
campaign_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
shares = (
|
||||
query = (
|
||||
session.query(CampaignShare)
|
||||
.filter(CampaignShare.tenant_id == principal.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None))
|
||||
.order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc())
|
||||
)
|
||||
total = query.order_by(None).count()
|
||||
pages = max(1, (total + page_size - 1) // page_size)
|
||||
shares = (
|
||||
query.order_by(
|
||||
CampaignShare.target_type.asc(),
|
||||
CampaignShare.target_id.asc(),
|
||||
CampaignShare.id.asc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return CampaignShareListResponse(shares=[CampaignShareItem.model_validate(item) for item in shares])
|
||||
return CampaignShareListResponse(
|
||||
shares=[CampaignShareItem.model_validate(item) for item in shares],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{campaign_id}/owner", response_model=CampaignResponse)
|
||||
@@ -3306,7 +3577,8 @@ def campaign_delivery_options(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
),
|
||||
postbox_available=postbox_integration().available,
|
||||
)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
@@ -3476,6 +3748,7 @@ def resolve_campaign_job_outcome(
|
||||
job_id=job_id,
|
||||
decision=payload.decision,
|
||||
note=payload.note,
|
||||
attempt_id=payload.attempt_id,
|
||||
commit=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
|
||||
Reference in New Issue
Block a user