Files
govoplan-files/src/govoplan_files/backend/storage/transfers.py
2026-07-14 13:22:11 +02:00

1110 lines
40 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Iterable
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import FileAsset, FileFolder
from govoplan_files.backend.storage.access import ensure_owner_access
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, RenamePlanItem, utcnow
from govoplan_files.backend.storage.files import (
_active_asset_exists,
_asset_owner_id,
_asset_query_for_owner,
_candidate_renamed_path,
_copy_asset_to_path,
_next_available_logical_path,
_normalize_conflict_strategy,
_resolution_by_path,
_soft_delete_conflicting_asset,
get_asset_for_user,
rename_asset,
)
from govoplan_files.backend.storage.folders import _active_folder_exists, _ensure_target_folder_hierarchy, _folder_query_for_owner
from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path
@dataclass(frozen=True, slots=True)
class _NormalizedTransferRequest:
operation: str
source_owner_type: str
target_owner_type: str
source_folder_paths: list[str]
target_folder: str
conflict_strategy: str
conflict_resolution_map: dict[str, FileConflictResolution]
@dataclass(frozen=True, slots=True)
class _NormalizedRenameRequest:
mode: str
selected_file_ids: list[str]
selected_folder_roots: list[str]
owner_type: str | None
owner_id: str | None
def transfer_selection(
session: Session,
*,
tenant_id: str,
user_id: str,
operation: str,
file_ids: list[str],
folder_paths: list[str],
source_owner_type: str,
source_owner_id: str,
target_owner_type: str,
target_owner_id: str,
target_folder: str,
conflict_strategy: str = "reject",
conflict_resolutions: Iterable[FileConflictResolution] | None = None,
is_admin: bool = False,
) -> tuple[int, int]:
"""Move or copy files/folders between user/group file spaces.
Folder transfers preserve the selected folder's basename below the target
folder. File transfers place files directly in the target folder. Existing
active target paths are handled by the requested conflict strategy. Copies
create new file assets/versions that reference the existing immutable blob.
"""
normalized = _normalize_transfer_request(
operation=operation,
source_owner_type=source_owner_type,
target_owner_type=target_owner_type,
folder_paths=folder_paths,
target_folder=target_folder,
conflict_strategy=conflict_strategy,
conflict_resolutions=conflict_resolutions,
)
operation = normalized.operation
source_owner_type = normalized.source_owner_type
target_owner_type = normalized.target_owner_type
source_folder_paths = normalized.source_folder_paths
target_folder = normalized.target_folder
conflict_strategy = normalized.conflict_strategy
conflict_resolution_map = normalized.conflict_resolution_map
_ensure_transfer_access_and_shape(
session,
tenant_id=tenant_id,
user_id=user_id,
source_owner_type=source_owner_type,
source_owner_id=source_owner_id,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
source_folder_paths=source_folder_paths,
target_folder=target_folder,
operation=operation,
is_admin=is_admin,
)
assets_by_id = _selected_assets_for_owner(
session,
tenant_id=tenant_id,
owner_type=source_owner_type,
owner_id=source_owner_id,
file_ids=file_ids,
)
folder_target_paths, all_targets = _plan_transfer_targets(
session,
tenant_id=tenant_id,
assets_by_id=assets_by_id,
operation=operation,
source_owner_type=source_owner_type,
source_owner_id=source_owner_id,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
source_folder_paths=source_folder_paths,
target_folder=target_folder,
conflict_strategy=conflict_strategy,
conflict_resolution_map=conflict_resolution_map,
)
if not all_targets and not source_folder_paths:
return 0, 0
all_targets = _resolve_transfer_targets(
session,
tenant_id=tenant_id,
assets_by_id=assets_by_id,
all_targets=all_targets,
operation=operation,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
conflict_strategy=conflict_strategy,
conflict_resolution_map=conflict_resolution_map,
)
copied_or_moved_folders = _ensure_transfer_target_hierarchy(
session,
tenant_id=tenant_id,
user_id=user_id,
operation=operation,
source_owner_type=source_owner_type,
source_owner_id=source_owner_id,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
folder_target_paths=folder_target_paths,
all_targets=all_targets,
)
copied_or_moved_files = _apply_transfer_assets(
session,
tenant_id=tenant_id,
user_id=user_id,
operation=operation,
assets_by_id=assets_by_id,
all_targets=all_targets,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
)
_apply_transfer_folder_records(
session,
tenant_id=tenant_id,
user_id=user_id,
operation=operation,
source_owner_type=source_owner_type,
source_owner_id=source_owner_id,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
folder_target_paths=folder_target_paths,
)
return copied_or_moved_files, copied_or_moved_folders
def _plan_transfer_targets(
session: Session,
*,
tenant_id: str,
assets_by_id: dict[str, FileAsset],
operation: str,
source_owner_type: str,
source_owner_id: str,
target_owner_type: str,
target_owner_id: str,
source_folder_paths: list[str],
target_folder: str,
conflict_strategy: str,
conflict_resolution_map: dict[str, FileConflictResolution],
) -> tuple[dict[str, str], dict[str, str]]:
folder_target_paths, folder_asset_targets = _plan_folder_transfer_targets(
session,
tenant_id=tenant_id,
assets_by_id=assets_by_id,
operation=operation,
source_owner_type=source_owner_type,
source_owner_id=source_owner_id,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
source_folder_paths=source_folder_paths,
target_folder=target_folder,
conflict_strategy=conflict_strategy,
conflict_resolution_map=conflict_resolution_map,
)
direct_file_targets = _direct_file_transfer_targets(
assets_by_id,
folder_asset_targets=folder_asset_targets,
target_folder=target_folder,
)
return folder_target_paths, {**folder_asset_targets, **direct_file_targets}
def _plan_folder_transfer_targets(
session: Session,
*,
tenant_id: str,
assets_by_id: dict[str, FileAsset],
operation: str,
source_owner_type: str,
source_owner_id: str,
target_owner_type: str,
target_owner_id: str,
source_folder_paths: list[str],
target_folder: str,
conflict_strategy: str,
conflict_resolution_map: dict[str, FileConflictResolution],
) -> tuple[dict[str, str], dict[str, str]]:
folder_asset_targets: dict[str, str] = {}
folder_target_paths: dict[str, str] = {}
for folder_path in source_folder_paths:
target_prefix = _resolved_folder_target_prefix(
session,
tenant_id=tenant_id,
operation=operation,
source_owner_type=source_owner_type,
source_owner_id=source_owner_id,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
folder_path=folder_path,
target_folder=target_folder,
conflict_strategy=conflict_strategy,
conflict_resolution_map=conflict_resolution_map,
)
if target_prefix is None:
continue
folder_target_paths[folder_path] = target_prefix
for asset in _assets_under_folder(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id, folder_path=folder_path):
relative = asset.display_path[len(folder_path) + 1 :]
folder_asset_targets[asset.id] = normalize_logical_path(f"{target_prefix}/{relative}")
assets_by_id[asset.id] = asset
return folder_target_paths, folder_asset_targets
def _resolved_folder_target_prefix(
session: Session,
*,
tenant_id: str,
operation: str,
source_owner_type: str,
source_owner_id: str,
target_owner_type: str,
target_owner_id: str,
folder_path: str,
target_folder: str,
conflict_strategy: str,
conflict_resolution_map: dict[str, FileConflictResolution],
) -> str | None:
folder_basename = PurePosixPath(folder_path).name
target_prefix = normalize_folder(f"{target_folder}/{folder_basename}" if target_folder else folder_basename)
same_owner_same_path = source_owner_type == target_owner_type and source_owner_id == target_owner_id and target_prefix == folder_path
if operation == "move" and same_owner_same_path:
return target_prefix
if not _active_folder_exists(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, path=target_prefix):
return target_prefix
resolution = conflict_resolution_map.get(target_prefix)
action = resolution.action if resolution else conflict_strategy
if action == "skip":
return None
if action == "reject":
raise FileStorageError(f"Target folder already exists: {target_prefix}")
if action == "rename":
return _next_available_folder_path(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, desired_path=target_prefix)
return target_prefix
def _next_available_folder_path(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
desired_path: str,
) -> str:
counter = 1
candidate = desired_path
while _active_folder_exists(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, path=candidate):
candidate = _candidate_renamed_path(desired_path, counter)
counter += 1
return normalize_folder(candidate)
def _assets_under_folder(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
folder_path: str,
) -> list[FileAsset]:
return _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter(
FileAsset.deleted_at.is_(None),
FileAsset.display_path.like(f"{folder_path}/%"),
).all()
def _direct_file_transfer_targets(
assets_by_id: dict[str, FileAsset],
*,
folder_asset_targets: dict[str, str],
target_folder: str,
) -> dict[str, str]:
return {
file_id: normalize_logical_path(join_folder_filename(target_folder, filename_from_path(asset.display_path)))
for file_id, asset in assets_by_id.items()
if file_id not in folder_asset_targets
}
def _resolve_transfer_targets(
session: Session,
*,
tenant_id: str,
assets_by_id: dict[str, FileAsset],
all_targets: dict[str, str],
operation: str,
target_owner_type: str,
target_owner_id: str,
conflict_strategy: str,
conflict_resolution_map: dict[str, FileConflictResolution],
) -> dict[str, str]:
resolved_targets: dict[str, str] = {}
reserved_targets: set[str] = set()
for asset_id, target_path in all_targets.items():
normalized_target = _resolve_transfer_asset_target(
session,
tenant_id=tenant_id,
source_asset=assets_by_id[asset_id],
target_path=target_path,
operation=operation,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
conflict_strategy=conflict_strategy,
conflict_resolution_map=conflict_resolution_map,
reserved_targets=reserved_targets,
)
if normalized_target is None:
continue
reserved_targets.add(normalized_target)
resolved_targets[asset_id] = normalized_target
return resolved_targets
def _resolve_transfer_asset_target(
session: Session,
*,
tenant_id: str,
source_asset: FileAsset,
target_path: str,
operation: str,
target_owner_type: str,
target_owner_id: str,
conflict_strategy: str,
conflict_resolution_map: dict[str, FileConflictResolution],
reserved_targets: set[str],
) -> str | None:
same_owner = source_asset.owner_type == target_owner_type and _asset_owner_id(source_asset) == target_owner_id
exclude = source_asset.id if operation == "move" and same_owner else None
normalized_target = normalize_logical_path(target_path)
resolution = conflict_resolution_map.get(normalized_target)
action = resolution.action if resolution else conflict_strategy
if resolution and resolution.new_path and action == "rename":
normalized_target = normalize_logical_path(resolution.new_path)
conflict = _active_asset_exists(
session,
tenant_id=tenant_id,
owner_type=target_owner_type,
owner_id=target_owner_id,
path=normalized_target,
exclude_asset_id=exclude,
) or normalized_target in reserved_targets
if not conflict and action != "rename":
return normalized_target
if conflict and action == "reject":
raise FileStorageError(f"Target file already exists: {normalized_target}")
if conflict and action == "skip":
return None
if conflict and action == "overwrite":
_soft_delete_conflicting_asset(
session,
tenant_id=tenant_id,
owner_type=target_owner_type,
owner_id=target_owner_id,
path=normalized_target,
exclude_asset_id=exclude,
)
return normalized_target
if action == "rename":
return _next_available_logical_path(
session,
tenant_id=tenant_id,
owner_type=target_owner_type,
owner_id=target_owner_id,
desired_path=normalized_target,
reserved_paths=reserved_targets,
exclude_asset_id=exclude,
)
return normalized_target
def _ensure_transfer_target_hierarchy(
session: Session,
*,
tenant_id: str,
user_id: str,
operation: str,
source_owner_type: str,
source_owner_id: str,
target_owner_type: str,
target_owner_id: str,
folder_target_paths: dict[str, str],
all_targets: dict[str, str],
) -> int:
same_owner_transfer = source_owner_type == target_owner_type and source_owner_id == target_owner_id
for target_path in folder_target_paths.values():
path_to_create = target_path
if operation == "move" and same_owner_transfer:
parent = str(PurePosixPath(target_path).parent)
path_to_create = "" if parent == "." else parent
if path_to_create:
_ensure_target_folder_hierarchy(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, path=path_to_create)
for target_path in all_targets.values():
parent = str(PurePosixPath(target_path).parent)
if parent and parent != ".":
_ensure_target_folder_hierarchy(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, path=parent)
return len(folder_target_paths)
def _apply_transfer_assets(
session: Session,
*,
tenant_id: str,
user_id: str,
operation: str,
assets_by_id: dict[str, FileAsset],
all_targets: dict[str, str],
target_owner_type: str,
target_owner_id: str,
) -> int:
if operation == "copy":
for asset_id, target_path in all_targets.items():
_copy_asset_to_path(session, assets_by_id[asset_id], tenant_id=tenant_id, target_owner_type=target_owner_type, target_owner_id=target_owner_id, target_path=target_path, user_id=user_id)
return len(all_targets)
return _move_transfer_assets(
session,
tenant_id=tenant_id,
user_id=user_id,
assets_by_id=assets_by_id,
all_targets=all_targets,
target_owner_type=target_owner_type,
target_owner_id=target_owner_id,
)
def _move_transfer_assets(
session: Session,
*,
tenant_id: str,
user_id: str,
assets_by_id: dict[str, FileAsset],
all_targets: dict[str, str],
target_owner_type: str,
target_owner_id: str,
) -> int:
now = utcnow()
moved = 0
for asset_id, target_path in all_targets.items():
asset = assets_by_id[asset_id]
same_owner = asset.owner_type == target_owner_type and _asset_owner_id(asset) == target_owner_id
if same_owner:
if normalize_logical_path(asset.display_path) == target_path:
continue
rename_asset(asset, new_path=target_path)
session.add(asset)
else:
_copy_asset_to_path(session, asset, tenant_id=tenant_id, target_owner_type=target_owner_type, target_owner_id=target_owner_id, target_path=target_path, user_id=user_id)
asset.deleted_at = now
session.add(asset)
moved += 1
return moved
def _apply_transfer_folder_records(
session: Session,
*,
tenant_id: str,
user_id: str,
operation: str,
source_owner_type: str,
source_owner_id: str,
target_owner_type: str,
target_owner_id: str,
folder_target_paths: dict[str, str],
) -> None:
now = utcnow()
same_owner_transfer = operation == "move" and source_owner_type == target_owner_type and source_owner_id == target_owner_id
for source_path, target_prefix in folder_target_paths.items():
for folder in _folder_rows_under_path(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id, source_path=source_path):
new_path = _folder_transfer_target_path(folder.path, source_path=source_path, target_prefix=target_prefix)
if same_owner_transfer:
folder.path = new_path
session.add(folder)
continue
_ensure_target_folder_hierarchy(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, path=new_path)
if operation == "move":
folder.deleted_at = now
session.add(folder)
def _folder_rows_under_path(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
source_path: str,
) -> list[FileFolder]:
return _folder_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter(
FileFolder.deleted_at.is_(None),
or_(FileFolder.path == source_path, FileFolder.path.like(f"{source_path}/%")),
).all()
def _folder_transfer_target_path(path: str, *, source_path: str, target_prefix: str) -> str:
relative = "" if path == source_path else path[len(source_path) + 1 :]
return normalize_folder(f"{target_prefix}/{relative}" if relative else target_prefix)
def _normalize_transfer_request(
*,
operation: str,
source_owner_type: str,
target_owner_type: str,
folder_paths: list[str],
target_folder: str,
conflict_strategy: str,
conflict_resolutions: Iterable[FileConflictResolution] | None,
) -> _NormalizedTransferRequest:
operation = operation.lower().strip()
if operation not in {"move", "copy"}:
raise FileStorageError("Unsupported transfer operation")
return _NormalizedTransferRequest(
operation=operation,
source_owner_type=source_owner_type.lower().strip(),
target_owner_type=target_owner_type.lower().strip(),
source_folder_paths=[normalized for path in folder_paths if (normalized := normalize_folder(path))],
target_folder=normalize_folder(target_folder),
conflict_strategy=_normalize_conflict_strategy(conflict_strategy),
conflict_resolution_map=_resolution_by_path(conflict_resolutions),
)
def _ensure_transfer_access_and_shape(
session: Session,
*,
tenant_id: str,
user_id: str,
source_owner_type: str,
source_owner_id: str,
target_owner_type: str,
target_owner_id: str,
source_folder_paths: list[str],
target_folder: str,
operation: str,
is_admin: bool,
) -> None:
ensure_owner_access(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id, user_id=user_id, is_admin=is_admin)
ensure_owner_access(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, is_admin=is_admin)
if operation != "move" or source_owner_type != target_owner_type or source_owner_id != target_owner_id:
return
for folder_path in source_folder_paths:
if target_folder == folder_path or target_folder.startswith(f"{folder_path}/"):
raise FileStorageError("Cannot move a folder into itself or one of its child folders")
def _rename_basename(name: str, *, mode: str, new_name: str | None = None, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str:
if mode == "direct":
cleaned = normalize_logical_path(new_name or "", fallback_filename="item")
if "/" in cleaned:
raise FileStorageError("Rename expects a name, not a path")
return cleaned
stem_path = PurePosixPath(name)
suffixes = "".join(stem_path.suffixes)
stem = name[: -len(suffixes)] if suffixes else name
if mode == "prefix":
return prefix + name
if mode == "suffix":
return f"{stem}{suffix}{suffixes}"
if mode == "replace":
return name.replace(find or "", replacement) if find else name
raise FileStorageError("Unsupported rename mode")
def _rename_path(path: str, *, mode: str, new_name: str | None = None, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str:
normalized = normalize_logical_path(path)
logical = PurePosixPath(normalized)
folder = "" if str(logical.parent) == "." else str(logical.parent)
next_name = _rename_basename(logical.name, mode=mode, new_name=new_name, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
return normalize_logical_path(f"{folder}/{next_name}" if folder else next_name)
def _rename_relative_recursive(relative_path: str, *, mode: str, new_name: str | None = None, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str:
parts = normalize_logical_path(relative_path).split("/")
return normalize_logical_path(
"/".join(
_rename_basename(part, mode=mode, new_name=new_name if len(parts) == 1 else None, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
for part in parts
if part
)
)
def build_rename_preview(asset: FileAsset, *, mode: str, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str:
return _rename_path(asset.display_path, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
def _collapse_folder_roots(folder_paths: list[str]) -> list[str]:
roots = sorted({normalize_folder(path) for path in folder_paths if normalize_folder(path)}, key=lambda item: (item.count("/"), item))
collapsed: list[str] = []
for path in roots:
if any(path == root or path.startswith(f"{root}/") for root in collapsed):
continue
collapsed.append(path)
return collapsed
def _selected_assets_for_owner(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
file_ids: list[str],
) -> dict[str, FileAsset]:
selected_file_ids = list(dict.fromkeys(file_ids))
if not selected_file_ids:
return {}
assets = {
asset.id: asset
for asset in _asset_query_for_owner(
session,
tenant_id=tenant_id,
owner_type=owner_type,
owner_id=owner_id,
)
.filter(FileAsset.id.in_(selected_file_ids), FileAsset.deleted_at.is_(None))
.all()
}
if len(assets) != len(selected_file_ids):
raise FileStorageError("File not found")
return assets
def _path_under_root(path: str, root: str) -> bool:
normalized = normalize_logical_path(path)
return normalized == root or normalized.startswith(f"{root}/")
def _folder_new_path_for_root(path: str, root: str, new_root: str, *, recursive: bool, mode: str, find: str | None, replacement: str, prefix: str, suffix: str) -> str:
if path == root:
return normalize_folder(new_root)
relative = path[len(root) + 1 :]
if recursive:
relative = _rename_relative_recursive(relative, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
return normalize_folder(f"{new_root}/{relative}")
def _file_new_path_for_root(path: str, root: str, new_root: str, *, recursive: bool, mode: str, find: str | None, replacement: str, prefix: str, suffix: str) -> str:
relative = path[len(root) + 1 :]
if recursive:
relative = _rename_relative_recursive(relative, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
return normalize_logical_path(f"{new_root}/{relative}")
def _rename_selection(
session: Session,
*,
tenant_id: str,
user_id: str,
file_ids: list[str],
folder_paths: list[str],
owner_type: str | None,
owner_id: str | None,
mode: str,
new_name: str | None = None,
find: str | None = None,
replacement: str = "",
prefix: str = "",
suffix: str = "",
recursive: bool = False,
dry_run: bool = True,
is_admin: bool = False,
allow_legacy_without_owner_context: bool = False,
) -> list[RenamePlanItem]:
normalized = _normalize_rename_request(
file_ids=file_ids,
folder_paths=folder_paths,
owner_type=owner_type,
owner_id=owner_id,
mode=mode,
)
mode = normalized.mode
selected_file_ids = normalized.selected_file_ids
selected_folder_roots = normalized.selected_folder_roots
owner_type_norm = normalized.owner_type
owner_id_norm = normalized.owner_id
assets = _collect_rename_assets(
session,
tenant_id=tenant_id,
user_id=user_id,
selected_file_ids=selected_file_ids,
owner_type=owner_type_norm,
owner_id=owner_id_norm,
is_admin=is_admin,
allow_legacy_without_owner_context=allow_legacy_without_owner_context,
)
folder_rows_by_path, affected_folder_paths = _collect_rename_folder_scope(
session,
tenant_id=tenant_id,
user_id=user_id,
assets=assets,
selected_folder_roots=selected_folder_roots,
owner_type=owner_type_norm,
owner_id=owner_id_norm,
is_admin=is_admin,
)
folder_plan, root_target_paths = _build_folder_rename_plan(
selected_folder_roots,
affected_folder_paths=affected_folder_paths,
mode=mode,
new_name=new_name,
find=find,
replacement=replacement,
prefix=prefix,
suffix=suffix,
recursive=recursive,
)
file_plan = _build_file_rename_plan(
assets,
selected_folder_roots=selected_folder_roots,
root_target_paths=root_target_paths,
mode=mode,
new_name=new_name,
find=find,
replacement=replacement,
prefix=prefix,
suffix=suffix,
recursive=recursive,
)
_validate_rename_plans(
session,
tenant_id=tenant_id,
assets=assets,
file_plan=file_plan,
folder_plan=folder_plan,
selected_folder_roots=selected_folder_roots,
owner_type=owner_type_norm,
owner_id=owner_id_norm,
)
plan = _rename_plan_items(assets=assets, file_plan=file_plan, folder_plan=folder_plan)
if dry_run:
return plan
_apply_rename_plans(session, assets=assets, folder_rows_by_path=folder_rows_by_path, file_plan=file_plan, folder_plan=folder_plan)
return plan
def _collect_rename_assets(
session: Session,
*,
tenant_id: str,
user_id: str,
selected_file_ids: list[str],
owner_type: str | None,
owner_id: str | None,
is_admin: bool,
allow_legacy_without_owner_context: bool,
) -> dict[str, FileAsset]:
if not selected_file_ids:
return {}
if owner_type and owner_id:
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
return _selected_assets_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, file_ids=selected_file_ids)
if not allow_legacy_without_owner_context:
raise FileStorageError("Bulk rename requires an owner file space")
assets: dict[str, FileAsset] = {}
for file_id in selected_file_ids:
asset = get_asset_for_user(session, tenant_id=tenant_id, user_id=user_id, asset_id=file_id, require_write=True, is_admin=is_admin)
assets[asset.id] = asset
return assets
def _collect_rename_folder_scope(
session: Session,
*,
tenant_id: str,
user_id: str,
assets: dict[str, FileAsset],
selected_folder_roots: list[str],
owner_type: str | None,
owner_id: str | None,
is_admin: bool,
) -> tuple[dict[str, FileFolder], set[str]]:
if not selected_folder_roots or not owner_type or not owner_id:
return {}, set()
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
folder_rows_by_path: dict[str, FileFolder] = {}
affected_folder_paths: set[str] = set()
for root in selected_folder_roots:
rows = _folder_rows_under_path(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, source_path=root)
if not rows and not _folder_has_assets(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, root=root):
raise FileStorageError(f"Folder not found: {root}")
for row in rows:
folder_rows_by_path[row.path] = row
affected_folder_paths.add(row.path)
for asset in _assets_under_folder(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, folder_path=root):
assets[asset.id] = asset
return folder_rows_by_path, affected_folder_paths
def _folder_has_assets(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, root: str) -> bool:
return bool(
_asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id)
.filter(FileAsset.deleted_at.is_(None), FileAsset.display_path.like(f"{root}/%"))
.first()
)
def _build_folder_rename_plan(
selected_folder_roots: list[str],
*,
affected_folder_paths: set[str],
mode: str,
new_name: str | None,
find: str | None,
replacement: str,
prefix: str,
suffix: str,
recursive: bool,
) -> tuple[dict[str, str], dict[str, str]]:
folder_plan: dict[str, str] = {}
root_target_paths: dict[str, str] = {}
for root in selected_folder_roots:
new_root = _rename_root_target(root, mode=mode, new_name=new_name, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
root_target_paths[root] = new_root
if root in affected_folder_paths:
folder_plan[root] = new_root
for path in sorted(affected_folder_paths, key=lambda item: item.count("/")):
if path != root and _path_under_root(path, root):
folder_plan[path] = _folder_new_path_for_root(path, root, new_root, recursive=recursive, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
return folder_plan, root_target_paths
def _rename_root_target(
root: str,
*,
mode: str,
new_name: str | None,
find: str | None,
replacement: str,
prefix: str,
suffix: str,
) -> str:
if mode != "direct":
return normalize_folder(_rename_path(root, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix))
root_parent = "" if str(PurePosixPath(root).parent) == "." else str(PurePosixPath(root).parent)
root_new_name = _rename_basename(PurePosixPath(root).name, mode=mode, new_name=new_name)
return normalize_folder(f"{root_parent}/{root_new_name}" if root_parent else root_new_name)
def _build_file_rename_plan(
assets: dict[str, FileAsset],
*,
selected_folder_roots: list[str],
root_target_paths: dict[str, str],
mode: str,
new_name: str | None,
find: str | None,
replacement: str,
prefix: str,
suffix: str,
recursive: bool,
) -> dict[str, str]:
file_plan: dict[str, str] = {}
folder_roots_by_length = sorted(selected_folder_roots, key=len, reverse=True)
for asset in assets.values():
matched_root = next((root for root in folder_roots_by_length if _path_under_root(asset.display_path, root)), None)
if matched_root:
root_new = root_target_paths.get(matched_root)
if root_new:
file_plan[asset.id] = _file_new_path_for_root(asset.display_path, matched_root, root_new, recursive=recursive, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
else:
file_plan[asset.id] = _rename_path(asset.display_path, mode=mode, new_name=new_name, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
return file_plan
def _validate_rename_plans(
session: Session,
*,
tenant_id: str,
assets: dict[str, FileAsset],
file_plan: dict[str, str],
folder_plan: dict[str, str],
selected_folder_roots: list[str],
owner_type: str | None,
owner_id: str | None,
) -> None:
_validate_file_rename_targets(session, tenant_id=tenant_id, assets=assets, file_plan=file_plan)
if selected_folder_roots and owner_type and owner_id:
_validate_folder_rename_targets(session, tenant_id=tenant_id, folder_plan=folder_plan, owner_type=owner_type, owner_id=owner_id)
def _validate_file_rename_targets(
session: Session,
*,
tenant_id: str,
assets: dict[str, FileAsset],
file_plan: dict[str, str],
) -> None:
target_files: set[str] = set()
for asset_id, target in file_plan.items():
normalized = normalize_logical_path(target)
if normalized in target_files:
raise FileStorageError(f"Rename would create duplicate file path: {normalized}")
target_files.add(normalized)
asset = assets[asset_id]
owner_id = _asset_owner_id(asset)
if _active_asset_exists(session, tenant_id=tenant_id, owner_type=asset.owner_type, owner_id=owner_id, path=normalized, exclude_asset_id=asset.id):
raise FileStorageError(f"Target file already exists: {normalized}")
if _active_folder_exists(session, tenant_id=tenant_id, owner_type=asset.owner_type, owner_id=owner_id, path=normalized):
raise FileStorageError(f"Target path is already a folder: {normalized}")
def _validate_folder_rename_targets(
session: Session,
*,
tenant_id: str,
folder_plan: dict[str, str],
owner_type: str,
owner_id: str,
) -> None:
target_folders: set[str] = set()
for target in folder_plan.values():
normalized = normalize_folder(target)
if normalized in target_folders:
raise FileStorageError(f"Rename would create duplicate folder path: {normalized}")
target_folders.add(normalized)
if _active_folder_exists(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, path=normalized, exclude_paths=set(folder_plan.keys())):
raise FileStorageError(f"Target folder already exists: {normalized}")
if _active_asset_exists(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, path=normalized):
raise FileStorageError(f"Target path is already a file: {normalized}")
def _rename_plan_items(
*,
assets: dict[str, FileAsset],
file_plan: dict[str, str],
folder_plan: dict[str, str],
) -> list[RenamePlanItem]:
plan = [RenamePlanItem(kind="folder", id=old_path, old_path=old_path, new_path=new_path) for old_path, new_path in sorted(folder_plan.items())]
for asset_id, new_path in file_plan.items():
asset = assets[asset_id]
plan.append(RenamePlanItem(kind="file", id=asset.id, old_path=asset.display_path, new_path=new_path))
return plan
def _apply_rename_plans(
session: Session,
*,
assets: dict[str, FileAsset],
folder_rows_by_path: dict[str, FileFolder],
file_plan: dict[str, str],
folder_plan: dict[str, str],
) -> None:
for old_path, new_path in folder_plan.items():
folder = folder_rows_by_path.get(old_path)
if folder:
folder.path = normalize_folder(new_path)
session.add(folder)
for asset_id, new_path in file_plan.items():
rename_asset(assets[asset_id], new_path=new_path)
session.add(assets[asset_id])
def _normalize_rename_request(
*,
file_ids: list[str],
folder_paths: list[str],
owner_type: str | None,
owner_id: str | None,
mode: str,
) -> _NormalizedRenameRequest:
clean_mode = mode.lower().strip()
if clean_mode not in {"direct", "prefix", "suffix", "replace"}:
raise FileStorageError("Unsupported rename mode")
selected_file_ids = list(dict.fromkeys(file_ids))
selected_folder_roots = _collapse_folder_roots(folder_paths)
if not selected_file_ids and not selected_folder_roots:
raise FileStorageError("No files or folders selected")
if selected_folder_roots and (not owner_type or not owner_id):
raise FileStorageError("Folder rename requires an owner file space")
if clean_mode == "direct" and (len(selected_file_ids) + len(selected_folder_roots)) != 1:
raise FileStorageError("Direct rename requires exactly one selected item")
return _NormalizedRenameRequest(
mode=clean_mode,
selected_file_ids=selected_file_ids,
selected_folder_roots=selected_folder_roots,
owner_type=owner_type.lower().strip() if owner_type else None,
owner_id=owner_id,
)
def rename_selection(
session: Session,
*,
tenant_id: str,
user_id: str,
file_ids: list[str],
folder_paths: list[str],
owner_type: str,
owner_id: str,
mode: str,
new_name: str | None = None,
find: str | None = None,
replacement: str = "",
prefix: str = "",
suffix: str = "",
recursive: bool = False,
dry_run: bool = True,
is_admin: bool = False,
) -> list[RenamePlanItem]:
return _rename_selection(
session,
tenant_id=tenant_id,
user_id=user_id,
file_ids=file_ids,
folder_paths=folder_paths,
owner_type=owner_type,
owner_id=owner_id,
mode=mode,
new_name=new_name,
find=find,
replacement=replacement,
prefix=prefix,
suffix=suffix,
recursive=recursive,
dry_run=dry_run,
is_admin=is_admin,
)
def legacy_rename_selection_without_owner_context(
session: Session,
*,
tenant_id: str,
user_id: str,
file_ids: list[str],
mode: str,
new_name: str | None = None,
find: str | None = None,
replacement: str = "",
prefix: str = "",
suffix: str = "",
dry_run: bool = True,
is_admin: bool = False,
) -> list[RenamePlanItem]:
"""Compatibility path for historical file-only callers that lack owner scope."""
return _rename_selection(
session,
tenant_id=tenant_id,
user_id=user_id,
file_ids=file_ids,
folder_paths=[],
owner_type=None,
owner_id=None,
mode=mode,
new_name=new_name,
find=find,
replacement=replacement,
prefix=prefix,
suffix=suffix,
recursive=False,
dry_run=dry_run,
is_admin=is_admin,
allow_legacy_without_owner_context=True,
)