399 lines
15 KiB
Python
399 lines
15 KiB
Python
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
|