"""Attachment service — upload, list, download, delete with file storage.""" from __future__ import annotations import os import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.models.attachment import Attachment # Storage path for uploaded files STORAGE_PATH = os.environ.get("STORAGE_PATH", "/data/uploads") def _attachment_to_dict(a: Attachment) -> dict[str, Any]: """Serialize an Attachment ORM object to dict.""" return { "id": str(a.id), "entity_type": a.entity_type, "entity_id": str(a.entity_id), "filename": a.filename, "file_path": a.file_path, "mime_type": a.mime_type, "file_size": a.file_size, "uploaded_by": str(a.uploaded_by) if a.uploaded_by else None, "created_at": a.created_at.isoformat() if a.created_at else None, "updated_at": a.updated_at.isoformat() if a.updated_at else None, } def _generate_unique_filename(original_filename: str) -> str: """Generate a unique filename using UUID + original extension.""" ext = os.path.splitext(original_filename)[1] return f"{uuid.uuid4().hex}{ext}" async def save_attachment( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, filename: str, file_content: bytes, mime_type: str, ) -> dict[str, Any]: """Save a file to storage and create an Attachment record.""" # Ensure storage directory exists entity_dir = os.path.join(STORAGE_PATH, entity_type, str(entity_id)) os.makedirs(entity_dir, exist_ok=True) # Generate unique filename unique_filename = _generate_unique_filename(filename) file_path = os.path.join(entity_dir, unique_filename) # Write file to disk with open(file_path, "wb") as f: f.write(file_content) file_size = len(file_content) # Create DB record attachment = Attachment( tenant_id=tenant_id, entity_type=entity_type, entity_id=entity_id, filename=filename, file_path=file_path, mime_type=mime_type, file_size=file_size, uploaded_by=user_id, ) db.add(attachment) await db.flush() await db.refresh(attachment) await log_audit( db, tenant_id, user_id, "upload", "attachment", attachment.id, changes={"filename": filename, "entity_type": entity_type, "entity_id": str(entity_id)}, ) return _attachment_to_dict(attachment) async def list_attachments( db: AsyncSession, tenant_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, ) -> dict[str, Any]: """List attachments for a specific entity.""" q = select(Attachment).where( Attachment.tenant_id == tenant_id, Attachment.entity_type == entity_type, Attachment.entity_id == entity_id, Attachment.deleted_at.is_(None), ).order_by(Attachment.created_at.desc()) result = await db.execute(q) attachments = result.scalars().all() return { "items": [_attachment_to_dict(a) for a in attachments], "total": len(attachments), } async def get_attachment( db: AsyncSession, tenant_id: uuid.UUID, attachment_id: uuid.UUID, ) -> dict[str, Any] | None: """Get a single attachment by ID.""" q = select(Attachment).where( Attachment.id == attachment_id, Attachment.tenant_id == tenant_id, Attachment.deleted_at.is_(None), ) result = await db.execute(q) attachment = result.scalar_one_or_none() if attachment is None: return None return _attachment_to_dict(attachment) async def delete_attachment( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, attachment_id: uuid.UUID, ) -> bool: """Soft-delete an attachment (keeps file on disk for audit trail).""" q = select(Attachment).where( Attachment.id == attachment_id, Attachment.tenant_id == tenant_id, Attachment.deleted_at.is_(None), ) result = await db.execute(q) attachment = result.scalar_one_or_none() if attachment is None: return False attachment.deleted_at = datetime.now(UTC) await db.flush() await log_audit( db, tenant_id, user_id, "delete", "attachment", attachment_id, changes={"filename": attachment.filename}, ) return True