Their three commits (730d0bd,5a9b543,387edfb) reached production by push and auto-deployed.387edfbexpects a users.username column that the live database does not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes never applied either (projects still has only id, name, description, start_date, pm_user_id, created_at), so nothing depends on the reverted code. This commit sets the tree back to exactlya02b3ea— verified with git diff against it — while keeping history linear: the work itself is preserved on 'pm' at387edfb.
260 lines
11 KiB
Python
260 lines
11 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, Request, Response, UploadFile
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import UserContext, get_current_user, get_optional_user, 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, UnauthorizedError, ValidationError
|
|
from app.utils.preview_token import make_preview_token, verify_preview_token
|
|
|
|
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, inline: bool = False) -> 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.
|
|
|
|
``inline=True`` makes the browser render the file (PDF in a viewer,
|
|
image in the tab) instead of downloading it — required by the preview
|
|
modal, whose <iframe> would otherwise trigger a download.
|
|
"""
|
|
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 name that keeps the extension
|
|
fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
|
|
"preview" if inline else "attachment"
|
|
)
|
|
kind = "inline" if inline else "attachment"
|
|
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
|
|
|
|
|
|
async def _require_file_access(
|
|
request: Request,
|
|
token: str | None,
|
|
quote_id: uuid.UUID,
|
|
kind: str,
|
|
attachment_id: uuid.UUID,
|
|
db: AsyncSession,
|
|
) -> None:
|
|
"""Authorise a file read by session header OR a signed ``?t=`` token.
|
|
|
|
These two GET routes sit outside the global Bearer dependency because a
|
|
browser cannot attach an Authorization header to <iframe>/<img> loads.
|
|
A caller is accepted when it presents either a valid Casdoor session
|
|
(resolved by get_optional_user) or a signed token scoped to exactly
|
|
this file, which expires within minutes.
|
|
"""
|
|
if request is not None and request.headers.get("authorization"):
|
|
user = await get_optional_user(request, db)
|
|
if user is not None and user.role is not None:
|
|
return
|
|
if token and verify_preview_token(token, quote_id, kind, attachment_id):
|
|
return
|
|
raise UnauthorizedError("دسترسی به این فایل مجاز نیست — ابتدا وارد شوید")
|
|
|
|
|
|
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, dependencies=[Depends(get_current_user)])
|
|
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", dependencies=[Depends(get_current_user)])
|
|
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]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preview URL — a real, short-lived URL the browser can load in an
|
|
# <iframe>/<img> (which cannot send an Authorization header). Using a real
|
|
# URL instead of a blob: avoids download managers that hijack blob URLs and
|
|
# leave 0-byte files behind.
|
|
# ---------------------------------------------------------------------------
|
|
@router.get("/{quote_id}/preview-url/{kind}/{attachment_id}")
|
|
async def preview_url(
|
|
quote_id: uuid.UUID,
|
|
kind: str,
|
|
attachment_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
_ctx: UserContext = Depends(get_current_user),
|
|
):
|
|
if kind not in ("invoice", "proforma"):
|
|
raise ValidationError("نوع پیوست نامعتبر است", field="kind")
|
|
await _quote_or_404(db, quote_id)
|
|
await _attachment_or_404(db, quote_id, attachment_id, kind)
|
|
token = make_preview_token(quote_id, kind, attachment_id)
|
|
# مسیر نسبی برگردانده میشود؛ فرانت با BASE_URL خودش کاملش میکند
|
|
return {
|
|
"data": {
|
|
"url": f"/quotes/{quote_id}/{kind}/{attachment_id}?disposition=inline&t={token}",
|
|
"token": token,
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/{quote_id}/invoice/{attachment_id}")
|
|
async def download_invoice(
|
|
quote_id: uuid.UUID,
|
|
attachment_id: uuid.UUID,
|
|
disposition: str = "attachment",
|
|
t: str | None = None,
|
|
request: Request = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await _quote_or_404(db, quote_id)
|
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
|
await _require_file_access(request, t, quote_id, "invoice", attachment_id, db)
|
|
content = await file_service.read_bytes(attachment)
|
|
return Response(
|
|
content=content,
|
|
media_type=attachment.mime_type,
|
|
headers={
|
|
"Content-Disposition": _content_disposition(
|
|
attachment.file_name, inline=disposition == "inline"
|
|
),
|
|
"X-File-Id": str(attachment.id),
|
|
},
|
|
)
|
|
|
|
|
|
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
|
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, dependencies=[Depends(get_current_user)])
|
|
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", dependencies=[Depends(get_current_user)])
|
|
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,
|
|
disposition: str = "attachment",
|
|
t: str | None = None,
|
|
request: Request = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await _quote_or_404(db, quote_id)
|
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
|
await _require_file_access(request, t, quote_id, "proforma", attachment_id, db)
|
|
content = await file_service.read_bytes(attachment)
|
|
return Response(
|
|
content=content,
|
|
media_type=attachment.mime_type,
|
|
headers={
|
|
"Content-Disposition": _content_disposition(
|
|
attachment.file_name, inline=disposition == "inline"
|
|
),
|
|
"X-File-Id": str(attachment.id),
|
|
},
|
|
)
|
|
|
|
|
|
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
|
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}}
|