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
|
||||
Reference in New Issue
Block a user