Files
ERP/backend/app/routers/files.py
T
MMD f7b1f7f751 feat(security): JWT required on every API route, gated dev fallback, role checks
- main.py: global get_current_user dependency on all routers except
  /auth/login and /health; prod fail-fast startup guard rejects wildcard
  CORS, default credentials, DEV_LOGIN_ENABLED
- inventory/pm: X-User-Role header identity fallback now only active when
  PROCUREMENT_DEV_LOGIN_ENABLED=true (was unconditional in production)
- item_decisions writes + attachment deletes: require_role(
  procurement_manager, admin) — require_role was defined but never used
- config.py: ENVIRONMENT flag, comma-separated CORS_ORIGINS, no default
  Casdoor client secret/admin password, DEV_LOGIN_ENABLED=false
- frontend: authenticated file downloads (Bearer + blob) — <a href> links
  cannot send the token; FileList/QuoteDetail/AcceptModal/EditModal/
  PurchaseHistory converted; DEV_LOGIN_ENABLED build arg defaults false
- smoke tests authenticate via POST /auth/login (SMOKE_USER/SMOKE_PASSWORD)
- traefik: security headers, API rate limit, 12MB body cap (prod overlay)
- nginx client_max_body_size aligned to 12MB
2026-09-18 22:02:09 +03:30

179 lines
8.0 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.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}}