Files
govoplan-campaign/src/govoplan_campaign/backend/routes/transfers.py
T
zemion 3934e7fedb
Module Package Release / publish-packages (push) Successful in 12s
feat(campaigns): add portable campaign transfers
2026-08-22 04:01:39 +02:00

379 lines
12 KiB
Python

from __future__ import annotations
import copy
from importlib import metadata
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.transfers import (
CampaignImportInspection,
CampaignTransferError,
build_campaign_portable_package,
inspect_campaign_portable_package,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignVersion,
)
from govoplan_campaign.backend.persistence.campaigns import (
create_campaign_version_from_json,
)
from govoplan_campaign.backend.route_support import (
_campaign_response_context,
_get_campaign_for_principal,
_require_permission,
_write_current_version_snapshot_if_available,
)
from govoplan_campaign.backend.schemas import (
CampaignExportRequest,
CampaignImportApplyRequest,
CampaignImportApplyResponse,
CampaignImportPreviewRequest,
CampaignImportPreviewResponse,
CampaignPortablePackageResponse,
CampaignResponse,
CampaignVersionResponse,
)
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.db.session import get_session
router = APIRouter(tags=["campaigns"])
@router.post(
"/campaigns/{campaign_id}/versions/{version_id}/exports",
response_model=CampaignPortablePackageResponse,
)
def export_campaign_package(
campaign_id: str,
version_id: str,
payload: CampaignExportRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("campaigns:campaign:export")
),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal)
version = (
session.query(CampaignVersion)
.filter(
CampaignVersion.id == version_id,
CampaignVersion.campaign_id == campaign.id,
)
.one_or_none()
)
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign version not found",
)
scopes = set(payload.scopes)
if "recipients" in scopes:
_require_permission(principal, "campaigns:recipient:read")
_require_permission(principal, "campaigns:recipient:export")
if "review_state" in scopes:
_require_permission(principal, "campaigns:report:read")
if "delivery_history" in scopes:
_require_permission(principal, "campaigns:report:export")
_require_permission(principal, "campaigns:recipient:read")
_require_permission(principal, "campaigns:recipient:export")
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
.all()
if "delivery_history" in scopes
else ()
)
issues = (
session.query(CampaignIssue)
.filter(CampaignIssue.campaign_version_id == version.id)
.order_by(CampaignIssue.id.asc())
.all()
if "review_state" in scopes
else ()
)
try:
package = build_campaign_portable_package(
campaign=campaign,
version=version,
scopes=payload.scopes,
jobs=jobs,
issues=issues,
module_version=_module_version(),
)
audit_from_principal(
session,
principal,
action="campaign.portable_export_created",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign.id,
"package_id": package["package_id"],
"package_sha256": package["integrity"]["package_sha256"],
"format_version": package["format_version"],
"scopes": package["scopes"],
"item_counts": package["manifest"]["item_counts"],
"redactions": package["manifest"]["redactions"],
},
commit=True,
)
except CampaignTransferError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
except Exception:
session.rollback()
raise
return package
@router.post(
"/campaign-transfers/imports/preview",
response_model=CampaignImportPreviewResponse,
)
def preview_campaign_import(
payload: CampaignImportPreviewRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("campaigns:campaign:import")
),
):
_require_permission(principal, "campaigns:campaign:create")
inspection = _inspect_import_request(
session,
principal,
package=payload.package,
selected_scopes=payload.selected_scopes,
external_id=payload.external_id,
name=payload.name,
)
return inspection.preview
@router.post(
"/campaign-transfers/imports",
response_model=CampaignImportApplyResponse,
status_code=status.HTTP_201_CREATED,
)
def import_campaign_package(
payload: CampaignImportApplyRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("campaigns:campaign:import")
),
):
_require_permission(principal, "campaigns:campaign:create")
inspection = _inspect_import_request(
session,
principal,
package=payload.package,
selected_scopes=payload.selected_scopes,
external_id=payload.external_id,
name=payload.name,
)
_require_import_scope_permissions(principal, inspection)
preview = inspection.preview
if payload.expected_package_sha256 != preview.get("package_sha256"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The Campaign package changed after preview. Preview it again before importing.",
)
if not preview["compatible"] or inspection.configuration is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": "The Campaign package is not compatible.",
"errors": preview["errors"],
},
)
destination = preview["destination"]
package_id = str(preview["package_id"])
package_sha256 = str(preview["package_sha256"])
receipt = {
"package_id": package_id,
"package_sha256": package_sha256,
"format_version": preview["format_version"],
"source": copy.deepcopy(preview["source"]),
"selected_scopes": list(preview["selected_scopes"]),
"created": copy.deepcopy(preview["will_create"]),
"skipped": copy.deepcopy(preview["will_skip"]),
}
try:
campaign, version = create_campaign_version_from_json(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
raw_json=inspection.configuration,
source_filename=f"{package_id}.govoplan-campaign.json",
source_base_path=None,
commit=False,
)
campaign.settings = {
**inspection.portable_settings,
"portable_import": receipt,
}
session.add(campaign)
audit_from_principal(
session,
principal,
action="campaign.portable_import_applied",
object_type="campaign",
object_id=campaign.id,
details={
"version_id": version.id,
"external_id": destination["external_id"],
"package_id": package_id,
"package_sha256": package_sha256,
"format_version": preview["format_version"],
"selected_scopes": preview["selected_scopes"],
"created_codes": [item["code"] for item in preview["will_create"]],
"skipped_codes": [item["code"] for item in preview["will_skip"]],
},
commit=True,
)
session.refresh(campaign)
session.refresh(version)
_write_current_version_snapshot_if_available(version)
except Exception:
session.rollback()
raise
return CampaignImportApplyResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
),
receipt=receipt,
)
def _inspect_import_request(
session: Session,
principal: ApiPrincipal,
*,
package: dict[str, Any],
selected_scopes: list[str] | None,
external_id: str | None,
name: str | None,
) -> CampaignImportInspection:
source = package.get("source")
source = source if isinstance(source, dict) else {}
metadata_payload = package.get("payload")
metadata_payload = metadata_payload if isinstance(metadata_payload, dict) else {}
metadata_scope = metadata_payload.get("metadata")
metadata_scope = metadata_scope if isinstance(metadata_scope, dict) else {}
source_external_id = str(
metadata_scope.get("external_id")
or source.get("campaign_external_id")
or "campaign"
)
destination_external_id = _portable_import_external_id(
session,
tenant_id=principal.tenant_id,
source_external_id=source_external_id,
requested=external_id,
)
destination_name = str(
name
or metadata_scope.get("name")
or source.get("campaign_name")
or "Imported campaign"
).strip()
if not destination_name:
destination_name = "Imported campaign"
inspection = inspect_campaign_portable_package(
package,
selected_scopes=selected_scopes,
external_id=destination_external_id,
name=destination_name,
)
if _campaign_external_id_exists(
session, principal.tenant_id, destination_external_id
):
inspection.preview["compatible"] = False
inspection.preview["errors"].append(
"The destination Campaign ID already exists in this tenant."
)
return CampaignImportInspection(
preview=inspection.preview,
configuration=None,
portable_settings=inspection.portable_settings,
)
return inspection
def _portable_import_external_id(
session: Session,
*,
tenant_id: str,
source_external_id: str,
requested: str | None,
) -> str:
if requested is not None:
candidate = requested.strip()
if not candidate:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Campaign ID cannot be empty.",
)
return candidate
stem = f"{source_external_id[:238]}-import"
for suffix in ("", *(f"-{number}" for number in range(2, 10_000))):
candidate = f"{stem[:255 - len(suffix)]}{suffix}"
if not _campaign_external_id_exists(session, tenant_id, candidate):
return candidate
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="No available Campaign import identifier could be generated.",
)
def _campaign_external_id_exists(
session: Session, tenant_id: str, external_id: str
) -> bool:
return (
session.query(Campaign.id)
.filter(
Campaign.tenant_id == tenant_id,
Campaign.external_id == external_id,
)
.first()
is not None
)
def _require_import_scope_permissions(
principal: ApiPrincipal, inspection: CampaignImportInspection
) -> None:
selected = set(inspection.preview.get("selected_scopes") or [])
if "recipients" in selected:
_require_permission(principal, "campaigns:recipient:import")
_require_permission(principal, "campaigns:recipient:write")
def _module_version() -> str:
try:
return metadata.version("govoplan-campaign")
except metadata.PackageNotFoundError:
return "development"
__all__ = [
"export_campaign_package",
"import_campaign_package",
"preview_campaign_import",
"router",
]