"""FastAPI application factory for SneakySwole. Creates and configures the FastAPI app with routes, templates, static files, and structured logging. """ from pathlib import Path import structlog from alembic import command as alembic_command from alembic.config import Config as AlembicConfig from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlmodel import Session from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.requests import Request as StarletteRequest from starlette.responses import Response from fastapi.responses import RedirectResponse from app.models.user import User from app.config import get_settings from app.database import get_engine, get_db_session from app.logging_config import setup_logging from app.routes.exercises import router as exercises_router from app.routes.history import router as history_router from app.routes.health import router as health_router from app.routes.pages import router as pages_router from app.routes.profiles import router as profiles_router from app.routes.logging import router as logging_router from app.routes.workouts import router as workouts_router from app.routes.dashboard import router as dashboard_router from app.services.seed_service import SeedService from app.services.user_service import UserService from app.utils.auth import NoProfileSelectedError logger = structlog.get_logger(__name__) # Template and static file directories _BASE_DIR = Path(__file__).resolve().parent TEMPLATES_DIR = _BASE_DIR / "templates" STATIC_DIR = _BASE_DIR / "static" class NavContextMiddleware(BaseHTTPMiddleware): """Injects profiles and active_profile into request.state for templates.""" async def dispatch(self, request: StarletteRequest, call_next: RequestResponseEndpoint) -> Response: request.state.profiles = [] request.state.active_profile = None if hasattr(request.app.state, "engine"): try: with Session(request.app.state.engine) as session: user_service = UserService(session) request.state.profiles = user_service.list_users() profile_id = request.cookies.get("active_profile_id") if profile_id and profile_id.isdigit(): request.state.active_profile = user_service.get_user_by_id(int(profile_id)) except Exception: pass return await call_next(request) def _run_migrations(database_url: str) -> None: """Run Alembic migrations to bring the DB schema up to date. On a fresh DB this creates all tables via the initial migration. On an existing DB created by create_all (no alembic_version table), stamps the last known pre-migration revision, then upgrades. """ from sqlalchemy import create_engine as sa_create_engine, inspect project_root = Path(__file__).resolve().parent.parent alembic_cfg = AlembicConfig(str(project_root / "alembic.ini")) alembic_cfg.set_main_option("script_location", str(project_root / "alembic")) alembic_cfg.set_main_option("sqlalchemy.url", database_url) engine = sa_create_engine(database_url) inspector = inspect(engine) existing_tables = inspector.get_table_names() if existing_tables and "alembic_version" not in existing_tables: # DB was created by create_all — stamp it at the last revision # that matches the current schema so migrations run from there. # Check which schema state we're in by looking at columns. columns = {c["name"] for c in inspector.get_columns("user_exercise_programs")} if "wk1_reps" in columns: # Old schema: stamp at remove-auth migration (before rep ladder) alembic_command.stamp(alembic_cfg, "a1b2c3d4e5f6") logger.info("alembic_stamped", revision="a1b2c3d4e5f6", reason="legacy_db_old_schema") elif "starting_weight" in columns: # New schema already: stamp at head alembic_command.stamp(alembic_cfg, "head") logger.info("alembic_stamped", revision="head", reason="legacy_db_new_schema") else: # Unknown state — stamp at initial and let migrations sort it out alembic_command.stamp(alembic_cfg, "1855836abf6c") logger.info("alembic_stamped", revision="1855836abf6c", reason="legacy_db_unknown") elif not existing_tables: # Fresh DB — create alembic_version table so upgrade starts from scratch logger.info("fresh_database_detected") engine.dispose() alembic_command.upgrade(alembic_cfg, "head") logger.info("migrations_applied") def create_app() -> FastAPI: """Create and configure the FastAPI application. Returns: A fully configured FastAPI application instance. """ settings = get_settings() setup_logging(log_level=settings.app_log_level) app = FastAPI( title="SneakySwole", description="Open-source workout tracking and programming", version="0.1.0", ) # Redirect to home when no profile is selected @app.exception_handler(NoProfileSelectedError) async def _no_profile_handler(request, exc): return RedirectResponse(url="/", status_code=302) # Mount static files app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # Jinja2 templates (available to routes via request.state or dependency) templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) app.state.templates = templates # Nav context middleware — injects profiles/active_profile into request.state app.add_middleware(NavContextMiddleware) # Register route modules app.include_router(exercises_router) app.include_router(health_router) app.include_router(history_router) app.include_router(logging_router) app.include_router(pages_router) app.include_router(profiles_router) app.include_router(workouts_router) app.include_router(dashboard_router) # Database setup — run Alembic migrations instead of create_all engine = get_engine(settings.database_url) _run_migrations(settings.database_url) app.state.engine = engine # DB session dependency for routes def _get_session(): yield from get_db_session(engine) app.dependency_overrides[get_db_session] = _get_session # Seed database on startup @app.on_event("startup") async def on_startup(): with Session(engine) as session: seed_service = SeedService(session) seed_service.seed_all() logger.info("app_started", environment=settings.app_env) return app # Uvicorn entry point app = create_app()