refactor(api): split campaign workflow routers
This commit is contained in:
1
src/govoplan_campaign/backend/routes/__init__.py
Normal file
1
src/govoplan_campaign/backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Focused HTTP route modules for the campaign API."""
|
||||
398
src/govoplan_campaign/backend/routes/attachments.py
Normal file
398
src/govoplan_campaign/backend/routes/attachments.py
Normal file
@@ -0,0 +1,398 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
load_campaign_config_from_json,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
files_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import (
|
||||
CampaignPathSecurityError,
|
||||
assert_server_safe_campaign_paths,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
||||
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
|
||||
from govoplan_campaign.backend.persistence.versions import (
|
||||
is_version_final_locked,
|
||||
is_user_locked_version,
|
||||
)
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_get_campaign_for_tenant,
|
||||
_get_version_for_tenant,
|
||||
_require_mail_profile_use_if_needed,
|
||||
_require_permission,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
class CampaignAttachmentPreviewRequest(BaseModel):
|
||||
include_unmatched: bool = True
|
||||
include_unlinked_candidates: bool = False
|
||||
campaign_json: dict[str, object] | None = None
|
||||
|
||||
|
||||
class CampaignAttachmentPreviewResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
shared_file_count: int
|
||||
candidate_file_count: int = 0
|
||||
matched_file_count: int = 0
|
||||
linked_file_count: int = 0
|
||||
unlinked_file_count: int = 0
|
||||
rules: list[dict[str, object]] = Field(default_factory=list)
|
||||
linkable_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
unused_shared_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignAttachmentLinkMatchesRequest(BaseModel):
|
||||
campaign_json: dict[str, object] | None = None
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class CampaignAttachmentLinkMatchesResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
matched_file_count: int
|
||||
already_linked_file_count: int
|
||||
linked_file_count: int
|
||||
dry_run: bool = False
|
||||
linked_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
linkable_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _file_preview(session: Session, asset) -> dict[str, object]:
|
||||
version, blob = files_integration().current_version_and_blob(session, asset)
|
||||
return {
|
||||
"id": asset.id,
|
||||
"version_id": version.id,
|
||||
"blob_id": blob.id,
|
||||
"display_path": asset.display_path,
|
||||
"filename": asset.filename,
|
||||
"owner_type": asset.owner_type,
|
||||
"owner_id": asset.owner_user_id
|
||||
if asset.owner_type == "user"
|
||||
else asset.owner_group_id,
|
||||
"checksum_sha256": blob.checksum_sha256,
|
||||
"size_bytes": blob.size_bytes,
|
||||
"content_type": blob.content_type,
|
||||
"linked_to_campaign": True,
|
||||
}
|
||||
|
||||
|
||||
def _managed_preview_file(item: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
"id": item["asset_id"],
|
||||
"version_id": item["version_id"],
|
||||
"blob_id": item["blob_id"],
|
||||
"display_path": item["display_path"],
|
||||
"filename": item["filename"],
|
||||
"owner_type": item["owner_type"],
|
||||
"owner_id": item["owner_id"],
|
||||
"checksum_sha256": item["checksum_sha256"],
|
||||
"size_bytes": item["size_bytes"],
|
||||
"content_type": item["content_type"],
|
||||
"linked_to_campaign": bool(item.get("linked_to_campaign", True)),
|
||||
}
|
||||
|
||||
|
||||
def _attachment_preview_for_version(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
raw: dict[str, object],
|
||||
include_unmatched: bool,
|
||||
include_unlinked_candidates: bool,
|
||||
) -> CampaignAttachmentPreviewResponse:
|
||||
files = files_integration()
|
||||
assert_server_safe_campaign_paths(raw, managed_files_available=files.available)
|
||||
with files.prepared_campaign_snapshot(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
raw_json=raw,
|
||||
include_bytes=False,
|
||||
prefix="govoplan-managed-preview-",
|
||||
include_unlinked_candidates=include_unlinked_candidates,
|
||||
user_id=principal.user.id,
|
||||
is_admin=has_scope(principal, "files:file:admin"),
|
||||
) as prepared:
|
||||
prepared_raw = load_campaign_json(prepared.path)
|
||||
config = load_campaign_config_from_json(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
raw_json=prepared_raw,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
report = resolve_campaign_attachments(config, campaign_file=prepared.path)
|
||||
rules: list[dict[str, object]] = []
|
||||
matched_asset_ids: set[str] = set()
|
||||
linked_asset_ids: set[str] = set()
|
||||
linkable_by_id: dict[str, dict[str, object]] = {}
|
||||
|
||||
for entry in report.entries:
|
||||
for attachment in entry.attachments:
|
||||
managed_matches = files.managed_match_payloads(
|
||||
attachment.matches, prepared.managed_files_by_local_path
|
||||
)
|
||||
matches: list[dict[str, object]] = []
|
||||
for item in managed_matches:
|
||||
asset_id = str(item["asset_id"])
|
||||
matched_asset_ids.add(asset_id)
|
||||
if bool(item.get("linked_to_campaign", True)):
|
||||
linked_asset_ids.add(asset_id)
|
||||
preview = _managed_preview_file(item)
|
||||
matches.append(preview)
|
||||
if not preview["linked_to_campaign"]:
|
||||
linkable_by_id.setdefault(asset_id, preview)
|
||||
if not matches:
|
||||
matches = [
|
||||
{
|
||||
"id": "",
|
||||
"display_path": match,
|
||||
"filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1],
|
||||
"owner_type": "legacy",
|
||||
"owner_id": "",
|
||||
"linked_to_campaign": True,
|
||||
}
|
||||
for match in attachment.matches
|
||||
]
|
||||
rules.append(
|
||||
{
|
||||
"source": attachment.scope.value,
|
||||
"entry_index": entry.entry_index,
|
||||
"entry_id": entry.entry_id,
|
||||
"index": attachment.index,
|
||||
"attachment_id": attachment.attachment_id,
|
||||
"label": attachment.label,
|
||||
"required": attachment.required,
|
||||
"pattern": attachment.file_filter,
|
||||
"base_path_name": attachment.base_path_name,
|
||||
"base_path": attachment.base_path,
|
||||
"status": attachment.status.value,
|
||||
"behavior": attachment.behavior.value
|
||||
if attachment.behavior
|
||||
else None,
|
||||
"zip_included": attachment.zip_enabled,
|
||||
"zip_mode": attachment.zip_mode.value,
|
||||
"zip_archive_id": attachment.zip_archive_id,
|
||||
"zip_filename": attachment.zip_filename,
|
||||
"matches": matches,
|
||||
"match_count": len(matches),
|
||||
"linked_match_count": sum(
|
||||
1
|
||||
for match in matches
|
||||
if bool(match.get("linked_to_campaign", True))
|
||||
),
|
||||
"unlinked_match_count": sum(
|
||||
1
|
||||
for match in matches
|
||||
if not bool(match.get("linked_to_campaign", True))
|
||||
),
|
||||
"issues": [
|
||||
issue.model_dump(mode="json") for issue in attachment.issues
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
unused = [
|
||||
asset
|
||||
for asset in prepared.shared_assets
|
||||
if asset.id not in matched_asset_ids
|
||||
]
|
||||
return CampaignAttachmentPreviewResponse(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
shared_file_count=len(prepared.shared_assets),
|
||||
candidate_file_count=len(
|
||||
getattr(prepared, "candidate_assets", prepared.shared_assets)
|
||||
),
|
||||
matched_file_count=len(matched_asset_ids),
|
||||
linked_file_count=len(linked_asset_ids),
|
||||
unlinked_file_count=len(linkable_by_id),
|
||||
rules=rules,
|
||||
linkable_files=list(linkable_by_id.values()),
|
||||
unused_shared_files=[_file_preview(session, asset) for asset in unused]
|
||||
if include_unmatched
|
||||
else [],
|
||||
)
|
||||
|
||||
|
||||
def _link_campaign_attachment_matches(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
raw: dict[str, object],
|
||||
dry_run: bool = False,
|
||||
) -> CampaignAttachmentLinkMatchesResponse:
|
||||
preview = _attachment_preview_for_version(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=raw,
|
||||
include_unmatched=False,
|
||||
include_unlinked_candidates=True,
|
||||
)
|
||||
file_ids = [
|
||||
str(item.get("id") or "") for item in preview.linkable_files if item.get("id")
|
||||
]
|
||||
linked_files: list[dict[str, object]] = []
|
||||
if file_ids and not dry_run:
|
||||
files = files_integration()
|
||||
shares = files.share_assets_with_campaign(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
file_ids=file_ids,
|
||||
user_id=principal.user.id,
|
||||
is_admin=has_scope(principal, "files:file:admin"),
|
||||
)
|
||||
share_by_asset_id = {
|
||||
str(item.get("file_asset_id") or ""): item for item in shares
|
||||
}
|
||||
linked_files = [
|
||||
{**item, "share": share_by_asset_id.get(str(item.get("id") or ""))}
|
||||
for item in preview.linkable_files
|
||||
]
|
||||
return CampaignAttachmentLinkMatchesResponse(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
matched_file_count=preview.matched_file_count,
|
||||
already_linked_file_count=preview.linked_file_count,
|
||||
linked_file_count=0 if dry_run else len(file_ids),
|
||||
dry_run=dry_run,
|
||||
linked_files=linked_files,
|
||||
linkable_files=preview.linkable_files,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/attachments/preview",
|
||||
response_model=CampaignAttachmentPreviewResponse,
|
||||
)
|
||||
def preview_campaign_attachments(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignAttachmentPreviewRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||
)
|
||||
|
||||
payload = payload or CampaignAttachmentPreviewRequest()
|
||||
raw = (
|
||||
payload.campaign_json
|
||||
if isinstance(payload.campaign_json, dict)
|
||||
else version.raw_json
|
||||
)
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
_require_mail_profile_use_if_needed(principal, raw)
|
||||
try:
|
||||
return _attachment_preview_for_version(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=raw,
|
||||
include_unmatched=payload.include_unmatched,
|
||||
include_unlinked_candidates=payload.include_unlinked_candidates,
|
||||
)
|
||||
except CampaignPathSecurityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/attachments/link-matches",
|
||||
response_model=CampaignAttachmentLinkMatchesResponse,
|
||||
)
|
||||
def link_campaign_attachment_matches(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignAttachmentLinkMatchesRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
|
||||
):
|
||||
_require_permission(principal, "files:file:share")
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||
)
|
||||
if is_user_locked_version(version) or is_version_final_locked(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Locked campaign versions cannot link new attachment files",
|
||||
)
|
||||
payload = payload or CampaignAttachmentLinkMatchesRequest()
|
||||
raw = (
|
||||
payload.campaign_json
|
||||
if isinstance(payload.campaign_json, dict)
|
||||
else version.raw_json
|
||||
)
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
_require_mail_profile_use_if_needed(principal, raw)
|
||||
try:
|
||||
result = _link_campaign_attachment_matches(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=raw,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.attachment_matches_linked"
|
||||
if not payload.dry_run
|
||||
else "campaign.attachment_matches_link_previewed",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"matched_file_count": result.matched_file_count,
|
||||
"already_linked_file_count": result.already_linked_file_count,
|
||||
"linked_file_count": result.linked_file_count,
|
||||
"dry_run": result.dry_run,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
1362
src/govoplan_campaign/backend/routes/campaigns.py
Normal file
1362
src/govoplan_campaign/backend/routes/campaigns.py
Normal file
File diff suppressed because it is too large
Load Diff
639
src/govoplan_campaign/backend/routes/delivery.py
Normal file
639
src/govoplan_campaign/backend/routes/delivery.py
Normal file
@@ -0,0 +1,639 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
AppendSentRequest,
|
||||
CampaignActionResponse,
|
||||
CampaignRetryJobsRequest,
|
||||
CampaignSendJobRequest,
|
||||
CampaignSendUnattemptedRequest,
|
||||
CampaignResolveOutcomeRequest,
|
||||
CampaignDeliveryOptionsResponse,
|
||||
MockCampaignSendRequest,
|
||||
MockCampaignSendResponse,
|
||||
QueueCampaignRequest,
|
||||
QueueCampaignResponse,
|
||||
SendCampaignNowRequest,
|
||||
SendCampaignNowResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
JobImapStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
postbox_integration,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_send_campaign_now_result,
|
||||
send_campaign_now_audit_details,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.versions import (
|
||||
is_user_locked_version,
|
||||
)
|
||||
|
||||
from govoplan_campaign.backend.dev.mock_campaign import (
|
||||
MockCampaignSendError,
|
||||
run_mock_campaign_send,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueingError,
|
||||
SynchronousSendRejected,
|
||||
cancel_campaign_jobs,
|
||||
enqueue_pending_imap_appends,
|
||||
pause_campaign_jobs,
|
||||
queue_campaign_jobs,
|
||||
queue_failed_jobs_for_retry,
|
||||
queue_unattempted_jobs,
|
||||
reconcile_job_outcome,
|
||||
resume_campaign_jobs,
|
||||
send_campaign_now,
|
||||
send_single_campaign_job,
|
||||
synchronous_send_options,
|
||||
)
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_get_campaign_for_tenant,
|
||||
_get_version_for_tenant,
|
||||
_require_campaign_profile_use_if_needed,
|
||||
_require_campaign_versions_profile_use,
|
||||
_require_mail_profile_use_if_needed,
|
||||
_require_permission,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@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,
|
||||
),
|
||||
postbox_available=postbox_integration().available,
|
||||
)
|
||||
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,
|
||||
payload: QueueCampaignRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or QueueCampaignRequest()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = queue_campaign_jobs(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
include_warnings=payload.include_warnings,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.queued"
|
||||
if not payload.dry_run
|
||||
else "campaign.queue_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result.as_dict(),
|
||||
commit=True,
|
||||
)
|
||||
return QueueCampaignResponse(**result.as_dict())
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/jobs/retry", response_model=CampaignActionResponse)
|
||||
def retry_campaign_jobs(
|
||||
campaign_id: str,
|
||||
payload: CampaignRetryJobsRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:retry")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignRetryJobsRequest()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = queue_failed_jobs_for_retry(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
job_ids=payload.job_ids or None,
|
||||
include_permanent=payload.include_permanent,
|
||||
force_max_attempts=payload.force_max_attempts,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.jobs_retry_queued"
|
||||
if not payload.dry_run
|
||||
else "campaign.jobs_retry_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/jobs/send-unattempted", response_model=CampaignActionResponse
|
||||
)
|
||||
def send_unattempted_campaign_jobs(
|
||||
campaign_id: str,
|
||||
payload: CampaignSendUnattemptedRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignSendUnattemptedRequest()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = queue_unattempted_jobs(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
job_ids=payload.job_ids or None,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.unattempted_jobs_queued"
|
||||
if not payload.dry_run
|
||||
else "campaign.unattempted_jobs_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/jobs/{job_id}/send", response_model=CampaignActionResponse)
|
||||
def send_single_campaign_job_endpoint(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignSendJobRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignSendJobRequest()
|
||||
_require_campaign_profile_use_if_needed(session, principal, campaign_id, None)
|
||||
try:
|
||||
result = send_single_campaign_job(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
job_id=job_id,
|
||||
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",
|
||||
object_type="campaign_job",
|
||||
object_id=job_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/jobs/{job_id}/resolve-outcome",
|
||||
response_model=CampaignActionResponse,
|
||||
)
|
||||
def resolve_campaign_job_outcome(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignResolveOutcomeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
try:
|
||||
result = reconcile_job_outcome(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
job_id=job_id,
|
||||
decision=payload.decision,
|
||||
note=payload.note,
|
||||
attempt_id=payload.attempt_id,
|
||||
commit=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.job_outcome_reconciled",
|
||||
object_type="campaign_job",
|
||||
object_id=job_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)
|
||||
def mock_send_campaign(
|
||||
campaign_id: str,
|
||||
payload: MockCampaignSendRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
|
||||
):
|
||||
"""Run a fully visible mock delivery flow without mutating campaign state.
|
||||
|
||||
The route validates and builds the selected version, then optionally records
|
||||
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
|
||||
real SMTP/IMAP servers and it does not mark the version sent/final.
|
||||
"""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
payload = payload or MockCampaignSendRequest()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = run_mock_campaign_send(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
send=payload.send,
|
||||
include_warnings=payload.include_warnings,
|
||||
include_needs_review=payload.include_needs_review,
|
||||
append_sent=payload.append_sent,
|
||||
clear_mailbox=payload.clear_mailbox,
|
||||
check_files=payload.check_files,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.mock_send"
|
||||
if payload.send
|
||||
else "campaign.mock_send_review",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details={
|
||||
"version_id": result.get("version_id"),
|
||||
"send_requested": payload.send,
|
||||
"sent_count": result.get("send", {}).get("sent_count"),
|
||||
"failed_count": result.get("send", {}).get("failed_count"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return MockCampaignSendResponse(result=result)
|
||||
except MockCampaignSendError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse)
|
||||
def send_campaign_now_endpoint(
|
||||
campaign_id: str,
|
||||
payload: SendCampaignNowRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
"""Preflight and synchronously send a policy-bounded built execution."""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
payload = payload or SendCampaignNowRequest()
|
||||
try:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version_id = payload.version_id or campaign.current_version_id
|
||||
if not version_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign has no current version",
|
||||
)
|
||||
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
validation_result: dict[str, object] | None = (
|
||||
version.validation_summary
|
||||
if isinstance(version.validation_summary, dict)
|
||||
else None
|
||||
)
|
||||
build_result: dict[str, object] | None = (
|
||||
version.build_summary if isinstance(version.build_summary, dict) else None
|
||||
)
|
||||
if is_user_locked_version(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.",
|
||||
)
|
||||
if (
|
||||
not version.locked_at
|
||||
or not validation_result
|
||||
or validation_result.get("ok") is not True
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign version must be validated and locked before dry-run or sending.",
|
||||
)
|
||||
if not build_result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign version must be built before dry-run or sending.",
|
||||
)
|
||||
|
||||
delivery_result = send_campaign_now(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_warnings=payload.include_warnings,
|
||||
dry_run=payload.dry_run,
|
||||
use_rate_limit=payload.use_rate_limit,
|
||||
enqueue_imap_task=payload.enqueue_imap_task,
|
||||
).as_dict()
|
||||
response_result = public_send_campaign_now_result(
|
||||
delivery_result,
|
||||
validation_summary=validation_result,
|
||||
build_summary=build_result,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.sent_now"
|
||||
if not payload.dry_run
|
||||
else "campaign.send_now_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=send_campaign_now_audit_details(delivery_result),
|
||||
commit=True,
|
||||
)
|
||||
return SendCampaignNowResponse(result=response_result)
|
||||
except SynchronousSendRejected as exc:
|
||||
# A synchronous request stages queue state before the all-message
|
||||
# preflight can run. Rejecting that preflight must not leave work
|
||||
# eligible for a background worker when no provider effect occurred.
|
||||
session.rollback()
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse)
|
||||
def pause_campaign(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
result = pause_campaign_jobs(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.paused",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse)
|
||||
def resume_campaign(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
version_ids = {
|
||||
row[0]
|
||||
for row in session.query(CampaignJob.campaign_version_id)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == principal.tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.queue_status == JobQueueStatus.PAUSED.value,
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
}
|
||||
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
||||
try:
|
||||
result = resume_campaign_jobs(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.resumed",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse)
|
||||
def cancel_campaign(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
result = cancel_campaign_jobs(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.cancelled",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse)
|
||||
def append_sent(
|
||||
campaign_id: str,
|
||||
payload: AppendSentRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
payload = payload or AppendSentRequest()
|
||||
version_ids = {
|
||||
row[0]
|
||||
for row in session.query(CampaignJob.campaign_version_id)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == principal.tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.send_status.in_(
|
||||
[JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value]
|
||||
),
|
||||
CampaignJob.imap_status.in_(
|
||||
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
|
||||
),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
}
|
||||
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
||||
try:
|
||||
result = enqueue_pending_imap_appends(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
run_inline=payload.run_inline,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.append_sent_enqueued"
|
||||
if not payload.dry_run
|
||||
else "campaign.append_sent_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
436
src/govoplan_campaign/backend/routes/jobs.py
Normal file
436
src/govoplan_campaign/backend/routes/jobs.py
Normal file
@@ -0,0 +1,436 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignJobsResponse,
|
||||
CampaignJobsDeltaResponse,
|
||||
CampaignJobDetailResponse,
|
||||
CampaignJobDiagnosticsResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.core.change_sequence import (
|
||||
decode_sequence_watermark,
|
||||
encode_sequence_watermark,
|
||||
sequence_entries_since,
|
||||
sequence_watermark_is_expired,
|
||||
)
|
||||
from govoplan_campaign.backend.change_tracking import (
|
||||
CAMPAIGNS_MODULE_ID,
|
||||
CAMPAIGN_JOBS_COLLECTION,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_get_campaign_for_tenant,
|
||||
_require_permission,
|
||||
job_attempt_rows as _job_attempt_rows,
|
||||
)
|
||||
from govoplan_campaign.backend.services.job_queries import (
|
||||
CampaignJobsQuery,
|
||||
_campaign_jobs_delta_watermark,
|
||||
_campaign_jobs_page_response,
|
||||
_campaign_jobs_query_context,
|
||||
_job_attempts_payload,
|
||||
_job_detail_payload,
|
||||
_job_diagnostics_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
|
||||
def list_jobs(
|
||||
campaign_id: str,
|
||||
filters: CampaignJobsQuery = Depends(CampaignJobsQuery),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
"""Return a lightweight, paginated job list with server-side filters.
|
||||
|
||||
Complete recipients, attachment metadata, issues and attempt history are
|
||||
available from the separate job-detail endpoint.
|
||||
"""
|
||||
|
||||
_campaign, base_filters, filtered, review_metadata, reviewed_keys = (
|
||||
_campaign_jobs_query_context(
|
||||
session,
|
||||
principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
send_status=filters.send_status,
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
)
|
||||
)
|
||||
return _campaign_jobs_page_response(
|
||||
session,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
base_filters=base_filters,
|
||||
filtered=filtered,
|
||||
reviewed_keys=reviewed_keys,
|
||||
review_metadata=review_metadata,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
)
|
||||
|
||||
|
||||
def _campaign_jobs_full_delta_response(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
campaign_id: str,
|
||||
version_id: str | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
send_status: list[str] | None,
|
||||
validation_status: list[str] | None,
|
||||
imap_status: list[str] | None,
|
||||
query_text: str | None,
|
||||
grid_filters: dict[str, str] | None,
|
||||
sort_by: str,
|
||||
sort_direction: str,
|
||||
cursor: str | None = None,
|
||||
) -> CampaignJobsDeltaResponse:
|
||||
_campaign, base_filters, filtered, review_metadata, reviewed_keys = (
|
||||
_campaign_jobs_query_context(
|
||||
session,
|
||||
principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
send_status=send_status,
|
||||
validation_status=validation_status,
|
||||
imap_status=imap_status,
|
||||
query_text=query_text,
|
||||
grid_filters=grid_filters,
|
||||
)
|
||||
)
|
||||
payload = _campaign_jobs_page_response(
|
||||
session,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
base_filters=base_filters,
|
||||
filtered=filtered,
|
||||
reviewed_keys=reviewed_keys,
|
||||
review_metadata=review_metadata,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
send_status=send_status,
|
||||
validation_status=validation_status,
|
||||
imap_status=imap_status,
|
||||
query_text=query_text,
|
||||
grid_filters=grid_filters,
|
||||
sort_by=sort_by,
|
||||
sort_direction=sort_direction,
|
||||
cursor=cursor,
|
||||
)
|
||||
return CampaignJobsDeltaResponse(
|
||||
**payload.model_dump(),
|
||||
deleted=[],
|
||||
watermark=_campaign_jobs_delta_watermark(session, principal.tenant_id),
|
||||
has_more=False,
|
||||
full=True,
|
||||
)
|
||||
|
||||
|
||||
def _job_filter_membership_can_shift(
|
||||
*,
|
||||
send_status: list[str] | None,
|
||||
validation_status: list[str] | None,
|
||||
imap_status: list[str] | None,
|
||||
query_text: str | None,
|
||||
grid_filters: dict[str, str] | None,
|
||||
sort_by: str,
|
||||
sort_direction: str,
|
||||
) -> bool:
|
||||
return bool(
|
||||
send_status
|
||||
or validation_status
|
||||
or imap_status
|
||||
or (query_text and query_text.strip())
|
||||
or grid_filters
|
||||
or sort_by != "number"
|
||||
or sort_direction != "asc"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/jobs/delta", response_model=CampaignJobsDeltaResponse)
|
||||
def list_jobs_delta(
|
||||
campaign_id: str,
|
||||
filters: CampaignJobsQuery = Depends(CampaignJobsQuery),
|
||||
since: str | None = None,
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
if since is None:
|
||||
return _campaign_jobs_full_delta_response(
|
||||
session,
|
||||
principal=principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
)
|
||||
|
||||
campaign, base_filters, filtered, review_metadata, reviewed_keys = (
|
||||
_campaign_jobs_query_context(
|
||||
session,
|
||||
principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
send_status=filters.send_status,
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
)
|
||||
)
|
||||
try:
|
||||
since_sequence = decode_sequence_watermark(since)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
if sequence_watermark_is_expired(
|
||||
session,
|
||||
since=since_sequence,
|
||||
tenant_id=principal.tenant_id,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collections=(CAMPAIGN_JOBS_COLLECTION,),
|
||||
):
|
||||
return _campaign_jobs_full_delta_response(
|
||||
session,
|
||||
principal=principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
)
|
||||
|
||||
entries_plus_one = sequence_entries_since(
|
||||
session,
|
||||
since=since_sequence,
|
||||
tenant_id=principal.tenant_id,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collections=(CAMPAIGN_JOBS_COLLECTION,),
|
||||
limit=limit + 1,
|
||||
)
|
||||
has_more = len(entries_plus_one) > limit
|
||||
entries = entries_plus_one[:limit]
|
||||
relevant_entries = [
|
||||
entry
|
||||
for entry in entries
|
||||
if (entry.payload or {}).get("campaign_id") == campaign.id
|
||||
and (
|
||||
not filters.version_id
|
||||
or (entry.payload or {}).get("version_id") == filters.version_id
|
||||
)
|
||||
]
|
||||
|
||||
if relevant_entries and (
|
||||
_job_filter_membership_can_shift(
|
||||
send_status=filters.send_status,
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
)
|
||||
or any(entry.operation in {"created", "deleted"} for entry in relevant_entries)
|
||||
):
|
||||
return _campaign_jobs_full_delta_response(
|
||||
session,
|
||||
principal=principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
)
|
||||
|
||||
changed_job_ids = {
|
||||
entry.resource_id
|
||||
for entry in relevant_entries
|
||||
if entry.resource_type == "campaign_job" and entry.operation != "deleted"
|
||||
}
|
||||
payload = _campaign_jobs_page_response(
|
||||
session,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
base_filters=base_filters,
|
||||
filtered=filtered,
|
||||
reviewed_keys=reviewed_keys,
|
||||
review_metadata=review_metadata,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
changed_job_ids=changed_job_ids,
|
||||
)
|
||||
watermark = (
|
||||
encode_sequence_watermark(entries[-1].id)
|
||||
if has_more and entries
|
||||
else _campaign_jobs_delta_watermark(session, principal.tenant_id)
|
||||
)
|
||||
return CampaignJobsDeltaResponse(
|
||||
**payload.model_dump(),
|
||||
deleted=[],
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/jobs/{job_id}", response_model=CampaignJobDetailResponse)
|
||||
def get_job_detail(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
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 = _job_attempt_rows(
|
||||
session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id == job.id)
|
||||
.order_by(SendAttempt.attempt_number.asc()),
|
||||
label="SMTP attempts for this campaign job",
|
||||
)
|
||||
imap_attempts = _job_attempt_rows(
|
||||
session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id == job.id)
|
||||
.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,
|
||||
postbox_attempts,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/jobs/{job_id}/diagnostics",
|
||||
response_model=CampaignJobDiagnosticsResponse,
|
||||
)
|
||||
def get_job_diagnostics(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:diagnostic:read")),
|
||||
):
|
||||
"""Return infrastructure details only to campaign operators/admins."""
|
||||
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
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 = _job_attempt_rows(
|
||||
session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id == job.id)
|
||||
.order_by(SendAttempt.attempt_number.asc()),
|
||||
label="SMTP diagnostics for this campaign job",
|
||||
)
|
||||
imap_attempts = _job_attempt_rows(
|
||||
session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id == job.id)
|
||||
.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,
|
||||
)
|
||||
198
src/govoplan_campaign/backend/routes/reports.py
Normal file
198
src/govoplan_campaign/backend/routes/reports.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
ReportEmailRequest,
|
||||
ReportEmailResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignVersion,
|
||||
)
|
||||
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.reports.emailing import (
|
||||
CampaignReportEmailError,
|
||||
send_campaign_report_email,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
MailProfileError,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
)
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_require_mail_profile_use_if_needed,
|
||||
_require_permission,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/summary")
|
||||
def campaign_summary(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
include_jobs: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
"""Return dashboard-friendly campaign status counters and summaries."""
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
if include_jobs:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
try:
|
||||
return generate_campaign_report(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_jobs=include_jobs,
|
||||
include_recent_failures=include_jobs,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/report")
|
||||
def campaign_report(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
include_jobs: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
|
||||
):
|
||||
"""Return the recipient-level JSON report for one campaign."""
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
try:
|
||||
return generate_campaign_report(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_jobs=include_jobs,
|
||||
include_recent_failures=include_jobs,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/report/jobs.csv")
|
||||
def campaign_jobs_csv(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:export")),
|
||||
):
|
||||
"""Export per-job campaign status as CSV."""
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
|
||||
try:
|
||||
csv_text = generate_jobs_csv(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
return Response(
|
||||
content=csv_text,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse)
|
||||
def email_campaign_report(
|
||||
campaign_id: str,
|
||||
payload: ReportEmailRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")),
|
||||
):
|
||||
"""Generate a campaign report and send it to one or more email addresses."""
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
selected_version_id = payload.version_id or campaign.current_version_id
|
||||
selected_version = (
|
||||
session.get(CampaignVersion, selected_version_id)
|
||||
if selected_version_id
|
||||
else None
|
||||
)
|
||||
if selected_version is not None and selected_version.campaign_id == campaign.id:
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal,
|
||||
selected_version.raw_json
|
||||
if isinstance(selected_version.raw_json, dict)
|
||||
else {},
|
||||
)
|
||||
try:
|
||||
result = send_campaign_report_email(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
to=payload.to,
|
||||
include_jobs=payload.include_jobs,
|
||||
attach_jobs_csv=payload.attach_jobs_csv,
|
||||
attach_report_json=payload.attach_report_json,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="report.email_sent"
|
||||
if not payload.dry_run
|
||||
else "report.email_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result.as_dict(),
|
||||
commit=True,
|
||||
)
|
||||
return ReportEmailResponse(result=result.as_dict())
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except (
|
||||
CampaignReportEmailError,
|
||||
MailProfileError,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("Campaign report email failed with an unexpected internal error")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Campaign report email could not be completed.",
|
||||
) from exc
|
||||
272
src/govoplan_campaign/backend/routes/sharing.py
Normal file
272
src/govoplan_campaign/backend/routes/sharing.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignShareItem,
|
||||
CampaignShareListResponse,
|
||||
CampaignShareTargetItem,
|
||||
CampaignShareTargetsResponse,
|
||||
CampaignShareUpsertRequest,
|
||||
CampaignOwnerUpdateRequest,
|
||||
CampaignResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignShare,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_access_directory,
|
||||
_clear_current_version_mail_profile_for_owner_transfer,
|
||||
_get_campaign_for_principal,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@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")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
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
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
query = session.query(CampaignShare).filter(
|
||||
CampaignShare.tenant_id == principal.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
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],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{campaign_id}/owner", response_model=CampaignResponse)
|
||||
def update_campaign_owner(
|
||||
campaign_id: str,
|
||||
payload: CampaignOwnerUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
if payload.owner_user_id and payload.owner_group_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Choose either a user owner or a group owner, not both",
|
||||
)
|
||||
directory = _access_directory()
|
||||
if payload.owner_user_id:
|
||||
owner = directory.get_user(payload.owner_user_id)
|
||||
if owner is not None and (
|
||||
owner.tenant_id != principal.tenant_id or owner.status != "active"
|
||||
):
|
||||
owner = None
|
||||
if owner is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found"
|
||||
)
|
||||
if payload.owner_group_id:
|
||||
group = directory.get_group(payload.owner_group_id)
|
||||
if group is not None and (
|
||||
group.tenant_id != principal.tenant_id or group.status != "active"
|
||||
):
|
||||
group = None
|
||||
if group is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found"
|
||||
)
|
||||
owner_changed = (
|
||||
campaign.owner_user_id != payload.owner_user_id
|
||||
or campaign.owner_group_id != payload.owner_group_id
|
||||
)
|
||||
mail_profile_reselection_required = False
|
||||
if owner_changed:
|
||||
mail_profile_reselection_required = (
|
||||
_clear_current_version_mail_profile_for_owner_transfer(session, campaign)
|
||||
)
|
||||
|
||||
campaign.owner_user_id = payload.owner_user_id
|
||||
campaign.owner_group_id = payload.owner_group_id
|
||||
session.add(campaign)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.owner_updated",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={
|
||||
**payload.model_dump(),
|
||||
"mail_profile_reselection_required": mail_profile_reselection_required,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignResponse.model_validate(campaign)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/shares",
|
||||
response_model=CampaignShareItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def upsert_campaign_share(
|
||||
campaign_id: str,
|
||||
payload: CampaignShareUpsertRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
directory = _access_directory()
|
||||
if payload.target_type == "user":
|
||||
target = directory.get_user(payload.target_id)
|
||||
if target is not None and (
|
||||
target.tenant_id != principal.tenant_id or target.status != "active"
|
||||
):
|
||||
target = None
|
||||
else:
|
||||
target = directory.get_group(payload.target_id)
|
||||
if target is not None and (
|
||||
target.tenant_id != principal.tenant_id or target.status != "active"
|
||||
):
|
||||
target = None
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found"
|
||||
)
|
||||
share = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.target_type == payload.target_type,
|
||||
CampaignShare.target_id == payload.target_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if share is None:
|
||||
share = CampaignShare(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
target_type=payload.target_type,
|
||||
target_id=payload.target_id,
|
||||
permission=payload.permission,
|
||||
created_by_user_id=principal.user.id,
|
||||
)
|
||||
else:
|
||||
share.permission = payload.permission
|
||||
share.revoked_at = None
|
||||
session.add(share)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.share_upserted",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details=payload.model_dump(),
|
||||
commit=True,
|
||||
)
|
||||
return CampaignShareItem.model_validate(share)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{campaign_id}/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
def revoke_campaign_share(
|
||||
campaign_id: str,
|
||||
share_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
share = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.id == share_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.tenant_id == principal.tenant_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if share is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign share not found"
|
||||
)
|
||||
share.revoked_at = utc_now()
|
||||
session.add(share)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.share_revoked",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={"share_id": share_id},
|
||||
commit=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Queue / delivery control -------------------------------------------------
|
||||
667
src/govoplan_campaign/backend/routes/versions.py
Normal file
667
src/govoplan_campaign/backend/routes/versions.py
Normal file
@@ -0,0 +1,667 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
BuildCampaignRequest,
|
||||
CampaignCreateResponse,
|
||||
CampaignResponse,
|
||||
CampaignVersionDetailResponse,
|
||||
CampaignVersionResponse,
|
||||
CampaignVersionSetStepRequest,
|
||||
CampaignReviewStateRequest,
|
||||
CampaignVersionUpdateRequest,
|
||||
CampaignPartialValidationRequest,
|
||||
CampaignPartialValidationResponse,
|
||||
ValidateCampaignRequest,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_payload,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
build_campaign_version,
|
||||
validate_campaign_version,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError
|
||||
from govoplan_campaign.backend.persistence.versions import (
|
||||
LockedCampaignVersionError,
|
||||
fork_campaign_version_for_edit,
|
||||
is_version_final_locked,
|
||||
is_user_locked_version,
|
||||
get_campaign_version_for_tenant,
|
||||
lock_campaign_version_temporarily,
|
||||
permanently_lock_campaign_version,
|
||||
publish_campaign_version,
|
||||
unlock_user_locked_campaign_version,
|
||||
unlock_validated_campaign_version,
|
||||
update_campaign_version,
|
||||
update_campaign_review_state,
|
||||
validate_campaign_partial,
|
||||
)
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_campaign_response_context,
|
||||
_campaign_version_detail_response,
|
||||
_get_campaign_for_principal,
|
||||
_get_campaign_for_tenant,
|
||||
_get_version_for_principal,
|
||||
_get_version_for_tenant,
|
||||
_require_mail_profile_use_if_needed,
|
||||
_require_permission,
|
||||
_update_campaign_version_detail_response,
|
||||
_write_current_version_snapshot_if_available,
|
||||
bounded_query_rows as _bounded_query_rows,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.attachments import (
|
||||
CampaignAttachmentLinkMatchesResponse,
|
||||
_link_campaign_attachment_matches,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@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 = _bounded_query_rows(
|
||||
session.query(CampaignVersion)
|
||||
.filter(CampaignVersion.campaign_id == campaign.id)
|
||||
.order_by(CampaignVersion.version_number.desc()),
|
||||
limit=limit,
|
||||
label="Campaign version history",
|
||||
)
|
||||
return [
|
||||
CampaignVersionResponse.model_validate(
|
||||
item,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
for item in versions
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse
|
||||
)
|
||||
def get_version_detail(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
try:
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
return CampaignVersionDetailResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse
|
||||
)
|
||||
def fork_version_for_edit(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")),
|
||||
):
|
||||
"""Create the campaign's next and only editable working version.
|
||||
|
||||
A new working copy may be created only after the current version is
|
||||
permanently user-locked or delivery-final. Validation and temporary user
|
||||
locks must be removed in place instead of creating parallel drafts.
|
||||
"""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
payload = payload or CampaignVersionUpdateRequest()
|
||||
source_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if source_version.campaign_id != campaign_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||
)
|
||||
effective_json = (
|
||||
payload.campaign_json
|
||||
if isinstance(payload.campaign_json, dict)
|
||||
else source_version.raw_json
|
||||
)
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal,
|
||||
effective_json if isinstance(effective_json, dict) else {},
|
||||
)
|
||||
try:
|
||||
version = fork_campaign_version_for_edit(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
raw_json=payload.campaign_json,
|
||||
current_flow=payload.current_flow or "manual",
|
||||
current_step=payload.current_step,
|
||||
editor_state=payload.editor_state,
|
||||
source_filename=payload.source_filename,
|
||||
source_base_path=payload.source_base_path,
|
||||
autosave=True,
|
||||
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
|
||||
commit=False,
|
||||
)
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_forked_for_edit",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"source_version_id": version_id,
|
||||
"version_number": version.version_number,
|
||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
_write_current_version_snapshot_if_available(version)
|
||||
return CampaignCreateResponse(
|
||||
campaign=CampaignResponse.model_validate(campaign),
|
||||
version=CampaignVersionResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
),
|
||||
)
|
||||
except LockedCampaignVersionError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
) from exc
|
||||
except CampaignPathSecurityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except CampaignPersistenceError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/unlock-validation",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def unlock_version_validation(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
"""Unlock a successfully validated version before delivery starts.
|
||||
|
||||
Unlocking invalidates validation/build state and removes generated jobs for
|
||||
that version. Sent/final versions cannot be unlocked and must be copied.
|
||||
"""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: unlock_validated_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_validation_unlocked",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/lock-temporarily",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def lock_version_temporarily(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: lock_campaign_version_temporarily(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=principal.user.id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_user_locked_temporarily",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/unlock-user-lock",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def unlock_version_user_lock(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: unlock_user_locked_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_user_lock_removed",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/lock-permanently",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def lock_version_permanently(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: permanently_lock_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=principal.user.id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_user_locked_permanently",
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse
|
||||
)
|
||||
def update_version_detail(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
return _update_campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
version_id,
|
||||
payload,
|
||||
autosave=False,
|
||||
audit_action="campaign.version_updated",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/autosave",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def autosave_version(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
return _update_campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
version_id,
|
||||
payload,
|
||||
autosave=True,
|
||||
audit_action="campaign.version_autosaved",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/set-step",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def set_version_step(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionSetStepRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: update_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
current_flow=payload.current_flow,
|
||||
current_step=payload.current_step,
|
||||
autosave=True,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_step_updated",
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"current_flow": payload.current_flow,
|
||||
"current_step": payload.current_step,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/review-state",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def set_version_review_state(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignReviewStateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
version = update_campaign_review_state(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
inspection_complete=payload.inspection_complete,
|
||||
reviewed_message_keys=payload.reviewed_message_keys,
|
||||
user_id=principal.user.id,
|
||||
commit=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.message_review_updated",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"inspection_complete": payload.inspection_complete,
|
||||
"reviewed_message_count": len(payload.reviewed_message_keys),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignVersionDetailResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
except LockedCampaignVersionError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
) from exc
|
||||
except CampaignPersistenceError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/validate-partial",
|
||||
response_model=CampaignPartialValidationResponse,
|
||||
)
|
||||
def validate_version_partial(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignPartialValidationRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign_json = (
|
||||
payload.campaign_json
|
||||
if payload and payload.campaign_json is not None
|
||||
else version.raw_json
|
||||
)
|
||||
result = validate_campaign_partial(
|
||||
campaign_json, section=payload.section if payload else None
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_partially_validated",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"section": result.get("section"),
|
||||
"ok": result.get("ok"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignPartialValidationResponse(**result)
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/publish",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def publish_version(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: publish_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=principal.user.id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_user_locked_permanently",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/validate")
|
||||
def validate_version(
|
||||
version_id: str,
|
||||
payload: ValidateCampaignRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
|
||||
):
|
||||
_get_version_for_principal(session, version_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or ValidateCampaignRequest()
|
||||
try:
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
if is_user_locked_version(version) or is_version_final_locked(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.",
|
||||
)
|
||||
link_result: CampaignAttachmentLinkMatchesResponse | None = None
|
||||
if payload.check_files and payload.link_unshared_matches:
|
||||
_require_permission(principal, "files:file:share")
|
||||
campaign = _get_campaign_for_tenant(
|
||||
session, version.campaign_id, principal.tenant_id
|
||||
)
|
||||
link_result = _link_campaign_attachment_matches(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
dry_run=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.attachment_matches_linked",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"matched_file_count": link_result.matched_file_count,
|
||||
"already_linked_file_count": link_result.already_linked_file_count,
|
||||
"linked_file_count": link_result.linked_file_count,
|
||||
"during_validation": True,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
result = validate_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
check_files=payload.check_files,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.validated",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"check_files": payload.check_files,
|
||||
"link_unshared_matches": payload.link_unshared_matches,
|
||||
"linked_file_count": link_result.linked_file_count
|
||||
if link_result
|
||||
else 0,
|
||||
"ok": result.get("ok"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return public_campaign_payload(
|
||||
result,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/build")
|
||||
def build_version(
|
||||
version_id: str,
|
||||
payload: BuildCampaignRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")),
|
||||
):
|
||||
version = _get_version_for_principal(session, version_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
try:
|
||||
result = build_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
write_eml=payload.write_eml if payload else True,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.messages_built",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"write_eml": payload.write_eml if payload else True,
|
||||
"built_count": result.get("built_count"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return public_campaign_payload(
|
||||
result,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
Reference in New Issue
Block a user