- db-backup sidecar: pg_dump -Fc of procurement+casdoor every BACKUP_INTERVAL_SECONDS into BACKUP_DIR, retention via BACKUP_KEEP_DAYS - audit_log table (migration.sql + runtime ensure_audit_table) with model + transactional helper (utils/audit.record) — wired into user create/role-change and inventory transaction creation - get_actor: quote_events.actor now stores the VERIFIED user's display name instead of the raw bearer token
286 lines
11 KiB
Python
286 lines
11 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 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_dashboard_tables,
|
|
ensure_material_topic_column,
|
|
ensure_quote_projects,
|
|
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
|
|
|
|
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_audit_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)
|
|
|
|
# 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
|
|
|
|
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"}
|
|
|
|
|
|
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},
|
|
)
|