diff --git a/app/services/attachment_service.py b/app/services/attachment_service.py new file mode 100644 index 0000000..c57e504 --- /dev/null +++ b/app/services/attachment_service.py @@ -0,0 +1,150 @@ +"""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