refactor(i-g): BUG-018 God-Object Split 2 — dms/routes.py von 1492 auf 650 Zeilen (-56%)
Check Cross-Plugin Imports / check (push) Has been cancelled

- 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
This commit is contained in:
Agent Zero
2026-08-26 22:02:32 +02:00
parent 4fee01cadf
commit f445aa69d5
5 changed files with 981 additions and 867 deletions
+108
View File
@@ -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 ───
+383
View File
@@ -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 ───
+25 -867
View File
@@ -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)
@@ -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,
}
+242
View File
@@ -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 ───