448 lines
23 KiB
Python
448 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import PurePosixPath
|
|
from typing import Iterable
|
|
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.db.models import FileAsset, FileFolder
|
|
from app.storage.access import ensure_owner_access
|
|
from app.storage.common import FileConflictResolution, FileStorageError, RenamePlanItem, utcnow
|
|
from app.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,
|
|
current_version_and_blob,
|
|
get_asset_for_user,
|
|
rename_asset,
|
|
)
|
|
from app.storage.folders import _active_folder_exists, _ensure_target_folder_hierarchy, _folder_query_for_owner
|
|
from app.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path
|
|
|
|
|
|
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.
|
|
"""
|
|
|
|
operation = operation.lower().strip()
|
|
if operation not in {"move", "copy"}:
|
|
raise FileStorageError("Unsupported transfer operation")
|
|
source_owner_type = source_owner_type.lower().strip()
|
|
target_owner_type = target_owner_type.lower().strip()
|
|
source_folder_paths = [normalize_folder(path) for path in folder_paths if normalize_folder(path)]
|
|
target_folder = normalize_folder(target_folder)
|
|
conflict_strategy = _normalize_conflict_strategy(conflict_strategy)
|
|
conflict_resolution_map = _resolution_by_path(conflict_resolutions)
|
|
|
|
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" and source_owner_type == target_owner_type and source_owner_id == target_owner_id:
|
|
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")
|
|
|
|
assets_by_id: dict[str, FileAsset] = {}
|
|
for file_id in file_ids:
|
|
asset = get_asset_for_user(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
asset_id=file_id,
|
|
require_write=operation == "move",
|
|
is_admin=is_admin,
|
|
)
|
|
assets_by_id[asset.id] = asset
|
|
|
|
folder_asset_targets: dict[str, str] = {}
|
|
folder_target_paths: dict[str, str] = {}
|
|
for folder_path in source_folder_paths:
|
|
folder_basename = PurePosixPath(folder_path).name
|
|
target_prefix = normalize_folder(f"{target_folder}/{folder_basename}" if target_folder else folder_basename)
|
|
folder_target_paths[folder_path] = target_prefix
|
|
if operation == "copy" or not (source_owner_type == target_owner_type and source_owner_id == target_owner_id and target_prefix == folder_path):
|
|
if _active_folder_exists(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, path=target_prefix):
|
|
resolution = conflict_resolution_map.get(target_prefix)
|
|
action = resolution.action if resolution else conflict_strategy
|
|
if action == "skip":
|
|
folder_target_paths.pop(folder_path, None)
|
|
continue
|
|
if action == "rename":
|
|
# Rename the selected folder root while preserving its contents below it.
|
|
counter = 1
|
|
candidate = target_prefix
|
|
while _active_folder_exists(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, path=candidate):
|
|
candidate = _candidate_renamed_path(target_prefix, counter)
|
|
counter += 1
|
|
target_prefix = normalize_folder(candidate)
|
|
folder_target_paths[folder_path] = target_prefix
|
|
elif action == "reject":
|
|
raise FileStorageError(f"Target folder already exists: {target_prefix}")
|
|
# overwrite on folders means merge into the existing folder; conflicting files are still handled below.
|
|
source_assets = _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id).filter(
|
|
FileAsset.deleted_at.is_(None),
|
|
FileAsset.display_path.like(f"{folder_path}/%"),
|
|
).all()
|
|
for asset in source_assets:
|
|
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
|
|
|
|
direct_file_targets: dict[str, str] = {}
|
|
for file_id, asset in assets_by_id.items():
|
|
if file_id in folder_asset_targets:
|
|
continue
|
|
direct_file_targets[file_id] = normalize_logical_path(join_folder_filename(target_folder, filename_from_path(asset.display_path)))
|
|
|
|
all_targets = {**folder_asset_targets, **direct_file_targets}
|
|
if not all_targets and not source_folder_paths:
|
|
return 0, 0
|
|
|
|
resolved_targets: dict[str, str] = {}
|
|
skipped_asset_ids: set[str] = set()
|
|
reserved_targets: set[str] = set()
|
|
for asset_id, target_path in all_targets.items():
|
|
source_asset = assets_by_id[asset_id]
|
|
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 conflict:
|
|
if action == "reject":
|
|
raise FileStorageError(f"Target file already exists: {normalized_target}")
|
|
if action == "skip":
|
|
skipped_asset_ids.add(asset_id)
|
|
continue
|
|
if 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,
|
|
)
|
|
elif action == "rename":
|
|
normalized_target = _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,
|
|
)
|
|
elif action == "rename":
|
|
normalized_target = _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,
|
|
)
|
|
reserved_targets.add(normalized_target)
|
|
resolved_targets[asset_id] = normalized_target
|
|
all_targets = resolved_targets
|
|
|
|
copied_or_moved_files = 0
|
|
copied_or_moved_folders = 0
|
|
|
|
# Create target folder hierarchy first, including selected folder roots and
|
|
# any nested folders/files parents.
|
|
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)
|
|
copied_or_moved_folders += 1
|
|
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)
|
|
|
|
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)
|
|
copied_or_moved_files += 1
|
|
return copied_or_moved_files, copied_or_moved_folders
|
|
|
|
now = utcnow()
|
|
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)
|
|
copied_or_moved_files += 1
|
|
|
|
# Move/copy persisted folder records after files. For cross-owner moves, create
|
|
# target records and soft-delete source records. For same-owner moves, rename
|
|
# them in place.
|
|
for source_path, target_prefix in folder_target_paths.items():
|
|
folder_rows = _folder_query_for_owner(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id).filter(
|
|
FileFolder.deleted_at.is_(None),
|
|
or_(FileFolder.path == source_path, FileFolder.path.like(f"{source_path}/%")),
|
|
).all()
|
|
for folder in folder_rows:
|
|
relative = "" if folder.path == source_path else folder.path[len(source_path) + 1 :]
|
|
new_path = normalize_folder(f"{target_prefix}/{relative}" if relative else target_prefix)
|
|
if operation == "move" and source_owner_type == target_owner_type and source_owner_id == target_owner_id:
|
|
folder.path = new_path
|
|
session.add(folder)
|
|
else:
|
|
_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)
|
|
return copied_or_moved_files, copied_or_moved_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 _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,
|
|
) -> list[RenamePlanItem]:
|
|
mode = mode.lower().strip()
|
|
if 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 mode == "direct" and (len(selected_file_ids) + len(selected_folder_roots)) != 1:
|
|
raise FileStorageError("Direct rename requires exactly one selected item")
|
|
|
|
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
|
|
|
|
owner_type_norm = owner_type.lower().strip() if owner_type else None
|
|
owner_id_norm = owner_id
|
|
folder_rows_by_path: dict[str, FileFolder] = {}
|
|
affected_folder_paths: set[str] = set()
|
|
if selected_folder_roots and owner_type_norm and owner_id_norm:
|
|
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, user_id=user_id, is_admin=is_admin)
|
|
for root in selected_folder_roots:
|
|
rows = _folder_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm).filter(
|
|
FileFolder.deleted_at.is_(None),
|
|
or_(FileFolder.path == root, FileFolder.path.like(f"{root}/%")),
|
|
).all()
|
|
if not rows and not _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm).filter(FileAsset.deleted_at.is_(None), FileAsset.display_path.like(f"{root}/%")).first():
|
|
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 _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm).filter(FileAsset.deleted_at.is_(None), FileAsset.display_path.like(f"{root}/%")).all():
|
|
assets[asset.id] = asset
|
|
|
|
folder_plan: dict[str, str] = {}
|
|
root_target_paths: dict[str, str] = {}
|
|
for root in selected_folder_roots:
|
|
if mode == "direct":
|
|
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)
|
|
new_root = normalize_folder(f"{root_parent}/{root_new_name}" if root_parent else root_new_name)
|
|
else:
|
|
new_root = normalize_folder(_rename_path(root, mode=mode, 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)
|
|
|
|
file_plan: dict[str, str] = {}
|
|
for asset in assets.values():
|
|
matched_root = next((root for root in sorted(selected_folder_roots, key=len, reverse=True) if _path_under_root(asset.display_path, root)), None)
|
|
if matched_root:
|
|
root_new = root_target_paths.get(matched_root)
|
|
if not root_new:
|
|
continue
|
|
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)
|
|
|
|
# Detect duplicate targets and existing active target conflicts before applying.
|
|
target_files: set[str] = set()
|
|
target_folders: set[str] = set()
|
|
affected_file_ids = set(file_plan)
|
|
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]
|
|
if _active_asset_exists(session, tenant_id=tenant_id, owner_type=asset.owner_type, owner_id=_asset_owner_id(asset), 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=_asset_owner_id(asset), path=normalized):
|
|
raise FileStorageError(f"Target path is already a folder: {normalized}")
|
|
if selected_folder_roots and owner_type_norm and owner_id_norm:
|
|
for old_path, target in folder_plan.items():
|
|
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_norm, owner_id=owner_id_norm, 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_norm, owner_id=owner_id_norm, path=normalized):
|
|
raise FileStorageError(f"Target path is already a file: {normalized}")
|
|
|
|
plan: list[RenamePlanItem] = []
|
|
for old_path, new_path in sorted(folder_plan.items()):
|
|
plan.append(RenamePlanItem(kind="folder", id=old_path, old_path=old_path, new_path=new_path))
|
|
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))
|
|
|
|
if dry_run:
|
|
return plan
|
|
|
|
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])
|
|
return plan
|