Files
ERP/backend/app/routers/files.py
T
whqreza 3f4bbee6de fix(files): downloads with Persian filenames no longer 500
Both download endpoints (invoice/proforma) put the stored file_name raw into the Content-Disposition header. HTTP headers are latin-1, so any non-ASCII name (i.e. every Persian filename) raised UnicodeEncodeError before the body was sent -> HTTP 500. Uploads were unaffected.

Content-Disposition is now built by a helper following RFC 6266 / RFC 5987: the UTF-8 name is percent-encoded in filename*=UTF-8''..., with a header-safe ASCII fallback in filename= (quotes and control characters stripped; a name that folds away entirely becomes attachment<ext>).

Verified locally after reproducing the exact production error (same latin-1 positions 22-27): Persian proforma + invoice downloads 500 -> 200 with filename*= carrying the full Persian name and bytes sha256-matching the MinIO object; ASCII filename regression 200; helper edge cases (CRLF/quote injection stripped, empty name -> attachment, pure-Persian png -> attachment.png); no UnicodeEncodeError in the api logs after the fix.
2026-09-15 17:38:47 +03:30

174 lines
7.6 KiB
Python

"""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.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
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}")
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}")
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}}