Files
govoplan-campaign/src/govoplan_campaign/backend/route_support.py

602 lines
20 KiB
Python

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)