chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:21 +02:00
parent 18e6c3eb9b
commit 13fd7fc3bd
50 changed files with 12678 additions and 1109 deletions
+231 -2
View File
@@ -6,7 +6,7 @@ from pathlib import PurePosixPath
from typing import Any, Iterable
from uuid import uuid4
from sqlalchemy import or_
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
@@ -16,6 +16,7 @@ from govoplan_files.backend.storage.access import ensure_owner_access, ensure_sh
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow
from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component
from govoplan_files.backend.storage.provenance import source_provenance_from_metadata
def _campaign_access_provider() -> CampaignAccessProvider:
@@ -181,6 +182,136 @@ def create_file_asset(
return UploadedStoredFile(asset=asset, version=version, blob=blob)
def sync_file_asset_from_source(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
user_id: str,
filename: str,
data: bytes,
metadata: dict[str, Any],
folder: str | None = None,
display_path: str | None = None,
content_type: str | None = None,
campaign_id: str | None = None,
conflict_strategy: str = "rename",
is_admin: bool = False,
) -> tuple[UploadedStoredFile, str, str | None]:
owner_type = owner_type.lower().strip()
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
provenance = source_provenance_from_metadata(metadata)
if not provenance:
raise FileStorageError("Connector sync requires source provenance")
existing = find_asset_by_source(
session,
tenant_id=tenant_id,
owner_type=owner_type,
owner_id=owner_id,
source_provenance=provenance,
)
if existing is None:
stored = create_file_asset(
session,
tenant_id=tenant_id,
owner_type=owner_type,
owner_id=owner_id,
user_id=user_id,
filename=filename,
data=data,
folder=folder,
display_path=display_path,
content_type=content_type,
metadata=metadata,
campaign_id=campaign_id,
conflict_strategy=conflict_strategy,
is_admin=is_admin,
)
return stored, "created", None
previous_version_id = existing.current_version_id
stored, action = update_file_asset_content(
session,
existing,
tenant_id=tenant_id,
user_id=user_id,
filename=filename,
data=data,
content_type=content_type,
metadata=metadata,
)
if campaign_id:
share_file(session, tenant_id=tenant_id, asset=existing, target_type="campaign", target_id=campaign_id, permission="read", user_id=user_id)
return stored, action, previous_version_id
def find_asset_by_source(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
source_provenance: dict[str, Any],
) -> FileAsset | None:
wanted = _source_identity(source_provenance)
if wanted is None:
return None
assets = (
_asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id)
.filter(FileAsset.deleted_at.is_(None))
.order_by(FileAsset.updated_at.desc())
.all()
)
for asset in assets:
if _source_identity(source_provenance_from_metadata(asset.metadata_ or {})) == wanted:
return asset
return None
def update_file_asset_content(
session: Session,
asset: FileAsset,
*,
tenant_id: str,
user_id: str,
filename: str,
data: bytes,
content_type: str | None,
metadata: dict[str, Any],
) -> tuple[UploadedStoredFile, str]:
if asset.tenant_id != tenant_id or asset.deleted_at is not None:
raise FileStorageError("File not found")
safe_filename = filename_from_path(normalize_logical_path(filename, fallback_filename="file"))
if not content_type:
content_type = mimetypes.guess_type(safe_filename)[0] or "application/octet-stream"
current_version, current_blob = current_version_and_blob(session, asset)
checksum = hashlib.sha256(data).hexdigest()
asset.metadata_ = metadata
session.add(asset)
if current_blob.checksum_sha256 == checksum and current_blob.size_bytes == len(data):
return UploadedStoredFile(asset=asset, version=current_version, blob=current_blob), "unchanged"
blob = _get_or_create_blob(session, tenant_id=tenant_id, data=data, filename=safe_filename, content_type=content_type)
version = FileVersion(
tenant_id=tenant_id,
file_asset_id=asset.id,
blob_id=blob.id,
version_number=_next_version_number(session, asset.id),
filename_at_upload=safe_filename,
display_path_at_upload=asset.display_path,
content_type=content_type,
size_bytes=blob.size_bytes,
checksum_sha256=blob.checksum_sha256,
created_by_user_id=user_id,
)
session.add(version)
session.flush()
asset.current_version_id = version.id
session.add(asset)
return UploadedStoredFile(asset=asset, version=version, blob=blob), "updated"
def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_id: str, require_write: bool = False, is_admin: bool = False) -> FileAsset:
asset = session.get(FileAsset, asset_id)
if not asset or asset.tenant_id != tenant_id or asset.deleted_at is not None:
@@ -224,6 +355,71 @@ def list_assets_for_user(
include_deleted: bool = False,
is_admin: bool = False,
) -> list[FileAsset]:
query = _asset_visibility_query_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
owner_type=owner_type,
owner_id=owner_id,
campaign_id=campaign_id,
path_prefix=path_prefix,
include_deleted=include_deleted,
is_admin=is_admin,
)
return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc(), FileAsset.id.asc()).all()
def list_assets_for_user_window(
session: Session,
*,
tenant_id: str,
user_id: str,
owner_type: str | None = None,
owner_id: str | None = None,
campaign_id: str | None = None,
path_prefix: str | None = None,
include_deleted: bool = False,
is_admin: bool = False,
page_size: int,
after_display_path: str | None = None,
after_updated_at=None,
after_id: str | None = None,
) -> tuple[list[FileAsset], bool]:
query = _asset_visibility_query_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
owner_type=owner_type,
owner_id=owner_id,
campaign_id=campaign_id,
path_prefix=path_prefix,
include_deleted=include_deleted,
is_admin=is_admin,
)
if after_display_path is not None and after_updated_at is not None and after_id:
query = query.filter(
or_(
FileAsset.display_path > after_display_path,
and_(FileAsset.display_path == after_display_path, FileAsset.updated_at < after_updated_at),
and_(FileAsset.display_path == after_display_path, FileAsset.updated_at == after_updated_at, FileAsset.id > after_id),
)
)
rows = query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc(), FileAsset.id.asc()).limit(page_size + 1).all()
return rows[:page_size], len(rows) > page_size
def _asset_visibility_query_for_user(
session: Session,
*,
tenant_id: str,
user_id: str,
owner_type: str | None = None,
owner_id: str | None = None,
campaign_id: str | None = None,
path_prefix: str | None = None,
include_deleted: bool = False,
is_admin: bool = False,
):
query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id)
if not include_deleted:
query = query.filter(FileAsset.deleted_at.is_(None))
@@ -255,7 +451,7 @@ def list_assets_for_user(
prefix = normalize_folder(path_prefix)
if prefix:
query = query.filter(FileAsset.display_path.like(f"{prefix}/%"))
return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc()).all()
return query
def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]:
@@ -553,6 +749,39 @@ def _normalize_conflict_strategy(strategy: str | None) -> str:
return normalized
def _next_version_number(session: Session, asset_id: str) -> int:
row = (
session.query(FileVersion.version_number)
.filter(FileVersion.file_asset_id == asset_id)
.order_by(FileVersion.version_number.desc())
.first()
)
return (int(row[0]) if row else 0) + 1
def _source_identity(provenance: dict[str, Any] | None) -> tuple[object, ...] | None:
if not provenance:
return None
connector_id = _clean_identity(provenance.get("connector_id"))
provider = _clean_identity(provenance.get("provider"))
external_id = _clean_identity(provenance.get("external_id"))
if connector_id and external_id:
return ("external_id", connector_id, provider, external_id)
external_path = _clean_identity(provenance.get("external_path"))
metadata = provenance.get("metadata") if isinstance(provenance.get("metadata"), dict) else {}
library_id = _clean_identity(metadata.get("library_id") or metadata.get("profile_id") or metadata.get("share"))
if connector_id and external_path:
return ("external_path", connector_id, provider, library_id, external_path)
return None
def _clean_identity(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _copy_asset_to_path(
session: Session,
asset: FileAsset,