379 lines
12 KiB
Python
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",
|
|
]
|