- 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
206 lines
8.0 KiB
Python
206 lines
8.0 KiB
Python
"""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 Depends, 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.auth import get_current_user
|
|
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")
|
|
|
|
|
|
def _verify_production_settings() -> None:
|
|
"""Fail-fast guard: refuse to boot in prod with dev defaults.
|
|
|
|
These exact misconfigurations are what previously shipped (wildcard CORS
|
|
with credentials, default MinIO/Postgres/Casdoor credentials, and the
|
|
always-on X-User-Role identity fallback). Ops fix: set the real values
|
|
in the server-side .env — see docs/SECRET-MANAGEMENT.md.
|
|
"""
|
|
problems: list[str] = []
|
|
if not settings.cors_origin_list or "*" in settings.cors_origin_list:
|
|
problems.append("PROCUREMENT_CORS_ORIGINS must list exact origins (no '*')")
|
|
if settings.MINIO_ACCESS_KEY == "minioadmin" or settings.MINIO_SECRET_KEY == "minioadmin":
|
|
problems.append("MinIO credentials are still the default minioadmin")
|
|
if not settings.CASDOOR_CLIENT_SECRET:
|
|
problems.append("PROCUREMENT_CASDOOR_CLIENT_SECRET is not set")
|
|
if not settings.CASDOOR_ADMIN_PASSWORD:
|
|
problems.append("PROCUREMENT_CASDOOR_ADMIN_PASSWORD is not set")
|
|
if settings.DEV_LOGIN_ENABLED:
|
|
problems.append("PROCUREMENT_DEV_LOGIN_ENABLED must be false in production")
|
|
if problems:
|
|
raise RuntimeError(
|
|
"Refusing to start in production — fix the environment:\n - " + "\n - ".join(problems)
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI):
|
|
if settings.is_prod:
|
|
_verify_production_settings()
|
|
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_origin_list,
|
|
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):
|
|
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
# JWT auth is required on EVERY router; the auth router stays public
|
|
# (POST /auth/login issues the tokens) and /health lives below, outside the
|
|
# /api/v1 prefix. Routers with their own finer-grained scopes (inventory,
|
|
# pm, users) keep them — the global dependency guarantees nothing is open
|
|
# by omission.
|
|
from app.routers.auth import router as _auth_router # noqa: E402
|
|
|
|
for router in ROUTERS:
|
|
if router is _auth_router:
|
|
app.include_router(router, prefix=API_V1_PREFIX)
|
|
else:
|
|
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
|
|
|
|
|
|
@app.get("/health", tags=["system"])
|
|
async def health():
|
|
return {"status": "ok", "service": "procurement-api"}
|