602 lines
20 KiB
Python
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)
|