Finance-Accounts/ar-aging-app/backend/app/api/main.py

111 lines
3.6 KiB
Python

"""FastAPI application entrypoint."""
from __future__ import annotations
import asyncio
import logging
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from .. import APP_NAME, APP_VERSION
from ..config import CORS_ORIGINS, DATA_DIR
from ..db.database import ENGINE, init_db
from . import auth
from .routes import (
sessions, files, processing, results, settings as settings_routes, export, ar, control,
analytics, controls, payouts, accounts_summary, fx,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_app: FastAPI):
init_db()
# A restart mid-job leaves a closing stuck on "processing" forever — recover it.
from ..services.jobs import recover_stale_jobs
recover_stale_jobs()
# Daily retention sweep (exports only; uploads are the audit source and are kept).
from ..services.retention import retention_loop
sweeper = asyncio.create_task(retention_loop())
yield
sweeper.cancel()
app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan)
@app.middleware("http")
async def _request_log(request: Request, call_next):
"""One line per API request: method, path, status, duration."""
if not request.url.path.startswith("/api"):
return await call_next(request)
t0 = time.perf_counter()
response = await call_next(request)
ms = (time.perf_counter() - t0) * 1000
# /status is polled sub-second during processing; logging it would drown everything.
if not request.url.path.endswith("/status"):
logger.info("%s %s -> %d (%.0f ms)",
request.method, request.url.path, response.status_code, ms)
return response
# Registered BEFORE CORSMiddleware so CORS stays outermost (Starlette applies middleware
# in reverse registration order) and 401 responses still carry CORS headers.
app.middleware("http")(auth.auth_middleware)
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
def health() -> dict:
"""Liveness + readiness: DB reachable and the data dir writable — deep enough for a
load balancer / monitor probe, fast enough to hit every few seconds."""
checks = {"db": "ok", "data_dir": "ok"}
status = "ok"
try:
with ENGINE.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception as e: # noqa: BLE001
checks["db"] = f"error: {type(e).__name__}"
status = "degraded"
try:
probe = DATA_DIR / ".health-probe"
probe.write_text("ok")
probe.unlink()
except OSError as e:
checks["data_dir"] = f"error: {type(e).__name__}"
status = "degraded"
return {"status": status, "app": APP_NAME, "version": APP_VERSION, "checks": checks}
app.include_router(auth.router)
app.include_router(sessions.router)
app.include_router(files.router)
app.include_router(processing.router)
app.include_router(results.router)
app.include_router(settings_routes.router)
app.include_router(settings_routes.rules_router)
app.include_router(settings_routes.meta_router)
app.include_router(export.router)
app.include_router(ar.router)
app.include_router(control.router)
app.include_router(analytics.router)
app.include_router(controls.router)
app.include_router(payouts.router)
app.include_router(accounts_summary.router)
app.include_router(fx.router)