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

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)