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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
@@ -12,12 +14,24 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import SQLModel, Session
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.models.user import User
|
||||
from app.config import get_settings
|
||||
from app.database import get_engine, get_db_session
|
||||
from app.logging_config import setup_logging
|
||||
from app.routes.auth import router as auth_router
|
||||
from app.routes.exercises import router as exercises_router
|
||||
from app.routes.health import router as health_router
|
||||
from app.routes.pages import router as pages_router
|
||||
from app.routes.profiles import router as profiles_router
|
||||
from app.routes.workouts import router as workouts_router
|
||||
from app.services.seed_service import SeedService
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.user_service import UserService
|
||||
from app.utils.auth import SESSION_COOKIE_NAME
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -27,6 +41,37 @@ TEMPLATES_DIR = _BASE_DIR / "templates"
|
||||
STATIC_DIR = _BASE_DIR / "static"
|
||||
|
||||
|
||||
class NavContextMiddleware(BaseHTTPMiddleware):
|
||||
"""Injects admin, profiles, and active_profile into request.state for templates."""
|
||||
|
||||
async def dispatch(self, request: StarletteRequest, call_next: RequestResponseEndpoint) -> Response:
|
||||
request.state.admin = None
|
||||
request.state.profiles = []
|
||||
request.state.active_profile = None
|
||||
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token and hasattr(request.app.state, "engine"):
|
||||
try:
|
||||
with Session(request.app.state.engine) as session:
|
||||
secret_key = getattr(request.app.state, "secret_key", "")
|
||||
auth_service = AuthService(session, secret_key=secret_key)
|
||||
user_id = auth_service.validate_session_token(token)
|
||||
if user_id:
|
||||
admin = session.get(User, user_id)
|
||||
if admin and admin.is_admin:
|
||||
request.state.admin = admin
|
||||
user_service = UserService(session)
|
||||
request.state.profiles = user_service.list_users(exclude_admin=True)
|
||||
|
||||
profile_id = request.cookies.get("active_profile_id")
|
||||
if profile_id and profile_id.isdigit():
|
||||
request.state.active_profile = user_service.get_user_by_id(int(profile_id))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
@@ -49,9 +94,19 @@ def create_app() -> FastAPI:
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
app.state.templates = templates
|
||||
|
||||
# Secret key for session signing
|
||||
app.state.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
|
||||
|
||||
# Nav context middleware — injects admin/profiles/active_profile into request.state
|
||||
app.add_middleware(NavContextMiddleware)
|
||||
|
||||
# Register route modules
|
||||
app.include_router(auth_router)
|
||||
app.include_router(exercises_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(pages_router)
|
||||
app.include_router(profiles_router)
|
||||
app.include_router(workouts_router)
|
||||
|
||||
# Database setup
|
||||
engine = get_engine(settings.database_url)
|
||||
|
||||
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>
|
||||
{% block nav_items %}
|
||||
<!-- Phase 3 adds: profile switcher, login/logout -->
|
||||
{% include "partials/nav.html" ignore missing %}
|
||||
{% endblock %}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% block flash %}{% endblock %}
|
||||
{% block flash %}
|
||||
{% include "partials/flash_message.html" ignore missing %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</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
|
||||
- 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)
|
||||
- Profile switcher in nav (admin selects active user profile)
|
||||
- Admin can create and edit user profiles (name, weight, height, goals)
|
||||
- Workout day viewer — warmup routine + main exercises with full form cues
|
||||
- Exercise library browser (search/filter by muscle group, workout day)
|
||||
- All interactions via HTMX partials (no JSON APIs, no vanilla JS)
|
||||
**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.
|
||||
|
||||
**Key files:**
|
||||
- `app/services/auth_service.py` — AuthService (bcrypt auth + itsdangerous session tokens)
|
||||
- `app/utils/auth.py` — `get_current_admin_user` (303 redirect to /login), `get_active_profile_id`
|
||||
- `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
|
||||
|
||||
|
||||
@@ -6,4 +6,7 @@ requires-python = ">=3.12"
|
||||
|
||||
[build-system]
|
||||
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
|
||||
python-dotenv>=1.0.0,<2.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
|
||||
sqlmodel>=0.0.22,<1.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