Files
leocrm/app/plugins/builtins/kommunikation/dms_bridge.py
T

193 lines
5.8 KiB
Python
Raw Normal View History

"""DMS Bridge — integrates with the DMS plugin for file storage."""
from __future__ import annotations
import logging
import os
import uuid
from typing import Any
2026-07-25 09:19:32 +02:00
import aiofiles
from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
2026-07-25 21:03:46 +02:00
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)
2026-07-25 09:19:32 +02:00
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()