"""Invoice / proforma attachments — multipart upload, list, download, delete. Multi-file contract (P1-28..33 extended): a quote may hold several attachments per kind; every POST creates a new one. - POST /quotes/{quote_id}/invoice → 201, new attachment metadata - GET /quotes/{quote_id}/invoice → list of attachment metadata - GET /quotes/{quote_id}/invoice/{file_id} → file bytes - DELETE /quotes/{quote_id}/invoice/{file_id} → delete one attachment The same four endpoints exist under /proforma. Bytes live in MinIO (PROCUREMENT_MINIO_BUCKET); the `quote_attachments` table holds metadata. Both invoice and proforma uploads are allowed in every quote status (the user can attach the invoice from the edit page at any time). """ import uuid from fastapi import APIRouter, Depends, File, Response, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from app.db.engine import get_db from app.models.procurement import Quote from app.services import file_service from app.utils.errors import NotFoundError, ValidationError router = APIRouter(prefix="/quotes", tags=["files"]) ALLOWED_MIME_TYPES = { "image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", } ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"} # حداکثر حجم مجاز هر فایل — ۵ مگابایت MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote: quote = await db.get(Quote, quote_id) if quote is None: raise NotFoundError("پیشنهاد (استعلام) یافت نشد") return quote def _validate_upload(file: UploadFile) -> str: mime = (file.content_type or "").lower() if mime not in ALLOWED_MIME_TYPES: raise ValidationError("فرمت فایل مجاز نیست — فقط تصویر یا PDF", field="file") name = (file.filename or "").lower() if name and not any(name.endswith(ext) for ext in ALLOWED_EXTENSIONS): raise ValidationError("پسوند فایل مجاز نیست", field="file") return mime async def _attachment_or_404( db: AsyncSession, quote_id: uuid.UUID, attachment_id: uuid.UUID, kind: str ) -> file_service.QuoteAttachment: """Load an attachment and ensure it belongs to the given quote/kind.""" attachment = await file_service.get(db, attachment_id) if attachment.quote_id != quote_id or attachment.kind != kind: raise NotFoundError("فایل پیوست یافت نشد") return attachment # --------------------------------------------------------------------------- # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # --------------------------------------------------------------------------- @router.post("/{quote_id}/invoice", status_code=201) async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) mime = _validate_upload(file) content = await file.read() if len(content) > MAX_FILE_SIZE_BYTES: raise ValidationError("حجم فایل از حد مجاز (۵ مگابایت) بیشتر است", field="file") stored = await file_service.create(db, quote_id, "invoice", file.filename or "invoice", content, mime) return {"data": stored.to_dict()} @router.get("/{quote_id}/invoice") async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) attachments = await file_service.list_for(db, quote_id, "invoice") return {"data": [a.to_dict() for a in attachments]} @router.get("/{quote_id}/invoice/{attachment_id}") async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice") content = await file_service.read_bytes(attachment) return Response( content=content, media_type=attachment.mime_type, headers={ "Content-Disposition": f'attachment; filename="{attachment.file_name}"', "X-File-Id": str(attachment.id), }, ) @router.delete("/{quote_id}/invoice/{attachment_id}") async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice") await file_service.remove(db, attachment) return {"data": {"deleted": True}} # --------------------------------------------------------------------------- # Proforma — no status restriction # --------------------------------------------------------------------------- @router.post("/{quote_id}/proforma", status_code=201) async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) mime = _validate_upload(file) content = await file.read() if len(content) > MAX_FILE_SIZE_BYTES: raise ValidationError("حجم فایل از حد مجاز (۵ مگابایت) بیشتر است", field="file") stored = await file_service.create(db, quote_id, "proforma", file.filename or "proforma", content, mime) return {"data": stored.to_dict()} @router.get("/{quote_id}/proforma") async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) attachments = await file_service.list_for(db, quote_id, "proforma") return {"data": [a.to_dict() for a in attachments]} @router.get("/{quote_id}/proforma/{attachment_id}") async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma") content = await file_service.read_bytes(attachment) return Response( content=content, media_type=attachment.mime_type, headers={ "Content-Disposition": f'attachment; filename="{attachment.file_name}"', "X-File-Id": str(attachment.id), }, ) @router.delete("/{quote_id}/proforma/{attachment_id}") async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma") await file_service.remove(db, attachment) return {"data": {"deleted": True}}