77 lines
2.5 KiB
Python
77 lines
2.5 KiB
Python
"""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)
|