"""DMS plugin routes — folders, files, preview, OnlyOffice, internal sharing, search, bulk.""" from __future__ import annotations import os import uuid from fastapi import ( APIRouter, Body, Depends, File, Form, HTTPException, Response, UploadFile, status, ) from fastapi.responses import StreamingResponse from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user 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 ( BulkDeleteRequest, BulkMoveRequest, FileUpdate, FolderCreate, FolderUpdate, ShareRemoveRequest, ShareRequest, ) from app.plugins.builtins.permissions.models import Permission router = APIRouter(prefix="/api/v1/dms", tags=["dms"]) # Configurable storage base path DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms") # Office file extensions mapped to OnlyOffice file types OFFICE_EXTENSIONS = { ".docx": "docx", ".xlsx": "xlsx", ".pptx": "pptx", } # Max file size: 100 MB MAX_FILE_SIZE = 100 * 1024 * 1024 def _parse_uuid(val: str, field: str) -> uuid.UUID: try: return uuid.UUID(val) except (ValueError, TypeError): raise HTTPException( 400, detail={"detail": f"Invalid {field}", "code": "invalid_id"} ) from None def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str: """Build on-disk storage path for a file.""" return os.path.join(DMS_STORAGE_BASE, str(tenant_id), str(file_id)) def _get_file_extension(filename: str) -> str: """Extract lowercase extension including dot.""" return os.path.splitext(filename)[1].lower() # ─── Folders ─── @router.get("/folders") async def list_folders( parent_id: str | None = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive).""" tenant_id = uuid.UUID(current_user["tenant_id"]) # Fetch all non-deleted folders for tenant result = await db.execute( select(Folder).where( Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) all_folders = result.scalars().all() # 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) 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"} ) folder = Folder( tenant_id=tenant_id, name=body.name, parent_id=parent_id, created_by=user_id, ) db.add(folder) await db.flush() # 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}") 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"]) 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"}) 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) 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"]) 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"}) 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() return Response(status_code=status.HTTP_204_NO_CONTENT) # ─── Files ─── @router.post("/files/upload", status_code=status.HTTP_201_CREATED) async def upload_file( file: UploadFile = File(...), folder_id: str | None = Form(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC5: POST /api/v1/dms/files/upload → 201, file stored + metadata.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) fid = _parse_uuid(folder_id, "folder_id") if folder_id else None # Validate folder exists if specified if fid is not None: folder_result = await db.execute( select(Folder).where( Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if folder_result.scalar_one_or_none() is None: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) # Read file content content = await file.read() file_size = len(content) if file_size > MAX_FILE_SIZE: raise HTTPException( 413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"} ) # Create file record file_id = uuid.uuid4() storage_path = _file_storage_path(tenant_id, file_id) # Ensure directory exists and write file os.makedirs(os.path.dirname(storage_path), exist_ok=True) with open(storage_path, "wb") as f: # noqa: ASYNC230 f.write(content) mime_type = file.content_type or "application/octet-stream" dms_file = DmsFile( id=file_id, tenant_id=tenant_id, name=file.filename or "unnamed", folder_id=fid, uploaded_by=user_id, mime_type=mime_type, size_bytes=file_size, storage_path=storage_path, ) db.add(dms_file) await db.flush() return { "id": str(dms_file.id), "name": dms_file.name, "folder_id": str(dms_file.folder_id) if dms_file.folder_id else None, "uploaded_by": str(dms_file.uploaded_by), "mime_type": dms_file.mime_type, "size_bytes": dms_file.size_bytes, "storage_path": dms_file.storage_path, "deleted_at": None, "created_at": dms_file.created_at.isoformat() if dms_file.created_at else None, "updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None, } @router.get("/files/{file_id}") async def get_file( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC6: GET /api/v1/dms/files/{id} → 200 + file metadata.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) return { "id": str(dms_file.id), "name": dms_file.name, "folder_id": str(dms_file.folder_id) if dms_file.folder_id else None, "uploaded_by": str(dms_file.uploaded_by), "mime_type": dms_file.mime_type, "size_bytes": dms_file.size_bytes, "storage_path": dms_file.storage_path, "deleted_at": None, "created_at": dms_file.created_at.isoformat() if dms_file.created_at else None, "updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None, } @router.patch("/files/{file_id}") async def update_file( file_id: str, body: FileUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC7: PATCH /api/v1/dms/files/{id} → 200, rename/move.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) data = body.model_dump(exclude_unset=True) if "name" in data and data["name"] is not None: dms_file.name = data["name"] if "folder_id" in data: new_folder = _parse_uuid(data["folder_id"], "folder_id") if data["folder_id"] else None if new_folder is not None: folder_result = await db.execute( select(Folder).where( Folder.id == new_folder, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if folder_result.scalar_one_or_none() is None: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) dms_file.folder_id = new_folder await db.flush() await db.refresh(dms_file) return { "id": str(dms_file.id), "name": dms_file.name, "folder_id": str(dms_file.folder_id) if dms_file.folder_id else None, "uploaded_by": str(dms_file.uploaded_by), "mime_type": dms_file.mime_type, "size_bytes": dms_file.size_bytes, "storage_path": dms_file.storage_path, "deleted_at": None, "created_at": dms_file.created_at.isoformat() if dms_file.created_at else None, "updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None, } @router.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_file( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC8: DELETE /api/v1/dms/files/{id} → 204, soft-delete.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) from datetime import UTC, datetime dms_file.deleted_at = datetime.now(UTC) await db.flush() return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/files/{file_id}/restore") async def restore_file( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC9: POST /api/v1/dms/files/{id}/restore → 200, restored from trash.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_not(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "Deleted file not found", "code": "not_found"}) dms_file.deleted_at = None await db.flush() await db.refresh(dms_file) return { "id": str(dms_file.id), "name": dms_file.name, "folder_id": str(dms_file.folder_id) if dms_file.folder_id else None, "uploaded_by": str(dms_file.uploaded_by), "mime_type": dms_file.mime_type, "size_bytes": dms_file.size_bytes, "storage_path": dms_file.storage_path, "deleted_at": None, "created_at": dms_file.created_at.isoformat() if dms_file.created_at else None, "updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None, } # ─── Preview & Edit ─── @router.get("/files/{file_id}/preview") async def preview_file( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC10: GET /api/v1/dms/files/{id}/preview → 200 + PDF stream.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) if dms_file.mime_type != "application/pdf": raise HTTPException( 400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"} ) if not os.path.exists(dms_file.storage_path): raise HTTPException( 404, detail={"detail": "File not found on disk", "code": "file_missing"} ) def _stream(): with open(dms_file.storage_path, "rb") as f: yield from iter(lambda: f.read(65536), b"") return StreamingResponse( _stream(), media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{dms_file.name}"'}, ) @router.post("/files/{file_id}/edit-session") async def create_edit_session( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + OnlyOffice config.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = current_user["user_id"] user_name = current_user.get("name", "Unknown") fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) dms_file = result.scalar_one_or_none() if dms_file is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) ext = _get_file_extension(dms_file.name) if ext not in OFFICE_EXTENSIONS: raise HTTPException( 400, detail={ "detail": "Only Office files (docx, xlsx, pptx) are supported", "code": "not_office", }, ) file_type = OFFICE_EXTENSIONS[ext] download_url = f"/api/v1/dms/files/{fid}/preview" callback_url = f"/api/v1/dms/files/{fid}/callback" config = { "document": { "fileType": file_type, "key": str(uuid.uuid4()), "title": dms_file.name, "url": download_url, }, "editorConfig": { "mode": "edit", "callbackUrl": callback_url, "user": { "id": user_id, "name": user_name, }, }, } return config # ─── Internal Sharing ─── @router.post("/files/{file_id}/share") async def share_file( file_id: str, body: ShareRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC12: POST /api/v1/dms/files/{id}/share → 200, internal share created.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") # Verify file exists file_result = await db.execute( select(DmsFile).where( DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), ) ) if file_result.scalar_one_or_none() is None: raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"}) created_perms: list[dict] = [] for uid_str in body.user_ids: uid = _parse_uuid(uid_str, "user_id") # Check if already exists existing = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.user_id == uid, Permission.access_level == body.access_level, ) ) if existing.scalar_one_or_none() is None: perm = Permission( tenant_id=tenant_id, file_id=fid, user_id=uid, group_id=None, access_level=body.access_level, ) db.add(perm) await db.flush() created_perms.append( { "id": str(perm.id), "file_id": str(fid), "user_id": str(uid), "group_id": None, "access_level": body.access_level, } ) for gid_str in body.group_ids: gid = _parse_uuid(gid_str, "group_id") existing = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.group_id == gid, Permission.access_level == body.access_level, ) ) if existing.scalar_one_or_none() is None: perm = Permission( tenant_id=tenant_id, file_id=fid, user_id=uuid.uuid4(), # placeholder user_id for group shares group_id=gid, access_level=body.access_level, ) db.add(perm) await db.flush() created_perms.append( { "id": str(perm.id), "file_id": str(fid), "user_id": str(perm.user_id), "group_id": str(gid), "access_level": body.access_level, } ) return { "file_id": str(fid), "shared_with": created_perms, "count": len(created_perms), } @router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT) async def remove_share( file_id: str, body: ShareRemoveRequest = Body(...), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC13: DELETE /api/v1/dms/files/{id}/share → 204, share removed.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") if body.user_id: uid = _parse_uuid(body.user_id, "user_id") result = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.user_id == uid, ) ) perms = result.scalars().all() for p in perms: await db.delete(p) if body.group_id: gid = _parse_uuid(body.group_id, "group_id") result = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.group_id == gid, ) ) perms = result.scalars().all() for p in perms: await db.delete(p) await db.flush() return Response(status_code=status.HTTP_204_NO_CONTENT) # ─── Search & Bulk ─── @router.get("/search") async def search_files( q: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC16: GET /api/v1/dms/search?q=text → 200 + matching files (ILIKE).""" tenant_id = uuid.UUID(current_user["tenant_id"]) result = await db.execute( select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None), DmsFile.name.ilike(f"%{q}%"), ) ) files = result.scalars().all() return [ { "id": str(f.id), "name": f.name, "folder_id": str(f.folder_id) if f.folder_id else None, "uploaded_by": str(f.uploaded_by), "mime_type": f.mime_type, "size_bytes": f.size_bytes, "deleted_at": None, "created_at": f.created_at.isoformat() if f.created_at else None, } for f in files ] @router.get("/shared-with-me") async def shared_with_me( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC17: GET /api/v1/dms/shared-with-me → 200 + shared files list.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) # Query permissions for this user and join with files perm_result = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.user_id == user_id, ) ) perms = perm_result.scalars().all() file_ids = {p.file_id for p in perms} if not file_ids: return [] file_result = await db.execute( select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.id.in_(file_ids), DmsFile.deleted_at.is_(None), ) ) files = file_result.scalars().all() # Map permissions for access_level perm_map: dict[uuid.UUID, str] = {} for p in perms: if p.file_id in file_ids: perm_map[p.file_id] = p.access_level return [ { "id": str(f.id), "name": f.name, "folder_id": str(f.folder_id) if f.folder_id else None, "uploaded_by": str(f.uploaded_by), "mime_type": f.mime_type, "size_bytes": f.size_bytes, "access_level": perm_map.get(f.id, "read"), "created_at": f.created_at.isoformat() if f.created_at else None, } for f in files ] @router.post("/files/bulk-move") async def bulk_move( body: BulkMoveRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC18: POST /api/v1/dms/files/bulk-move → 200, files moved.""" tenant_id = uuid.UUID(current_user["tenant_id"]) target_folder_id = ( _parse_uuid(body.target_folder_id, "target_folder_id") if body.target_folder_id else None ) # Validate target folder if specified if target_folder_id is not None: folder_result = await db.execute( select(Folder).where( Folder.id == target_folder_id, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None), ) ) if folder_result.scalar_one_or_none() is None: raise HTTPException( 404, detail={"detail": "Target folder not found", "code": "not_found"} ) file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids] result = await db.execute( select(DmsFile).where( DmsFile.tenant_id == tenant_id, DmsFile.id.in_(file_ids), DmsFile.deleted_at.is_(None), ) ) files = result.scalars().all() moved_count = 0 for f in files: f.folder_id = target_folder_id moved_count += 1 await db.flush() return { "moved": moved_count, "file_ids": [str(fid) for fid in file_ids], "target_folder_id": str(target_folder_id) if target_folder_id else None, } @router.post("/files/bulk-delete") async def bulk_delete( body: BulkDeleteRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """AC19: POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted.""" tenant_id = uuid.UUID(current_user["tenant_id"]) file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids] from datetime import UTC, datetime now = datetime.now(UTC) result = await db.execute( update(DmsFile) .where( DmsFile.tenant_id == tenant_id, DmsFile.id.in_(file_ids), DmsFile.deleted_at.is_(None), ) .values(deleted_at=now) ) deleted_count = result.rowcount await db.flush() return { "deleted": deleted_count, "file_ids": body.file_ids, }