Files
SneakySwole/app/main.py
Phillip Tarrant 52e48f8ed4 feat: replace wk1/wk4 targets with 6→8→10→12 rep ladder progression
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>
2026-03-13 13:57:02 -05:00

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()