"""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.core.storage import get_storage_backend from app.models.attachment import Attachment from app.core.visibility import apply_visibility_filter, check_single_entity_access 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.""" # Generate unique filename and relative storage path unique_filename = _generate_unique_filename(filename) file_path = f"{entity_type}/{entity_id}/{unique_filename}" # Save file via storage backend storage = get_storage_backend() await storage.save(file_path, 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, owner_id=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, user_id: uuid.UUID | None = None, is_system_admin: bool = False, ) -> 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()) if user_id and not is_system_admin: q = await apply_visibility_filter( db, q, "attachment", Attachment, user_id, tenant_id, is_system_admin ) 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, user_id: uuid.UUID | None = None, is_system_admin: bool = False, ) -> 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 if user_id and not is_system_admin: has_access = await check_single_entity_access( db, "attachment", attachment.id, user_id, tenant_id, "read", is_system_admin ) if not has_access: raise PermissionError("No access") return _attachment_to_dict(attachment) async def delete_attachment( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, attachment_id: uuid.UUID, is_system_admin: bool = False, ) -> 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 if not is_system_admin: has_access = await check_single_entity_access( db, "attachment", attachment.id, user_id, tenant_id, "admin", is_system_admin ) if not has_access: raise PermissionError("No access") 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