From d1051293b26dedee7137e72b0919f0d519a03b5f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 20 Jul 2026 20:06:04 +0200 Subject: [PATCH] feat(files): expose campaign attachment linking --- src/govoplan_files/backend/capabilities.py | 2 + .../backend/storage/campaign_attachments.py | 174 +++++++++++++++++- 2 files changed, 172 insertions(+), 4 deletions(-) diff --git a/src/govoplan_files/backend/capabilities.py b/src/govoplan_files/backend/capabilities.py index 5e79516..e5b862a 100644 --- a/src/govoplan_files/backend/capabilities.py +++ b/src/govoplan_files/backend/capabilities.py @@ -17,6 +17,7 @@ from govoplan_files.backend.storage.campaign_attachments import ( managed_match_payloads, prepared_campaign_snapshot, public_attachment_summary_payload, + share_assets_with_campaign, ) from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs from govoplan_files.backend.storage.files import current_version_and_blob @@ -44,6 +45,7 @@ class FilesCampaignCapability: annotate_built_messages_with_managed_files = staticmethod(annotate_built_messages_with_managed_files) record_campaign_attachment_uses_for_jobs = staticmethod(record_campaign_attachment_uses_for_jobs) current_version_and_blob = staticmethod(current_version_and_blob) + share_assets_with_campaign = staticmethod(share_assets_with_campaign) @staticmethod def mark_job_attachment_uses_sent(session: Any, job: Any) -> None: diff --git a/src/govoplan_files/backend/storage/campaign_attachments.py b/src/govoplan_files/backend/storage/campaign_attachments.py index 4e08b92..6b57ec8 100644 --- a/src/govoplan_files/backend/storage/campaign_attachments.py +++ b/src/govoplan_files/backend/storage/campaign_attachments.py @@ -12,10 +12,11 @@ from typing import Any, Iterator from sqlalchemy.orm import Session -from govoplan_files.backend.db.models import FileAsset +from govoplan_files.backend.db.models import FileAsset, FileShare from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend from govoplan_files.backend.storage.common import FileStorageError -from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user +from govoplan_files.backend.storage.files import current_versions_and_blobs, get_asset_for_user, list_assets_for_user, share_files +from govoplan_files.backend.storage.access import ensure_owner_access from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component from govoplan_files.backend.storage.provenance import source_provenance_from_metadata, source_revision_from_metadata @@ -37,6 +38,7 @@ class ManagedAttachmentFile: checksum_sha256: str size_bytes: int content_type: str | None + linked_to_campaign: bool = True source_provenance: dict[str, Any] | None = None source_revision: str | None = None @@ -56,6 +58,7 @@ class PreparedCampaignSnapshot: raw_json: dict[str, Any] managed_files_by_local_path: dict[str, ManagedAttachmentFile] shared_assets: list[FileAsset] + candidate_assets: list[FileAsset] def parse_managed_source(value: object) -> tuple[str, str] | None: @@ -117,6 +120,99 @@ def _iter_rule_dicts(attachments: dict[str, Any], raw_json: dict[str, Any]): yield rule +def _managed_base_sources(raw_json: dict[str, Any]) -> list[tuple[str, str, str]]: + attachments = raw_json.get("attachments") + if not isinstance(attachments, dict): + return [] + base_paths = attachments.get("base_paths") + if not isinstance(base_paths, list): + return [] + sources: list[tuple[str, str, str]] = [] + seen: set[tuple[str, str, str]] = set() + for item in base_paths: + if not isinstance(item, dict): + continue + parsed_source = parse_managed_source(item.get("source")) + if parsed_source is None: + continue + owner_type, owner_id = parsed_source + old_path = str(item.get("path") or ".").strip() or "." + logical_root = "" if old_path in {"", ".", "/"} else normalize_folder(old_path) + key = (owner_type, owner_id, logical_root) + if key in seen: + continue + seen.add(key) + sources.append(key) + return sources + + +def _campaign_linked_asset_ids( + session: Session, + *, + tenant_id: str, + campaign_id: str, + asset_ids: list[str], +) -> set[str]: + if not asset_ids: + return set() + linked: set[str] = set() + for offset in range(0, len(asset_ids), 900): + chunk = asset_ids[offset : offset + 900] + rows = ( + session.query(FileShare.file_asset_id) + .filter( + FileShare.tenant_id == tenant_id, + FileShare.file_asset_id.in_(chunk), + FileShare.target_type == "campaign", + FileShare.target_id == campaign_id, + FileShare.revoked_at.is_(None), + ) + .all() + ) + linked.update(row[0] for row in rows) + return linked + + +def _candidate_assets_for_managed_sources( + session: Session, + *, + tenant_id: str, + campaign_id: str, + raw_json: dict[str, Any], + user_id: str, + is_admin: bool, + shared_assets: list[FileAsset], +) -> tuple[list[FileAsset], set[str]]: + assets_by_id: dict[str, FileAsset] = {asset.id: asset for asset in shared_assets} + for owner_type, owner_id, logical_root in _managed_base_sources(raw_json): + ensure_owner_access( + session, + tenant_id=tenant_id, + owner_type=owner_type, + owner_id=owner_id, + user_id=user_id, + is_admin=is_admin, + ) + for asset in list_assets_for_user( + session, + tenant_id=tenant_id, + user_id=user_id, + owner_type=owner_type, + owner_id=owner_id, + path_prefix=logical_root or None, + is_admin=is_admin, + ): + assets_by_id.setdefault(asset.id, asset) + candidate_assets = sorted(assets_by_id.values(), key=lambda asset: (asset.display_path, asset.updated_at, asset.id)) + linked_ids = _campaign_linked_asset_ids( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + asset_ids=[asset.id for asset in candidate_assets], + ) + return candidate_assets, linked_ids + + def _selected_base_path( rule: dict[str, Any], prepared_by_id: dict[str, tuple[str, str]], @@ -144,6 +240,9 @@ def prepare_campaign_snapshot( raw_json: dict[str, Any], destination: Path, include_bytes: bool, + include_unlinked_candidates: bool = False, + user_id: str = "", + is_admin: bool = False, ) -> PreparedCampaignSnapshot: """Create a temporary file-oriented campaign snapshot for managed attachments. @@ -175,13 +274,25 @@ def prepare_campaign_snapshot( campaign_id=campaign_id, is_admin=True, ) + candidate_assets = shared_assets + linked_asset_ids = {asset.id for asset in shared_assets} + if include_unlinked_candidates: + candidate_assets, linked_asset_ids = _candidate_assets_for_managed_sources( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + raw_json=prepared_json, + user_id=user_id, + is_admin=is_admin, + shared_assets=shared_assets, + ) assets_by_owner: dict[tuple[str, str], list[FileAsset]] = defaultdict(list) - for asset in shared_assets: + for asset in candidate_assets: owner_id = _asset_owner_id(asset) if owner_id: assets_by_owner[(asset.owner_type, owner_id)].append(asset) - version_blobs = current_versions_and_blobs(session, shared_assets) + version_blobs = current_versions_and_blobs(session, candidate_assets) backend = get_storage_backend() if include_bytes else None manifest: dict[str, ManagedAttachmentFile] = {} @@ -240,6 +351,7 @@ def prepare_campaign_snapshot( checksum_sha256=blob.checksum_sha256, size_bytes=blob.size_bytes, content_type=blob.content_type, + linked_to_campaign=asset.id in linked_asset_ids, source_provenance=source_provenance_from_metadata(asset.metadata_), source_revision=source_revision_from_metadata(asset.metadata_), ) @@ -262,6 +374,7 @@ def prepare_campaign_snapshot( raw_json=prepared_json, managed_files_by_local_path=manifest, shared_assets=shared_assets, + candidate_assets=candidate_assets, ) @@ -274,6 +387,9 @@ def prepared_campaign_snapshot( raw_json: dict[str, Any], include_bytes: bool, prefix: str = "govoplan-managed-campaign-", + include_unlinked_candidates: bool = False, + user_id: str = "", + is_admin: bool = False, ) -> Iterator[PreparedCampaignSnapshot]: temp_dir = Path(tempfile.mkdtemp(prefix=prefix)) try: @@ -284,6 +400,9 @@ def prepared_campaign_snapshot( raw_json=raw_json, destination=temp_dir, include_bytes=include_bytes, + include_unlinked_candidates=include_unlinked_candidates, + user_id=user_id, + is_admin=is_admin, ) finally: shutil.rmtree(temp_dir, ignore_errors=True) @@ -301,6 +420,53 @@ def managed_match_payloads( return payloads +def share_assets_with_campaign( + session: Session, + *, + tenant_id: str, + campaign_id: str, + file_ids: list[str], + user_id: str, + is_admin: bool = False, +) -> list[dict[str, Any]]: + unique_ids = list(dict.fromkeys(file_id for file_id in file_ids if file_id)) + if not unique_ids: + return [] + assets = [ + get_asset_for_user( + session, + tenant_id=tenant_id, + user_id=user_id, + asset_id=file_id, + require_write=True, + is_admin=is_admin, + ) + for file_id in unique_ids + ] + shares = share_files( + session, + tenant_id=tenant_id, + assets=assets, + target_type="campaign", + target_id=campaign_id, + permission="read", + user_id=user_id, + ) + session.flush() + return [ + { + "id": share.id, + "file_asset_id": share.file_asset_id, + "target_type": share.target_type, + "target_id": share.target_id, + "permission": share.permission, + "created_at": share.created_at.isoformat() if share.created_at else None, + "revoked_at": share.revoked_at.isoformat() if share.revoked_at else None, + } + for share in shares + ] + + def public_attachment_summary_payload(value: Any) -> dict[str, Any]: """Return an attachment summary without temporary materialization paths.