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", ]