feat(files): expose campaign attachment linking
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user