import logging from contextlib import asynccontextmanager from pathlib import Path import structlog from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from src.config import settings STATIC_DIR = Path("src/static") def _ensure_static_dir() -> None: """Create src/static with a stub index.html if the frontend hasn't been built yet.""" STATIC_DIR.mkdir(parents=True, exist_ok=True) idx = STATIC_DIR / "index.html" if not idx.exists(): idx.write_text( "

Frontend not built. Run: cd web && npm run build

" ) from src.middleware.logging import LoggingMiddleware from src.routers import admin, auth, dashboard, events, ingest, keys, projects from src.routers import calendar, tasks, manual_entries, budgets, tags, devops, exports, reports, omg from src.services.scheduler import scheduler, setup_scheduler BASE = settings.BASE_PATH def _configure_logging() -> None: shared_processors = [ structlog.contextvars.merge_contextvars, structlog.stdlib.add_log_level, structlog.processors.TimeStamper(fmt="iso"), ] if settings.LOG_FORMAT == "json": renderer = structlog.processors.JSONRenderer() else: renderer = structlog.dev.ConsoleRenderer(colors=True) structlog.configure( processors=shared_processors + [renderer], wrapper_class=structlog.make_filtering_bound_logger(logging.INFO), context_class=dict, logger_factory=structlog.PrintLoggerFactory(), cache_logger_on_first_use=True, ) @asynccontextmanager async def lifespan(app: FastAPI): _configure_logging() _ensure_static_dir() setup_scheduler() scheduler.start() yield scheduler.shutdown() app = FastAPI( title=settings.APP_TITLE, docs_url=f"{BASE}/docs" if settings.DEBUG else None, redoc_url=None, root_path=BASE, lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.add_middleware(LoggingMiddleware) for router in [ auth.router, keys.router, admin.router, ingest.router, dashboard.router, events.router, projects.router, calendar.router, tasks.router, manual_entries.router, budgets.router, tags.router, devops.router, exports.router, reports.router, omg.router, ]: app.include_router(router) @app.get("/healthz", include_in_schema=False) async def health(): return {"status": "ok"} app.mount("/static", StaticFiles(directory="src/static"), name="static") _NO_CACHE = {"Cache-Control": "no-cache, no-store, must-revalidate"} @app.get("/", include_in_schema=False) @app.get("", include_in_schema=False) async def spa_root(): return FileResponse("src/static/index.html", headers=_NO_CACHE) @app.get("/{path:path}", include_in_schema=False) async def spa_fallback(path: str, request: Request): if path.startswith("api/") or path.startswith("static/"): from fastapi import HTTPException raise HTTPException(status_code=404) return FileResponse("src/static/index.html", headers=_NO_CACHE)