feat: add Phase 3 Workout UI — auth, profiles, workout viewer, exercise browser

Build the core user-facing experience with admin login (bcrypt + signed
session cookies), profile switcher, workout day viewer with warmups and
exercise cards, and HTMX-powered exercise browser with search/filter.

- AuthService with bcrypt password verification and itsdangerous session tokens
- Auth dependency redirects to /login (303) for unauthenticated requests
- NavContextMiddleware injects admin/profiles/active_profile into all templates
- Profile management (list, switch, edit) with cookie-based active profile
- Workout day viewer shows warmups + exercises + per-user programming targets
- Exercise browser with HTMX filter dropdowns (no page reloads)
- Flash message partial for success/error feedback
- 12 new tests (66 total passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 11:14:52 -06:00
parent 1f47103480
commit 23754ea239
29 changed files with 1267 additions and 11 deletions

View File

@@ -4,6 +4,8 @@ Creates and configures the FastAPI app with routes, templates,
static files, and structured logging.
"""
import os
import secrets
from pathlib import Path
import structlog
@@ -12,12 +14,24 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlmodel import SQLModel, Session
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request as StarletteRequest
from starlette.responses import Response
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.auth import router as auth_router
from app.routes.exercises import router as exercises_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.workouts import router as workouts_router
from app.services.seed_service import SeedService
from app.services.auth_service import AuthService
from app.services.user_service import UserService
from app.utils.auth import SESSION_COOKIE_NAME
logger = structlog.get_logger(__name__)
@@ -27,6 +41,37 @@ TEMPLATES_DIR = _BASE_DIR / "templates"
STATIC_DIR = _BASE_DIR / "static"
class NavContextMiddleware(BaseHTTPMiddleware):
"""Injects admin, profiles, and active_profile into request.state for templates."""
async def dispatch(self, request: StarletteRequest, call_next: RequestResponseEndpoint) -> Response:
request.state.admin = None
request.state.profiles = []
request.state.active_profile = None
token = request.cookies.get(SESSION_COOKIE_NAME)
if token and hasattr(request.app.state, "engine"):
try:
with Session(request.app.state.engine) as session:
secret_key = getattr(request.app.state, "secret_key", "")
auth_service = AuthService(session, secret_key=secret_key)
user_id = auth_service.validate_session_token(token)
if user_id:
admin = session.get(User, user_id)
if admin and admin.is_admin:
request.state.admin = admin
user_service = UserService(session)
request.state.profiles = user_service.list_users(exclude_admin=True)
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 create_app() -> FastAPI:
"""Create and configure the FastAPI application.
@@ -49,9 +94,19 @@ def create_app() -> FastAPI:
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.state.templates = templates
# Secret key for session signing
app.state.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
# Nav context middleware — injects admin/profiles/active_profile into request.state
app.add_middleware(NavContextMiddleware)
# Register route modules
app.include_router(auth_router)
app.include_router(exercises_router)
app.include_router(health_router)
app.include_router(pages_router)
app.include_router(profiles_router)
app.include_router(workouts_router)
# Database setup
engine = get_engine(settings.database_url)