Phase 6: DMS & Attachments — Streaming, Deduplikation, API-Bereinigung
Check Cross-Plugin Imports / check (push) Has been cancelled

6.4 Upload streamen:
- attachment_service.save_attachment: Streamt in 1MB Chunks statt await file.read()
- routes/attachments.py: Uebergibt UploadFile direkt statt bytes

6.5 Download streamen:
- DMS preview_file: FileResponse fuer LocalStorage (automatisches Streaming)
- Kein storage.read() mehr fuer LocalStorage

6.6 Tenantlokale Deduplikation:
- DMS Upload: Prueft content_hash vor Erstellung, wiederverwendet existierendes File
- attachment_service: Dedup bereits vorhanden, jetzt mit Streaming kompatibel
- Migration 0098: Partial Unique Index (tenant_id, content_hash) WHERE content_hash IS NOT NULL AND deleted_at IS NULL

6.7 API-Ausgabe bereinigt:
- attachment_service: storage_path und content_hash aus API-Ausgaben entfernt
- DMS routes: content_hash aus 4 API-Endpunkten entfernt

Tests: 54/54 bestanden (17 Workspace + 13 API Token + 24 Command)
This commit is contained in:
Agent Zero
2026-08-03 14:21:43 +02:00
parent ff975ca0a6
commit 29d55cb187
4 changed files with 147 additions and 46 deletions
+48 -25
View File
@@ -519,19 +519,34 @@ async def upload_file(
mime_type = upload_data["mime_type"]
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,
# 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)
)
db.add(dms_file)
await db.flush()
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()
return {
"id": str(dms_file.id),
@@ -540,7 +555,6 @@ async def upload_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"content_hash": dms_file.content_hash,
"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,
@@ -580,7 +594,6 @@ async def get_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"content_hash": dms_file.content_hash,
"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,
@@ -729,7 +742,6 @@ async def update_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"content_hash": dms_file.content_hash,
"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,
@@ -806,7 +818,6 @@ async def restore_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"content_hash": dms_file.content_hash,
"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,
@@ -853,16 +864,28 @@ async def preview_file(
404, detail={"detail": "File not found on disk", "code": "file_missing"}
)
content = await storage.read(dms_file.storage_path)
# Stream file directly from storage without loading into RAM
from fastapi.responses import FileResponse as FastApiFileResponse
import os as _os
def _stream():
yield content
return StreamingResponse(
_stream(),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{dms_file.name}"'},
)
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}"'},
)
@router.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))])