feat(files): expose campaign attachment linking

This commit is contained in:
2026-07-20 20:06:04 +02:00
parent 8c5f149f07
commit d1051293b2
2 changed files with 172 additions and 4 deletions

View File

@@ -17,6 +17,7 @@ from govoplan_files.backend.storage.campaign_attachments import (
managed_match_payloads, managed_match_payloads,
prepared_campaign_snapshot, prepared_campaign_snapshot,
public_attachment_summary_payload, 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.campaign_usage import record_campaign_attachment_uses_for_jobs
from govoplan_files.backend.storage.files import current_version_and_blob 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) 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) record_campaign_attachment_uses_for_jobs = staticmethod(record_campaign_attachment_uses_for_jobs)
current_version_and_blob = staticmethod(current_version_and_blob) current_version_and_blob = staticmethod(current_version_and_blob)
share_assets_with_campaign = staticmethod(share_assets_with_campaign)
@staticmethod @staticmethod
def mark_job_attachment_uses_sent(session: Any, job: Any) -> None: def mark_job_attachment_uses_sent(session: Any, job: Any) -> None:

View File

@@ -12,10 +12,11 @@ from typing import Any, Iterator
from sqlalchemy.orm import Session 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.backends import StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.common import FileStorageError 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.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 from govoplan_files.backend.storage.provenance import source_provenance_from_metadata, source_revision_from_metadata
@@ -37,6 +38,7 @@ class ManagedAttachmentFile:
checksum_sha256: str checksum_sha256: str
size_bytes: int size_bytes: int
content_type: str | None content_type: str | None
linked_to_campaign: bool = True
source_provenance: dict[str, Any] | None = None source_provenance: dict[str, Any] | None = None
source_revision: str | None = None source_revision: str | None = None
@@ -56,6 +58,7 @@ class PreparedCampaignSnapshot:
raw_json: dict[str, Any] raw_json: dict[str, Any]
managed_files_by_local_path: dict[str, ManagedAttachmentFile] managed_files_by_local_path: dict[str, ManagedAttachmentFile]
shared_assets: list[FileAsset] shared_assets: list[FileAsset]
candidate_assets: list[FileAsset]
def parse_managed_source(value: object) -> tuple[str, str] | None: 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 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( def _selected_base_path(
rule: dict[str, Any], rule: dict[str, Any],
prepared_by_id: dict[str, tuple[str, str]], prepared_by_id: dict[str, tuple[str, str]],
@@ -144,6 +240,9 @@ def prepare_campaign_snapshot(
raw_json: dict[str, Any], raw_json: dict[str, Any],
destination: Path, destination: Path,
include_bytes: bool, include_bytes: bool,
include_unlinked_candidates: bool = False,
user_id: str = "",
is_admin: bool = False,
) -> PreparedCampaignSnapshot: ) -> PreparedCampaignSnapshot:
"""Create a temporary file-oriented campaign snapshot for managed attachments. """Create a temporary file-oriented campaign snapshot for managed attachments.
@@ -175,13 +274,25 @@ def prepare_campaign_snapshot(
campaign_id=campaign_id, campaign_id=campaign_id,
is_admin=True, 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) 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) owner_id = _asset_owner_id(asset)
if owner_id: if owner_id:
assets_by_owner[(asset.owner_type, owner_id)].append(asset) 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 backend = get_storage_backend() if include_bytes else None
manifest: dict[str, ManagedAttachmentFile] = {} manifest: dict[str, ManagedAttachmentFile] = {}
@@ -240,6 +351,7 @@ def prepare_campaign_snapshot(
checksum_sha256=blob.checksum_sha256, checksum_sha256=blob.checksum_sha256,
size_bytes=blob.size_bytes, size_bytes=blob.size_bytes,
content_type=blob.content_type, content_type=blob.content_type,
linked_to_campaign=asset.id in linked_asset_ids,
source_provenance=source_provenance_from_metadata(asset.metadata_), source_provenance=source_provenance_from_metadata(asset.metadata_),
source_revision=source_revision_from_metadata(asset.metadata_), source_revision=source_revision_from_metadata(asset.metadata_),
) )
@@ -262,6 +374,7 @@ def prepare_campaign_snapshot(
raw_json=prepared_json, raw_json=prepared_json,
managed_files_by_local_path=manifest, managed_files_by_local_path=manifest,
shared_assets=shared_assets, shared_assets=shared_assets,
candidate_assets=candidate_assets,
) )
@@ -274,6 +387,9 @@ def prepared_campaign_snapshot(
raw_json: dict[str, Any], raw_json: dict[str, Any],
include_bytes: bool, include_bytes: bool,
prefix: str = "govoplan-managed-campaign-", prefix: str = "govoplan-managed-campaign-",
include_unlinked_candidates: bool = False,
user_id: str = "",
is_admin: bool = False,
) -> Iterator[PreparedCampaignSnapshot]: ) -> Iterator[PreparedCampaignSnapshot]:
temp_dir = Path(tempfile.mkdtemp(prefix=prefix)) temp_dir = Path(tempfile.mkdtemp(prefix=prefix))
try: try:
@@ -284,6 +400,9 @@ def prepared_campaign_snapshot(
raw_json=raw_json, raw_json=raw_json,
destination=temp_dir, destination=temp_dir,
include_bytes=include_bytes, include_bytes=include_bytes,
include_unlinked_candidates=include_unlinked_candidates,
user_id=user_id,
is_admin=is_admin,
) )
finally: finally:
shutil.rmtree(temp_dir, ignore_errors=True) shutil.rmtree(temp_dir, ignore_errors=True)
@@ -301,6 +420,53 @@ def managed_match_payloads(
return 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]: def public_attachment_summary_payload(value: Any) -> dict[str, Any]:
"""Return an attachment summary without temporary materialization paths. """Return an attachment summary without temporary materialization paths.