"""Invoice / proforma attachments — metadata in `quote_attachments`, bytes in MinIO. Multi-file contract: a quote may hold any number of attachments per kind (invoice / proforma). Every upload creates a new DB row and a new object (object key `{quote_id}/{kind}/{attachment_id}` inside the MinIO bucket). """ import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.files import QuoteAttachment from app.services import storage from app.utils.errors import NotFoundError def _object_key(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID) -> str: return f"{quote_id}/{kind}/{attachment_id}" async def create( db: AsyncSession, quote_id: uuid.UUID, kind: str, file_name: str, content: bytes, mime_type: str, ) -> QuoteAttachment: """Upload the bytes to MinIO, then persist the metadata row.""" attachment_id = uuid.uuid4() object_key = _object_key(quote_id, kind, attachment_id) await storage.put_object(object_key, content, mime_type) attachment = QuoteAttachment( id=attachment_id, quote_id=quote_id, kind=kind, file_name=file_name, mime_type=mime_type, size_bytes=len(content), object_key=object_key, ) db.add(attachment) try: await db.flush() except Exception: # Metadata insert failed — best-effort cleanup of the orphan object. await storage.remove_object(object_key) raise return attachment async def list_for(db: AsyncSession, quote_id: uuid.UUID, kind: str) -> list[QuoteAttachment]: result = await db.execute( select(QuoteAttachment) .where(QuoteAttachment.quote_id == quote_id, QuoteAttachment.kind == kind) .order_by(QuoteAttachment.uploaded_at) ) return list(result.scalars().all()) async def get(db: AsyncSession, attachment_id: uuid.UUID) -> QuoteAttachment: attachment = await db.get(QuoteAttachment, attachment_id) if attachment is None: raise NotFoundError("فایل پیوست یافت نشد") return attachment async def read_bytes(attachment: QuoteAttachment) -> bytes: """Stream the object bytes from MinIO.""" return await storage.get_object_bytes(attachment.object_key) async def remove(db: AsyncSession, attachment: QuoteAttachment) -> None: """Delete the object from MinIO (best-effort) and the metadata row.""" await storage.remove_object(attachment.object_key) await db.delete(attachment)