"""DMS Folder-CRUD Routen — extrahiert aus routes.py (BUG-018 God-Object-Split).""" from __future__ import annotations import uuid from fastapi import ( APIRouter, Depends, HTTPException, Response, status, ) from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.core.visibility import apply_visibility_filter, check_single_entity_access from app.deps import get_current_user, require_permission, require_workspace_scope from app.plugins.builtins.dms.common import ( _parse_uuid, ) from app.plugins.builtins.dms.models import File as DmsFile from app.plugins.builtins.dms.models import Folder from app.plugins.builtins.dms.schemas import FolderCreate, FolderUpdate from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract _perms_contract = get_perms_contract() Permission = _perms_contract.Permission router = APIRouter(tags=["dms"]) @router.get("/folders", dependencies=[Depends(require_permission("dms:read"))]) async def list_folders( parent_id: str | None = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), workspace_scope: dict | None = Depends(require_workspace_scope("dms")), ): """AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive). Phase N3: an active workspace scope (X-Workspace-ID) reduces the tree to the folder subtree — pure AND-restriction, never a grant. """ tenant_id = uuid.UUID(current_user["tenant_id"]) # Fetch all non-deleted folders for tenant with visibility filter user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" query = select(Folder).where( Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) query = await apply_visibility_filter( db, query, "dms_folder", Folder, user_id, tenant_id, is_system_admin ) result = await db.execute(query) all_folders = result.scalars().all() # Phase N3: reduce to the scope subtree (folder_ids dimension) if workspace_scope: from app.services.workspace_scope_service import expand_folder_scope scope_folder_ids = workspace_scope.get("folder_ids") if isinstance(scope_folder_ids, list) and scope_folder_ids: subtree = await expand_folder_scope(db, Folder, scope_folder_ids) allowed = subtree or set() all_folders = [f for f in all_folders if f.id in allowed] # Build lookup map folder_map: dict[uuid.UUID, dict] = {} for f in all_folders: folder_map[f.id] = { "id": str(f.id), "name": f.name, "parent_id": str(f.parent_id) if f.parent_id else None, "created_by": str(f.created_by), "deleted_at": None, "path": "", "children": [], } # Build path for each folder def _build_path(folder_id: uuid.UUID) -> str: if folder_id not in folder_map: return "" f = folder_map[folder_id] if f["parent_id"] and uuid.UUID(f["parent_id"]) in folder_map: parent_path = _build_path(uuid.UUID(f["parent_id"])) return f"{parent_path}/{f['name']}" return f["name"] for fid in folder_map: folder_map[fid]["path"] = _build_path(fid) # Build tree root_nodes: list[dict] = [] target_parent: uuid.UUID | None = None if parent_id is not None: target_parent = _parse_uuid(parent_id, "parent_id") for f in all_folders: node = folder_map[f.id] if f.parent_id is not None and f.parent_id in folder_map: folder_map[f.parent_id]["children"].append(node) elif f.parent_id is None: root_nodes.append(node) if target_parent is not None: # Return children of specified parent parent_node = folder_map.get(target_parent) if parent_node is None: raise HTTPException( 404, detail={"detail": "Parent folder not found", "code": "not_found"} ) return parent_node["children"] return root_nodes @router.post("/folders", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))]) async def create_folder( body: FolderCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC2: POST /api/v1/dms/folders → 201, folder created with path.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) parent_id = _parse_uuid(body.parent_id, "parent_id") if body.parent_id else None # Validate parent exists if specified if parent_id is not None: parent_result = await db.execute( select(Folder).where( Folder.id == parent_id, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if parent_result.scalar_one_or_none() is None: raise HTTPException( 404, detail={"detail": "Parent folder not found", "code": "not_found"} ) # Check name uniqueness within same parent (non-deleted) existing = await db.execute( select(Folder).where( Folder.tenant_id == tenant_id, Folder.name == body.name, Folder.parent_id == parent_id if parent_id else Folder.parent_id.is_(None), Folder.deleted_at.is_(None), ) ) if existing.scalar_one_or_none() is not None: raise HTTPException( 409, detail={"detail": "Folder name already exists", "code": "duplicate"} ) # Lifecycle hook: dms.folder.before_create from app.core.hooks import do_action await do_action("dms.folder.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id) folder = Folder( tenant_id=tenant_id, name=body.name, parent_id=parent_id, created_by=user_id, ) db.add(folder) await db.flush() # Lifecycle hook: dms.folder.after_create await do_action("dms.folder.after_create", {'id': str(folder.id), 'name': folder.name, 'parent_id': str(folder.parent_id) if folder.parent_id else None}, db=db, tenant_id=tenant_id, user_id=user_id) # Build path path = body.name if parent_id is not None: parent_path_result = await db.execute(select(Folder).where(Folder.id == parent_id)) parent_folder = parent_path_result.scalar_one_or_none() if parent_folder: # Recursively build path path_parts = [body.name] current = parent_folder while current is not None: path_parts.insert(0, current.name) if current.parent_id is not None: cur_result = await db.execute( select(Folder).where(Folder.id == current.parent_id) ) current = cur_result.scalar_one_or_none() else: current = None path = "/".join(path_parts) return { "id": str(folder.id), "name": folder.name, "parent_id": str(folder.parent_id) if folder.parent_id else None, "created_by": str(folder.created_by), "deleted_at": None, "path": path, "children": [], } @router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))]) async def update_folder( folder_id: str, body: FolderUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC3: PATCH /api/v1/dms/folders/{id} → 200, rename/move.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(folder_id, "folder_id") result = await db.execute( select(Folder).where( Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) folder = result.scalar_one_or_none() if folder is None: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "write", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) data = body.model_dump(exclude_unset=True) if "name" in data and data["name"] is not None: # Check uniqueness if name is changing new_parent_id = folder.parent_id if "parent_id" in data and data["parent_id"] is not None: new_parent_id = _parse_uuid(data["parent_id"], "parent_id") dup = await db.execute( select(Folder).where( Folder.tenant_id == tenant_id, Folder.name == data["name"], Folder.id != fid, Folder.parent_id == new_parent_id if new_parent_id else Folder.parent_id.is_(None), Folder.deleted_at.is_(None), ) ) if dup.scalar_one_or_none() is not None: raise HTTPException( 409, detail={"detail": "Folder name already exists", "code": "duplicate"} ) folder.name = data["name"] if "parent_id" in data: new_parent = _parse_uuid(data["parent_id"], "parent_id") if data["parent_id"] else None if new_parent is not None: # Validate parent exists and not creating a cycle if new_parent == fid: raise HTTPException( 400, detail={"detail": "Cannot move folder into itself", "code": "invalid_move"} ) parent_result = await db.execute( select(Folder).where( Folder.id == new_parent, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if parent_result.scalar_one_or_none() is None: raise HTTPException( 404, detail={"detail": "Parent folder not found", "code": "not_found"} ) # Check for cycle: ensure new_parent is not a descendant of folder async def _is_descendant(ancestor_id: uuid.UUID, descendant_id: uuid.UUID) -> bool: cur_result = await db.execute(select(Folder).where(Folder.id == descendant_id)) cur = cur_result.scalar_one_or_none() while cur is not None and cur.parent_id is not None: if cur.parent_id == ancestor_id: return True p_result = await db.execute(select(Folder).where(Folder.id == cur.parent_id)) cur = p_result.scalar_one_or_none() return False if await _is_descendant(fid, new_parent): raise HTTPException( 400, detail={ "detail": "Cannot move folder into its own descendant", "code": "invalid_move", }, ) folder.parent_id = new_parent await db.flush() # Build path path_parts = [folder.name] current_id = folder.parent_id while current_id is not None: cur_result = await db.execute(select(Folder).where(Folder.id == current_id)) cur = cur_result.scalar_one_or_none() if cur is None: break path_parts.insert(0, cur.name) current_id = cur.parent_id path = "/".join(path_parts) return { "id": str(folder.id), "name": folder.name, "parent_id": str(folder.parent_id) if folder.parent_id else None, "created_by": str(folder.created_by), "deleted_at": None, "path": path, "children": [], } @router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))]) async def delete_folder( folder_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("role") == "admin" fid = _parse_uuid(folder_id, "folder_id") result = await db.execute( select(Folder).where( Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) folder = result.scalar_one_or_none() if folder is None: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin): raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) # Lifecycle hook: dms.folder.before_delete from app.core.hooks import do_action await do_action("dms.folder.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid)) from datetime import UTC, datetime now = datetime.now(UTC) # Recursively collect all descendant folder IDs all_folder_ids: list[uuid.UUID] = [fid] queue: list[uuid.UUID] = [fid] while queue: current_id = queue.pop(0) children_result = await db.execute( select(Folder).where( Folder.parent_id == current_id, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) for child in children_result.scalars().all(): all_folder_ids.append(child.id) queue.append(child.id) # Soft-delete all folders await db.execute(update(Folder).where(Folder.id.in_(all_folder_ids)).values(deleted_at=now)) # Soft-delete all files in those folders await db.execute( update(DmsFile) .where( DmsFile.tenant_id == tenant_id, DmsFile.folder_id.in_(all_folder_ids), DmsFile.deleted_at.is_(None), ) .values(deleted_at=now) ) await db.flush() # Lifecycle hook: dms.folder.after_delete from app.core.hooks import do_action await do_action("dms.folder.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid)) return Response(status_code=status.HTTP_204_NO_CONTENT) # ─── Files ───