"""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 os import uuid from urllib.parse import quote from fastapi import APIRouter, Depends, File, Response, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from app.auth import require_role 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"]) # حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود # برای هر کاربر واردشده آزاد است. _attachment_deleter = require_role("procurement_manager", "admin") 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 def _content_disposition(file_name: str) -> str: """Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames. HTTP headers are encoded as latin-1, so a Persian name raises UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback in ``filename`` for old clients. """ raw = file_name.encode("ascii", "ignore").decode("ascii") # keep the fallback header-safe: no quotes, no control characters fallback = "".join(ch for ch in raw if ch >= " " and ch != '"').strip() ext = os.path.splitext(file_name)[1] stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback if not stem or not any(ch.isalnum() for ch in stem): # nothing meaningful survived ASCII-folding (e.g. a pure-Persian # name): use a generic attachment name that keeps the extension fallback = f"attachment{ext}" if ext else "attachment" return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}" 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": _content_disposition(attachment.file_name), "X-File-Id": str(attachment.id), }, ) @router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter)]) 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": _content_disposition(attachment.file_name), "X-File-Id": str(attachment.id), }, ) @router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter)]) 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}}