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. static files, and structured logging.
""" """
import os
import secrets
from pathlib import Path from pathlib import Path
import structlog import structlog
@@ -12,12 +14,24 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlmodel import SQLModel, Session 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.config import get_settings
from app.database import get_engine, get_db_session from app.database import get_engine, get_db_session
from app.logging_config import setup_logging 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.health import router as health_router
from app.routes.pages import router as pages_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.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__) logger = structlog.get_logger(__name__)
@@ -27,6 +41,37 @@ TEMPLATES_DIR = _BASE_DIR / "templates"
STATIC_DIR = _BASE_DIR / "static" 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: def create_app() -> FastAPI:
"""Create and configure the FastAPI application. """Create and configure the FastAPI application.
@@ -49,9 +94,19 @@ def create_app() -> FastAPI:
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.state.templates = templates 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 # Register route modules
app.include_router(auth_router)
app.include_router(exercises_router)
app.include_router(health_router) app.include_router(health_router)
app.include_router(pages_router) app.include_router(pages_router)
app.include_router(profiles_router)
app.include_router(workouts_router)
# Database setup # Database setup
engine = get_engine(settings.database_url) engine = get_engine(settings.database_url)

99
app/routes/auth.py Normal file
View File

@@ -0,0 +1,99 @@
"""Authentication routes for admin login and logout.
Handles the login form, credential verification, session cookie
management, and logout.
"""
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlmodel import Session
from app.database import get_db_session
from app.services.auth_service import AuthService
from app.utils.auth import SESSION_COOKIE_NAME
logger = structlog.get_logger(__name__)
router = APIRouter(tags=["auth"])
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Render the login form.
Args:
request: The incoming HTTP request.
Returns:
Rendered login page HTML.
"""
templates = request.app.state.templates
return templates.TemplateResponse("pages/login.html", {
"request": request,
"error": None,
})
@router.post("/login")
async def login_submit(
request: Request,
session: Session = Depends(get_db_session),
):
"""Process login form submission.
Verifies credentials and sets a session cookie on success.
Re-renders the login page with an error on failure.
Args:
request: The incoming HTTP request.
session: Database session.
Returns:
Redirect to home on success, or login page with error.
"""
form = await request.form()
username = form.get("username", "")
password = form.get("password", "")
secret_key = request.app.state.secret_key
auth_service = AuthService(session, secret_key=secret_key)
user = auth_service.authenticate(username, password)
if user is None:
templates = request.app.state.templates
return templates.TemplateResponse("pages/login.html", {
"request": request,
"error": "Invalid username or password.",
}, status_code=200)
# Create session token and set cookie — httponly and samesite for security
token = auth_service.create_session_token(user_id=user.id)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
samesite="lax",
max_age=86400, # 24 hours
)
logger.info("login_success", username=username)
return response
@router.get("/logout")
async def logout(request: Request):
"""Clear the session cookie and redirect to login.
Args:
request: The incoming HTTP request.
Returns:
Redirect to login page.
"""
response = RedirectResponse(url="/login", status_code=303)
response.delete_cookie(key=SESSION_COOKIE_NAME)
response.delete_cookie(key="active_profile_id")
logger.info("logout")
return response

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,
})

151
app/routes/profiles.py Normal file
View File

@@ -0,0 +1,151 @@
"""Profile management routes.
Admin can view, create, edit user profiles and switch the active profile.
"""
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.user_service import UserService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/profiles", tags=["profiles"])
@router.get("", response_class=HTMLResponse)
async def list_profiles(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""List all user profiles for the admin.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered profile list page.
"""
user_service = UserService(session)
profiles = user_service.list_users(exclude_admin=True)
active_profile_id = get_active_profile_id(request)
templates = request.app.state.templates
return templates.TemplateResponse("pages/profiles.html", {
"request": request,
"profiles": profiles,
"active_profile_id": active_profile_id,
"admin": admin,
})
@router.post("/switch")
async def switch_profile(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Switch the active user profile.
Sets a cookie with the selected profile ID.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Redirect to the referring page with profile cookie set.
"""
form = await request.form()
profile_id = form.get("profile_id", "")
# Validate profile exists and is not an admin
user_service = UserService(session)
profile = user_service.get_user_by_id(int(profile_id)) if profile_id.isdigit() else None
referer = request.headers.get("referer", "/")
response = RedirectResponse(url=referer, status_code=303)
if profile and not profile.is_admin:
response.set_cookie(
key="active_profile_id",
value=str(profile.id),
httponly=True,
samesite="lax",
max_age=86400,
)
logger.info("profile_switched", profile_id=profile.id, name=profile.display_name)
else:
logger.warning("profile_switch_failed", profile_id=profile_id)
return response
@router.get("/{profile_id}/edit", response_class=HTMLResponse)
async def edit_profile_page(
profile_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Render the profile edit form.
Args:
profile_id: The profile ID to edit.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered profile edit page.
"""
user_service = UserService(session)
profile = user_service.get_user_by_id(profile_id)
templates = request.app.state.templates
return templates.TemplateResponse("pages/profile_edit.html", {
"request": request,
"profile": profile,
"admin": admin,
})
@router.post("/{profile_id}/edit")
async def update_profile(
profile_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Process profile edit form submission.
Args:
profile_id: The profile ID to update.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Redirect to profiles page.
"""
form = await request.form()
user_service = UserService(session)
user_service.update_user(
profile_id,
display_name=form.get("display_name", ""),
height=form.get("height", ""),
weight=form.get("weight", ""),
goals=form.get("goals", ""),
)
return RedirectResponse(url="/profiles", status_code=303)

98
app/routes/workouts.py Normal file
View File

@@ -0,0 +1,98 @@
"""Workout day viewer routes.
Displays the warmup routine and main exercises for each workout day,
with the active profile's programming targets.
"""
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session, select
from app.database import get_db_session
from app.models.user import User
from app.models.user_exercise_program import UserExerciseProgram
from app.services.exercise_service import ExerciseService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/workouts", tags=["workouts"])
@router.get("", response_class=HTMLResponse)
async def workout_days_list(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""List all workout days as clickable cards.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered workout days list page.
"""
exercise_service = ExerciseService(session)
days = exercise_service.list_workout_days()
templates = request.app.state.templates
return templates.TemplateResponse("pages/workout_days.html", {
"request": request,
"days": days,
"admin": admin,
})
@router.get("/{day_name}", response_class=HTMLResponse)
async def workout_day_detail(
day_name: str,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Display a full workout day -- warmups + exercises with form cues.
Args:
day_name: The workout day name (e.g., "push", "pull").
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered workout day detail page.
"""
exercise_service = ExerciseService(session)
# Normalize day name for DB lookup (e.g., "push" -> "Push", "full-body" -> "Full Body")
day_display = day_name.replace("-", " ").title()
warmups = exercise_service.list_warmups()
exercises = exercise_service.list_exercises(workout_day=day_display)
# Get active profile's programming if set
active_profile_id = get_active_profile_id(request)
programs = {}
active_profile = None
if active_profile_id:
active_profile = session.get(User, active_profile_id)
if active_profile:
statement = select(UserExerciseProgram).where(
UserExerciseProgram.user_id == active_profile_id
)
for prog in session.exec(statement).all():
programs[prog.exercise_id] = prog
templates = request.app.state.templates
return templates.TemplateResponse("pages/workout_day.html", {
"request": request,
"day_name": day_display,
"warmups": warmups,
"exercises": exercises,
"programs": programs,
"active_profile": active_profile,
"admin": admin,
})

View File

@@ -0,0 +1,100 @@
"""Service layer for admin authentication and session management.
Handles password verification, session token creation/validation.
Uses bcrypt for password hashing and itsdangerous for session signing.
"""
from typing import Optional
import bcrypt
import structlog
from itsdangerous import BadSignature, URLSafeTimedSerializer
from sqlmodel import Session, select
from app.models.user import User
logger = structlog.get_logger(__name__)
# Session token max age: 24 hours
SESSION_MAX_AGE_SECONDS = 86400
class AuthService:
"""Handles admin authentication and session token management.
Args:
session: An active SQLModel Session.
secret_key: Secret key for signing session tokens.
"""
def __init__(self, session: Session, secret_key: str) -> None:
self._session = session
self._serializer = URLSafeTimedSerializer(secret_key)
def authenticate(self, username: str, password: str) -> Optional[User]:
"""Verify admin credentials and return the User if valid.
Only admin users can authenticate. Non-admin users are rejected.
Args:
username: The username to check.
password: The plaintext password to verify.
Returns:
The authenticated User, or None if credentials are invalid.
"""
statement = select(User).where(User.username == username)
user = self._session.exec(statement).first()
if user is None:
logger.warning("auth_failed", reason="user_not_found", username=username)
return None
if not user.is_admin:
logger.warning("auth_failed", reason="not_admin", username=username)
return None
if not user.password_hash:
logger.warning("auth_failed", reason="no_password_hash", username=username)
return None
# Verify password against bcrypt hash
if not bcrypt.checkpw(
password.encode("utf-8"),
user.password_hash.encode("utf-8"),
):
logger.warning("auth_failed", reason="wrong_password", username=username)
return None
logger.info("auth_success", username=username)
return user
def create_session_token(self, user_id: int) -> str:
"""Create a signed session token for the given user.
Args:
user_id: The authenticated user's ID.
Returns:
A signed, URL-safe token string.
"""
return self._serializer.dumps({"user_id": user_id})
def validate_session_token(self, token: str) -> Optional[int]:
"""Validate a session token and extract the user_id.
Args:
token: The session token to validate.
Returns:
The user_id if the token is valid, or None.
"""
try:
data = self._serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
return data.get("user_id")
except BadSignature:
logger.warning("session_invalid", reason="bad_signature")
return None
except Exception:
logger.warning("session_invalid", reason="unknown_error")
return None

View File

@@ -27,14 +27,16 @@
</ul> </ul>
<ul> <ul>
{% block nav_items %} {% block nav_items %}
<!-- Phase 3 adds: profile switcher, login/logout --> {% include "partials/nav.html" ignore missing %}
{% endblock %} {% endblock %}
</ul> </ul>
</nav> </nav>
</header> </header>
<main class="container"> <main class="container">
{% block flash %}{% endblock %} {% block flash %}
{% include "partials/flash_message.html" ignore missing %}
{% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Exercise Library — SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>Exercise Library</h1>
<p>Browse all exercises with form cues and programming details.</p>
</hgroup>
<!-- HTMX-powered filters -->
<div class="grid">
<select name="workout_day"
hx-get="/exercises/search"
hx-target="#exercise-results"
hx-include="[name='muscle_group']">
<option value="">All Days</option>
{% for day in workout_days %}
<option value="{{ day.name }}">{{ day.name }}</option>
{% endfor %}
</select>
<select name="muscle_group"
hx-get="/exercises/search"
hx-target="#exercise-results"
hx-include="[name='workout_day']">
<option value="">All Muscle Groups</option>
{% for mg in muscle_groups %}
<option value="{{ mg }}">{{ mg }}</option>
{% endfor %}
</select>
</div>
<!-- Exercise results (replaced by HTMX on filter change) -->
<div id="exercise-results">
{% include "partials/exercise_list.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}SneakySwole — Login{% endblock %}
{% block content %}
<article>
<header>
<h2>Admin Login</h2>
</header>
{% if error %}
<div class="flash-error" role="alert">{{ error }}</div>
{% endif %}
<form method="POST" action="/login">
<label for="username">Username</label>
<input type="text" id="username" name="username"
placeholder="Enter username" required autofocus>
<label for="password">Password</label>
<input type="password" id="password" name="password"
placeholder="Enter password" required>
<button type="submit">Login</button>
</form>
</article>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Edit Profile — {{ profile.display_name }}{% endblock %}
{% block content %}
<article>
<header>
<h2>Edit Profile: {{ profile.display_name }}</h2>
</header>
<form method="POST" action="/profiles/{{ profile.id }}/edit">
<label for="display_name">Display Name</label>
<input type="text" id="display_name" name="display_name"
value="{{ profile.display_name }}" required>
<label for="height">Height</label>
<input type="text" id="height" name="height"
value="{{ profile.height or '' }}" placeholder="e.g., 6'0&quot;">
<label for="weight">Weight</label>
<input type="text" id="weight" name="weight"
value="{{ profile.weight or '' }}" placeholder="e.g., 260 lbs">
<label for="goals">Goals</label>
<textarea id="goals" name="goals"
placeholder="Training goals...">{{ profile.goals or '' }}</textarea>
<div class="grid">
<a href="/profiles" role="button" class="outline secondary">Cancel</a>
<button type="submit">Save Changes</button>
</div>
</form>
</article>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}User Profiles — SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>User Profiles</h1>
<p>Manage training profiles.</p>
</hgroup>
<div class="grid">
{% for profile in profiles %}
{% include "partials/profile_card.html" %}
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}{{ day_name }} Day — SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>{{ day_name }} Day</h1>
{% if active_profile %}
<p>Training as: <strong>{{ active_profile.display_name }}</strong></p>
{% else %}
<p>No profile selected — <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
<!-- Warmup Section -->
<section>
<h2>Warmup</h2>
{% include "partials/warmup_list.html" %}
</section>
<!-- Exercises Section -->
<section>
<h2>Exercises</h2>
{% for exercise in exercises %}
{% set program = programs.get(exercise.id) %}
{% include "partials/exercise_card.html" %}
{% endfor %}
</section>
<a href="/workouts" role="button" class="outline">Back to All Days</a>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Workout Days — SneakySwole{% endblock %}
{% block content %}
<h1>Workout Days</h1>
<div class="grid">
{% for day in days %}
<article>
<header>
<h3>Day {{ day.day_number }}: {{ day.name }}</h3>
</header>
<p>{{ day.description }}</p>
<footer>
<a href="/workouts/{{ day.name|lower|replace(' ', '-') }}"
role="button">View Workout</a>
</footer>
</article>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,28 @@
<article>
<header>
<hgroup>
<h3>{{ exercise.name }}</h3>
<p>{{ exercise.muscle_group }} | {{ exercise.sets }} sets | Tempo: {{ exercise.tempo }}</p>
</hgroup>
</header>
{% if program %}
<div class="grid">
<div>
<small>Week 1</small>
<p>{{ program.wk1_reps }} reps @ {{ program.wk1_weight }}</p>
</div>
<div>
<small>Week 4</small>
<p>{{ program.wk4_reps }} reps @ {{ program.wk4_weight }}</p>
</div>
</div>
{% endif %}
<details>
<summary>Form Cues</summary>
<p>{{ exercise.form_cues }}</p>
</details>
<!-- Phase 4 adds: inline logging form here -->
</article>

View File

@@ -0,0 +1,16 @@
{% for exercise in exercises %}
<article>
<header>
<hgroup>
<h3>{{ exercise.name }}</h3>
<p>{{ exercise.muscle_group }} | {{ exercise.workout_day }} Day | {{ exercise.sets }} sets | Tempo: {{ exercise.tempo }}</p>
</hgroup>
</header>
<details>
<summary>Form Cues</summary>
<p>{{ exercise.form_cues }}</p>
</details>
</article>
{% else %}
<p>No exercises found matching your filters.</p>
{% endfor %}

View File

@@ -0,0 +1,6 @@
{% if flash_success %}
<div class="flash-success" role="status">{{ flash_success }}</div>
{% endif %}
{% if flash_error %}
<div class="flash-error" role="alert">{{ flash_error }}</div>
{% endif %}

View File

@@ -0,0 +1,35 @@
{% set admin = request.state.admin %}
{% set profiles = request.state.profiles %}
{% set active_profile = request.state.active_profile %}
{% if admin %}
<li>
<details class="dropdown">
<summary>
{% if active_profile %}
{{ active_profile.display_name }}
{% else %}
Select Profile
{% endif %}
</summary>
<ul dir="rtl">
{% for profile in profiles %}
<li>
<form method="POST" action="/profiles/switch" style="margin:0;">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<button type="submit" class="outline secondary"
style="width:100%; text-align:left; border:none;">
{{ profile.display_name }}
</button>
</form>
</li>
{% endfor %}
</ul>
</details>
</li>
<li><a href="/workouts">Workouts</a></li>
<li><a href="/exercises">Exercises</a></li>
<li><a href="/profiles">Profiles</a></li>
<li><a href="/logout">Logout</a></li>
{% else %}
<li><a href="/login">Login</a></li>
{% endif %}

View File

@@ -0,0 +1,12 @@
<article>
<header>
<hgroup>
<h3>{{ profile.display_name }}</h3>
<p>{{ profile.height }} | {{ profile.weight }}</p>
</hgroup>
</header>
<p>{{ profile.goals or "No goals set" }}</p>
<footer>
<a href="/profiles/{{ profile.id }}/edit" role="button" class="outline">Edit</a>
</footer>
</article>

View File

@@ -0,0 +1,23 @@
<table>
<thead>
<tr>
<th>Exercise</th>
<th>Type</th>
<th>Reps</th>
</tr>
</thead>
<tbody>
{% for warmup in warmups %}
<tr>
<td>
<details>
<summary>{{ warmup.name }}</summary>
<p>{{ warmup.form_cues }}</p>
</details>
</td>
<td>{{ warmup.type }}</td>
<td>{{ warmup.reps }}</td>
</tr>
{% endfor %}
</tbody>
</table>

87
app/utils/auth.py Normal file
View File

@@ -0,0 +1,87 @@
"""Authentication dependencies for FastAPI route protection.
Provides dependency functions that verify the admin session cookie
and return the authenticated User, or redirect to /login.
"""
from typing import Optional
import structlog
from fastapi import Depends, Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.auth_service import AuthService
logger = structlog.get_logger(__name__)
# Cookie name for the admin session
SESSION_COOKIE_NAME = "session"
def get_current_admin_user(request: Request, session: Session = Depends(get_db_session)) -> User:
"""FastAPI dependency that extracts and validates the admin session.
Reads the session cookie, validates the token, and returns the
authenticated admin User. Redirects to /login (303) if not authenticated.
Args:
request: The incoming HTTP request.
session: Database session (injected by FastAPI).
Returns:
The authenticated admin User.
Raises:
RedirectResponse: 303 redirect to /login if no valid admin session.
"""
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise _login_redirect()
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 is None:
raise _login_redirect()
user = session.get(User, user_id)
if user is None or not user.is_admin:
raise _login_redirect()
return user
def get_active_profile_id(request: Request) -> Optional[int]:
"""Extract the active profile ID from the session cookie.
The admin selects which user profile to view/log as. This is stored
in a separate cookie called 'active_profile_id'.
Args:
request: The incoming HTTP request.
Returns:
The active profile user ID, or None if not set.
"""
profile_id = request.cookies.get("active_profile_id")
if profile_id and profile_id.isdigit():
return int(profile_id)
return None
def _login_redirect():
"""Create a redirect exception to the login page.
Returns:
An HTTPException-compatible RedirectResponse.
"""
from fastapi import HTTPException
response = RedirectResponse(url="/login", status_code=303)
exc = HTTPException(status_code=303, detail="Not authenticated")
exc.response = response
return exc

View File

@@ -25,16 +25,42 @@ Build the database schema and seed it from YAML config files.
- Two initial user profiles seeded: Phillip and Daughter - Two initial user profiles seeded: Phillip and Daughter
- Service layer for all DB access (no direct queries from routes) - Service layer for all DB access (no direct queries from routes)
## Phase 3: Workout UI ### Phase 3: Workout UI
The core user-facing experience — viewing workouts and managing profiles. **Completed:** 2026-02-24
- Admin login (simple session-based auth, password hashed) **Summary:** Built the core user-facing experience — admin login with bcrypt + signed session cookies, profile switcher, workout day viewer with warmups and exercise cards, and HTMX-powered exercise browser with search/filter. Added Jinja2 context processor middleware for automatic nav context injection.
- Profile switcher in nav (admin selects active user profile)
- Admin can create and edit user profiles (name, weight, height, goals) **Key files:**
- Workout day viewer — warmup routine + main exercises with full form cues - `app/services/auth_service.py` — AuthService (bcrypt auth + itsdangerous session tokens)
- Exercise library browser (search/filter by muscle group, workout day) - `app/utils/auth.py``get_current_admin_user` (303 redirect to /login), `get_active_profile_id`
- All interactions via HTMX partials (no JSON APIs, no vanilla JS) - `app/routes/auth.py` — login/logout routes
- `app/routes/profiles.py` — profile list, switch, edit routes
- `app/routes/workouts.py` — workout day list + detail viewer
- `app/routes/exercises.py` — exercise browser with HTMX search
- `app/templates/partials/nav.html` — profile switcher dropdown (reads from request.state)
- `app/main.py` — NavContextMiddleware, secret_key, all routers registered
**Endpoints created:**
- `GET /login` — render login form
- `POST /login` — authenticate and set session cookie
- `GET /logout` — clear session, redirect to /login
- `GET /profiles` — list user profiles
- `POST /profiles/switch` — set active profile cookie
- `GET /profiles/{id}/edit` — profile edit form
- `POST /profiles/{id}/edit` — update profile
- `GET /workouts` — workout day cards
- `GET /workouts/{day_name}` — warmups + exercises + programming targets
- `GET /exercises` — exercise browser with filter dropdowns
- `GET /exercises/search` — HTMX partial for filtered exercise list
**Key details:**
- Auth uses 303 redirect to /login (not 401) for browser UX
- Nav context injected via `NavContextMiddleware` into `request.state` (admin, profiles, active_profile)
- Session cookie: httponly=True, samesite="lax", max_age=86400 (24h)
- `itsdangerous>=2.2.0` added to requirements.txt
- `python-multipart>=0.0.20` added for form data parsing
- 66 tests pass (12 new Phase 3 tests + 54 existing)
## Phase 4: Logging & Tracking ## Phase 4: Logging & Tracking

View File

@@ -6,4 +6,7 @@ requires-python = ">=3.12"
[build-system] [build-system]
requires = ["setuptools>=75.0"] requires = ["setuptools>=75.0"]
build-backend = "setuptools.backends._legacy:_Backend" build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]

View File

@@ -4,6 +4,8 @@ jinja2>=3.1.0,<4.0.0
structlog>=24.0.0,<25.0.0 structlog>=24.0.0,<25.0.0
python-dotenv>=1.0.0,<2.0.0 python-dotenv>=1.0.0,<2.0.0
bcrypt>=4.2.0,<5.0.0 bcrypt>=4.2.0,<5.0.0
itsdangerous>=2.2.0,<3.0.0
python-multipart>=0.0.20,<1.0.0
pyyaml>=6.0.0,<7.0.0 pyyaml>=6.0.0,<7.0.0
sqlmodel>=0.0.22,<1.0.0 sqlmodel>=0.0.22,<1.0.0
alembic>=1.14.0,<2.0.0 alembic>=1.14.0,<2.0.0

View File

@@ -0,0 +1,56 @@
"""Tests for the auth dependency (require_admin)."""
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from app.utils.auth import get_current_admin_user, get_active_profile_id
class TestAuthDependency:
"""Tests for the require_admin dependency."""
def test_redirects_when_no_session_cookie(self) -> None:
"""Should redirect to /login (303) when no session cookie is present."""
request = MagicMock()
request.cookies = {}
with pytest.raises(HTTPException) as exc_info:
get_current_admin_user(request=request, session=MagicMock())
assert exc_info.value.status_code == 303
def test_redirects_when_invalid_token(self) -> None:
"""Should redirect to /login (303) when session cookie has invalid token."""
request = MagicMock()
request.cookies = {"session": "invalid-token"}
request.app.state.secret_key = "test-secret"
mock_session = MagicMock()
mock_session.get.return_value = None
with pytest.raises(HTTPException) as exc_info:
get_current_admin_user(request=request, session=mock_session)
assert exc_info.value.status_code == 303
class TestGetActiveProfileId:
"""Tests for the get_active_profile_id dependency."""
def test_returns_profile_id_from_cookie(self) -> None:
"""Should return the integer profile ID from cookie."""
request = MagicMock()
request.cookies = {"active_profile_id": "5"}
assert get_active_profile_id(request) == 5
def test_returns_none_when_no_cookie(self) -> None:
"""Should return None when no active_profile_id cookie is set."""
request = MagicMock()
request.cookies = {}
assert get_active_profile_id(request) is None
def test_returns_none_for_non_numeric(self) -> None:
"""Should return None for non-numeric cookie values."""
request = MagicMock()
request.cookies = {"active_profile_id": "abc"}
assert get_active_profile_id(request) is None

43
tests/test_auth_routes.py Normal file
View File

@@ -0,0 +1,43 @@
"""Tests for login/logout routes."""
from fastapi.testclient import TestClient
class TestLoginPage:
"""Tests for GET /login."""
def test_login_page_returns_200(self, client: TestClient) -> None:
"""GET /login should render the login form."""
response = client.get("/login")
assert response.status_code == 200
assert "Login" in response.text
def test_login_page_has_form(self, client: TestClient) -> None:
"""GET /login should contain a login form with username and password."""
response = client.get("/login")
assert 'name="username"' in response.text
assert 'name="password"' in response.text
class TestLoginPost:
"""Tests for POST /login."""
def test_login_invalid_credentials_shows_error(self, client: TestClient) -> None:
"""POST /login with wrong credentials should show error message."""
response = client.post(
"/login",
data={"username": "wrong", "password": "wrong"},
follow_redirects=False,
)
# Should re-render login page with error or redirect back
assert response.status_code in (200, 303)
class TestLogout:
"""Tests for GET /logout."""
def test_logout_redirects_to_login(self, client: TestClient) -> None:
"""GET /logout should clear session and redirect to login."""
response = client.get("/logout", follow_redirects=False)
assert response.status_code in (303, 307)
assert "/login" in response.headers.get("location", "")

View File

@@ -0,0 +1,93 @@
"""Tests for the AuthService class."""
import bcrypt
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.services.auth_service import AuthService
class TestAuthService:
"""Tests for admin authentication logic."""
def _setup(self):
"""Create an in-memory DB with an admin user."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
# Create admin user with known password
pw_hash = bcrypt.hashpw(b"adminpass", bcrypt.gensalt()).decode("utf-8")
admin = User(
username="admin",
password_hash=pw_hash,
display_name="Admin",
is_admin=True,
)
session.add(admin)
session.commit()
service = AuthService(session, secret_key="test-secret-key")
return session, service
def test_authenticate_valid_credentials(self) -> None:
"""authenticate should return the User for valid credentials."""
session, service = self._setup()
user = service.authenticate("admin", "adminpass")
assert user is not None
assert user.username == "admin"
session.close()
def test_authenticate_wrong_password(self) -> None:
"""authenticate should return None for wrong password."""
session, service = self._setup()
user = service.authenticate("admin", "wrongpass")
assert user is None
session.close()
def test_authenticate_unknown_user(self) -> None:
"""authenticate should return None for unknown username."""
session, service = self._setup()
user = service.authenticate("nobody", "anything")
assert user is None
session.close()
def test_authenticate_non_admin(self) -> None:
"""authenticate should return None for non-admin users."""
session, service = self._setup()
pw_hash = bcrypt.hashpw(b"userpass", bcrypt.gensalt()).decode("utf-8")
non_admin = User(
username="regular",
password_hash=pw_hash,
display_name="Regular",
is_admin=False,
)
session.add(non_admin)
session.commit()
user = service.authenticate("regular", "userpass")
assert user is None
session.close()
def test_create_session_token(self) -> None:
"""create_session_token should return a non-empty signed string."""
session, service = self._setup()
token = service.create_session_token(user_id=1)
assert isinstance(token, str)
assert len(token) > 0
session.close()
def test_validate_session_token(self) -> None:
"""validate_session_token should return the user_id from a valid token."""
session, service = self._setup()
token = service.create_session_token(user_id=42)
user_id = service.validate_session_token(token)
assert user_id == 42
session.close()
def test_validate_session_token_invalid(self) -> None:
"""validate_session_token should return None for tampered tokens."""
session, service = self._setup()
user_id = service.validate_session_token("fake-token-value")
assert user_id is None
session.close()

View File

@@ -0,0 +1,24 @@
"""Tests for exercise browser routes."""
from fastapi.testclient import TestClient
class TestExerciseBrowser:
"""Tests for GET /exercises."""
def test_exercise_browser_requires_auth(self, client: TestClient) -> None:
"""GET /exercises should require admin login."""
response = client.get("/exercises", follow_redirects=False)
assert response.status_code in (401, 303)
class TestExerciseSearch:
"""Tests for HTMX exercise search."""
def test_exercise_search_requires_auth(self, client: TestClient) -> None:
"""GET /exercises/search should require admin login."""
response = client.get(
"/exercises/search?workout_day=Push",
follow_redirects=False,
)
assert response.status_code in (401, 303)

View File

@@ -0,0 +1,26 @@
"""Tests for profile management routes."""
from fastapi.testclient import TestClient
class TestProfileSwitcher:
"""Tests for POST /profiles/switch."""
def test_switch_profile_requires_auth(self, client: TestClient) -> None:
"""POST /profiles/switch should require admin login."""
response = client.post(
"/profiles/switch",
data={"profile_id": "1"},
follow_redirects=False,
)
# Should redirect to login or return 401
assert response.status_code in (401, 303)
class TestProfileList:
"""Tests for GET /profiles."""
def test_profiles_page_requires_auth(self, client: TestClient) -> None:
"""GET /profiles should require admin login."""
response = client.get("/profiles", follow_redirects=False)
assert response.status_code in (401, 303)

View File

@@ -0,0 +1,17 @@
"""Tests for workout day viewer routes."""
from fastapi.testclient import TestClient
class TestWorkoutDayViewer:
"""Tests for GET /workouts/<day_name>."""
def test_workout_day_requires_auth(self, client: TestClient) -> None:
"""GET /workouts/push should require admin login."""
response = client.get("/workouts/push", follow_redirects=False)
assert response.status_code in (401, 303)
def test_workout_days_list_requires_auth(self, client: TestClient) -> None:
"""GET /workouts should require admin login."""
response = client.get("/workouts", follow_redirects=False)
assert response.status_code in (401, 303)