2026-07-23 08:42:26 +02:00
|
|
|
"""DMS plugin routes — folders, files, preview, Collabora, internal sharing, search, bulk."""
|
2026-06-29 20:48:58 +02:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
from fastapi import (
|
|
|
|
|
APIRouter,
|
|
|
|
|
Body,
|
|
|
|
|
Depends,
|
|
|
|
|
File,
|
|
|
|
|
Form,
|
|
|
|
|
HTTPException,
|
|
|
|
|
Response,
|
|
|
|
|
UploadFile,
|
|
|
|
|
status,
|
|
|
|
|
)
|
|
|
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
|
from sqlalchemy import select, update
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.core.db import get_db
|
2026-08-03 15:08:13 +02:00
|
|
|
from app.core.storage import get_storage_backend, LocalStorage
|
2026-07-29 02:28:52 +02:00
|
|
|
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
2026-07-23 08:42:26 +02:00
|
|
|
from app.deps import get_current_user, require_permission
|
2026-06-29 20:48:58 +02:00
|
|
|
from app.plugins.builtins.dms.models import File as DmsFile
|
|
|
|
|
from app.plugins.builtins.dms.models import Folder
|
|
|
|
|
from app.plugins.builtins.dms.schemas import (
|
|
|
|
|
BulkDeleteRequest,
|
|
|
|
|
BulkMoveRequest,
|
|
|
|
|
FileUpdate,
|
|
|
|
|
FolderCreate,
|
|
|
|
|
FolderUpdate,
|
|
|
|
|
ShareRemoveRequest,
|
|
|
|
|
ShareRequest,
|
|
|
|
|
)
|
2026-07-25 21:03:46 +02:00
|
|
|
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
|
2026-07-26 23:15:34 +02:00
|
|
|
|
|
|
|
|
# Get Permission model from the permissions contract
|
|
|
|
|
_perms_contract = get_perms_contract()
|
|
|
|
|
Permission = _perms_contract.Permission
|
2026-06-29 20:48:58 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
# Office file extensions mapped to Collabora file types
|
2026-06-29 20:48:58 +02:00
|
|
|
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:
|
2026-07-23 08:42:26 +02:00
|
|
|
"""Build relative storage path for a file (relative to storage base)."""
|
|
|
|
|
return f"{tenant_id}/{file_id}"
|
2026-06-29 20:48:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_file_extension(filename: str) -> str:
|
|
|
|
|
"""Extract lowercase extension including dot."""
|
|
|
|
|
return os.path.splitext(filename)[1].lower()
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 21:03:46 +02:00
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
2026-07-31 00:58:05 +02:00
|
|
|
# Blocked file extensions for security
|
|
|
|
|
BLOCKED_EXTENSIONS = {
|
|
|
|
|
".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi",
|
|
|
|
|
".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf",
|
2026-08-03 22:32:03 +02:00
|
|
|
".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",
|
2026-07-31 00:58:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 21:03:46 +02:00
|
|
|
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming uploads
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
# ─── Folders ───
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.get("/folders", dependencies=[Depends(require_permission("dms:read"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
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"])
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
# 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
|
2026-06-29 20:48:58 +02:00
|
|
|
)
|
2026-07-29 02:28:52 +02:00
|
|
|
result = await db.execute(query)
|
2026-06-29 20:48:58 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.post("/folders", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
async def create_folder(
|
|
|
|
|
body: FolderCreate,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""AC2: POST /api/v1/dms/folders → 201, folder created with path."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
parent_id = _parse_uuid(body.parent_id, "parent_id") if body.parent_id else None
|
|
|
|
|
|
|
|
|
|
# Validate parent exists if specified
|
|
|
|
|
if parent_id is not None:
|
|
|
|
|
parent_result = await db.execute(
|
|
|
|
|
select(Folder).where(
|
|
|
|
|
Folder.id == parent_id,
|
|
|
|
|
Folder.tenant_id == tenant_id,
|
|
|
|
|
Folder.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if parent_result.scalar_one_or_none() is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
404, detail={"detail": "Parent folder not found", "code": "not_found"}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Check name uniqueness within same parent (non-deleted)
|
|
|
|
|
existing = await db.execute(
|
|
|
|
|
select(Folder).where(
|
|
|
|
|
Folder.tenant_id == tenant_id,
|
|
|
|
|
Folder.name == body.name,
|
|
|
|
|
Folder.parent_id == parent_id if parent_id else Folder.parent_id.is_(None),
|
|
|
|
|
Folder.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if existing.scalar_one_or_none() is not None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
folder = Folder(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
name=body.name,
|
|
|
|
|
parent_id=parent_id,
|
|
|
|
|
created_by=user_id,
|
|
|
|
|
)
|
|
|
|
|
db.add(folder)
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
# Build path
|
|
|
|
|
path = body.name
|
|
|
|
|
if parent_id is not None:
|
|
|
|
|
parent_path_result = await db.execute(select(Folder).where(Folder.id == parent_id))
|
|
|
|
|
parent_folder = parent_path_result.scalar_one_or_none()
|
|
|
|
|
if parent_folder:
|
|
|
|
|
# Recursively build path
|
|
|
|
|
path_parts = [body.name]
|
|
|
|
|
current = parent_folder
|
|
|
|
|
while current is not None:
|
|
|
|
|
path_parts.insert(0, current.name)
|
|
|
|
|
if current.parent_id is not None:
|
|
|
|
|
cur_result = await db.execute(
|
|
|
|
|
select(Folder).where(Folder.id == current.parent_id)
|
|
|
|
|
)
|
|
|
|
|
current = cur_result.scalar_one_or_none()
|
|
|
|
|
else:
|
|
|
|
|
current = None
|
|
|
|
|
path = "/".join(path_parts)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"id": str(folder.id),
|
|
|
|
|
"name": folder.name,
|
|
|
|
|
"parent_id": str(folder.parent_id) if folder.parent_id else None,
|
|
|
|
|
"created_by": str(folder.created_by),
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"path": path,
|
|
|
|
|
"children": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
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"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
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": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
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"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
|
|
|
|
|
# Recursively collect all descendant folder IDs
|
|
|
|
|
all_folder_ids: list[uuid.UUID] = [fid]
|
|
|
|
|
queue: list[uuid.UUID] = [fid]
|
|
|
|
|
while queue:
|
|
|
|
|
current_id = queue.pop(0)
|
|
|
|
|
children_result = await db.execute(
|
|
|
|
|
select(Folder).where(
|
|
|
|
|
Folder.parent_id == current_id,
|
|
|
|
|
Folder.tenant_id == tenant_id,
|
|
|
|
|
Folder.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for child in children_result.scalars().all():
|
|
|
|
|
all_folder_ids.append(child.id)
|
|
|
|
|
queue.append(child.id)
|
|
|
|
|
|
|
|
|
|
# Soft-delete all folders
|
|
|
|
|
await db.execute(update(Folder).where(Folder.id.in_(all_folder_ids)).values(deleted_at=now))
|
|
|
|
|
|
|
|
|
|
# Soft-delete all files in those folders
|
|
|
|
|
await db.execute(
|
|
|
|
|
update(DmsFile)
|
|
|
|
|
.where(
|
|
|
|
|
DmsFile.tenant_id == tenant_id,
|
|
|
|
|
DmsFile.folder_id.in_(all_folder_ids),
|
|
|
|
|
DmsFile.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
.values(deleted_at=now)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Files ───
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 20:45:42 +02:00
|
|
|
@router.post("/files/upload", status_code=status.HTTP_201_CREATED, response_model=None, dependencies=[Depends(require_permission("dms:write"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
async def upload_file(
|
|
|
|
|
file: UploadFile = File(...),
|
|
|
|
|
folder_id: str | None = Form(None),
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""AC5: POST /api/v1/dms/files/upload → 201, file stored + metadata."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
fid = _parse_uuid(folder_id, "folder_id") if folder_id else None
|
|
|
|
|
|
2026-07-31 00:58:05 +02:00
|
|
|
# Check for blocked file types
|
|
|
|
|
if _is_blocked_filetype(file.filename or ""):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
400,
|
|
|
|
|
detail={"detail": "File type not allowed", "code": "blocked_filetype"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-03 22:32:03 +02:00
|
|
|
# MIME type validation: verify content_type against allowlist
|
|
|
|
|
mime_type = file.content_type or "application/octet-stream"
|
|
|
|
|
if not any(mime_type.startswith(prefix) for prefix in ALLOWED_MIME_PREFIXES):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
400,
|
|
|
|
|
detail={"detail": f"MIME type '{mime_type}' not allowed", "code": "blocked_mimetype"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
# Validate folder exists if specified
|
|
|
|
|
if fid is not None:
|
|
|
|
|
folder_result = await db.execute(
|
|
|
|
|
select(Folder).where(
|
|
|
|
|
Folder.id == fid,
|
|
|
|
|
Folder.tenant_id == tenant_id,
|
|
|
|
|
Folder.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if folder_result.scalar_one_or_none() is None:
|
|
|
|
|
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
|
|
|
|
|
2026-07-26 20:45:42 +02:00
|
|
|
# Stream file to storage — avoid loading entire file into RAM
|
2026-07-25 21:03:46 +02:00
|
|
|
import hashlib
|
|
|
|
|
CHUNK_SIZE = 1024 * 1024 # 1MB chunks
|
|
|
|
|
sha256 = hashlib.sha256()
|
|
|
|
|
file_size = 0
|
2026-06-29 20:48:58 +02:00
|
|
|
|
2026-07-26 20:45:42 +02:00
|
|
|
async def chunk_stream():
|
|
|
|
|
nonlocal file_size
|
|
|
|
|
while True:
|
|
|
|
|
chunk = await file.read(CHUNK_SIZE)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
file_size += len(chunk)
|
|
|
|
|
if file_size > MAX_FILE_SIZE:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
|
|
|
|
|
)
|
|
|
|
|
sha256.update(chunk)
|
|
|
|
|
yield chunk
|
2026-06-29 20:48:58 +02:00
|
|
|
|
2026-07-26 23:15:34 +02:00
|
|
|
# ── Hook: dms.before_upload (Filter) — can modify filename ──
|
|
|
|
|
from app.core.hooks import apply_filters
|
|
|
|
|
upload_data = {
|
|
|
|
|
"filename": file.filename or "unnamed",
|
|
|
|
|
"mime_type": file.content_type or "application/octet-stream",
|
|
|
|
|
}
|
|
|
|
|
upload_data = await apply_filters("dms.before_upload", upload_data)
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
# Create file record
|
|
|
|
|
file_id = uuid.uuid4()
|
|
|
|
|
storage_path = _file_storage_path(tenant_id, file_id)
|
|
|
|
|
|
2026-07-26 20:45:42 +02:00
|
|
|
# Save file via storage backend (true streaming, no RAM accumulation)
|
2026-07-23 08:42:26 +02:00
|
|
|
storage = get_storage_backend()
|
2026-07-26 20:45:42 +02:00
|
|
|
await storage.save_stream(storage_path, chunk_stream())
|
|
|
|
|
|
|
|
|
|
content_hash = sha256.hexdigest()
|
2026-06-29 20:48:58 +02:00
|
|
|
|
2026-07-26 23:15:34 +02:00
|
|
|
mime_type = upload_data["mime_type"]
|
2026-06-29 20:48:58 +02:00
|
|
|
|
2026-08-03 14:21:43 +02:00
|
|
|
# Tenant-local deduplication: check for existing file with same content_hash
|
|
|
|
|
existing_q = await db.execute(
|
|
|
|
|
select(DmsFile).where(
|
|
|
|
|
DmsFile.tenant_id == tenant_id,
|
|
|
|
|
DmsFile.content_hash == content_hash,
|
|
|
|
|
DmsFile.deleted_at.is_(None),
|
|
|
|
|
).limit(1)
|
2026-06-29 20:48:58 +02:00
|
|
|
)
|
2026-08-03 14:21:43 +02:00
|
|
|
existing_file = existing_q.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if existing_file:
|
|
|
|
|
# Deduplicate: reuse existing file, remove the duplicate we just saved
|
|
|
|
|
await storage.delete(storage_path)
|
|
|
|
|
dms_file = existing_file
|
|
|
|
|
else:
|
|
|
|
|
dms_file = DmsFile(
|
|
|
|
|
id=file_id,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
name=upload_data["filename"],
|
|
|
|
|
folder_id=fid,
|
|
|
|
|
uploaded_by=user_id,
|
|
|
|
|
mime_type=mime_type,
|
|
|
|
|
size_bytes=file_size,
|
|
|
|
|
storage_path=storage_path,
|
|
|
|
|
content_hash=content_hash,
|
|
|
|
|
)
|
|
|
|
|
db.add(dms_file)
|
|
|
|
|
await db.flush()
|
2026-06-29 20:48:58 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"id": str(dms_file.id),
|
|
|
|
|
"name": dms_file.name,
|
|
|
|
|
"folder_id": str(dms_file.folder_id) if dms_file.folder_id else None,
|
|
|
|
|
"uploaded_by": str(dms_file.uploaded_by),
|
|
|
|
|
"mime_type": dms_file.mime_type,
|
|
|
|
|
"size_bytes": dms_file.size_bytes,
|
2026-08-12 20:47:43 +02:00
|
|
|
"content_hash": dms_file.content_hash,
|
2026-06-29 20:48:58 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
|
|
|
|
|
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.get("/files/{file_id}", dependencies=[Depends(require_permission("dms:read"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
async def get_file(
|
|
|
|
|
file_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""AC6: GET /api/v1/dms/files/{id} → 200 + file metadata."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "read", is_system_admin):
|
|
|
|
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
return {
|
|
|
|
|
"id": str(dms_file.id),
|
|
|
|
|
"name": dms_file.name,
|
|
|
|
|
"folder_id": str(dms_file.folder_id) if dms_file.folder_id else None,
|
|
|
|
|
"uploaded_by": str(dms_file.uploaded_by),
|
|
|
|
|
"mime_type": dms_file.mime_type,
|
|
|
|
|
"size_bytes": dms_file.size_bytes,
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
|
|
|
|
|
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.get("/files", dependencies=[Depends(require_permission("dms:read"))])
|
2026-07-20 17:31:04 +02:00
|
|
|
async def list_all_files(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""List all non-deleted files for the current tenant."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-07-20 17:31:04 +02:00
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
query = select(DmsFile).where(
|
|
|
|
|
DmsFile.tenant_id == tenant_id,
|
|
|
|
|
DmsFile.deleted_at.is_(None),
|
2026-07-20 17:31:04 +02:00
|
|
|
)
|
2026-07-29 02:28:52 +02:00
|
|
|
query = await apply_visibility_filter(
|
|
|
|
|
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
|
|
|
|
)
|
|
|
|
|
result = await db.execute(query)
|
2026-07-20 17:31:04 +02:00
|
|
|
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,
|
|
|
|
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
|
|
|
|
}
|
|
|
|
|
for f in files
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.get("/folders/{folder_id}/files", dependencies=[Depends(require_permission("dms:read"))])
|
2026-07-20 17:31:04 +02:00
|
|
|
async def list_files_in_folder(
|
|
|
|
|
folder_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""List all non-deleted files in a specific folder (non-recursive)."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-07-20 17:31:04 +02:00
|
|
|
fid = _parse_uuid(folder_id, "folder_id")
|
|
|
|
|
|
|
|
|
|
# Validate folder exists
|
|
|
|
|
folder_result = await db.execute(
|
|
|
|
|
select(Folder).where(
|
|
|
|
|
Folder.id == fid,
|
|
|
|
|
Folder.tenant_id == tenant_id,
|
|
|
|
|
Folder.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if folder_result.scalar_one_or_none() is None:
|
|
|
|
|
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
query = select(DmsFile).where(
|
|
|
|
|
DmsFile.tenant_id == tenant_id,
|
|
|
|
|
DmsFile.folder_id == fid,
|
|
|
|
|
DmsFile.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
query = await apply_visibility_filter(
|
|
|
|
|
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
2026-07-20 17:31:04 +02:00
|
|
|
)
|
2026-07-29 02:28:52 +02:00
|
|
|
result = await db.execute(query)
|
2026-07-20 17:31:04 +02:00
|
|
|
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,
|
|
|
|
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
|
|
|
|
}
|
|
|
|
|
for f in files
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.patch("/files/{file_id}", dependencies=[Depends(require_permission("dms:write"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
async def update_file(
|
|
|
|
|
file_id: str,
|
|
|
|
|
body: FileUpdate,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""AC7: PATCH /api/v1/dms/files/{id} → 200, rename/move."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
data = body.model_dump(exclude_unset=True)
|
|
|
|
|
|
|
|
|
|
if "name" in data and data["name"] is not None:
|
|
|
|
|
dms_file.name = data["name"]
|
|
|
|
|
|
|
|
|
|
if "folder_id" in data:
|
|
|
|
|
new_folder = _parse_uuid(data["folder_id"], "folder_id") if data["folder_id"] else None
|
|
|
|
|
if new_folder is not None:
|
|
|
|
|
folder_result = await db.execute(
|
|
|
|
|
select(Folder).where(
|
|
|
|
|
Folder.id == new_folder,
|
|
|
|
|
Folder.tenant_id == tenant_id,
|
|
|
|
|
Folder.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if folder_result.scalar_one_or_none() is None:
|
|
|
|
|
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
|
|
|
|
dms_file.folder_id = new_folder
|
|
|
|
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(dms_file)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"id": str(dms_file.id),
|
|
|
|
|
"name": dms_file.name,
|
|
|
|
|
"folder_id": str(dms_file.folder_id) if dms_file.folder_id else None,
|
|
|
|
|
"uploaded_by": str(dms_file.uploaded_by),
|
|
|
|
|
"mime_type": dms_file.mime_type,
|
|
|
|
|
"size_bytes": dms_file.size_bytes,
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
|
|
|
|
|
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.delete("/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
async def delete_file(
|
|
|
|
|
file_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""AC8: DELETE /api/v1/dms/files/{id} → 204, soft-delete."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "delete", is_system_admin):
|
|
|
|
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
dms_file.deleted_at = datetime.now(UTC)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.post("/files/{file_id}/restore", dependencies=[Depends(require_permission("dms:write"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
async def restore_file(
|
|
|
|
|
file_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""AC9: POST /api/v1/dms/files/{id}/restore → 200, restored from trash."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
fid = _parse_uuid(file_id, "file_id")
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(DmsFile).where(
|
|
|
|
|
DmsFile.id == fid,
|
|
|
|
|
DmsFile.tenant_id == tenant_id,
|
|
|
|
|
DmsFile.deleted_at.is_not(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
dms_file = result.scalar_one_or_none()
|
|
|
|
|
if dms_file is None:
|
|
|
|
|
raise HTTPException(404, detail={"detail": "Deleted file not found", "code": "not_found"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
dms_file.deleted_at = None
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(dms_file)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"id": str(dms_file.id),
|
|
|
|
|
"name": dms_file.name,
|
|
|
|
|
"folder_id": str(dms_file.folder_id) if dms_file.folder_id else None,
|
|
|
|
|
"uploaded_by": str(dms_file.uploaded_by),
|
|
|
|
|
"mime_type": dms_file.mime_type,
|
|
|
|
|
"size_bytes": dms_file.size_bytes,
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
|
|
|
|
|
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Preview & Edit ───
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.get("/files/{file_id}/preview", dependencies=[Depends(require_permission("dms:read"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
async def preview_file(
|
|
|
|
|
file_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""AC10: GET /api/v1/dms/files/{id}/preview → 200 + PDF stream."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "read", is_system_admin):
|
|
|
|
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
if dms_file.mime_type != "application/pdf":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"}
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
storage = get_storage_backend()
|
|
|
|
|
if not await storage.exists(dms_file.storage_path):
|
2026-06-29 20:48:58 +02:00
|
|
|
raise HTTPException(
|
|
|
|
|
404, detail={"detail": "File not found on disk", "code": "file_missing"}
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-03 14:21:43 +02:00
|
|
|
# Stream file directly from storage without loading into RAM
|
|
|
|
|
from fastapi.responses import FileResponse as FastApiFileResponse
|
|
|
|
|
import os as _os
|
|
|
|
|
|
|
|
|
|
if isinstance(storage, LocalStorage):
|
|
|
|
|
# LocalStorage: use FileResponse for automatic streaming
|
|
|
|
|
full_path = storage._full_path(dms_file.storage_path)
|
|
|
|
|
return FastApiFileResponse(
|
|
|
|
|
path=full_path,
|
|
|
|
|
media_type="application/pdf",
|
|
|
|
|
headers={"Content-Disposition": f'inline; filename="{dms_file.name}"'},
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# S3 or other: fall back to read (TODO: implement S3 streaming)
|
|
|
|
|
content = await storage.read(dms_file.storage_path)
|
|
|
|
|
def _stream():
|
|
|
|
|
yield content
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
_stream(),
|
|
|
|
|
media_type="application/pdf",
|
|
|
|
|
headers={"Content-Disposition": f'inline; filename="{dms_file.name}"'},
|
|
|
|
|
)
|
2026-06-29 20:48:58 +02:00
|
|
|
|
|
|
|
|
|
2026-08-03 15:02:39 +02:00
|
|
|
@router.get("/files/{file_id}/download", dependencies=[Depends(require_permission("dms:read"))])
|
|
|
|
|
async def download_file(
|
|
|
|
|
file_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
|
|
|
|
"""Download any file type — streams directly from storage without loading into RAM."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_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, "read", is_admin):
|
|
|
|
|
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
|
|
|
|
|
|
|
|
|
storage = get_storage_backend()
|
|
|
|
|
if not await storage.exists(dms_file.storage_path):
|
|
|
|
|
raise HTTPException(404, detail={"detail": "File not found on disk", "code": "file_missing"})
|
|
|
|
|
|
|
|
|
|
from fastapi.responses import FileResponse as FastApiFileResponse
|
|
|
|
|
if isinstance(storage, LocalStorage):
|
|
|
|
|
full_path = storage._full_path(dms_file.storage_path)
|
|
|
|
|
return FastApiFileResponse(
|
|
|
|
|
path=full_path,
|
|
|
|
|
media_type=dms_file.mime_type or "application/octet-stream",
|
|
|
|
|
filename=dms_file.name,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
content = await storage.read(dms_file.storage_path)
|
|
|
|
|
def _stream():
|
|
|
|
|
yield content
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
_stream(),
|
|
|
|
|
media_type=dms_file.mime_type or "application/octet-stream",
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="{dms_file.name}"'},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
async def create_edit_session(
|
|
|
|
|
file_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
):
|
2026-07-23 08:42:26 +02:00
|
|
|
"""AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + Collabora config."""
|
2026-06-29 20:48:58 +02:00
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = current_user["user_id"]
|
|
|
|
|
user_name = current_user.get("name", "Unknown")
|
2026-07-29 02:28:52 +02:00
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
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 ───
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.post("/files/{file_id}/share", dependencies=[Depends(require_permission("dms:share"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
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"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
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,
|
2026-07-25 03:02:41 +02:00
|
|
|
user_id=uuid.UUID(current_user["user_id"]),
|
2026-06-29 20:48:58 +02:00
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:share"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
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"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
fid = _parse_uuid(file_id, "file_id")
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
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 ───
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.get("/search", dependencies=[Depends(require_permission("dms:read"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
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"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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
|
2026-06-29 20:48:58 +02:00
|
|
|
)
|
2026-07-29 02:28:52 +02:00
|
|
|
result = await db.execute(query)
|
2026-06-29 20:48:58 +02:00
|
|
|
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
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.get("/shared-with-me", dependencies=[Depends(require_permission("dms:read"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
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"])
|
2026-07-29 02:28:52 +02:00
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
|
|
|
|
|
# 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:
|
2026-07-25 09:19:32 +02:00
|
|
|
return {"items": [], "total": 0}
|
2026-06-29 20:48:58 +02:00
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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
|
2026-06-29 20:48:58 +02:00
|
|
|
)
|
2026-07-29 02:28:52 +02:00
|
|
|
result = await db.execute(query)
|
|
|
|
|
files = result.scalars().all()
|
2026-06-29 20:48:58 +02:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.post("/files/bulk-move", dependencies=[Depends(require_permission("dms:write"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
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"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
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]
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
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
|
2026-06-29 20:48:58 +02:00
|
|
|
)
|
2026-07-29 02:28:52 +02:00
|
|
|
result = await db.execute(query)
|
2026-06-29 20:48:58 +02:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 08:42:26 +02:00
|
|
|
@router.post("/files/bulk-delete", dependencies=[Depends(require_permission("dms:delete"))])
|
2026-06-29 20:48:58 +02:00
|
|
|
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"])
|
2026-07-29 02:28:52 +02:00
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
is_system_admin = current_user.get("role") == "admin"
|
2026-06-29 20:48:58 +02:00
|
|
|
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
|
2026-07-29 02:28:52 +02:00
|
|
|
# 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]
|
|
|
|
|
|
2026-06-29 20:48:58 +02:00
|
|
|
result = await db.execute(
|
|
|
|
|
update(DmsFile)
|
|
|
|
|
.where(
|
|
|
|
|
DmsFile.tenant_id == tenant_id,
|
2026-07-29 02:28:52 +02:00
|
|
|
DmsFile.id.in_(accessible_ids),
|
2026-06-29 20:48:58 +02:00
|
|
|
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,
|
|
|
|
|
}
|