384 lines
14 KiB
Python
384 lines
14 KiB
Python
|
|
"""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 ───
|