abbe7a18fc
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
193 lines
5.8 KiB
Python
193 lines
5.8 KiB
Python
"""DMS Bridge — integrates with the DMS plugin for file storage."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from typing import Any
|
|
|
|
import aiofiles
|
|
from fastapi import UploadFile
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.plugins.builtins.dms.contracts import get_contract as get_dms_contract
|
|
|
|
_dms = get_dms_contract()
|
|
DmsFile = _dms.DmsFile
|
|
Folder = _dms.Folder
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
|
|
COMM_FOLDER_NAME = "_kommunikation"
|
|
MAX_DIRECT_UPLOAD = 100 * 1024 * 1024 # 100 MB — larger files must be DMS references
|
|
|
|
|
|
class DmsBridge:
|
|
"""Bridge to the DMS plugin for attachment storage."""
|
|
|
|
@staticmethod
|
|
async def ensure_comm_folder(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
) -> Folder:
|
|
"""Ensure the _kommunikation root folder exists in DMS."""
|
|
result = await db.execute(
|
|
select(Folder).where(
|
|
Folder.name == COMM_FOLDER_NAME,
|
|
Folder.parent_id.is_(None),
|
|
Folder.tenant_id == tenant_id,
|
|
Folder.deleted_at.is_(None),
|
|
)
|
|
)
|
|
folder = result.scalar_one_or_none()
|
|
if folder is None:
|
|
folder = Folder(
|
|
tenant_id=tenant_id,
|
|
name=COMM_FOLDER_NAME,
|
|
parent_id=None,
|
|
created_by=user_id,
|
|
)
|
|
db.add(folder)
|
|
await db.flush()
|
|
return folder
|
|
|
|
@staticmethod
|
|
async def ensure_conversation_folder(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
conversation_id: uuid.UUID,
|
|
) -> Folder:
|
|
"""Ensure a sub-folder for a specific conversation exists."""
|
|
root_folder = await DmsBridge.ensure_comm_folder(db, tenant_id, user_id)
|
|
conv_name = str(conversation_id)
|
|
result = await db.execute(
|
|
select(Folder).where(
|
|
Folder.name == conv_name,
|
|
Folder.parent_id == root_folder.id,
|
|
Folder.tenant_id == tenant_id,
|
|
Folder.deleted_at.is_(None),
|
|
)
|
|
)
|
|
folder = result.scalar_one_or_none()
|
|
if folder is None:
|
|
folder = Folder(
|
|
tenant_id=tenant_id,
|
|
name=conv_name,
|
|
parent_id=root_folder.id,
|
|
created_by=user_id,
|
|
)
|
|
db.add(folder)
|
|
await db.flush()
|
|
return folder
|
|
|
|
@staticmethod
|
|
async def store_attachment(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
conversation_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
file: UploadFile,
|
|
) -> dict[str, Any]:
|
|
"""Store an uploaded file in the DMS under _kommunikation/{conversation_id}/.
|
|
|
|
Returns dict with file_id, file_name, file_type, file_size.
|
|
"""
|
|
# Check size limit
|
|
content = await file.read()
|
|
file_size = len(content)
|
|
|
|
if file_size > MAX_DIRECT_UPLOAD:
|
|
raise ValueError(
|
|
f"File size {file_size} exceeds direct upload limit ({MAX_DIRECT_UPLOAD} bytes). "
|
|
f"Use DMS reference instead."
|
|
)
|
|
|
|
# Ensure conversation folder
|
|
folder = await DmsBridge.ensure_conversation_folder(
|
|
db, tenant_id, user_id, conversation_id
|
|
)
|
|
|
|
# Save file to disk
|
|
file_id = uuid.uuid4()
|
|
file_ext = os.path.splitext(file.filename or "")[1] or ""
|
|
storage_path = os.path.join(
|
|
DMS_STORAGE_BASE,
|
|
str(tenant_id),
|
|
str(folder.id),
|
|
f"{file_id}{file_ext}",
|
|
)
|
|
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
|
|
async with aiofiles.open(storage_path, "wb") as f:
|
|
await f.write(content)
|
|
|
|
# Create DMS file record
|
|
dms_file = DmsFile(
|
|
tenant_id=tenant_id,
|
|
name=file.filename or f"{file_id}",
|
|
folder_id=folder.id,
|
|
uploaded_by=user_id,
|
|
mime_type=file.content_type or "application/octet-stream",
|
|
size_bytes=file_size,
|
|
storage_path=storage_path,
|
|
)
|
|
db.add(dms_file)
|
|
await db.flush()
|
|
|
|
return {
|
|
"file_id": str(dms_file.id),
|
|
"file_name": dms_file.name,
|
|
"file_type": dms_file.mime_type,
|
|
"file_size": dms_file.size_bytes,
|
|
"file_source": "comm",
|
|
}
|
|
|
|
@staticmethod
|
|
async def reference_external_file(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
file_id: uuid.UUID,
|
|
) -> dict[str, Any] | None:
|
|
"""Reference an existing DMS file without copying it.
|
|
|
|
Returns dict with file metadata or None if file not found.
|
|
"""
|
|
result = await db.execute(
|
|
select(DmsFile).where(
|
|
DmsFile.id == file_id,
|
|
DmsFile.tenant_id == tenant_id,
|
|
DmsFile.deleted_at.is_(None),
|
|
)
|
|
)
|
|
dms_file = result.scalar_one_or_none()
|
|
if dms_file is None:
|
|
return None
|
|
|
|
return {
|
|
"file_id": str(dms_file.id),
|
|
"file_name": dms_file.name,
|
|
"file_type": dms_file.mime_type,
|
|
"file_size": dms_file.size_bytes,
|
|
"file_source": "dms",
|
|
}
|
|
|
|
@staticmethod
|
|
async def get_file(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
file_id: uuid.UUID,
|
|
) -> DmsFile | None:
|
|
"""Get a DMS file by ID."""
|
|
result = await db.execute(
|
|
select(DmsFile).where(
|
|
DmsFile.id == file_id,
|
|
DmsFile.tenant_id == tenant_id,
|
|
DmsFile.deleted_at.is_(None),
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|