feat(campaigns): add portable campaign transfers
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user