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