refactor(api): split campaign workflow routers
This commit is contained in:
601
src/govoplan_campaign/backend/route_support.py
Normal file
601
src/govoplan_campaign/backend/route_support.py
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import dataclasses
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import and_, exists, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
|
CAMPAIGN_MAIL_SERVER_KEYS,
|
||||||
|
campaign_mail_profile_id,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignIssue,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignShare,
|
||||||
|
CampaignStatus,
|
||||||
|
CampaignVersion,
|
||||||
|
CampaignVersionWorkflowState,
|
||||||
|
RecipientImportMappingProfile,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.path_security import CampaignPathSecurityError
|
||||||
|
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError
|
||||||
|
from govoplan_campaign.backend.persistence.versions import (
|
||||||
|
LockedCampaignVersionError,
|
||||||
|
is_user_locked_version,
|
||||||
|
is_version_final_locked,
|
||||||
|
is_version_locked,
|
||||||
|
update_campaign_version,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import (
|
||||||
|
CampaignVersionDetailResponse,
|
||||||
|
CampaignVersionUpdateRequest,
|
||||||
|
RecipientImportMappingProfilePayload,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.sending.execution import (
|
||||||
|
clear_execution_snapshot,
|
||||||
|
)
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
|
||||||
|
|
||||||
|
def _capability_payload(value: object) -> dict[str, Any]:
|
||||||
|
if dataclasses.is_dataclass(value):
|
||||||
|
return dataclasses.asdict(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return dict(value)
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
for key in (
|
||||||
|
"contact_id",
|
||||||
|
"address_book_id",
|
||||||
|
"display_name",
|
||||||
|
"email",
|
||||||
|
"email_label",
|
||||||
|
"organization",
|
||||||
|
"role_title",
|
||||||
|
"tags",
|
||||||
|
"source_kind",
|
||||||
|
"source_ref",
|
||||||
|
"source_revision",
|
||||||
|
"source_id",
|
||||||
|
"source_label",
|
||||||
|
"recipient_count",
|
||||||
|
"generated_at",
|
||||||
|
"recipients",
|
||||||
|
"fields",
|
||||||
|
"provenance",
|
||||||
|
):
|
||||||
|
if hasattr(value, key):
|
||||||
|
payload[key] = getattr(value, key)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_capability(name: str) -> object | None:
|
||||||
|
registry = get_registry()
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _access_directory() -> AccessDirectory:
|
||||||
|
registry = get_registry()
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Access directory capability is not configured",
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||||
|
if not isinstance(capability, AccessDirectory):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Access directory capability is invalid",
|
||||||
|
)
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _get_campaign_for_tenant(
|
||||||
|
session: Session, campaign_id: str, tenant_id: str
|
||||||
|
) -> Campaign:
|
||||||
|
campaign = session.get(Campaign, campaign_id)
|
||||||
|
if not campaign or campaign.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found"
|
||||||
|
)
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
|
def _get_version_for_tenant(
|
||||||
|
session: Session, version_id: str, tenant_id: str
|
||||||
|
) -> CampaignVersion:
|
||||||
|
version = session.get(CampaignVersion, version_id)
|
||||||
|
if not version:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||||
|
)
|
||||||
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
|
if not campaign or campaign.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]:
|
||||||
|
del session
|
||||||
|
return {
|
||||||
|
group.id
|
||||||
|
for group in _access_directory().groups_for_user(
|
||||||
|
principal.user.id, tenant_id=principal.tenant_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_acl_filter(session: Session, principal: ApiPrincipal):
|
||||||
|
if has_scope(principal, "tenant:*"):
|
||||||
|
return None
|
||||||
|
group_ids = _principal_group_ids(session, principal)
|
||||||
|
clauses = [Campaign.owner_user_id == principal.user.id]
|
||||||
|
if group_ids:
|
||||||
|
clauses.append(Campaign.owner_group_id.in_(group_ids))
|
||||||
|
share_clauses = [
|
||||||
|
and_(
|
||||||
|
CampaignShare.tenant_id == Campaign.tenant_id,
|
||||||
|
CampaignShare.campaign_id == Campaign.id,
|
||||||
|
CampaignShare.revoked_at.is_(None),
|
||||||
|
CampaignShare.target_type == "user",
|
||||||
|
CampaignShare.target_id == principal.user.id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if group_ids:
|
||||||
|
share_clauses.append(
|
||||||
|
and_(
|
||||||
|
CampaignShare.tenant_id == Campaign.tenant_id,
|
||||||
|
CampaignShare.campaign_id == Campaign.id,
|
||||||
|
CampaignShare.revoked_at.is_(None),
|
||||||
|
CampaignShare.target_type == "group",
|
||||||
|
CampaignShare.target_id.in_(group_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
clauses.append(exists().where(or_(*share_clauses)))
|
||||||
|
return or_(*clauses)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_acl_allows(
|
||||||
|
session: Session,
|
||||||
|
campaign: Campaign,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
write: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
if has_scope(principal, "tenant:*"):
|
||||||
|
return True
|
||||||
|
if campaign.owner_user_id == principal.user.id:
|
||||||
|
return True
|
||||||
|
group_ids = _principal_group_ids(session, principal)
|
||||||
|
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
|
||||||
|
return True
|
||||||
|
target_ids = [principal.user.id, *group_ids]
|
||||||
|
if not target_ids:
|
||||||
|
return False
|
||||||
|
query = session.query(CampaignShare).filter(
|
||||||
|
CampaignShare.tenant_id == campaign.tenant_id,
|
||||||
|
CampaignShare.campaign_id == campaign.id,
|
||||||
|
CampaignShare.revoked_at.is_(None),
|
||||||
|
or_(
|
||||||
|
CampaignShare.target_type == "user",
|
||||||
|
CampaignShare.target_type == "group",
|
||||||
|
),
|
||||||
|
CampaignShare.target_id.in_(target_ids),
|
||||||
|
)
|
||||||
|
shares = query.all()
|
||||||
|
if not shares:
|
||||||
|
return False
|
||||||
|
if not write:
|
||||||
|
return True
|
||||||
|
return any(item.permission == "write" for item in shares)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_campaign_acl(
|
||||||
|
session: Session,
|
||||||
|
campaign: Campaign,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
write: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if not _campaign_acl_allows(session, campaign, principal, write=write):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Campaign is not shared with this principal",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_campaign_for_principal(
|
||||||
|
session: Session, campaign_id: str, principal: ApiPrincipal, *, write: bool = False
|
||||||
|
) -> Campaign:
|
||||||
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||||
|
_require_campaign_acl(session, campaign, principal, write=write)
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
|
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
|
||||||
|
if not has_scope(principal, scope):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_query_for_principal(session: Session, principal: ApiPrincipal):
|
||||||
|
query = session.query(Campaign).filter(
|
||||||
|
Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted"
|
||||||
|
)
|
||||||
|
acl_filter = _campaign_acl_filter(session, principal)
|
||||||
|
if acl_filter is not None:
|
||||||
|
query = query.filter(acl_filter)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def _get_recipient_import_profile_for_principal(
|
||||||
|
session: Session, profile_id: str, principal: ApiPrincipal
|
||||||
|
) -> RecipientImportMappingProfile:
|
||||||
|
profile = session.get(RecipientImportMappingProfile, profile_id)
|
||||||
|
if (
|
||||||
|
not profile
|
||||||
|
or profile.tenant_id != principal.tenant_id
|
||||||
|
or profile.owner_user_id != principal.user.id
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Recipient import mapping profile not found",
|
||||||
|
)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_recipient_import_profile_payload(
|
||||||
|
profile: RecipientImportMappingProfile,
|
||||||
|
payload: RecipientImportMappingProfilePayload,
|
||||||
|
) -> None:
|
||||||
|
profile.name = payload.name.strip()
|
||||||
|
profile.column_count = payload.column_count
|
||||||
|
profile.headers = list(payload.headers)
|
||||||
|
profile.normalized_headers = list(payload.normalized_headers)
|
||||||
|
profile.ordered_header_fingerprint = payload.ordered_header_fingerprint
|
||||||
|
profile.unordered_header_fingerprint = payload.unordered_header_fingerprint
|
||||||
|
profile.delimiter = payload.delimiter
|
||||||
|
profile.header_rows = payload.header_rows
|
||||||
|
profile.quoted = payload.quoted
|
||||||
|
profile.value_separators = payload.value_separators
|
||||||
|
profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings]
|
||||||
|
|
||||||
|
|
||||||
|
def _recipient_sections_changed(
|
||||||
|
current: dict[str, object] | None, proposed: dict[str, object] | None
|
||||||
|
) -> bool:
|
||||||
|
if proposed is None:
|
||||||
|
return False
|
||||||
|
current = current or {}
|
||||||
|
return any(
|
||||||
|
current.get(key) != proposed.get(key) for key in ("recipients", "entries")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None:
|
||||||
|
return campaign_mail_profile_id(raw_json)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mail_profile_use_if_needed(
|
||||||
|
principal: ApiPrincipal, raw_json: dict[str, object] | None
|
||||||
|
) -> None:
|
||||||
|
if _campaign_mail_profile_id(raw_json) and not has_scope(
|
||||||
|
principal, "mail:profile:use"
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Missing scope: mail:profile:use",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_response_context(principal: ApiPrincipal) -> dict[str, bool]:
|
||||||
|
return {"include_diagnostics": has_scope(principal, "campaigns:diagnostic:read")}
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_version_detail_response(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
campaign_id: str,
|
||||||
|
mutation: Callable[[], CampaignVersion],
|
||||||
|
*,
|
||||||
|
audit_action: str,
|
||||||
|
details: dict[str, Any] | Callable[[CampaignVersion], dict[str, Any]] | None = None,
|
||||||
|
validation_error_status: int | None = None,
|
||||||
|
) -> CampaignVersionDetailResponse:
|
||||||
|
try:
|
||||||
|
version = mutation()
|
||||||
|
audit_details = (
|
||||||
|
details(version)
|
||||||
|
if callable(details)
|
||||||
|
else dict(details or {"campaign_id": campaign_id})
|
||||||
|
)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action=audit_action,
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version.id,
|
||||||
|
details=audit_details,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
_write_current_version_snapshot_if_available(version)
|
||||||
|
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 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 as exc:
|
||||||
|
session.rollback()
|
||||||
|
if validation_error_status is None:
|
||||||
|
raise
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=validation_error_status, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _update_campaign_version_detail_response(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str,
|
||||||
|
payload: CampaignVersionUpdateRequest,
|
||||||
|
*,
|
||||||
|
autosave: bool,
|
||||||
|
audit_action: str,
|
||||||
|
) -> CampaignVersionDetailResponse:
|
||||||
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
|
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||||
|
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
||||||
|
_require_permission(principal, "campaigns:recipient:write")
|
||||||
|
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||||
|
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,
|
||||||
|
raw_json=payload.campaign_json,
|
||||||
|
current_flow=payload.current_flow,
|
||||||
|
current_step=payload.current_step,
|
||||||
|
workflow_state=payload.workflow_state,
|
||||||
|
is_complete=payload.is_complete,
|
||||||
|
editor_state=payload.editor_state,
|
||||||
|
source_filename=payload.source_filename,
|
||||||
|
source_base_path=payload.source_base_path,
|
||||||
|
autosave=autosave,
|
||||||
|
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
|
||||||
|
commit=False,
|
||||||
|
),
|
||||||
|
audit_action=audit_action,
|
||||||
|
details=lambda version: {
|
||||||
|
"campaign_id": campaign_id,
|
||||||
|
"current_flow": version.current_flow,
|
||||||
|
"current_step": version.current_step,
|
||||||
|
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||||
|
},
|
||||||
|
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_campaign_profile_use_if_needed(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||||
|
target_version_id = version_id or campaign.current_version_id
|
||||||
|
if not target_version_id:
|
||||||
|
return
|
||||||
|
version = _get_version_for_tenant(session, target_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"
|
||||||
|
)
|
||||||
|
_require_mail_profile_use_if_needed(
|
||||||
|
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_campaign_versions_profile_use(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
campaign_id: str,
|
||||||
|
version_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""Authorize every historical version affected by a campaign-wide action."""
|
||||||
|
|
||||||
|
for version_id in sorted(version_ids):
|
||||||
|
_require_campaign_profile_use_if_needed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
campaign_id,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_version_for_principal(
|
||||||
|
session: Session,
|
||||||
|
version_id: str,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
write: bool = False,
|
||||||
|
) -> CampaignVersion:
|
||||||
|
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||||
|
campaign = _get_campaign_for_tenant(
|
||||||
|
session, version.campaign_id, principal.tenant_id
|
||||||
|
)
|
||||||
|
_require_campaign_acl(session, campaign, principal, write=write)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_campaign_metadata_to_current_version(
|
||||||
|
session: Session, campaign: Campaign
|
||||||
|
) -> None:
|
||||||
|
"""Keep editable version JSON aligned with version-independent campaign metadata.
|
||||||
|
|
||||||
|
Campaign metadata can be edited from the overview while individual campaign
|
||||||
|
sections save the current version JSON later. Without this sync, a later
|
||||||
|
version save can re-apply stale `campaign.name` / `campaign.id` values from
|
||||||
|
raw_json and make the old overview metadata appear to come back. Audit-safe
|
||||||
|
or validation-locked versions are left untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not campaign.current_version_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||||
|
if not version or version.campaign_id != campaign.id or is_version_locked(version):
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_json = copy.deepcopy(
|
||||||
|
version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
|
)
|
||||||
|
campaign_section = (
|
||||||
|
raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
||||||
|
)
|
||||||
|
raw_json["campaign"] = {
|
||||||
|
**campaign_section,
|
||||||
|
"id": campaign.external_id,
|
||||||
|
"name": campaign.name,
|
||||||
|
"description": campaign.description or "",
|
||||||
|
}
|
||||||
|
version.raw_json = raw_json
|
||||||
|
session.add(version)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_current_version_mail_profile_for_owner_transfer(
|
||||||
|
session: Session, campaign: Campaign
|
||||||
|
) -> bool:
|
||||||
|
"""Force explicit profile reselection after campaign ownership changes.
|
||||||
|
|
||||||
|
User/group-scoped reusable mail profiles are evaluated against the current
|
||||||
|
owner. Instead of trying to keep a stale selection across an ownership
|
||||||
|
transfer, clear the profile from the editable current version and invalidate
|
||||||
|
validation/build state so the operator has to reselect and revalidate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not campaign.current_version_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||||
|
if not version or version.campaign_id != campaign.id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
raw_json = copy.deepcopy(
|
||||||
|
version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
|
)
|
||||||
|
server = (
|
||||||
|
raw_json.get("server") if isinstance(raw_json.get("server"), dict) else None
|
||||||
|
)
|
||||||
|
if not isinstance(server, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
profile_id = _campaign_mail_profile_id(raw_json)
|
||||||
|
if not profile_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if is_version_final_locked(version) or is_user_locked_version(version):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Change owner only after creating an editable campaign version; the current version has a selected mail profile and is locked.",
|
||||||
|
)
|
||||||
|
|
||||||
|
next_server = dict(server)
|
||||||
|
for key in CAMPAIGN_MAIL_SERVER_KEYS:
|
||||||
|
next_server.pop(key, None)
|
||||||
|
next_server.pop("profile_id", None)
|
||||||
|
raw_json["server"] = next_server
|
||||||
|
|
||||||
|
version.raw_json = raw_json
|
||||||
|
version.validation_summary = None
|
||||||
|
version.build_summary = None
|
||||||
|
clear_execution_snapshot(version)
|
||||||
|
version.locked_at = None
|
||||||
|
version.locked_by_user_id = None
|
||||||
|
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||||
|
version.is_complete = False
|
||||||
|
|
||||||
|
editor_state = copy.deepcopy(version.editor_state or {})
|
||||||
|
editor_state.pop("review_send", None)
|
||||||
|
version.editor_state = editor_state
|
||||||
|
|
||||||
|
session.query(CampaignIssue).filter(
|
||||||
|
CampaignIssue.campaign_version_id == version.id
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
session.query(CampaignJob).filter(
|
||||||
|
CampaignJob.campaign_version_id == version.id
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
campaign.status = CampaignStatus.DRAFT.value
|
||||||
|
session.add(version)
|
||||||
|
_write_current_version_snapshot_if_available(version)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _write_current_version_snapshot_if_available(version: CampaignVersion) -> None:
|
||||||
|
try:
|
||||||
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
|
_write_campaign_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
_write_campaign_snapshot(version)
|
||||||
|
except Exception:
|
||||||
|
# The database state is authoritative for the WebUI. Snapshot writing is
|
||||||
|
# best-effort here because ownership changes should not fail due to an
|
||||||
|
# unavailable local runtime directory.
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_query_rows(query, *, limit: int, label: str):
|
||||||
|
rows = query.limit(limit + 1).all()
|
||||||
|
if len(rows) > limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||||
|
detail=(
|
||||||
|
f"{label} exceeds the maximum response size of {limit} rows. "
|
||||||
|
"Narrow the request or use a paginated/delta endpoint."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def job_attempt_rows(query, *, label: str):
|
||||||
|
return bounded_query_rows(query, limit=1000, label=label)
|
||||||
File diff suppressed because it is too large
Load Diff
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
|
||||||
884
src/govoplan_campaign/backend/services/job_queries.py
Normal file
884
src/govoplan_campaign/backend/services/job_queries.py
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Query, status
|
||||||
|
from sqlalchemy import and_, func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.schemas import (
|
||||||
|
CampaignJobsResponse,
|
||||||
|
CampaignJobDiagnosticsResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.change_sequence import (
|
||||||
|
encode_sequence_watermark,
|
||||||
|
max_sequence_id,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.pagination import (
|
||||||
|
KeysetCursorError,
|
||||||
|
decode_keyset_cursor,
|
||||||
|
encode_keyset_cursor,
|
||||||
|
keyset_query_fingerprint,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.change_tracking import (
|
||||||
|
CAMPAIGNS_MODULE_ID,
|
||||||
|
CAMPAIGN_JOBS_COLLECTION,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignVersion,
|
||||||
|
ImapAppendAttempt,
|
||||||
|
JobImapStatus,
|
||||||
|
JobPostboxStatus,
|
||||||
|
JobQueueStatus,
|
||||||
|
JobSendStatus,
|
||||||
|
JobValidationStatus,
|
||||||
|
PostboxDeliveryAttempt,
|
||||||
|
SendAttempt,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.response_security import (
|
||||||
|
public_campaign_payload,
|
||||||
|
public_delivery_result_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.route_support import (
|
||||||
|
_get_campaign_for_principal,
|
||||||
|
_get_campaign_for_tenant,
|
||||||
|
_get_version_for_tenant,
|
||||||
|
_require_permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs"
|
||||||
|
|
||||||
|
|
||||||
|
def _job_review_key(job: CampaignJob) -> str:
|
||||||
|
return str(job.entry_id or job.entry_index)
|
||||||
|
|
||||||
|
|
||||||
|
def _job_summary_payload(
|
||||||
|
job: CampaignJob,
|
||||||
|
*,
|
||||||
|
reviewed_keys: set[str] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
review_key = _job_review_key(job)
|
||||||
|
return {
|
||||||
|
"id": job.id,
|
||||||
|
"campaign_version_id": job.campaign_version_id,
|
||||||
|
"entry_index": job.entry_index,
|
||||||
|
"entry_id": job.entry_id,
|
||||||
|
"recipient_email": job.recipient_email,
|
||||||
|
"subject": job.subject,
|
||||||
|
"message_id_header": job.message_id_header,
|
||||||
|
"build_status": job.build_status,
|
||||||
|
"validation_status": job.validation_status,
|
||||||
|
"queue_status": job.queue_status,
|
||||||
|
"send_status": job.send_status,
|
||||||
|
"delivery_channel_policy": getattr(job, "delivery_channel_policy", "mail"),
|
||||||
|
"postbox_status": getattr(job, "postbox_status", "not_requested"),
|
||||||
|
"imap_status": job.imap_status,
|
||||||
|
"eml_size_bytes": job.eml_size_bytes,
|
||||||
|
"eml_sha256": job.eml_sha256,
|
||||||
|
"attempt_count": job.attempt_count,
|
||||||
|
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
|
||||||
|
"postbox_target_count": len(
|
||||||
|
getattr(job, "resolved_postbox_targets", None) or []
|
||||||
|
),
|
||||||
|
"last_error": public_delivery_result_message(
|
||||||
|
last_error=job.last_error,
|
||||||
|
send_status=job.send_status,
|
||||||
|
imap_status=job.imap_status,
|
||||||
|
postbox_status=getattr(job, "postbox_status", "not_requested"),
|
||||||
|
),
|
||||||
|
"queued_at": job.queued_at,
|
||||||
|
"outcome_unknown_at": job.outcome_unknown_at,
|
||||||
|
"sent_at": job.sent_at,
|
||||||
|
"created_at": job.created_at,
|
||||||
|
"updated_at": job.updated_at,
|
||||||
|
"issues_count": len(job.issues_snapshot or []),
|
||||||
|
"attachment_count": len(job.resolved_attachments or []),
|
||||||
|
"review_key": review_key,
|
||||||
|
"reviewed": review_key in reviewed_keys if reviewed_keys is not None else False,
|
||||||
|
"matched_file_count": sum(
|
||||||
|
len(item.get("matches") or [])
|
||||||
|
for item in (job.resolved_attachments or [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
**_job_summary_payload(job),
|
||||||
|
"message_id_header": job.message_id_header,
|
||||||
|
"issues": job.issues_snapshot or [],
|
||||||
|
"attachments": public_campaign_payload(job.resolved_attachments or []),
|
||||||
|
"resolved_recipients": job.resolved_recipients or {},
|
||||||
|
"resolved_postbox_targets": getattr(job, "resolved_postbox_targets", None)
|
||||||
|
or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_attempts_payload(
|
||||||
|
send_attempts: list[SendAttempt],
|
||||||
|
imap_attempts: list[ImapAppendAttempt],
|
||||||
|
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
|
||||||
|
*,
|
||||||
|
include_diagnostics: bool = False,
|
||||||
|
) -> dict[str, list[dict[str, object]]]:
|
||||||
|
smtp_payloads: list[dict[str, object]] = []
|
||||||
|
for attempt in send_attempts:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"id": attempt.id,
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"status": attempt.status,
|
||||||
|
"smtp_status_code": attempt.smtp_status_code,
|
||||||
|
"started_at": attempt.started_at,
|
||||||
|
"finished_at": attempt.finished_at,
|
||||||
|
}
|
||||||
|
if include_diagnostics:
|
||||||
|
payload["claim_token"] = attempt.claim_token
|
||||||
|
payload["smtp_response"] = attempt.smtp_response
|
||||||
|
payload["error_type"] = attempt.error_type
|
||||||
|
payload["error_message"] = attempt.error_message
|
||||||
|
smtp_payloads.append(payload)
|
||||||
|
|
||||||
|
imap_payloads: list[dict[str, object]] = []
|
||||||
|
for attempt in imap_attempts:
|
||||||
|
payload = {
|
||||||
|
"id": attempt.id,
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"status": attempt.status,
|
||||||
|
"folder": attempt.folder,
|
||||||
|
"created_at": attempt.created_at,
|
||||||
|
"updated_at": attempt.updated_at,
|
||||||
|
}
|
||||||
|
if include_diagnostics:
|
||||||
|
payload["claim_token"] = attempt.claim_token
|
||||||
|
payload["error_message"] = attempt.error_message
|
||||||
|
imap_payloads.append(payload)
|
||||||
|
postbox_payloads: list[dict[str, object]] = []
|
||||||
|
for attempt in postbox_attempts:
|
||||||
|
payload = {
|
||||||
|
"id": attempt.id,
|
||||||
|
"target_index": attempt.target_index,
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"status": attempt.status,
|
||||||
|
"postbox_id": attempt.postbox_id,
|
||||||
|
"address": attempt.address,
|
||||||
|
"holder_count": attempt.holder_count,
|
||||||
|
"vacant": attempt.vacant,
|
||||||
|
"duplicate": attempt.duplicate,
|
||||||
|
"target": attempt.target_snapshot or {},
|
||||||
|
"started_at": attempt.started_at,
|
||||||
|
"finished_at": attempt.finished_at,
|
||||||
|
"error_code": attempt.error_code,
|
||||||
|
}
|
||||||
|
if include_diagnostics:
|
||||||
|
payload["idempotency_key"] = attempt.idempotency_key
|
||||||
|
payload["provider_delivery_id"] = attempt.provider_delivery_id
|
||||||
|
payload["provider_message_id"] = attempt.provider_message_id
|
||||||
|
payload["evidence"] = attempt.evidence or {}
|
||||||
|
payload["error_type"] = attempt.error_type
|
||||||
|
payload["error_message"] = attempt.error_message
|
||||||
|
postbox_payloads.append(payload)
|
||||||
|
return {
|
||||||
|
"smtp": smtp_payloads,
|
||||||
|
"imap": imap_payloads,
|
||||||
|
"postbox": postbox_payloads,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_diagnostics_payload(
|
||||||
|
job: CampaignJob,
|
||||||
|
send_attempts: list[SendAttempt],
|
||||||
|
imap_attempts: list[ImapAppendAttempt],
|
||||||
|
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
|
||||||
|
) -> CampaignJobDiagnosticsResponse:
|
||||||
|
return CampaignJobDiagnosticsResponse(
|
||||||
|
job_id=job.id,
|
||||||
|
campaign_id=job.campaign_id,
|
||||||
|
campaign_version_id=job.campaign_version_id,
|
||||||
|
storage={
|
||||||
|
"eml_local_path": job.eml_local_path,
|
||||||
|
"eml_storage_key": job.eml_storage_key,
|
||||||
|
"eml_size_bytes": job.eml_size_bytes,
|
||||||
|
"eml_sha256": job.eml_sha256,
|
||||||
|
},
|
||||||
|
worker_claim={
|
||||||
|
"claim_token": job.claim_token,
|
||||||
|
"claimed_at": job.claimed_at,
|
||||||
|
"smtp_started_at": job.smtp_started_at,
|
||||||
|
"outcome_unknown_at": job.outcome_unknown_at,
|
||||||
|
"last_error": job.last_error,
|
||||||
|
},
|
||||||
|
attempts=_job_attempts_payload(
|
||||||
|
send_attempts,
|
||||||
|
imap_attempts,
|
||||||
|
postbox_attempts,
|
||||||
|
include_diagnostics=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _review_metadata(
|
||||||
|
session: Session,
|
||||||
|
version: CampaignVersion | None,
|
||||||
|
base_filters: list[object],
|
||||||
|
) -> tuple[dict[str, object], set[str]]:
|
||||||
|
if version is None:
|
||||||
|
return _empty_review_metadata(), set()
|
||||||
|
|
||||||
|
review_state, state_is_current = _current_review_state(version)
|
||||||
|
reviewed_keys = _current_reviewed_keys(
|
||||||
|
review_state, state_is_current=state_is_current
|
||||||
|
)
|
||||||
|
counts = _review_metadata_counts(_review_rows(session, base_filters), reviewed_keys)
|
||||||
|
return {
|
||||||
|
"inspection_complete": bool(
|
||||||
|
state_is_current and review_state.get("inspection_complete") is True
|
||||||
|
),
|
||||||
|
**counts,
|
||||||
|
}, reviewed_keys
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_review_metadata() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"inspection_complete": False,
|
||||||
|
"blocking_count": 0,
|
||||||
|
"required_count": 0,
|
||||||
|
"reviewed_required_count": 0,
|
||||||
|
"bulk_acceptable_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_review_state(version: CampaignVersion) -> tuple[dict[str, object], bool]:
|
||||||
|
build_summary = (
|
||||||
|
version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||||
|
)
|
||||||
|
build_token = str(
|
||||||
|
build_summary.get("build_token") or build_summary.get("built_at") or ""
|
||||||
|
)
|
||||||
|
editor_state = (
|
||||||
|
version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||||
|
)
|
||||||
|
review_state = (
|
||||||
|
editor_state.get("review_send")
|
||||||
|
if isinstance(editor_state.get("review_send"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
state_token = str(review_state.get("build_token") or "")
|
||||||
|
return review_state, bool(build_token and state_token == build_token)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_reviewed_keys(
|
||||||
|
review_state: dict[str, object], *, state_is_current: bool
|
||||||
|
) -> set[str]:
|
||||||
|
return {
|
||||||
|
str(value)
|
||||||
|
for value in (review_state.get("reviewed_message_keys") or [])
|
||||||
|
if state_is_current and str(value).strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _review_rows(
|
||||||
|
session: Session, base_filters: list[object]
|
||||||
|
) -> list[tuple[object, int, str, str]]:
|
||||||
|
return (
|
||||||
|
session.query(
|
||||||
|
CampaignJob.entry_id,
|
||||||
|
CampaignJob.entry_index,
|
||||||
|
CampaignJob.build_status,
|
||||||
|
CampaignJob.validation_status,
|
||||||
|
)
|
||||||
|
.filter(*base_filters)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _review_metadata_counts(
|
||||||
|
review_rows: list[tuple[object, int, str, str]], reviewed_keys: set[str]
|
||||||
|
) -> dict[str, int]:
|
||||||
|
blocking_count = 0
|
||||||
|
required_count = 0
|
||||||
|
reviewed_required_count = 0
|
||||||
|
bulk_acceptable_count = 0
|
||||||
|
for entry_id, entry_index, build_status, validation_status in review_rows:
|
||||||
|
key = str(entry_id or entry_index)
|
||||||
|
if build_status != "built" or validation_status == "blocked":
|
||||||
|
blocking_count += 1
|
||||||
|
if validation_status == "needs_review":
|
||||||
|
required_count += 1
|
||||||
|
if key in reviewed_keys:
|
||||||
|
reviewed_required_count += 1
|
||||||
|
elif validation_status in {"warning", "excluded"}:
|
||||||
|
bulk_acceptable_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"blocking_count": blocking_count,
|
||||||
|
"required_count": required_count,
|
||||||
|
"reviewed_required_count": reviewed_required_count,
|
||||||
|
"bulk_acceptable_count": bulk_acceptable_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _status_counts(
|
||||||
|
session: Session, filters: list[object]
|
||||||
|
) -> dict[str, dict[str, int]]:
|
||||||
|
result: dict[str, dict[str, int]] = {}
|
||||||
|
for field_name in (
|
||||||
|
"build_status",
|
||||||
|
"validation_status",
|
||||||
|
"queue_status",
|
||||||
|
"send_status",
|
||||||
|
"postbox_status",
|
||||||
|
"imap_status",
|
||||||
|
):
|
||||||
|
column = getattr(CampaignJob, field_name)
|
||||||
|
rows = (
|
||||||
|
session.query(column, func.count(CampaignJob.id))
|
||||||
|
.filter(*filters)
|
||||||
|
.group_by(column)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
result[field_name.removesuffix("_status")] = {
|
||||||
|
str(value or "unknown"): int(count) for value, count in rows
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
CAMPAIGN_JOB_GRID_SORT_COLUMNS = {
|
||||||
|
"number": CampaignJob.entry_index,
|
||||||
|
"recipient": func.lower(func.coalesce(CampaignJob.recipient_email, "")),
|
||||||
|
"subject": func.lower(func.coalesce(CampaignJob.subject, "")),
|
||||||
|
"validation": CampaignJob.validation_status,
|
||||||
|
"queue": CampaignJob.queue_status,
|
||||||
|
"send": CampaignJob.send_status,
|
||||||
|
"postbox": CampaignJob.postbox_status,
|
||||||
|
"imap": CampaignJob.imap_status,
|
||||||
|
"attempts": CampaignJob.attempt_count,
|
||||||
|
"updated": CampaignJob.updated_at,
|
||||||
|
}
|
||||||
|
CAMPAIGN_JOB_GRID_LIST_FILTERS = {
|
||||||
|
"validation": (
|
||||||
|
CampaignJob.validation_status,
|
||||||
|
{item.value for item in JobValidationStatus},
|
||||||
|
),
|
||||||
|
"queue": (CampaignJob.queue_status, {item.value for item in JobQueueStatus}),
|
||||||
|
"send": (CampaignJob.send_status, {item.value for item in JobSendStatus}),
|
||||||
|
"postbox": (
|
||||||
|
CampaignJob.postbox_status,
|
||||||
|
{item.value for item in JobPostboxStatus},
|
||||||
|
),
|
||||||
|
"imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_grid_filter_expressions(
|
||||||
|
grid_filters: dict[str, str] | None,
|
||||||
|
) -> list[object]:
|
||||||
|
values = grid_filters or {}
|
||||||
|
expressions: list[object] = []
|
||||||
|
recipient = values.get("recipient", "").strip()
|
||||||
|
if recipient:
|
||||||
|
pattern = _contains_pattern(recipient)
|
||||||
|
expressions.append(
|
||||||
|
or_(
|
||||||
|
CampaignJob.recipient_email.ilike(pattern, escape="\\"),
|
||||||
|
CampaignJob.entry_id.ilike(pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
subject = values.get("subject", "").strip()
|
||||||
|
if subject:
|
||||||
|
expressions.append(
|
||||||
|
CampaignJob.subject.ilike(_contains_pattern(subject), escape="\\")
|
||||||
|
)
|
||||||
|
evidence = values.get("evidence", "").strip()
|
||||||
|
if evidence:
|
||||||
|
pattern = _contains_pattern(evidence)
|
||||||
|
expressions.append(
|
||||||
|
or_(
|
||||||
|
CampaignJob.message_id_header.ilike(pattern, escape="\\"),
|
||||||
|
CampaignJob.eml_sha256.ilike(pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
attempts = values.get("attempts", "").strip()
|
||||||
|
if attempts:
|
||||||
|
expressions.append(
|
||||||
|
_campaign_jobs_integer_filter(
|
||||||
|
CampaignJob.attempt_count, attempts, column_id="attempts"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for column_id, (column, allowed_values) in CAMPAIGN_JOB_GRID_LIST_FILTERS.items():
|
||||||
|
raw_value = values.get(column_id, "").strip()
|
||||||
|
if not raw_value:
|
||||||
|
continue
|
||||||
|
selected = _campaign_jobs_list_filter(
|
||||||
|
raw_value, column_id=column_id, allowed_values=allowed_values
|
||||||
|
)
|
||||||
|
expressions.append(column.in_(selected))
|
||||||
|
return expressions
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_list_filter(
|
||||||
|
raw_value: str, *, column_id: str, allowed_values: set[str]
|
||||||
|
) -> list[str]:
|
||||||
|
if raw_value.startswith("list:"):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_value[5:])
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid {column_id} list filter",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(parsed, list) or any(
|
||||||
|
not isinstance(value, str) for value in parsed
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid {column_id} list filter",
|
||||||
|
)
|
||||||
|
selected = list(
|
||||||
|
dict.fromkeys(value.strip() for value in parsed if value.strip())
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
selected = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
value.strip() for value in raw_value.split(",") if value.strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(selected) > 50 or any(value not in allowed_values for value in selected):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid {column_id} list filter",
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_integer_filter(column: object, raw_value: str, *, column_id: str):
|
||||||
|
operator, separator, value = raw_value.partition(":")
|
||||||
|
if not separator:
|
||||||
|
operator, value = "eq", operator
|
||||||
|
if operator not in {"eq", "gt", "gte", "lt", "lte"}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid {column_id} filter operator",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
expected = int(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid {column_id} filter value",
|
||||||
|
) from exc
|
||||||
|
if operator == "gt":
|
||||||
|
return column > expected
|
||||||
|
if operator == "gte":
|
||||||
|
return column >= expected
|
||||||
|
if operator == "lt":
|
||||||
|
return column < expected
|
||||||
|
if operator == "lte":
|
||||||
|
return column <= expected
|
||||||
|
return column == expected
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_pattern(value: str) -> str:
|
||||||
|
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
return f"%{escaped}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_ordering(sort_by: str, sort_direction: str) -> list[object]:
|
||||||
|
column = CAMPAIGN_JOB_GRID_SORT_COLUMNS.get(sort_by)
|
||||||
|
if column is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Unsupported Campaign job sort column",
|
||||||
|
)
|
||||||
|
primary = column.desc() if sort_direction == "desc" else column.asc()
|
||||||
|
return [primary, CampaignJob.id.asc()]
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_query_context(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str | None,
|
||||||
|
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 = None,
|
||||||
|
) -> tuple[Campaign, list[object], list[object], dict[str, object], set[str]]:
|
||||||
|
_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)
|
||||||
|
base_filters: list[object] = [
|
||||||
|
CampaignJob.campaign_id == campaign.id,
|
||||||
|
CampaignJob.tenant_id == principal.tenant_id,
|
||||||
|
]
|
||||||
|
selected_version: CampaignVersion | None = None
|
||||||
|
if version_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",
|
||||||
|
)
|
||||||
|
selected_version = version
|
||||||
|
base_filters.append(CampaignJob.campaign_version_id == version.id)
|
||||||
|
|
||||||
|
review_metadata, reviewed_keys = _review_metadata(
|
||||||
|
session, selected_version, base_filters
|
||||||
|
)
|
||||||
|
filtered = list(base_filters)
|
||||||
|
if send_status:
|
||||||
|
filtered.append(CampaignJob.send_status.in_(send_status))
|
||||||
|
if validation_status:
|
||||||
|
filtered.append(CampaignJob.validation_status.in_(validation_status))
|
||||||
|
if imap_status:
|
||||||
|
filtered.append(CampaignJob.imap_status.in_(imap_status))
|
||||||
|
if query_text and query_text.strip():
|
||||||
|
pattern = f"%{query_text.strip()}%"
|
||||||
|
filtered.append(
|
||||||
|
or_(
|
||||||
|
CampaignJob.recipient_email.ilike(pattern),
|
||||||
|
CampaignJob.subject.ilike(pattern),
|
||||||
|
CampaignJob.entry_id.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
filtered.extend(_campaign_jobs_grid_filter_expressions(grid_filters))
|
||||||
|
return campaign, base_filters, filtered, review_metadata, reviewed_keys
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_page_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str | None,
|
||||||
|
base_filters: list[object],
|
||||||
|
filtered: list[object],
|
||||||
|
reviewed_keys: set[str],
|
||||||
|
review_metadata: dict[str, object],
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
send_status: list[str] | None = None,
|
||||||
|
validation_status: list[str] | None = None,
|
||||||
|
imap_status: list[str] | None = None,
|
||||||
|
query_text: str | None = None,
|
||||||
|
grid_filters: dict[str, str] | None = None,
|
||||||
|
sort_by: str = "number",
|
||||||
|
sort_direction: str = "asc",
|
||||||
|
cursor: str | None = None,
|
||||||
|
changed_job_ids: set[str] | None = None,
|
||||||
|
) -> CampaignJobsResponse:
|
||||||
|
total_unfiltered, total = _campaign_jobs_page_counts(
|
||||||
|
session, base_filters=base_filters, filtered=filtered
|
||||||
|
)
|
||||||
|
pages = (total + page_size - 1) // page_size if total else 0
|
||||||
|
fingerprint = _campaign_jobs_cursor_fingerprint(
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
version_id=version_id,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
ordering = _campaign_jobs_ordering(sort_by, sort_direction)
|
||||||
|
rows_plus_one, start_cursor = _campaign_jobs_page_rows(
|
||||||
|
session,
|
||||||
|
filtered=filtered,
|
||||||
|
ordering=ordering,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_direction=sort_direction,
|
||||||
|
cursor=cursor,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
jobs = rows_plus_one[:page_size]
|
||||||
|
next_cursor = _campaign_jobs_next_cursor(
|
||||||
|
jobs,
|
||||||
|
rows_plus_one=rows_plus_one,
|
||||||
|
page_size=page_size,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_direction=sort_direction,
|
||||||
|
changed_job_ids=changed_job_ids,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
if changed_job_ids is not None:
|
||||||
|
jobs = [job for job in jobs if job.id in changed_job_ids]
|
||||||
|
return CampaignJobsResponse(
|
||||||
|
jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs],
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_unfiltered=total_unfiltered,
|
||||||
|
pages=pages,
|
||||||
|
cursor=start_cursor,
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
counts=_status_counts(session, base_filters),
|
||||||
|
filtered_counts=_status_counts(session, filtered),
|
||||||
|
review=review_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_page_counts(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
base_filters: list[object],
|
||||||
|
filtered: list[object],
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
total_unfiltered = int(
|
||||||
|
session.query(func.count(CampaignJob.id)).filter(*base_filters).scalar() or 0
|
||||||
|
)
|
||||||
|
total = int(
|
||||||
|
session.query(func.count(CampaignJob.id)).filter(*filtered).scalar() or 0
|
||||||
|
)
|
||||||
|
return total_unfiltered, total
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_page_rows(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
filtered: list[object],
|
||||||
|
ordering: list[object],
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
sort_by: str,
|
||||||
|
sort_direction: str,
|
||||||
|
cursor: str | None,
|
||||||
|
fingerprint: str,
|
||||||
|
) -> tuple[list[CampaignJob], str | None]:
|
||||||
|
if cursor:
|
||||||
|
page_query = _campaign_jobs_query_after_cursor(
|
||||||
|
session,
|
||||||
|
filtered=filtered,
|
||||||
|
cursor=cursor,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_direction=sort_direction,
|
||||||
|
)
|
||||||
|
return page_query.order_by(*ordering).limit(page_size + 1).all(), cursor
|
||||||
|
|
||||||
|
effective_offset = (page - 1) * page_size
|
||||||
|
page_query = session.query(CampaignJob).filter(*filtered)
|
||||||
|
start_cursor = _campaign_jobs_offset_cursor(
|
||||||
|
page_query,
|
||||||
|
ordering=ordering,
|
||||||
|
effective_offset=effective_offset,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_direction=sort_direction,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
page_query.order_by(*ordering)
|
||||||
|
.offset(effective_offset)
|
||||||
|
.limit(page_size + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return rows, start_cursor
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_query_after_cursor(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
filtered: list[object],
|
||||||
|
cursor: str,
|
||||||
|
fingerprint: str,
|
||||||
|
sort_by: str,
|
||||||
|
sort_direction: str,
|
||||||
|
):
|
||||||
|
if sort_by != "number" or sort_direction != "asc":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Campaign job cursors require number ascending order",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cursor_values = decode_keyset_cursor(
|
||||||
|
CAMPAIGN_JOBS_CURSOR_SCOPE, cursor, fingerprint=fingerprint
|
||||||
|
)
|
||||||
|
if cursor_values is None:
|
||||||
|
raise KeysetCursorError("Invalid pagination cursor")
|
||||||
|
except KeysetCursorError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
return (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(*filtered)
|
||||||
|
.filter(_campaign_jobs_cursor_condition(cursor_values))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_offset_cursor(
|
||||||
|
query,
|
||||||
|
*,
|
||||||
|
ordering: list[object],
|
||||||
|
effective_offset: int,
|
||||||
|
sort_by: str,
|
||||||
|
sort_direction: str,
|
||||||
|
fingerprint: str,
|
||||||
|
) -> str | None:
|
||||||
|
if effective_offset <= 0 or sort_by != "number" or sort_direction != "asc":
|
||||||
|
return None
|
||||||
|
previous_row = (
|
||||||
|
query.order_by(*ordering).offset(effective_offset - 1).limit(1).first()
|
||||||
|
)
|
||||||
|
if previous_row is None:
|
||||||
|
return None
|
||||||
|
return _campaign_jobs_cursor_for_row(previous_row, fingerprint=fingerprint)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_next_cursor(
|
||||||
|
jobs: list[CampaignJob],
|
||||||
|
*,
|
||||||
|
rows_plus_one: list[CampaignJob],
|
||||||
|
page_size: int,
|
||||||
|
sort_by: str,
|
||||||
|
sort_direction: str,
|
||||||
|
changed_job_ids: set[str] | None,
|
||||||
|
fingerprint: str,
|
||||||
|
) -> str | None:
|
||||||
|
cursor_supported = sort_by == "number" and sort_direction == "asc"
|
||||||
|
if (
|
||||||
|
changed_job_ids is not None
|
||||||
|
or not cursor_supported
|
||||||
|
or len(rows_plus_one) <= page_size
|
||||||
|
or not jobs
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return _campaign_jobs_cursor_for_row(jobs[-1], fingerprint=fingerprint)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_cursor_fingerprint(
|
||||||
|
*,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str | None,
|
||||||
|
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,
|
||||||
|
) -> str:
|
||||||
|
return keyset_query_fingerprint(
|
||||||
|
CAMPAIGN_JOBS_CURSOR_SCOPE,
|
||||||
|
{
|
||||||
|
"campaign_id": campaign_id,
|
||||||
|
"version_id": version_id or "",
|
||||||
|
"page_size": page_size,
|
||||||
|
"send_status": sorted(send_status or []),
|
||||||
|
"validation_status": sorted(validation_status or []),
|
||||||
|
"imap_status": sorted(imap_status or []),
|
||||||
|
"query": (query_text or "").strip(),
|
||||||
|
"grid_filters": sorted((grid_filters or {}).items()),
|
||||||
|
"order": [f"{sort_by}:{sort_direction}", "id:asc"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_cursor_for_row(row: CampaignJob, *, fingerprint: str) -> str:
|
||||||
|
return encode_keyset_cursor(
|
||||||
|
CAMPAIGN_JOBS_CURSOR_SCOPE,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
values={"id": row.id, "entry_index": row.entry_index},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_cursor_condition(cursor_values: dict[str, object]):
|
||||||
|
cursor_id = cursor_values.get("id")
|
||||||
|
cursor_index = cursor_values.get("entry_index")
|
||||||
|
if not isinstance(cursor_id, str) or not cursor_id:
|
||||||
|
raise KeysetCursorError("Invalid pagination cursor")
|
||||||
|
try:
|
||||||
|
entry_index = int(cursor_index)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise KeysetCursorError("Invalid pagination cursor") from exc
|
||||||
|
return or_(
|
||||||
|
CampaignJob.entry_index > entry_index,
|
||||||
|
and_(CampaignJob.entry_index == entry_index, CampaignJob.id > cursor_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_delta_watermark(session: Session, tenant_id: str) -> str:
|
||||||
|
return encode_sequence_watermark(
|
||||||
|
max_sequence_id(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
module_id=CAMPAIGNS_MODULE_ID,
|
||||||
|
collections=(CAMPAIGN_JOBS_COLLECTION,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignJobsQuery:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
version_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
|
cursor: str | None = Query(default=None),
|
||||||
|
send_status: list[str] | None = Query(default=None),
|
||||||
|
validation_status: list[str] | None = Query(default=None),
|
||||||
|
imap_status: list[str] | None = Query(default=None),
|
||||||
|
query_text: str | None = Query(default=None, alias="q", max_length=200),
|
||||||
|
sort_by: Literal[
|
||||||
|
"number",
|
||||||
|
"recipient",
|
||||||
|
"subject",
|
||||||
|
"validation",
|
||||||
|
"queue",
|
||||||
|
"send",
|
||||||
|
"postbox",
|
||||||
|
"imap",
|
||||||
|
"attempts",
|
||||||
|
"updated",
|
||||||
|
] = Query(default="number"),
|
||||||
|
sort_direction: Literal["asc", "desc"] = Query(default="asc"),
|
||||||
|
filter_recipient: str | None = Query(default=None, max_length=500),
|
||||||
|
filter_subject: str | None = Query(default=None, max_length=1000),
|
||||||
|
filter_validation: str | None = Query(default=None, max_length=1000),
|
||||||
|
filter_queue: str | None = Query(default=None, max_length=1000),
|
||||||
|
filter_send: str | None = Query(default=None, max_length=1000),
|
||||||
|
filter_postbox: str | None = Query(default=None, max_length=1000),
|
||||||
|
filter_imap: str | None = Query(default=None, max_length=1000),
|
||||||
|
filter_attempts: str | None = Query(default=None, max_length=100),
|
||||||
|
filter_evidence: str | None = Query(default=None, max_length=500),
|
||||||
|
) -> None:
|
||||||
|
self.version_id = version_id
|
||||||
|
self.page = page
|
||||||
|
self.page_size = page_size
|
||||||
|
self.cursor = cursor
|
||||||
|
self.send_status = send_status
|
||||||
|
self.validation_status = validation_status
|
||||||
|
self.imap_status = imap_status
|
||||||
|
self.query_text = query_text
|
||||||
|
self.sort_by = sort_by
|
||||||
|
self.sort_direction = sort_direction
|
||||||
|
self.grid_filters = {
|
||||||
|
column_id: value
|
||||||
|
for column_id, value in {
|
||||||
|
"recipient": filter_recipient,
|
||||||
|
"subject": filter_subject,
|
||||||
|
"validation": filter_validation,
|
||||||
|
"queue": filter_queue,
|
||||||
|
"send": filter_send,
|
||||||
|
"postbox": filter_postbox,
|
||||||
|
"imap": filter_imap,
|
||||||
|
"attempts": filter_attempts,
|
||||||
|
"evidence": filter_evidence,
|
||||||
|
}.items()
|
||||||
|
if value is not None and value.strip()
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.db.models import Account, Group, User
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
from govoplan_campaign.backend import router
|
from govoplan_campaign.backend import router as campaign_api
|
||||||
|
from govoplan_campaign.backend.routes import campaigns as campaign_routes
|
||||||
|
from govoplan_campaign.backend.routes import jobs as job_routes
|
||||||
|
from govoplan_campaign.backend.routes import reports as report_routes
|
||||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||||
from govoplan_campaign.backend.reports.aggregate import (
|
from govoplan_campaign.backend.reports.aggregate import (
|
||||||
AggregateCampaignReportError,
|
AggregateCampaignReportError,
|
||||||
@@ -37,10 +40,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
|||||||
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
|
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||||
pytest.raises(HTTPException) as full_report_denied,
|
pytest.raises(HTTPException) as full_report_denied,
|
||||||
):
|
):
|
||||||
router.campaign_report(
|
report_routes.campaign_report(
|
||||||
"campaign-1",
|
"campaign-1",
|
||||||
session=session,
|
session=session,
|
||||||
principal=principal, # type: ignore[arg-type]
|
principal=principal, # type: ignore[arg-type]
|
||||||
@@ -49,10 +52,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
|||||||
assert "campaigns:recipient:read" in full_report_denied.value.detail
|
assert "campaigns:recipient:read" in full_report_denied.value.detail
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||||
pytest.raises(HTTPException) as job_detail_denied,
|
pytest.raises(HTTPException) as job_detail_denied,
|
||||||
):
|
):
|
||||||
router.get_job_detail(
|
job_routes.get_job_detail(
|
||||||
"campaign-1",
|
"campaign-1",
|
||||||
"job-1",
|
"job-1",
|
||||||
session=session,
|
session=session,
|
||||||
@@ -61,10 +64,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
|||||||
assert job_detail_denied.value.status_code == 403
|
assert job_detail_denied.value.status_code == 403
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||||
pytest.raises(HTTPException) as report_email_denied,
|
pytest.raises(HTTPException) as report_email_denied,
|
||||||
):
|
):
|
||||||
router.email_campaign_report(
|
report_routes.email_campaign_report(
|
||||||
"campaign-1",
|
"campaign-1",
|
||||||
ReportEmailRequest(to=["auditor@example.test"]),
|
ReportEmailRequest(to=["auditor@example.test"]),
|
||||||
session=session,
|
session=session,
|
||||||
@@ -74,10 +77,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
|||||||
assert "campaigns:recipient:export" in report_email_denied.value.detail
|
assert "campaigns:recipient:export" in report_email_denied.value.detail
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||||
pytest.raises(HTTPException) as diagnostics_denied,
|
pytest.raises(HTTPException) as diagnostics_denied,
|
||||||
):
|
):
|
||||||
router.get_job_diagnostics(
|
job_routes.get_job_diagnostics(
|
||||||
"campaign-1",
|
"campaign-1",
|
||||||
"job-1",
|
"job-1",
|
||||||
session=session,
|
session=session,
|
||||||
@@ -93,14 +96,14 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
|
|||||||
safe_projection = Mock()
|
safe_projection = Mock()
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(router, "_get_campaign_for_principal") as acl,
|
patch.object(campaign_routes, "_get_campaign_for_principal") as acl,
|
||||||
patch.object(
|
patch.object(
|
||||||
router,
|
campaign_routes,
|
||||||
"generate_aggregate_campaign_report",
|
"generate_aggregate_campaign_report",
|
||||||
return_value=safe_projection,
|
return_value=safe_projection,
|
||||||
) as generate,
|
) as generate,
|
||||||
):
|
):
|
||||||
result = router.aggregate_campaign_report(
|
result = campaign_routes.aggregate_campaign_report(
|
||||||
"campaign-1",
|
"campaign-1",
|
||||||
session=session,
|
session=session,
|
||||||
principal=principal, # type: ignore[arg-type]
|
principal=principal, # type: ignore[arg-type]
|
||||||
@@ -118,7 +121,7 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
|
|||||||
|
|
||||||
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
|
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
|
||||||
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
|
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
|
||||||
route = next(item for item in router.router.routes if item.path == path)
|
route = next(item for item in campaign_api.router.routes if item.path == path)
|
||||||
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as denied:
|
with pytest.raises(HTTPException) as denied:
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from govoplan_campaign.backend import router
|
from govoplan_campaign.backend import route_support
|
||||||
|
from govoplan_campaign.backend.routes import campaigns as campaign_routes
|
||||||
|
from govoplan_campaign.backend.routes import versions as version_routes
|
||||||
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
|
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
|
||||||
|
|
||||||
|
|
||||||
@@ -34,13 +36,13 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(router, "_get_campaign_for_principal"),
|
patch.object(route_support, "_get_campaign_for_principal"),
|
||||||
patch.object(router, "_get_version_for_tenant", return_value=version),
|
patch.object(route_support, "_get_version_for_tenant", return_value=version),
|
||||||
patch.object(router, "update_campaign_version", side_effect=mutate),
|
patch.object(route_support, "update_campaign_version", side_effect=mutate),
|
||||||
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
patch.object(route_support, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException, match="audit unavailable") as captured:
|
with pytest.raises(HTTPException, match="audit unavailable") as captured:
|
||||||
router._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
|
route_support._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
|
||||||
session,
|
session,
|
||||||
principal, # type: ignore[arg-type]
|
principal, # type: ignore[arg-type]
|
||||||
"campaign-1",
|
"campaign-1",
|
||||||
@@ -68,15 +70,15 @@ def test_version_fork_rolls_back_when_its_audit_record_cannot_be_written() -> No
|
|||||||
return forked
|
return forked
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
patch.object(version_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||||
patch.object(router, "_require_permission"),
|
patch.object(version_routes, "_require_permission"),
|
||||||
patch.object(router, "_get_version_for_tenant", return_value=source),
|
patch.object(version_routes, "_get_version_for_tenant", return_value=source),
|
||||||
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
|
patch.object(version_routes, "_get_campaign_for_tenant", return_value=campaign),
|
||||||
patch.object(router, "fork_campaign_version_for_edit", side_effect=mutate),
|
patch.object(version_routes, "fork_campaign_version_for_edit", side_effect=mutate),
|
||||||
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
patch.object(version_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||||
):
|
):
|
||||||
with pytest.raises(RuntimeError, match="audit unavailable"):
|
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||||
router.fork_version_for_edit(
|
version_routes.fork_version_for_edit(
|
||||||
"campaign-1",
|
"campaign-1",
|
||||||
"version-1",
|
"version-1",
|
||||||
CampaignVersionUpdateRequest(),
|
CampaignVersionUpdateRequest(),
|
||||||
@@ -102,12 +104,12 @@ def test_metadata_update_rolls_back_when_its_audit_record_cannot_be_written() ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
patch.object(campaign_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||||
patch.object(router, "_sync_campaign_metadata_to_current_version"),
|
patch.object(campaign_routes, "_sync_campaign_metadata_to_current_version"),
|
||||||
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
patch.object(campaign_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||||
):
|
):
|
||||||
with pytest.raises(RuntimeError, match="audit unavailable"):
|
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||||
router.update_campaign_metadata_endpoint(
|
campaign_routes.update_campaign_metadata_endpoint(
|
||||||
"campaign-1",
|
"campaign-1",
|
||||||
CampaignUpdateRequest(name="New name"),
|
CampaignUpdateRequest(name="New name"),
|
||||||
session=session,
|
session=session,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from alembic.migration import MigrationContext
|
|||||||
from alembic.operations import Operations
|
from alembic.operations import Operations
|
||||||
from sqlalchemy import create_engine, inspect, text
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
|
||||||
from govoplan_campaign.backend import router
|
from govoplan_campaign.backend.routes import delivery as router
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
CampaignJob,
|
CampaignJob,
|
||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
@@ -410,14 +410,14 @@ def test_reconciliation_rolls_back_state_when_audit_fails(
|
|||||||
return {"decision": decision, "job_id": "job-1"}
|
return {"decision": decision, "job_id": "job-1"}
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("govoplan_campaign.backend.router._get_campaign_for_principal"),
|
patch("govoplan_campaign.backend.routes.delivery._get_campaign_for_principal"),
|
||||||
patch("govoplan_campaign.backend.router._require_permission"),
|
patch("govoplan_campaign.backend.routes.delivery._require_permission"),
|
||||||
patch(
|
patch(
|
||||||
"govoplan_campaign.backend.router.reconcile_job_outcome",
|
"govoplan_campaign.backend.routes.delivery.reconcile_job_outcome",
|
||||||
side_effect=mutate_without_commit,
|
side_effect=mutate_without_commit,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"govoplan_campaign.backend.router.audit_from_principal",
|
"govoplan_campaign.backend.routes.delivery.audit_from_principal",
|
||||||
side_effect=RuntimeError("audit unavailable"),
|
side_effect=RuntimeError("audit unavailable"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_access.backend.db.models import Account, Group, User
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||||
from govoplan_campaign.backend.router import (
|
from govoplan_campaign.backend.services.job_queries import (
|
||||||
_campaign_jobs_grid_filter_expressions,
|
_campaign_jobs_grid_filter_expressions,
|
||||||
_campaign_jobs_ordering,
|
_campaign_jobs_ordering,
|
||||||
_campaign_jobs_page_response,
|
_campaign_jobs_page_response,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from unittest.mock import call, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from govoplan_campaign.backend import router
|
from govoplan_campaign.backend import route_support
|
||||||
|
from govoplan_campaign.backend.routes import versions as router
|
||||||
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
|
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
|
||||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
CampaignMailProfileBoundaryError,
|
CampaignMailProfileBoundaryError,
|
||||||
@@ -214,7 +215,7 @@ def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
|
|||||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||||
patch.object(router, "_require_permission"),
|
patch.object(router, "_require_permission"),
|
||||||
patch.object(router, "_get_version_for_tenant", return_value=source),
|
patch.object(router, "_get_version_for_tenant", return_value=source),
|
||||||
patch.object(router, "has_scope", return_value=False),
|
patch.object(route_support, "has_scope", return_value=False),
|
||||||
patch.object(router, "fork_campaign_version_for_edit") as fork,
|
patch.object(router, "fork_campaign_version_for_edit") as fork,
|
||||||
):
|
):
|
||||||
with pytest.raises(HTTPException) as captured:
|
with pytest.raises(HTTPException) as captured:
|
||||||
@@ -234,8 +235,8 @@ def test_campaign_wide_effect_authorizes_every_affected_version() -> None:
|
|||||||
session = object()
|
session = object()
|
||||||
principal = SimpleNamespace(tenant_id="tenant-1")
|
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||||
|
|
||||||
with patch.object(router, "_require_campaign_profile_use_if_needed") as require_profile:
|
with patch.object(route_support, "_require_campaign_profile_use_if_needed") as require_profile:
|
||||||
router._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
|
route_support._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
|
||||||
session, # type: ignore[arg-type]
|
session, # type: ignore[arg-type]
|
||||||
principal, # type: ignore[arg-type]
|
principal, # type: ignore[arg-type]
|
||||||
"campaign-1",
|
"campaign-1",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_campaign.backend import router
|
from govoplan_campaign.backend.routes import campaigns as router
|
||||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||||
from govoplan_campaign.backend.persistence.versions import validate_campaign_partial
|
from govoplan_campaign.backend.persistence.versions import validate_campaign_partial
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from govoplan_campaign.backend import router
|
from govoplan_campaign.backend.routes import reports as router
|
||||||
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
||||||
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from govoplan_campaign.backend.response_security import public_campaign_payload
|
from govoplan_campaign.backend.response_security import public_campaign_payload
|
||||||
from govoplan_campaign.backend.router import (
|
from govoplan_campaign.backend.services.job_queries import (
|
||||||
_job_attempts_payload,
|
_job_attempts_payload,
|
||||||
_job_detail_payload,
|
_job_detail_payload,
|
||||||
_job_diagnostics_payload,
|
_job_diagnostics_payload,
|
||||||
|
|||||||
63
tests/test_route_registration.py
Normal file
63
tests/test_route_registration.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.router import router
|
||||||
|
from govoplan_campaign.backend.routes.attachments import router as attachments_router
|
||||||
|
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||||
|
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||||
|
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||||
|
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||||
|
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||||
|
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||||
|
|
||||||
|
|
||||||
|
def _operation_keys(candidate_router) -> list[tuple[str, str]]:
|
||||||
|
return [
|
||||||
|
(method, route.path)
|
||||||
|
for route in candidate_router.routes
|
||||||
|
for method in sorted(route.methods or ())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||||
|
workflow_routers = (
|
||||||
|
campaigns_router,
|
||||||
|
versions_router,
|
||||||
|
jobs_router,
|
||||||
|
reports_router,
|
||||||
|
sharing_router,
|
||||||
|
delivery_router,
|
||||||
|
attachments_router,
|
||||||
|
)
|
||||||
|
expected = [
|
||||||
|
operation
|
||||||
|
for workflow_router in workflow_routers
|
||||||
|
for operation in _operation_keys(workflow_router)
|
||||||
|
]
|
||||||
|
actual = _operation_keys(router)
|
||||||
|
|
||||||
|
assert actual == expected
|
||||||
|
assert len(actual) == 62
|
||||||
|
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_routes_are_owned_by_their_focused_router() -> None:
|
||||||
|
expectations = (
|
||||||
|
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
|
||||||
|
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
||||||
|
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
||||||
|
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
||||||
|
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
|
||||||
|
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
|
||||||
|
(
|
||||||
|
attachments_router,
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for workflow_router, operation in expectations:
|
||||||
|
assert operation in _operation_keys(workflow_router)
|
||||||
@@ -4,7 +4,7 @@ from datetime import UTC, datetime
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from govoplan_campaign.backend import router
|
from govoplan_campaign.backend.routes import delivery as router
|
||||||
from govoplan_campaign.backend.schemas import SendCampaignNowRequest
|
from govoplan_campaign.backend.schemas import SendCampaignNowRequest
|
||||||
from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult
|
from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from unittest.mock import Mock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from govoplan_campaign.backend import router
|
from govoplan_campaign.backend import router as campaign_api
|
||||||
|
from govoplan_campaign.backend.routes import delivery as router
|
||||||
from govoplan_campaign.backend.delivery_policy import (
|
from govoplan_campaign.backend.delivery_policy import (
|
||||||
CampaignDeliveryPolicyError,
|
CampaignDeliveryPolicyError,
|
||||||
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||||
@@ -295,7 +296,7 @@ def test_delivery_endpoints_require_their_mode_permission_and_recipient_authorit
|
|||||||
path: str,
|
path: str,
|
||||||
required_scope: str,
|
required_scope: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
route = next(item for item in router.router.routes if item.path == path)
|
route = next(item for item in campaign_api.router.routes if item.path == path)
|
||||||
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as missing_mode_permission:
|
with pytest.raises(HTTPException) as missing_mode_permission:
|
||||||
|
|||||||
Reference in New Issue
Block a user