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:
55
app/main.py
55
app/main.py
@@ -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
99
app/routes/auth.py
Normal 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
86
app/routes/exercises.py
Normal 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
151
app/routes/profiles.py
Normal 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
98
app/routes/workouts.py
Normal 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,
|
||||||
|
})
|
||||||
100
app/services/auth_service.py
Normal file
100
app/services/auth_service.py
Normal 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
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
38
app/templates/pages/exercise_browser.html
Normal file
38
app/templates/pages/exercise_browser.html
Normal 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 %}
|
||||||
27
app/templates/pages/login.html
Normal file
27
app/templates/pages/login.html
Normal 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 %}
|
||||||
34
app/templates/pages/profile_edit.html
Normal file
34
app/templates/pages/profile_edit.html
Normal 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"">
|
||||||
|
|
||||||
|
<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 %}
|
||||||
16
app/templates/pages/profiles.html
Normal file
16
app/templates/pages/profiles.html
Normal 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 %}
|
||||||
31
app/templates/pages/workout_day.html
Normal file
31
app/templates/pages/workout_day.html
Normal 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 %}
|
||||||
22
app/templates/pages/workout_days.html
Normal file
22
app/templates/pages/workout_days.html
Normal 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 %}
|
||||||
28
app/templates/partials/exercise_card.html
Normal file
28
app/templates/partials/exercise_card.html
Normal 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>
|
||||||
16
app/templates/partials/exercise_list.html
Normal file
16
app/templates/partials/exercise_list.html
Normal 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 %}
|
||||||
6
app/templates/partials/flash_message.html
Normal file
6
app/templates/partials/flash_message.html
Normal 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 %}
|
||||||
35
app/templates/partials/nav.html
Normal file
35
app/templates/partials/nav.html
Normal 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 %}
|
||||||
12
app/templates/partials/profile_card.html
Normal file
12
app/templates/partials/profile_card.html
Normal 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>
|
||||||
23
app/templates/partials/warmup_list.html
Normal file
23
app/templates/partials/warmup_list.html
Normal 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
87
app/utils/auth.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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*"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
56
tests/test_auth_dependency.py
Normal file
56
tests/test_auth_dependency.py
Normal 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
43
tests/test_auth_routes.py
Normal 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", "")
|
||||||
93
tests/test_auth_service.py
Normal file
93
tests/test_auth_service.py
Normal 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()
|
||||||
24
tests/test_exercise_routes.py
Normal file
24
tests/test_exercise_routes.py
Normal 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)
|
||||||
26
tests/test_profile_routes.py
Normal file
26
tests/test_profile_routes.py
Normal 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)
|
||||||
17
tests/test_workout_routes.py
Normal file
17
tests/test_workout_routes.py
Normal 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)
|
||||||
Reference in New Issue
Block a user