feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""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}}
|
||||
Reference in New Issue
Block a user