from __future__ import annotations from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, 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, response: Response, 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, ) result = CampaignVersionDetailResponse.model_validate( version, context=_campaign_response_context(principal), ) response.headers["ETag"] = version.strong_etag return 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}/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, response: Response, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), ): result = _update_campaign_version_detail_response( session, principal, campaign_id, version_id, payload, if_match=if_match, autosave=False, audit_action="campaign.version_updated", ) response.headers["ETag"] = result.strong_etag return result @router.post( "/{campaign_id}/versions/{version_id}/autosave", response_model=CampaignVersionDetailResponse, ) def autosave_version( campaign_id: str, version_id: str, payload: CampaignVersionUpdateRequest, response: Response, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), ): result = _update_campaign_version_detail_response( session, principal, campaign_id, version_id, payload, if_match=if_match, autosave=True, audit_action="campaign.version_autosaved", ) response.headers["ETag"] = result.strong_etag return result @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, issue_decisions=[ item.model_dump() for item in payload.issue_decisions ], 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), "issue_decision_count": len(payload.issue_decisions), }, 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, user_id=principal.user.id, ) 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