188 lines
5.7 KiB
Python
188 lines
5.7 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
|
||
|
|
|
||
|
|
from fastapi import UploadFile
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.plugins.builtins.dms.models import File as DmsFile, 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)
|
||
|
|
with open(storage_path, "wb") as f:
|
||
|
|
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()
|