from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from pydantic import BaseModel, Field 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 ( Campaign, CampaignVersion, ) from govoplan_core.db.session import get_session from govoplan_campaign.backend.persistence.campaigns import ( load_campaign_config_from_json, ) from govoplan_campaign.backend.integrations import ( files_integration, ) from govoplan_campaign.backend.path_security import ( CampaignPathSecurityError, assert_server_safe_campaign_paths, ) from govoplan_campaign.backend.campaign.loader import load_campaign_json from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments from govoplan_campaign.backend.persistence.versions import ( is_version_final_locked, is_user_locked_version, ) from govoplan_campaign.backend.route_support import ( _get_campaign_for_principal, _get_campaign_for_tenant, _get_version_for_tenant, _require_mail_profile_use_if_needed, _require_permission, ) router = APIRouter(prefix="/campaigns", tags=["campaigns"]) class CampaignAttachmentPreviewRequest(BaseModel): include_unmatched: bool = True include_unlinked_candidates: bool = False campaign_json: dict[str, object] | None = None class CampaignAttachmentPreviewResponse(BaseModel): campaign_id: str version_id: str shared_file_count: int candidate_file_count: int = 0 matched_file_count: int = 0 linked_file_count: int = 0 unlinked_file_count: int = 0 rules: list[dict[str, object]] = Field(default_factory=list) linkable_files: list[dict[str, object]] = Field(default_factory=list) unused_shared_files: list[dict[str, object]] = Field(default_factory=list) class CampaignAttachmentLinkMatchesRequest(BaseModel): campaign_json: dict[str, object] | None = None dry_run: bool = False class CampaignAttachmentLinkMatchesResponse(BaseModel): campaign_id: str version_id: str matched_file_count: int already_linked_file_count: int linked_file_count: int dry_run: bool = False linked_files: list[dict[str, object]] = Field(default_factory=list) linkable_files: list[dict[str, object]] = Field(default_factory=list) def _file_preview(session: Session, asset) -> dict[str, object]: version, blob = files_integration().current_version_and_blob(session, asset) return { "id": asset.id, "version_id": version.id, "blob_id": blob.id, "display_path": asset.display_path, "filename": asset.filename, "owner_type": asset.owner_type, "owner_id": asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id, "checksum_sha256": blob.checksum_sha256, "size_bytes": blob.size_bytes, "content_type": blob.content_type, "linked_to_campaign": True, } def _managed_preview_file(item: dict[str, object]) -> dict[str, object]: return { "id": item["asset_id"], "version_id": item["version_id"], "blob_id": item["blob_id"], "display_path": item["display_path"], "filename": item["filename"], "owner_type": item["owner_type"], "owner_id": item["owner_id"], "checksum_sha256": item["checksum_sha256"], "size_bytes": item["size_bytes"], "content_type": item["content_type"], "linked_to_campaign": bool(item.get("linked_to_campaign", True)), } def _attachment_preview_for_version( session: Session, principal: ApiPrincipal, *, campaign: Campaign, version: CampaignVersion, raw: dict[str, object], include_unmatched: bool, include_unlinked_candidates: bool, ) -> CampaignAttachmentPreviewResponse: files = files_integration() assert_server_safe_campaign_paths(raw, managed_files_available=files.available) with files.prepared_campaign_snapshot( session, tenant_id=principal.tenant_id, campaign_id=campaign.id, raw_json=raw, include_bytes=False, prefix="govoplan-managed-preview-", include_unlinked_candidates=include_unlinked_candidates, user_id=principal.user.id, is_admin=has_scope(principal, "files:file:admin"), ) as prepared: prepared_raw = load_campaign_json(prepared.path) config = load_campaign_config_from_json( session, tenant_id=principal.tenant_id, raw_json=prepared_raw, campaign_id=campaign.id, ) report = resolve_campaign_attachments(config, campaign_file=prepared.path) rules: list[dict[str, object]] = [] matched_asset_ids: set[str] = set() linked_asset_ids: set[str] = set() linkable_by_id: dict[str, dict[str, object]] = {} for entry in report.entries: for attachment in entry.attachments: managed_matches = files.managed_match_payloads( attachment.matches, prepared.managed_files_by_local_path ) matches: list[dict[str, object]] = [] for item in managed_matches: asset_id = str(item["asset_id"]) matched_asset_ids.add(asset_id) if bool(item.get("linked_to_campaign", True)): linked_asset_ids.add(asset_id) preview = _managed_preview_file(item) matches.append(preview) if not preview["linked_to_campaign"]: linkable_by_id.setdefault(asset_id, preview) if not matches: matches = [ { "id": "", "display_path": match, "filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1], "owner_type": "legacy", "owner_id": "", "linked_to_campaign": True, } for match in attachment.matches ] rules.append( { "source": attachment.scope.value, "entry_index": entry.entry_index, "entry_id": entry.entry_id, "index": attachment.index, "attachment_id": attachment.attachment_id, "label": attachment.label, "required": attachment.required, "pattern": attachment.file_filter, "base_path_name": attachment.base_path_name, "base_path": attachment.base_path, "status": attachment.status.value, "behavior": attachment.behavior.value if attachment.behavior else None, "zip_included": attachment.zip_enabled, "zip_mode": attachment.zip_mode.value, "zip_archive_id": attachment.zip_archive_id, "zip_filename": attachment.zip_filename, "matches": matches, "match_count": len(matches), "linked_match_count": sum( 1 for match in matches if bool(match.get("linked_to_campaign", True)) ), "unlinked_match_count": sum( 1 for match in matches if not bool(match.get("linked_to_campaign", True)) ), "issues": [ issue.model_dump(mode="json") for issue in attachment.issues ], } ) unused = [ asset for asset in prepared.shared_assets if asset.id not in matched_asset_ids ] return CampaignAttachmentPreviewResponse( campaign_id=campaign.id, version_id=version.id, shared_file_count=len(prepared.shared_assets), candidate_file_count=len( getattr(prepared, "candidate_assets", prepared.shared_assets) ), matched_file_count=len(matched_asset_ids), linked_file_count=len(linked_asset_ids), unlinked_file_count=len(linkable_by_id), rules=rules, linkable_files=list(linkable_by_id.values()), unused_shared_files=[_file_preview(session, asset) for asset in unused] if include_unmatched else [], ) def _link_campaign_attachment_matches( session: Session, principal: ApiPrincipal, *, campaign: Campaign, version: CampaignVersion, raw: dict[str, object], dry_run: bool = False, ) -> CampaignAttachmentLinkMatchesResponse: preview = _attachment_preview_for_version( session, principal, campaign=campaign, version=version, raw=raw, include_unmatched=False, include_unlinked_candidates=True, ) file_ids = [ str(item.get("id") or "") for item in preview.linkable_files if item.get("id") ] linked_files: list[dict[str, object]] = [] if file_ids and not dry_run: files = files_integration() shares = files.share_assets_with_campaign( session, tenant_id=principal.tenant_id, campaign_id=campaign.id, file_ids=file_ids, user_id=principal.user.id, is_admin=has_scope(principal, "files:file:admin"), ) share_by_asset_id = { str(item.get("file_asset_id") or ""): item for item in shares } linked_files = [ {**item, "share": share_by_asset_id.get(str(item.get("id") or ""))} for item in preview.linkable_files ] return CampaignAttachmentLinkMatchesResponse( campaign_id=campaign.id, version_id=version.id, matched_file_count=preview.matched_file_count, already_linked_file_count=preview.linked_file_count, linked_file_count=0 if dry_run else len(file_ids), dry_run=dry_run, linked_files=linked_files, linkable_files=preview.linkable_files, ) @router.post( "/{campaign_id}/versions/{version_id}/attachments/preview", response_model=CampaignAttachmentPreviewResponse, ) def preview_campaign_attachments( campaign_id: str, version_id: str, payload: CampaignAttachmentPreviewRequest | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("files:file:read")), ): _get_campaign_for_principal(session, campaign_id, principal) _require_permission(principal, "campaigns:recipient:read") campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) version = _get_version_for_tenant(session, 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" ) payload = payload or CampaignAttachmentPreviewRequest() raw = ( payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json ) raw = raw if isinstance(raw, dict) else {} _require_mail_profile_use_if_needed(principal, raw) try: return _attachment_preview_for_version( session, principal, campaign=campaign, version=version, raw=raw, include_unmatched=payload.include_unmatched, include_unlinked_candidates=payload.include_unlinked_candidates, ) except CampaignPathSecurityError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc @router.post( "/{campaign_id}/versions/{version_id}/attachments/link-matches", response_model=CampaignAttachmentLinkMatchesResponse, ) def link_campaign_attachment_matches( campaign_id: str, version_id: str, payload: CampaignAttachmentLinkMatchesRequest | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")), ): _require_permission(principal, "files:file:share") _get_campaign_for_principal(session, campaign_id, principal, write=True) _require_permission(principal, "campaigns:recipient:read") campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) version = _get_version_for_tenant(session, 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" ) if is_user_locked_version(version) or is_version_final_locked(version): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Locked campaign versions cannot link new attachment files", ) payload = payload or CampaignAttachmentLinkMatchesRequest() raw = ( payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json ) raw = raw if isinstance(raw, dict) else {} _require_mail_profile_use_if_needed(principal, raw) try: result = _link_campaign_attachment_matches( session, principal, campaign=campaign, version=version, raw=raw, dry_run=payload.dry_run, ) audit_from_principal( session, principal, action="campaign.attachment_matches_linked" if not payload.dry_run else "campaign.attachment_matches_link_previewed", object_type="campaign_version", object_id=version_id, details={ "matched_file_count": result.matched_file_count, "already_linked_file_count": result.already_linked_file_count, "linked_file_count": result.linked_file_count, "dry_run": result.dry_run, }, commit=True, ) return result except HTTPException: raise except Exception as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc