Simplifies the progression model to a universal rep ladder: every exercise follows 6→8→10→12 reps at current weight, then +5 lbs and reset to 6. Replaces per-user wk1/wk4 rep and weight targets with a single starting_weight field. - Add Alembic migration to drop wk1_reps/wk4_reps/wk1_weight/wk4_weight, add starting_weight (migrated from wk1_weight) - Run Alembic migrations on app startup instead of create_all, with auto-detection and stamping for legacy databases - Include alembic/ and alembic.ini in Docker image - Rewrite progression_service.get_suggestion() with ladder logic: climb, hold, weight_increase, hold_at_top, deload - Replace wk1/wk4 grid in exercise cards with rep ladder progress bar - Add color-coded progression badges by type - Change weight log input from text to number with pre-filled suggestion - Normalize weight input in routes (0→BW, bare number→N lbs) - Remove schedule page (route, template, nav link, tests) - Simplify user_programs.yaml from 4 fields to 1 per exercise - Update all tests for new schema and progression logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
177 lines
6.6 KiB
Python
177 lines
6.6 KiB
Python
"""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()
|