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

86
app/routes/exercises.py Normal file
View File

@@ -0,0 +1,86 @@
"""Exercise browser routes with HTMX search/filter support.
All filtering is done via HTMX partial responses — no JSON APIs.
"""
import structlog
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.exercise_service import ExerciseService
from app.utils.auth import get_current_admin_user
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/exercises", tags=["exercises"])
@router.get("", response_class=HTMLResponse)
async def exercise_browser(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Render the exercise browser page with all exercises.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered exercise browser page.
"""
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises()
workout_days = exercise_service.list_workout_days()
# Collect unique muscle groups for the filter dropdown
muscle_groups = sorted(set(ex.muscle_group for ex in exercises))
templates = request.app.state.templates
return templates.TemplateResponse("pages/exercise_browser.html", {
"request": request,
"exercises": exercises,
"workout_days": workout_days,
"muscle_groups": muscle_groups,
"admin": admin,
})
@router.get("/search", response_class=HTMLResponse)
async def exercise_search(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
workout_day: str = Query(default="", alias="workout_day"),
muscle_group: str = Query(default="", alias="muscle_group"),
):
"""Return filtered exercise list as an HTMX partial.
Called via hx-get from the exercise browser filter dropdowns.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
workout_day: Filter by workout day name.
muscle_group: Filter by muscle group.
Returns:
Rendered exercise list partial HTML.
"""
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises(
workout_day=workout_day or None,
muscle_group=muscle_group or None,
)
templates = request.app.state.templates
return templates.TemplateResponse("partials/exercise_list.html", {
"request": request,
"exercises": exercises,
})