feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""FastAPI application assembly.
|
||||
|
||||
- Routes: all routers under /api/v1
|
||||
- Errors: contract envelope {"error": {"code", "message", "field"}}
|
||||
- Lifespan: ensure units-dashboard tables (IF NOT EXISTS) + background
|
||||
auto-expiry scheduler for offered quotes past their expiry_date.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.config import settings
|
||||
from app.db.engine import AsyncSessionLocal, engine
|
||||
from app.db.init_db import (
|
||||
ensure_attachment_table,
|
||||
ensure_dashboard_tables,
|
||||
ensure_material_topic_column,
|
||||
ensure_quote_projects,
|
||||
ensure_v26,
|
||||
)
|
||||
from app.routers import ROUTERS
|
||||
from app.services.quote_service import auto_expire_quotes
|
||||
from app.utils.errors import AppError
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
|
||||
logger = logging.getLogger("procurement")
|
||||
|
||||
API_V1_PREFIX = "/api/v1"
|
||||
|
||||
|
||||
async def _auto_expire_loop() -> None:
|
||||
"""Background job: expire quotes with expiry_date < today AND status='offered'."""
|
||||
while True:
|
||||
await asyncio.sleep(settings.AUTO_EXPIRE_INTERVAL_SECONDS)
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await auto_expire_quotes(session)
|
||||
except Exception:
|
||||
logger.exception("Auto-expiry pass failed")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
await ensure_dashboard_tables(engine)
|
||||
await ensure_attachment_table(engine)
|
||||
await ensure_material_topic_column(engine)
|
||||
await ensure_quote_projects(engine)
|
||||
await ensure_v26(engine)
|
||||
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Procurement API",
|
||||
version="2.4.0",
|
||||
description="Backend for the procurement system — schema v2.4 (expert-opinion contracts P0/P1/P2)",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception handlers — contract error envelope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(_request: Request, exc: AppError):
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.body)
|
||||
|
||||
|
||||
# ترجمهی پیامهای اعتبارسنجی pydantic به فارسی — الگوهای رایج
|
||||
_VALIDATION_FA: list[tuple[str, str]] = [
|
||||
("field required", "فیلد الزامی خالی است"),
|
||||
("input should be a valid uuid", "شناسهی ارسالشده معتبر نیست"),
|
||||
("input should be a valid datetime", "تاریخ و ساعت واردشده معتبر نیست"),
|
||||
("input should be a valid date", "تاریخ واردشده معتبر نیست"),
|
||||
("input should be a valid integer", "عدد صحیح وارد کنید"),
|
||||
("input should be a valid number", "عدد معتبر وارد کنید"),
|
||||
("input should be a valid boolean", "مقدار درست/نادرست (بولی) وارد کنید"),
|
||||
("input should be greater than 0", "مقدار باید بزرگتر از صفر باشد"),
|
||||
("input should be greater than or equal to", "مقدار از حداقل مجاز کوچکتر است"),
|
||||
("input should be less than or equal to", "مقدار از حداکثر مجاز بزرگتر است"),
|
||||
("string should have at least 1 character", "متن نباید خالی باشد"),
|
||||
("string too short", "مقدار خیلی کوتاه است"),
|
||||
("string too long", "مقدار خیلی طولانی است"),
|
||||
("value error", "مقدار واردشده نامعتبر است"),
|
||||
]
|
||||
|
||||
|
||||
def _fa_validation_msg(msg: str) -> str:
|
||||
lower = msg.lower()
|
||||
for en, fa in _VALIDATION_FA:
|
||||
if en in lower:
|
||||
return fa
|
||||
return msg
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
||||
first = exc.errors()[0]
|
||||
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
||||
field = ".".join(loc) if loc else None
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": _fa_validation_msg(first.get("msg", "داده نامعتبر است")),
|
||||
"field": field,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(IntegrityError)
|
||||
async def integrity_error_handler(_request: Request, exc: IntegrityError):
|
||||
# Services pre-check FKs and duplicates, so an IntegrityError here almost
|
||||
# always means a unique-constraint violation → 409 CONFLICT.
|
||||
logger.warning("IntegrityError: %s", exc.orig)
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={"error": {"code": "CONFLICT", "message": "تعارض داده — رکورد تکراری یا ارجاع نامعتبر"}},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_error_handler(_request: Request, exc: Exception):
|
||||
logger.exception("Unhandled error: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": {"code": "INTERNAL_ERROR", "message": "خطای داخلی سرور"}},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
for router in ROUTERS:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
|
||||
|
||||
@app.get("/health", tags=["system"])
|
||||
async def health():
|
||||
return {"status": "ok", "service": "procurement-api"}
|
||||
Reference in New Issue
Block a user