From f445aa69d5bcb66ebd5fbe2903a4deb2f7de230e Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 26 Aug 2026 22:02:32 +0200 Subject: [PATCH] =?UTF-8?q?refactor(i-g):=20BUG-018=20God-Object=20Split?= =?UTF-8?q?=202=20=E2=80=94=20dms/routes.py=20von=201492=20auf=20650=20Zei?= =?UTF-8?q?len=20(-56%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common.py neu: alle Safety-/Storage-Helper und Konstanten (exakte Original-Implementierung) - folders_routes.py / sharing_routes.py / search_bulk_routes.py je eigener Router ohne Prefix - routes.py: File-Lifecycle-Kern bleibt physisch (MAX_FILE_SIZE-Test-Patch-Semantik), Rest als Re-Export-Fassade + include_router x3 - Beweis: DMS-Suite 129 Tests = 125 passed + 4 identische Vorbestand-Failures (Baseline-Referenz 1:1), 20/20 Routen via Router-Introspection, ruff clean --- app/plugins/builtins/dms/common.py | 108 +++ app/plugins/builtins/dms/folders_routes.py | 383 ++++++++ app/plugins/builtins/dms/routes.py | 892 +----------------- .../builtins/dms/search_bulk_routes.py | 223 +++++ app/plugins/builtins/dms/sharing_routes.py | 242 +++++ 5 files changed, 981 insertions(+), 867 deletions(-) create mode 100644 app/plugins/builtins/dms/common.py create mode 100644 app/plugins/builtins/dms/folders_routes.py create mode 100644 app/plugins/builtins/dms/search_bulk_routes.py create mode 100644 app/plugins/builtins/dms/sharing_routes.py diff --git a/app/plugins/builtins/dms/common.py b/app/plugins/builtins/dms/common.py new file mode 100644 index 0000000..7d92b5d --- /dev/null +++ b/app/plugins/builtins/dms/common.py @@ -0,0 +1,108 @@ +"""DMS gemeinsame Helper & Konstanten — BUG-018 God-Object-Split.""" + +from __future__ import annotations + +import os +import uuid + +from fastapi import HTTPException + +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 relative storage path for a file (relative to storage base).""" + return f"{tenant_id}/{file_id}" + + +def _get_file_extension(filename: str) -> str: + """Extract lowercase extension including dot.""" + return os.path.splitext(filename)[1].lower() + + + +def _sanitize_filename(filename: str) -> str: + """Sanitize a filename for safe use in Content-Disposition headers.""" + import re + # Extract basename only (strip any path components) + safe = os.path.basename(filename.replace('\\', '/')) + # Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode) + safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe) + # Collapse consecutive dots (path traversal prevention) + safe = re.sub(r'\.{2,}', '_', safe) + # Collapse multiple spaces + safe = re.sub(r' {2,}', ' ', safe) + # Strip leading dots and whitespace + safe = safe.lstrip('.').strip() + # Limit length + if len(safe) > 200: + name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '') + safe = name[:200] + ('.' + ext if ext else '') + return safe or 'file' + +# Blocked file extensions for security +BLOCKED_EXTENSIONS = { + ".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi", + ".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf", + ".php", ".py", ".pl", ".asp", ".aspx", ".jsp", ".svg", ".htaccess", + ".phtml", ".pht", ".cgi", ".cfm", ".erb", +} + +# Allowed MIME types for upload validation +ALLOWED_MIME_PREFIXES = { + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument", + "application/vnd.oasis.opendocument", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/zip", + "application/gzip", + "application/x-tar", + "application/json", + "application/xml", + "application/rtf", + "application/x-7z-compressed", + "application/x-rar-compressed", + "text/plain", + "text/csv", + "text/html", + "text/markdown", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/tiff", + "image/x-icon", + "audio/", + "video/", + "application/octet-stream", +} + + +def _is_blocked_filetype(filename: str) -> bool: + """Check if a file has a blocked (dangerous) extension.""" + ext = os.path.splitext(filename)[1].lower() + return ext in BLOCKED_EXTENSIONS + + +chunk_size = 1024 * 1024 # 1MB chunks for streaming uploads + +# ─── Folders ─── diff --git a/app/plugins/builtins/dms/folders_routes.py b/app/plugins/builtins/dms/folders_routes.py new file mode 100644 index 0000000..1ad4543 --- /dev/null +++ b/app/plugins/builtins/dms/folders_routes.py @@ -0,0 +1,383 @@ +"""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 +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), +): + """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 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() + + # 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 ─── diff --git a/app/plugins/builtins/dms/routes.py b/app/plugins/builtins/dms/routes.py index 4c925fe..9a73896 100644 --- a/app/plugins/builtins/dms/routes.py +++ b/app/plugins/builtins/dms/routes.py @@ -2,12 +2,10 @@ from __future__ import annotations -import os import uuid from fastapi import ( APIRouter, - Body, Depends, File, Form, @@ -17,25 +15,38 @@ from fastapi import ( status, ) from fastapi.responses import StreamingResponse -from sqlalchemy import select, update +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.core.storage import LocalStorage, get_storage_backend from app.core.visibility import apply_visibility_filter, check_single_entity_access from app.deps import get_current_user, require_permission + +# BUG-018 God-Object-Split: Helper/Konstanten leben jetzt in common.py; +# Re-Exports sichern Import- und Patch-Kompatibilitaet +# (tests patchen app.plugins.builtins.dms.routes.MAX_FILE_SIZE fuer den Upload). +from app.plugins.builtins.dms.common import ( # noqa: F401 + ALLOWED_MIME_PREFIXES, + BLOCKED_EXTENSIONS, + MAX_FILE_SIZE, + OFFICE_EXTENSIONS, + _file_storage_path, + _get_file_extension, + _is_blocked_filetype, + _parse_uuid, + _sanitize_filename, + chunk_size, +) +from app.plugins.builtins.dms.folders_routes import router as folders_router 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, FileMetadataResponse, FileUpdate, - FolderCreate, - FolderUpdate, - ShareRemoveRequest, - ShareRequest, ) +from app.plugins.builtins.dms.search_bulk_routes import router as search_bulk_router +from app.plugins.builtins.dms.sharing_routes import router as sharing_router from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract # Get Permission model from the permissions contract @@ -45,460 +56,6 @@ Permission = _perms_contract.Permission router = APIRouter(prefix="/api/v1/dms", tags=["dms"]) # Office file extensions mapped to Collabora 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 relative storage path for a file (relative to storage base).""" - return f"{tenant_id}/{file_id}" - - -def _get_file_extension(filename: str) -> str: - """Extract lowercase extension including dot.""" - return os.path.splitext(filename)[1].lower() - - - -def _sanitize_filename(filename: str) -> str: - """Sanitize a filename for safe use in Content-Disposition headers.""" - import re - # Extract basename only (strip any path components) - safe = os.path.basename(filename.replace('\\', '/')) - # Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode) - safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe) - # Collapse consecutive dots (path traversal prevention) - safe = re.sub(r'\.{2,}', '_', safe) - # Collapse multiple spaces - safe = re.sub(r' {2,}', ' ', safe) - # Strip leading dots and whitespace - safe = safe.lstrip('.').strip() - # Limit length - if len(safe) > 200: - name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '') - safe = name[:200] + ('.' + ext if ext else '') - return safe or 'file' - -# Blocked file extensions for security -BLOCKED_EXTENSIONS = { - ".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi", - ".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf", - ".php", ".py", ".pl", ".asp", ".aspx", ".jsp", ".svg", ".htaccess", - ".phtml", ".pht", ".cgi", ".cfm", ".erb", -} - -# Allowed MIME types for upload validation -ALLOWED_MIME_PREFIXES = { - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument", - "application/vnd.oasis.opendocument", - "application/vnd.ms-excel", - "application/vnd.ms-powerpoint", - "application/zip", - "application/gzip", - "application/x-tar", - "application/json", - "application/xml", - "application/rtf", - "application/x-7z-compressed", - "application/x-rar-compressed", - "text/plain", - "text/csv", - "text/html", - "text/markdown", - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "image/bmp", - "image/tiff", - "image/x-icon", - "audio/", - "video/", - "application/octet-stream", -} - - -def _is_blocked_filetype(filename: str) -> bool: - """Check if a file has a blocked (dangerous) extension.""" - ext = os.path.splitext(filename)[1].lower() - return ext in BLOCKED_EXTENSIONS - - -chunk_size = 1024 * 1024 # 1MB chunks for streaming uploads - -# ─── Folders ─── - - -@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), -): - """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 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() - - # 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 ─── - - @router.post("/files/upload", status_code=status.HTTP_201_CREATED, response_model=FileMetadataResponse, dependencies=[Depends(require_permission("dms:write"))]) async def upload_file( file: UploadFile = File(...), @@ -547,7 +104,7 @@ async def upload_file( # Stream file to storage — avoid loading entire file into RAM import hashlib - chunk_size = 1024 * 1024 # 1MB chunks + chunk_size = 1024 * 1024 # noqa: F811 (Original-Shadowing im Original auch so) sha256 = hashlib.sha256() file_size = 0 @@ -1087,406 +644,7 @@ async def download_file( ) -@router.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))]) -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 + Collabora config.""" - tenant_id = uuid.UUID(current_user["tenant_id"]) - user_id = current_user["user_id"] - user_name = current_user.get("name", "Unknown") - is_system_admin = current_user.get("role") == "admin" - 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 not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin): - raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) - - 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", dependencies=[Depends(require_permission("dms: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"]) - user_id = uuid.UUID(current_user["user_id"]) - is_system_admin = current_user.get("role") == "admin" - 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"}) - - if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin): - raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) - - 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.UUID(current_user["user_id"]), - 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, dependencies=[Depends(require_permission("dms:share"))]) -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"]) - user_id = uuid.UUID(current_user["user_id"]) - is_system_admin = current_user.get("role") == "admin" - fid = _parse_uuid(file_id, "file_id") - - if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin): - raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) - - 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", dependencies=[Depends(require_permission("dms:read"))]) -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"]) - user_id = uuid.UUID(current_user["user_id"]) - is_system_admin = current_user.get("role") == "admin" - - query = select(DmsFile).where( - DmsFile.tenant_id == tenant_id, - DmsFile.deleted_at.is_(None), - DmsFile.name.ilike(f"%{q}%"), - ) - query = await apply_visibility_filter( - db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin - ) - result = await db.execute(query) - 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", dependencies=[Depends(require_permission("dms:read"))]) -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"]) - is_system_admin = current_user.get("role") == "admin" - - # 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 {"items": [], "total": 0} - - query = select(DmsFile).where( - DmsFile.tenant_id == tenant_id, - DmsFile.id.in_(file_ids), - DmsFile.deleted_at.is_(None), - ) - query = await apply_visibility_filter( - db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin - ) - result = await db.execute(query) - files = 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", dependencies=[Depends(require_permission("dms:write"))]) -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"]) - user_id = uuid.UUID(current_user["user_id"]) - is_system_admin = current_user.get("role") == "admin" - 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] - - query = select(DmsFile).where( - DmsFile.tenant_id == tenant_id, - DmsFile.id.in_(file_ids), - DmsFile.deleted_at.is_(None), - ) - query = await apply_visibility_filter( - db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin - ) - result = await db.execute(query) - 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", dependencies=[Depends(require_permission("dms: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"]) - user_id = uuid.UUID(current_user["user_id"]) - is_system_admin = current_user.get("role") == "admin" - file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids] - - from datetime import UTC, datetime - - now = datetime.now(UTC) - - # Apply visibility filter to only delete files user has access to - query = select(DmsFile).where( - DmsFile.tenant_id == tenant_id, - DmsFile.id.in_(file_ids), - DmsFile.deleted_at.is_(None), - ) - query = await apply_visibility_filter( - db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin - ) - result = await db.execute(query) - accessible_files = result.scalars().all() - accessible_ids = [f.id for f in accessible_files] - - result = await db.execute( - update(DmsFile) - .where( - DmsFile.tenant_id == tenant_id, - DmsFile.id.in_(accessible_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, - } +# Sub-Router einbinden (BUG-018 Split): folders, sharing/collabora, search/bulk +router.include_router(folders_router) +router.include_router(sharing_router) +router.include_router(search_bulk_router) diff --git a/app/plugins/builtins/dms/search_bulk_routes.py b/app/plugins/builtins/dms/search_bulk_routes.py new file mode 100644 index 0000000..a4faee9 --- /dev/null +++ b/app/plugins/builtins/dms/search_bulk_routes.py @@ -0,0 +1,223 @@ +"""DMS Search / shared-with-me / Bulk Routen — extrahiert aus routes.py (BUG-018).""" + +from __future__ import annotations + +import uuid + +from fastapi import ( + APIRouter, + Depends, + HTTPException, +) +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 +from app.deps import get_current_user, require_permission +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 BulkDeleteRequest, BulkMoveRequest +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("/search", dependencies=[Depends(require_permission("dms:read"))]) +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"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("role") == "admin" + + query = select(DmsFile).where( + DmsFile.tenant_id == tenant_id, + DmsFile.deleted_at.is_(None), + DmsFile.name.ilike(f"%{q}%"), + ) + query = await apply_visibility_filter( + db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin + ) + result = await db.execute(query) + 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", dependencies=[Depends(require_permission("dms:read"))]) +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"]) + is_system_admin = current_user.get("role") == "admin" + + # 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 {"items": [], "total": 0} + + query = select(DmsFile).where( + DmsFile.tenant_id == tenant_id, + DmsFile.id.in_(file_ids), + DmsFile.deleted_at.is_(None), + ) + query = await apply_visibility_filter( + db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin + ) + result = await db.execute(query) + files = 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", dependencies=[Depends(require_permission("dms:write"))]) +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"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("role") == "admin" + 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] + + query = select(DmsFile).where( + DmsFile.tenant_id == tenant_id, + DmsFile.id.in_(file_ids), + DmsFile.deleted_at.is_(None), + ) + query = await apply_visibility_filter( + db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin + ) + result = await db.execute(query) + 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", dependencies=[Depends(require_permission("dms: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"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("role") == "admin" + file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids] + + from datetime import UTC, datetime + + now = datetime.now(UTC) + + # Apply visibility filter to only delete files user has access to + query = select(DmsFile).where( + DmsFile.tenant_id == tenant_id, + DmsFile.id.in_(file_ids), + DmsFile.deleted_at.is_(None), + ) + query = await apply_visibility_filter( + db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin + ) + result = await db.execute(query) + accessible_files = result.scalars().all() + accessible_ids = [f.id for f in accessible_files] + + result = await db.execute( + update(DmsFile) + .where( + DmsFile.tenant_id == tenant_id, + DmsFile.id.in_(accessible_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, + } diff --git a/app/plugins/builtins/dms/sharing_routes.py b/app/plugins/builtins/dms/sharing_routes.py new file mode 100644 index 0000000..fbfaab4 --- /dev/null +++ b/app/plugins/builtins/dms/sharing_routes.py @@ -0,0 +1,242 @@ +"""DMS Edit-Session/Collabora & Sharing Routen — extrahiert aus routes.py (BUG-018).""" + +from __future__ import annotations + +import uuid + +from fastapi import ( + APIRouter, + Body, + Depends, + HTTPException, + Response, + status, +) +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.core.visibility import check_single_entity_access +from app.deps import get_current_user, require_permission +from app.plugins.builtins.dms.common import ( + OFFICE_EXTENSIONS, + _get_file_extension, + _parse_uuid, +) +from app.plugins.builtins.dms.models import File as DmsFile +from app.plugins.builtins.dms.schemas import ShareRemoveRequest, ShareRequest +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.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))]) +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 + Collabora config.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = current_user["user_id"] + user_name = current_user.get("name", "Unknown") + is_system_admin = current_user.get("role") == "admin" + 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 not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin): + raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) + + 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", dependencies=[Depends(require_permission("dms: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"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("role") == "admin" + 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"}) + + if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin): + raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) + + 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.UUID(current_user["user_id"]), + 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, dependencies=[Depends(require_permission("dms:share"))]) +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"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("role") == "admin" + fid = _parse_uuid(file_id, "file_id") + + if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin): + raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"}) + + 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 ───