from __future__ import annotations import copy import dataclasses from collections.abc import Callable from typing import Any from fastapi import HTTPException, status from sqlalchemy import and_, exists, or_ from sqlalchemy.orm import Session from govoplan_campaign.backend.campaign.mail_profile_boundary import ( CAMPAIGN_MAIL_SERVER_KEYS, campaign_mail_profile_id, ) from govoplan_campaign.backend.db.models import ( Campaign, CampaignIssue, CampaignJob, CampaignShare, CampaignStatus, CampaignVersion, CampaignVersionWorkflowState, RecipientImportMappingProfile, ) from govoplan_campaign.backend.path_security import CampaignPathSecurityError from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError from govoplan_campaign.backend.persistence.versions import ( LockedCampaignVersionError, is_user_locked_version, is_version_final_locked, is_version_locked, update_campaign_version, ) from govoplan_campaign.backend.schemas import ( CampaignVersionDetailResponse, CampaignVersionUpdateRequest, RecipientImportMappingProfilePayload, ) from govoplan_campaign.backend.sending.execution import ( clear_execution_snapshot, ) from govoplan_core.audit.logging import audit_from_principal from govoplan_core.auth import ApiPrincipal, has_scope from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory from govoplan_core.core.runtime import get_registry def _capability_payload(value: object) -> dict[str, Any]: if dataclasses.is_dataclass(value): return dataclasses.asdict(value) if isinstance(value, dict): return dict(value) payload: dict[str, Any] = {} for key in ( "contact_id", "address_book_id", "display_name", "email", "email_label", "organization", "role_title", "tags", "source_kind", "source_ref", "source_revision", "source_id", "source_label", "recipient_count", "generated_at", "recipients", "fields", "provenance", ): if hasattr(value, key): payload[key] = getattr(value, key) return payload def _registry_capability(name: str) -> object | None: registry = get_registry() if ( registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name) ): return None return registry.capability(name) def _access_directory() -> AccessDirectory: registry = get_registry() if ( registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY) ): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is not configured", ) capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY) if not isinstance(capability, AccessDirectory): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is invalid", ) return capability def _get_campaign_for_tenant( session: Session, campaign_id: str, tenant_id: str ) -> Campaign: campaign = session.get(Campaign, campaign_id) if not campaign or campaign.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found" ) return campaign def _get_version_for_tenant( session: Session, version_id: str, tenant_id: str ) -> CampaignVersion: version = session.get(CampaignVersion, version_id) if not version: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found" ) campaign = session.get(Campaign, version.campaign_id) if not campaign or campaign.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found" ) return version def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]: del session return { group.id for group in _access_directory().groups_for_user( principal.user.id, tenant_id=principal.tenant_id ) } def _campaign_acl_filter(session: Session, principal: ApiPrincipal): if has_scope(principal, "tenant:*"): return None group_ids = _principal_group_ids(session, principal) clauses = [Campaign.owner_user_id == principal.user.id] if group_ids: clauses.append(Campaign.owner_group_id.in_(group_ids)) share_clauses = [ and_( CampaignShare.tenant_id == Campaign.tenant_id, CampaignShare.campaign_id == Campaign.id, CampaignShare.revoked_at.is_(None), CampaignShare.target_type == "user", CampaignShare.target_id == principal.user.id, ) ] if group_ids: share_clauses.append( and_( CampaignShare.tenant_id == Campaign.tenant_id, CampaignShare.campaign_id == Campaign.id, CampaignShare.revoked_at.is_(None), CampaignShare.target_type == "group", CampaignShare.target_id.in_(group_ids), ) ) clauses.append(exists().where(or_(*share_clauses))) return or_(*clauses) def _campaign_acl_allows( session: Session, campaign: Campaign, principal: ApiPrincipal, *, write: bool = False, ) -> bool: if has_scope(principal, "tenant:*"): return True if campaign.owner_user_id == principal.user.id: return True group_ids = _principal_group_ids(session, principal) if campaign.owner_group_id and campaign.owner_group_id in group_ids: return True target_ids = [principal.user.id, *group_ids] if not target_ids: return False query = session.query(CampaignShare).filter( CampaignShare.tenant_id == campaign.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None), or_( CampaignShare.target_type == "user", CampaignShare.target_type == "group", ), CampaignShare.target_id.in_(target_ids), ) shares = query.all() if not shares: return False if not write: return True return any(item.permission == "write" for item in shares) def _require_campaign_acl( session: Session, campaign: Campaign, principal: ApiPrincipal, *, write: bool = False, ) -> None: if not _campaign_acl_allows(session, campaign, principal, write=write): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Campaign is not shared with this principal", ) def _get_campaign_for_principal( session: Session, campaign_id: str, principal: ApiPrincipal, *, write: bool = False ) -> Campaign: campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) _require_campaign_acl(session, campaign, principal, write=write) return campaign def _require_permission(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}" ) def _campaign_query_for_principal(session: Session, principal: ApiPrincipal): query = session.query(Campaign).filter( Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted" ) acl_filter = _campaign_acl_filter(session, principal) if acl_filter is not None: query = query.filter(acl_filter) return query def _get_recipient_import_profile_for_principal( session: Session, profile_id: str, principal: ApiPrincipal ) -> RecipientImportMappingProfile: profile = session.get(RecipientImportMappingProfile, profile_id) if ( not profile or profile.tenant_id != principal.tenant_id or profile.owner_user_id != principal.user.id ): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Recipient import mapping profile not found", ) return profile def _apply_recipient_import_profile_payload( profile: RecipientImportMappingProfile, payload: RecipientImportMappingProfilePayload, ) -> None: profile.name = payload.name.strip() profile.column_count = payload.column_count profile.headers = list(payload.headers) profile.normalized_headers = list(payload.normalized_headers) profile.ordered_header_fingerprint = payload.ordered_header_fingerprint profile.unordered_header_fingerprint = payload.unordered_header_fingerprint profile.delimiter = payload.delimiter profile.header_rows = payload.header_rows profile.quoted = payload.quoted profile.value_separators = payload.value_separators profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings] def _recipient_sections_changed( current: dict[str, object] | None, proposed: dict[str, object] | None ) -> bool: if proposed is None: return False current = current or {} return any( current.get(key) != proposed.get(key) for key in ("recipients", "entries") ) def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None: return campaign_mail_profile_id(raw_json) def _require_mail_profile_use_if_needed( principal: ApiPrincipal, raw_json: dict[str, object] | None ) -> None: if _campaign_mail_profile_id(raw_json) and not has_scope( principal, "mail:profile:use" ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail:profile:use", ) def _campaign_response_context(principal: ApiPrincipal) -> dict[str, bool]: return {"include_diagnostics": has_scope(principal, "campaigns:diagnostic:read")} def _campaign_version_detail_response( session: Session, principal: ApiPrincipal, campaign_id: str, mutation: Callable[[], CampaignVersion], *, audit_action: str, details: dict[str, Any] | Callable[[CampaignVersion], dict[str, Any]] | None = None, validation_error_status: int | None = None, ) -> CampaignVersionDetailResponse: try: version = mutation() audit_details = ( details(version) if callable(details) else dict(details or {"campaign_id": campaign_id}) ) audit_from_principal( session, principal, action=audit_action, object_type="campaign_version", object_id=version.id, details=audit_details, commit=True, ) _write_current_version_snapshot_if_available(version) 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 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 as exc: session.rollback() if validation_error_status is None: raise raise HTTPException( status_code=validation_error_status, detail=str(exc) ) from exc def _update_campaign_version_detail_response( session: Session, principal: ApiPrincipal, campaign_id: str, version_id: str, payload: CampaignVersionUpdateRequest, *, autosave: bool, audit_action: str, ) -> CampaignVersionDetailResponse: _get_campaign_for_principal(session, campaign_id, principal, write=True) current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): _require_permission(principal, "campaigns:recipient:write") _require_mail_profile_use_if_needed(principal, payload.campaign_json) 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, raw_json=payload.campaign_json, current_flow=payload.current_flow, current_step=payload.current_step, workflow_state=payload.workflow_state, is_complete=payload.is_complete, editor_state=payload.editor_state, source_filename=payload.source_filename, source_base_path=payload.source_base_path, autosave=autosave, migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings, commit=False, ), audit_action=audit_action, details=lambda version: { "campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step, "legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings, }, validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT, ) def _require_campaign_profile_use_if_needed( session: Session, principal: ApiPrincipal, campaign_id: str, version_id: str | None = None, ) -> None: campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) target_version_id = version_id or campaign.current_version_id if not target_version_id: return version = _get_version_for_tenant(session, target_version_id, principal.tenant_id) if version.campaign_id != campaign.id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found" ) _require_mail_profile_use_if_needed( principal, version.raw_json if isinstance(version.raw_json, dict) else {} ) def _require_campaign_versions_profile_use( session: Session, principal: ApiPrincipal, campaign_id: str, version_ids: set[str], ) -> None: """Authorize every historical version affected by a campaign-wide action.""" for version_id in sorted(version_ids): _require_campaign_profile_use_if_needed( session, principal, campaign_id, version_id, ) def _get_version_for_principal( session: Session, version_id: str, principal: ApiPrincipal, *, write: bool = False, ) -> CampaignVersion: version = _get_version_for_tenant(session, version_id, principal.tenant_id) campaign = _get_campaign_for_tenant( session, version.campaign_id, principal.tenant_id ) _require_campaign_acl(session, campaign, principal, write=write) return version def _sync_campaign_metadata_to_current_version( session: Session, campaign: Campaign ) -> None: """Keep editable version JSON aligned with version-independent campaign metadata. Campaign metadata can be edited from the overview while individual campaign sections save the current version JSON later. Without this sync, a later version save can re-apply stale `campaign.name` / `campaign.id` values from raw_json and make the old overview metadata appear to come back. Audit-safe or validation-locked versions are left untouched. """ if not campaign.current_version_id: return version = session.get(CampaignVersion, campaign.current_version_id) if not version or version.campaign_id != campaign.id or is_version_locked(version): return raw_json = copy.deepcopy( version.raw_json if isinstance(version.raw_json, dict) else {} ) campaign_section = ( raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} ) raw_json["campaign"] = { **campaign_section, "id": campaign.external_id, "name": campaign.name, "description": campaign.description or "", } version.raw_json = raw_json session.add(version) def _clear_current_version_mail_profile_for_owner_transfer( session: Session, campaign: Campaign ) -> bool: """Force explicit profile reselection after campaign ownership changes. User/group-scoped reusable mail profiles are evaluated against the current owner. Instead of trying to keep a stale selection across an ownership transfer, clear the profile from the editable current version and invalidate validation/build state so the operator has to reselect and revalidate. """ if not campaign.current_version_id: return False version = session.get(CampaignVersion, campaign.current_version_id) if not version or version.campaign_id != campaign.id: return False raw_json = copy.deepcopy( version.raw_json if isinstance(version.raw_json, dict) else {} ) server = ( raw_json.get("server") if isinstance(raw_json.get("server"), dict) else None ) if not isinstance(server, dict): return False profile_id = _campaign_mail_profile_id(raw_json) if not profile_id: return False if is_version_final_locked(version) or is_user_locked_version(version): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Change owner only after creating an editable campaign version; the current version has a selected mail profile and is locked.", ) next_server = dict(server) for key in CAMPAIGN_MAIL_SERVER_KEYS: next_server.pop(key, None) next_server.pop("profile_id", None) raw_json["server"] = next_server version.raw_json = raw_json version.validation_summary = None version.build_summary = None clear_execution_snapshot(version) version.locked_at = None version.locked_by_user_id = None version.workflow_state = CampaignVersionWorkflowState.EDITING.value version.is_complete = False editor_state = copy.deepcopy(version.editor_state or {}) editor_state.pop("review_send", None) version.editor_state = editor_state session.query(CampaignIssue).filter( CampaignIssue.campaign_version_id == version.id ).delete(synchronize_session=False) session.query(CampaignJob).filter( CampaignJob.campaign_version_id == version.id ).delete(synchronize_session=False) campaign.status = CampaignStatus.DRAFT.value session.add(version) _write_current_version_snapshot_if_available(version) return True def _write_current_version_snapshot_if_available(version: CampaignVersion) -> None: try: from govoplan_campaign.backend.persistence.campaigns import ( _write_campaign_snapshot, ) _write_campaign_snapshot(version) except Exception: # The database state is authoritative for the WebUI. Snapshot writing is # best-effort here because ownership changes should not fail due to an # unavailable local runtime directory. return def bounded_query_rows(query, *, limit: int, label: str): rows = query.limit(limit + 1).all() if len(rows) > limit: raise HTTPException( status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=( f"{label} exceeds the maximum response size of {limit} rows. " "Narrow the request or use a paginated/delta endpoint." ), ) return rows def job_attempt_rows(query, *, label: str): return bounded_query_rows(query, limit=1000, label=label)