The pm branch's model expects these but nothing created them — login 500s with UndefinedColumnError users.username on any volume that predates the RBAC work, including fresh ones (migration.sql never had the column either). The pm author evidently altered his local DB by hand. ensure_rbac_schema() now creates all of it idempotently at startup, alongside the audit/bot_drafts/project_transfers ensures.
310 lines
12 KiB
Python
310 lines
12 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
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
|
|
import httpx
|
|
from fastapi import Depends, FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from prometheus_fastapi_instrumentator import Instrumentator
|
|
from slowapi import _rate_limit_exceeded_handler
|
|
from slowapi.errors import RateLimitExceeded
|
|
from sqlalchemy import text as sql_text
|
|
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_audit_table,
|
|
ensure_bot_drafts_table,
|
|
ensure_drop_category_tables,
|
|
ensure_dashboard_tables,
|
|
ensure_material_topic_column,
|
|
ensure_quote_projects,
|
|
ensure_rbac_schema,
|
|
ensure_project_transfers_table,
|
|
ensure_v26,
|
|
)
|
|
from app.observability import RequestContextMiddleware, setup_logging
|
|
from app.routers import ROUTERS
|
|
from app.services.quote_service import auto_expire_quotes
|
|
from app.utils.errors import AppError
|
|
from app.utils.ratelimit import limiter
|
|
|
|
setup_logging(settings.LOG_FORMAT)
|
|
logger = logging.getLogger("procurement")
|
|
|
|
if settings.SENTRY_DSN:
|
|
# GlitchTip/Sentry exception capture — after setup_logging so the SDK's
|
|
# own logs go through our formatter too. Disabled entirely without a DSN.
|
|
import sentry_sdk
|
|
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
|
|
|
sentry_sdk.init(
|
|
dsn=settings.SENTRY_DSN,
|
|
environment=settings.ENVIRONMENT,
|
|
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
|
|
integrations=[FastApiIntegration()],
|
|
send_default_pii=False,
|
|
)
|
|
logger.info("Sentry/GlitchTip exception capture enabled")
|
|
|
|
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)
|
|
await ensure_rbac_schema(engine)
|
|
await ensure_audit_table(engine)
|
|
await ensure_drop_category_tables(engine)
|
|
await ensure_bot_drafts_table(engine)
|
|
await ensure_project_transfers_table(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=["*"],
|
|
)
|
|
app.add_middleware(RequestContextMiddleware)
|
|
|
|
# Rate limiting — login brute-force brake (edge rate limit lives in Traefik).
|
|
app.state.limiter = limiter
|
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
|
|
# Prometheus /metrics (requests, latency, errors) — mounted at app level,
|
|
# outside the authenticated /api/v1 prefix; scrape target for the
|
|
# monitoring stack.
|
|
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
# The files router enforces its own access check per route: a browser cannot
|
|
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
|
|
# routes accept a short-lived signed ?t= token as well as a Bearer session.
|
|
from app.routers.files import router as _files_router # noqa: E402
|
|
from app.routers.drafts import router as _drafts_router # noqa: E402
|
|
|
|
for router in ROUTERS:
|
|
if router is _auth_router:
|
|
app.include_router(router, prefix=API_V1_PREFIX)
|
|
elif router is _files_router:
|
|
app.include_router(router, prefix=API_V1_PREFIX)
|
|
elif router is _drafts_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"}
|
|
|
|
|
|
async def _check_db() -> bool:
|
|
try:
|
|
async with AsyncSessionLocal() as session:
|
|
await session.execute(sql_text("SELECT 1"))
|
|
return True
|
|
except Exception:
|
|
logger.exception("readiness: database check failed")
|
|
return False
|
|
|
|
|
|
async def _check_minio() -> bool:
|
|
try:
|
|
from app.services.storage import get_client
|
|
|
|
get_client().bucket_exists(settings.MINIO_BUCKET)
|
|
return True
|
|
except Exception:
|
|
logger.exception("readiness: MinIO check failed")
|
|
return False
|
|
|
|
|
|
async def _check_casdoor() -> bool:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
resp = await client.get(
|
|
settings.CASDOOR_JWKS_URL or f"{settings.CASDOOR_ENDPOINT}/.well-known/jwks"
|
|
)
|
|
return resp.status_code == 200
|
|
except Exception:
|
|
logger.exception("readiness: Casdoor JWKS check failed")
|
|
return False
|
|
|
|
|
|
@app.get("/health/ready", tags=["system"])
|
|
async def health_ready():
|
|
"""Readiness: verifies the dependencies the API actually needs.
|
|
|
|
The deploy webhook polls this after a rebuild, so a deploy that breaks
|
|
the DB/MinIO/Casdoor connectivity is visible immediately.
|
|
"""
|
|
checks = {
|
|
"database": await _check_db(),
|
|
"minio": await _check_minio(),
|
|
"casdoor": await _check_casdoor(),
|
|
}
|
|
ok = all(checks.values())
|
|
return JSONResponse(
|
|
status_code=200 if ok else 503,
|
|
content={"status": "ok" if ok else "degraded", "service": "procurement-api", "checks": checks},
|
|
)
|