feat: replace admin auth with cookie-based profile picker
Remove all authentication (login, sessions, bcrypt, itsdangerous) since the app runs on a private homelab LAN. Replace with a profile picker landing page and cookie-based profile selection (1-year expiry). - Add Alembic migration to drop password_hash/is_admin columns - Delete auth service, auth routes, login template, and auth tests - Rewrite app/utils/auth.py with NoProfileSelectedError and require_active_profile dependency - Add profile creation flow (GET/POST /profiles/create) - Rewrite home page as profile picker with card layout - Update all route files to use profile dependency instead of admin auth - Remove bcrypt and itsdangerous from requirements - Remove admin_username/admin_password from config - Update all tests for new profile-based access model Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,8 +20,6 @@ class Settings(BaseSettings):
|
||||
"""Typed application settings loaded from environment variables.
|
||||
|
||||
Attributes:
|
||||
admin_username: Admin login username (from ADMIN_USERNAME env var).
|
||||
admin_password: Admin login password (from ADMIN_PASSWORD env var).
|
||||
app_env: Runtime environment — 'development' or 'production'.
|
||||
app_host: Host address to bind the server to.
|
||||
app_port: Port number for the server.
|
||||
@@ -29,8 +27,6 @@ class Settings(BaseSettings):
|
||||
database_url: SQLite connection string.
|
||||
"""
|
||||
|
||||
admin_username: str
|
||||
admin_password: str
|
||||
app_env: str = "development"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
@@ -40,6 +36,7 @@ class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
|
||||
43
app/main.py
43
app/main.py
@@ -4,8 +4,6 @@ Creates and configures the FastAPI app with routes, templates,
|
||||
static files, and structured logging.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
@@ -23,7 +21,6 @@ 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.history import router as history_router
|
||||
from app.routes.health import router as health_router
|
||||
@@ -34,9 +31,8 @@ from app.routes.workouts import router as workouts_router
|
||||
from app.routes.dashboard import router as dashboard_router
|
||||
from app.routes.schedule import router as schedule_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, NotAuthenticatedError
|
||||
from app.utils.auth import NoProfileSelectedError
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -47,30 +43,21 @@ STATIC_DIR = _BASE_DIR / "static"
|
||||
|
||||
|
||||
class NavContextMiddleware(BaseHTTPMiddleware):
|
||||
"""Injects admin, profiles, and active_profile into request.state for templates."""
|
||||
"""Injects 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"):
|
||||
if 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)
|
||||
user_service = UserService(session)
|
||||
request.state.profiles = user_service.list_users()
|
||||
|
||||
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))
|
||||
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
|
||||
|
||||
@@ -92,10 +79,10 @@ def create_app() -> FastAPI:
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Redirect unauthenticated requests to login
|
||||
@app.exception_handler(NotAuthenticatedError)
|
||||
async def _not_authenticated_handler(request, exc):
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
# Redirect to home when no profile is selected
|
||||
@app.exception_handler(NoProfileSelectedError)
|
||||
async def _no_profile_handler(request, exc):
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
@@ -104,14 +91,10 @@ 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
|
||||
# Nav context middleware — injects 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(history_router)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""User model for profile management.
|
||||
|
||||
Stores admin and regular user profiles with physical stats and goals.
|
||||
Stores user profiles with physical stats and goals.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -14,13 +14,11 @@ class User(SQLModel, table=True):
|
||||
|
||||
Attributes:
|
||||
id: Primary key, auto-incremented.
|
||||
username: Unique login identifier.
|
||||
password_hash: bcrypt-hashed password (admin only initially).
|
||||
username: Unique identifier.
|
||||
display_name: Human-readable name shown in the UI.
|
||||
height: User's height as a string (e.g., "6'0\"").
|
||||
weight: User's weight as a string (e.g., "260 lbs").
|
||||
goals: Free-text training goals.
|
||||
is_admin: Whether this user has admin privileges.
|
||||
created_at: Timestamp when the record was created.
|
||||
updated_at: Timestamp of the last update.
|
||||
"""
|
||||
@@ -29,11 +27,9 @@ class User(SQLModel, table=True):
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
username: str = Field(index=True, unique=True)
|
||||
password_hash: str = Field(default="")
|
||||
display_name: str = Field(default="")
|
||||
height: Optional[str] = Field(default=None)
|
||||
weight: Optional[str] = Field(default=None)
|
||||
goals: Optional[str] = Field(default=None)
|
||||
is_admin: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""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
|
||||
@@ -15,7 +15,7 @@ from app.models.user import User
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
from app.services.exercise_service import ExerciseService
|
||||
from app.services.progression_service import ProgressionService
|
||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||
from app.utils.auth import require_active_profile
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -26,25 +26,12 @@ router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""Render the progress dashboard for the active profile.
|
||||
|
||||
Shows: summary stats, volume by day chart, exercise progress links.
|
||||
"""
|
||||
active_profile_id = get_active_profile_id(request)
|
||||
active_profile = (
|
||||
session.get(User, active_profile_id)
|
||||
if active_profile_id
|
||||
else None
|
||||
)
|
||||
|
||||
stats = {}
|
||||
volume_data = {}
|
||||
if active_profile_id:
|
||||
analytics = AnalyticsService(session)
|
||||
stats = analytics.get_user_stats(active_profile_id)
|
||||
volume_data = analytics.get_volume_by_day(active_profile_id)
|
||||
"""Render the progress dashboard for the active profile."""
|
||||
analytics = AnalyticsService(session)
|
||||
stats = analytics.get_user_stats(profile.id)
|
||||
volume_data = analytics.get_volume_by_day(profile.id)
|
||||
|
||||
exercise_service = ExerciseService(session)
|
||||
exercises = exercise_service.list_exercises()
|
||||
@@ -55,8 +42,7 @@ async def dashboard(
|
||||
"stats": stats,
|
||||
"volume_data_json": json.dumps(volume_data),
|
||||
"exercises": exercises,
|
||||
"active_profile": active_profile,
|
||||
"admin": admin,
|
||||
"active_profile": profile,
|
||||
})
|
||||
|
||||
|
||||
@@ -65,31 +51,21 @@ async def exercise_progress(
|
||||
exercise_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""Render per-exercise progress page with charts and suggestions."""
|
||||
active_profile_id = get_active_profile_id(request)
|
||||
active_profile = (
|
||||
session.get(User, active_profile_id)
|
||||
if active_profile_id
|
||||
else None
|
||||
)
|
||||
|
||||
exercise_service = ExerciseService(session)
|
||||
exercise = exercise_service.get_exercise_by_id(exercise_id)
|
||||
|
||||
progress_data = {}
|
||||
suggestion = {}
|
||||
if active_profile_id:
|
||||
analytics = AnalyticsService(session)
|
||||
progress_data = analytics.get_exercise_progress(
|
||||
active_profile_id, exercise_id,
|
||||
)
|
||||
analytics = AnalyticsService(session)
|
||||
progress_data = analytics.get_exercise_progress(
|
||||
profile.id, exercise_id,
|
||||
)
|
||||
|
||||
progression = ProgressionService(session)
|
||||
suggestion = progression.get_suggestion(
|
||||
active_profile_id, exercise_id,
|
||||
)
|
||||
progression = ProgressionService(session)
|
||||
suggestion = progression.get_suggestion(
|
||||
profile.id, exercise_id,
|
||||
)
|
||||
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse("pages/exercise_progress.html", {
|
||||
@@ -97,6 +73,5 @@ async def exercise_progress(
|
||||
"exercise": exercise,
|
||||
"progress_data_json": json.dumps(progress_data),
|
||||
"suggestion": suggestion,
|
||||
"active_profile": active_profile,
|
||||
"admin": admin,
|
||||
"active_profile": profile,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Exercise browser routes with HTMX search/filter support.
|
||||
|
||||
All filtering is done via HTMX partial responses — no JSON APIs.
|
||||
All filtering is done via HTMX partial responses -- no JSON APIs.
|
||||
"""
|
||||
|
||||
import structlog
|
||||
@@ -11,7 +11,7 @@ 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
|
||||
from app.utils.auth import require_active_profile
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -22,18 +22,9 @@ router = APIRouter(prefix="/exercises", tags=["exercises"])
|
||||
async def exercise_browser(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
"""Render the exercise browser page with all exercises."""
|
||||
exercise_service = ExerciseService(session)
|
||||
exercises = exercise_service.list_exercises()
|
||||
workout_days = exercise_service.list_workout_days()
|
||||
@@ -47,7 +38,6 @@ async def exercise_browser(
|
||||
"exercises": exercises,
|
||||
"workout_days": workout_days,
|
||||
"muscle_groups": muscle_groups,
|
||||
"admin": admin,
|
||||
})
|
||||
|
||||
|
||||
@@ -55,24 +45,11 @@ async def exercise_browser(
|
||||
async def exercise_search(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
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.
|
||||
"""
|
||||
"""Return filtered exercise list as an HTMX partial."""
|
||||
exercise_service = ExerciseService(session)
|
||||
exercises = exercise_service.list_exercises(
|
||||
workout_day=workout_day or None,
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.models.user import User
|
||||
from app.services.exercise_service import ExerciseService
|
||||
from app.services.log_service import LogService
|
||||
from app.services.workout_session_service import WorkoutSessionService
|
||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||
from app.utils.auth import require_active_profile
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -24,31 +24,11 @@ router = APIRouter(prefix="/history", tags=["history"])
|
||||
async def log_history(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""Display log history for the active profile.
|
||||
|
||||
Shows a list of past workout sessions, most recent first.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
session: Database session.
|
||||
admin: The authenticated admin user.
|
||||
|
||||
Returns:
|
||||
Rendered log history page.
|
||||
"""
|
||||
active_profile_id = get_active_profile_id(request)
|
||||
active_profile = (
|
||||
session.get(User, active_profile_id)
|
||||
if active_profile_id
|
||||
else None
|
||||
)
|
||||
|
||||
sessions_list = []
|
||||
if active_profile_id:
|
||||
ws_service = WorkoutSessionService(session)
|
||||
sessions_list = ws_service.list_sessions(user_id=active_profile_id)
|
||||
"""Display log history for the active profile."""
|
||||
ws_service = WorkoutSessionService(session)
|
||||
sessions_list = ws_service.list_sessions(user_id=profile.id)
|
||||
|
||||
# Resolve workout day names for display
|
||||
exercise_service = ExerciseService(session)
|
||||
@@ -59,8 +39,7 @@ async def log_history(
|
||||
"request": request,
|
||||
"sessions": sessions_list,
|
||||
"days_by_id": days_by_id,
|
||||
"active_profile": active_profile,
|
||||
"admin": admin,
|
||||
"active_profile": profile,
|
||||
})
|
||||
|
||||
|
||||
@@ -69,19 +48,9 @@ async def session_detail(
|
||||
session_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""Display detailed logs for a specific workout session.
|
||||
|
||||
Args:
|
||||
session_id: The workout session ID.
|
||||
request: The incoming HTTP request.
|
||||
session: Database session.
|
||||
admin: The authenticated admin user.
|
||||
|
||||
Returns:
|
||||
Rendered session detail page.
|
||||
"""
|
||||
"""Display detailed logs for a specific workout session."""
|
||||
ws_service = WorkoutSessionService(session)
|
||||
ws = ws_service.get_session_by_id(session_id)
|
||||
|
||||
@@ -109,5 +78,4 @@ async def session_detail(
|
||||
"logs_by_exercise": logs_by_exercise,
|
||||
"exercises_by_id": exercises_by_id,
|
||||
"days_by_id": days_by_id,
|
||||
"admin": admin,
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.database import get_db_session
|
||||
from app.models.user import User
|
||||
from app.services.log_service import LogService
|
||||
from app.services.workout_session_service import WorkoutSessionService
|
||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||
from app.utils.auth import require_active_profile, get_active_profile_id
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -26,20 +26,12 @@ router = APIRouter(prefix="/log", tags=["logging"])
|
||||
async def log_set(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""Log a single set for an exercise.
|
||||
|
||||
Creates the workout session if it doesn't exist yet (auto-create).
|
||||
Returns the updated log entries partial for this exercise.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
session: Database session.
|
||||
admin: The authenticated admin user.
|
||||
|
||||
Returns:
|
||||
Rendered log entries partial for this exercise.
|
||||
"""
|
||||
form = await request.form()
|
||||
exercise_id = int(form.get("exercise_id", 0))
|
||||
@@ -49,18 +41,10 @@ async def log_set(
|
||||
weight = form.get("weight", "")
|
||||
felt_easy = form.get("felt_easy") == "on"
|
||||
|
||||
active_profile_id = get_active_profile_id(request)
|
||||
if not active_profile_id:
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse("partials/flash_message.html", {
|
||||
"request": request,
|
||||
"flash_error": "No profile selected. Switch profiles first.",
|
||||
})
|
||||
|
||||
# Get or create today's session
|
||||
ws_service = WorkoutSessionService(session)
|
||||
ws = ws_service.get_or_create_session(
|
||||
user_id=active_profile_id,
|
||||
user_id=profile.id,
|
||||
workout_day_id=workout_day_id,
|
||||
session_date=date.today(),
|
||||
)
|
||||
@@ -96,19 +80,9 @@ async def edit_log(
|
||||
log_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""Edit an existing log entry.
|
||||
|
||||
Args:
|
||||
log_id: The log entry ID.
|
||||
request: The incoming HTTP request.
|
||||
session: Database session.
|
||||
admin: The authenticated admin user.
|
||||
|
||||
Returns:
|
||||
Rendered updated log entry partial.
|
||||
"""
|
||||
"""Edit an existing log entry."""
|
||||
form = await request.form()
|
||||
log_service = LogService(session)
|
||||
|
||||
@@ -140,19 +114,9 @@ async def delete_log(
|
||||
log_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""Delete a log entry.
|
||||
|
||||
Args:
|
||||
log_id: The log entry ID.
|
||||
request: The incoming HTTP request.
|
||||
session: Database session.
|
||||
admin: The authenticated admin user.
|
||||
|
||||
Returns:
|
||||
Rendered updated log entries partial.
|
||||
"""
|
||||
"""Delete a log entry."""
|
||||
log_service = LogService(session)
|
||||
log = log_service.get_log_by_id(log_id)
|
||||
|
||||
|
||||
@@ -3,21 +3,27 @@
|
||||
Renders Jinja2 templates for user-facing pages.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.database import get_db_session
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def home_page(request: Request) -> HTMLResponse:
|
||||
"""Render the home page.
|
||||
async def home_page(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> HTMLResponse:
|
||||
"""Render the home page with profile picker or create-profile link."""
|
||||
user_service = UserService(session)
|
||||
profiles = user_service.list_users()
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
|
||||
Returns:
|
||||
Rendered home page HTML.
|
||||
"""
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse(request, "pages/home.html")
|
||||
return templates.TemplateResponse("pages/home.html", {
|
||||
"request": request,
|
||||
"profiles": profiles,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Profile management routes.
|
||||
|
||||
Admin can view, create, edit user profiles and switch the active profile.
|
||||
Users can view, create, edit profiles and switch the active profile.
|
||||
"""
|
||||
|
||||
import structlog
|
||||
@@ -11,7 +11,7 @@ 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
|
||||
from app.utils.auth import require_active_profile, get_active_profile_id
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -22,20 +22,11 @@ router = APIRouter(prefix="/profiles", tags=["profiles"])
|
||||
async def list_profiles(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
"""List all user profiles."""
|
||||
user_service = UserService(session)
|
||||
profiles = user_service.list_users(exclude_admin=True)
|
||||
profiles = user_service.list_users()
|
||||
active_profile_id = get_active_profile_id(request)
|
||||
|
||||
templates = request.app.state.templates
|
||||
@@ -43,7 +34,6 @@ async def list_profiles(
|
||||
"request": request,
|
||||
"profiles": profiles,
|
||||
"active_profile_id": active_profile_id,
|
||||
"admin": admin,
|
||||
})
|
||||
|
||||
|
||||
@@ -51,63 +41,101 @@ async def list_profiles(
|
||||
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.
|
||||
Sets a cookie with the selected profile ID (1 year expiry).
|
||||
"""
|
||||
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)
|
||||
response = RedirectResponse(url="/workouts", status_code=303)
|
||||
|
||||
if profile and not profile.is_admin:
|
||||
if profile:
|
||||
response.set_cookie(
|
||||
key="active_profile_id",
|
||||
value=str(profile.id),
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=86400,
|
||||
max_age=31536000, # 1 year
|
||||
)
|
||||
logger.info("profile_switched", profile_id=profile.id, name=profile.display_name)
|
||||
else:
|
||||
logger.warning("profile_switch_failed", profile_id=profile_id)
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/create", response_class=HTMLResponse)
|
||||
async def create_profile_form(request: Request):
|
||||
"""Render the create-profile form."""
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse("pages/profile_create.html", {
|
||||
"request": request,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_profile(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
):
|
||||
"""Create a new profile, set cookie, redirect to workouts."""
|
||||
form = await request.form()
|
||||
display_name = form.get("display_name", "").strip()
|
||||
|
||||
if not display_name:
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse("pages/profile_create.html", {
|
||||
"request": request,
|
||||
"error": "Display name is required.",
|
||||
})
|
||||
|
||||
user_service = UserService(session)
|
||||
# Generate username from display name
|
||||
username = display_name.lower().replace(" ", "_")
|
||||
|
||||
# Ensure uniqueness
|
||||
existing = user_service.get_user_by_username(username)
|
||||
if existing:
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse("pages/profile_create.html", {
|
||||
"request": request,
|
||||
"error": "A profile with that name already exists.",
|
||||
})
|
||||
|
||||
profile = user_service.create_user(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
height=form.get("height", "").strip() or None,
|
||||
weight=form.get("weight", "").strip() or None,
|
||||
goals=form.get("goals", "").strip() or None,
|
||||
)
|
||||
|
||||
response = RedirectResponse(url="/workouts", status_code=303)
|
||||
response.set_cookie(
|
||||
key="active_profile_id",
|
||||
value=str(profile.id),
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=31536000, # 1 year
|
||||
)
|
||||
logger.info("profile_created", profile_id=profile.id, name=profile.display_name)
|
||||
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),
|
||||
active_profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
"""Render the profile edit form."""
|
||||
user_service = UserService(session)
|
||||
profile = user_service.get_user_by_id(profile_id)
|
||||
|
||||
@@ -115,7 +143,6 @@ async def edit_profile_page(
|
||||
return templates.TemplateResponse("pages/profile_edit.html", {
|
||||
"request": request,
|
||||
"profile": profile,
|
||||
"admin": admin,
|
||||
})
|
||||
|
||||
|
||||
@@ -124,19 +151,9 @@ async def update_profile(
|
||||
profile_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
active_profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
"""Process profile edit form submission."""
|
||||
form = await request.form()
|
||||
user_service = UserService(session)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.database import get_db_session
|
||||
from app.models.user import User
|
||||
from app.services.exercise_service import ExerciseService
|
||||
from app.services.workout_session_service import WorkoutSessionService
|
||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||
from app.utils.auth import require_active_profile
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -25,20 +25,13 @@ router = APIRouter(prefix="/schedule", tags=["schedule"])
|
||||
async def schedule_view(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""Render the 4-week schedule calendar.
|
||||
|
||||
Shows a 4-week grid where each training day is mapped to a
|
||||
calendar date. Days with completed sessions are highlighted.
|
||||
"""
|
||||
active_profile_id = get_active_profile_id(request)
|
||||
active_profile = (
|
||||
session.get(User, active_profile_id)
|
||||
if active_profile_id
|
||||
else None
|
||||
)
|
||||
|
||||
exercise_service = ExerciseService(session)
|
||||
workout_days = exercise_service.list_workout_days()
|
||||
|
||||
@@ -50,12 +43,11 @@ async def schedule_view(
|
||||
completed_dates = set()
|
||||
|
||||
# Get completed sessions for highlighting
|
||||
if active_profile_id:
|
||||
ws_service = WorkoutSessionService(session)
|
||||
sessions_list = ws_service.list_sessions(
|
||||
user_id=active_profile_id, limit=100,
|
||||
)
|
||||
completed_dates = {ws.date for ws in sessions_list}
|
||||
ws_service = WorkoutSessionService(session)
|
||||
sessions_list = ws_service.list_sessions(
|
||||
user_id=profile.id, limit=100,
|
||||
)
|
||||
completed_dates = {ws.date for ws in sessions_list}
|
||||
|
||||
# 4 workout days per week, 4 weeks
|
||||
for week_num in range(4):
|
||||
@@ -81,6 +73,5 @@ async def schedule_view(
|
||||
return templates.TemplateResponse("pages/schedule.html", {
|
||||
"request": request,
|
||||
"weeks": weeks,
|
||||
"active_profile": active_profile,
|
||||
"admin": admin,
|
||||
"active_profile": profile,
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.services.exercise_service import ExerciseService
|
||||
from app.services.log_service import LogService
|
||||
from app.services.progression_service import ProgressionService
|
||||
from app.services.workout_session_service import WorkoutSessionService
|
||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||
from app.utils.auth import require_active_profile, get_active_profile_id
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -29,18 +29,9 @@ router = APIRouter(prefix="/workouts", tags=["workouts"])
|
||||
async def workout_days_list(
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
"""List all workout days as clickable cards."""
|
||||
exercise_service = ExerciseService(session)
|
||||
days = exercise_service.list_workout_days()
|
||||
|
||||
@@ -48,7 +39,6 @@ async def workout_days_list(
|
||||
return templates.TemplateResponse("pages/workout_days.html", {
|
||||
"request": request,
|
||||
"days": days,
|
||||
"admin": admin,
|
||||
})
|
||||
|
||||
|
||||
@@ -57,19 +47,9 @@ async def workout_day_detail(
|
||||
day_name: str,
|
||||
request: Request,
|
||||
session: Session = Depends(get_db_session),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
"""Display a full workout day -- warmups + exercises with form cues."""
|
||||
exercise_service = ExerciseService(session)
|
||||
|
||||
# Normalize day name for DB lookup (e.g., "push" -> "Push", "full-body" -> "Full Body")
|
||||
@@ -78,19 +58,15 @@ async def workout_day_detail(
|
||||
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)
|
||||
# Get active profile's programming
|
||||
active_profile_id = profile.id
|
||||
programs = {}
|
||||
active_profile = None
|
||||
existing_logs = {}
|
||||
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
|
||||
statement = select(UserExerciseProgram).where(
|
||||
UserExerciseProgram.user_id == active_profile_id
|
||||
)
|
||||
for prog in session.exec(statement).all():
|
||||
programs[prog.exercise_id] = prog
|
||||
|
||||
# Look up the workout day ID for logging forms
|
||||
days = exercise_service.list_workout_days()
|
||||
@@ -102,15 +78,14 @@ async def workout_day_detail(
|
||||
|
||||
# Get progression suggestions for each exercise
|
||||
suggestions = {}
|
||||
if active_profile_id:
|
||||
progression = ProgressionService(session)
|
||||
for exercise in exercises:
|
||||
suggestions[exercise.id] = progression.get_suggestion(
|
||||
active_profile_id, exercise.id,
|
||||
)
|
||||
progression = ProgressionService(session)
|
||||
for exercise in exercises:
|
||||
suggestions[exercise.id] = progression.get_suggestion(
|
||||
active_profile_id, exercise.id,
|
||||
)
|
||||
|
||||
# Load existing logs for today's session (if any)
|
||||
if active_profile_id and workout_day_id:
|
||||
if workout_day_id:
|
||||
ws_service = WorkoutSessionService(session)
|
||||
ws = ws_service.get_or_create_session(
|
||||
user_id=active_profile_id,
|
||||
@@ -129,9 +104,8 @@ async def workout_day_detail(
|
||||
"warmups": warmups,
|
||||
"exercises": exercises,
|
||||
"programs": programs,
|
||||
"active_profile": active_profile,
|
||||
"active_profile": profile,
|
||||
"existing_logs": existing_logs,
|
||||
"suggestions": suggestions,
|
||||
"workout_day_id": workout_day_id,
|
||||
"admin": admin,
|
||||
})
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""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
|
||||
@@ -4,11 +4,9 @@ Reads config/exercises.yaml and config/user_programs.yaml to populate
|
||||
the exercise library, warmups, workout days, and user programs.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import structlog
|
||||
import yaml
|
||||
from sqlmodel import Session, select
|
||||
@@ -43,14 +41,7 @@ class SeedService:
|
||||
self._config_dir = config_dir or Path(__file__).resolve().parent.parent.parent / "config"
|
||||
|
||||
def _load_yaml(self, filename: str) -> dict:
|
||||
"""Load and parse a YAML file from the config directory.
|
||||
|
||||
Args:
|
||||
filename: Name of the YAML file to load.
|
||||
|
||||
Returns:
|
||||
Parsed YAML content as a dictionary.
|
||||
"""
|
||||
"""Load and parse a YAML file from the config directory."""
|
||||
filepath = self._config_dir / filename
|
||||
with open(filepath, "r") as f:
|
||||
return yaml.safe_load(f)
|
||||
@@ -111,48 +102,8 @@ class SeedService:
|
||||
self._session.commit()
|
||||
logger.info("seed_complete", table="warmups", count=len(warmups))
|
||||
|
||||
def seed_admin(self) -> None:
|
||||
"""Create admin user from environment variables if not exists.
|
||||
|
||||
Reads ADMIN_USERNAME and ADMIN_PASSWORD from env,
|
||||
hashes the password with bcrypt, and creates the admin user.
|
||||
"""
|
||||
admin_username = os.environ.get("ADMIN_USERNAME", "admin")
|
||||
admin_password = os.environ.get("ADMIN_PASSWORD", "")
|
||||
|
||||
existing = self._session.exec(
|
||||
select(User).where(User.username == admin_username)
|
||||
).first()
|
||||
if existing:
|
||||
logger.info("seed_skipped", table="users", reason="admin already exists")
|
||||
return
|
||||
|
||||
if not admin_password:
|
||||
logger.warning("seed_skipped", table="users", reason="ADMIN_PASSWORD not set")
|
||||
return
|
||||
|
||||
# Hash password with bcrypt
|
||||
password_hash = bcrypt.hashpw(
|
||||
admin_password.encode("utf-8"),
|
||||
bcrypt.gensalt(),
|
||||
).decode("utf-8")
|
||||
|
||||
admin = User(
|
||||
username=admin_username,
|
||||
password_hash=password_hash,
|
||||
display_name="Admin",
|
||||
is_admin=True,
|
||||
)
|
||||
self._session.add(admin)
|
||||
self._session.commit()
|
||||
logger.info("seed_complete", table="users", user="admin")
|
||||
|
||||
def seed_user_programs(self) -> None:
|
||||
"""Seed user profiles and their exercise programs from user_programs.yaml.
|
||||
|
||||
Creates non-admin user profiles and links them to exercises
|
||||
with week 1/4 rep and weight targets.
|
||||
"""
|
||||
"""Seed user profiles and their exercise programs from user_programs.yaml."""
|
||||
data = self._load_yaml("user_programs.yaml")
|
||||
programs = data.get("programs", [])
|
||||
|
||||
@@ -172,12 +123,10 @@ class SeedService:
|
||||
else:
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash="", # Non-admin users don't log in initially
|
||||
display_name=display_name,
|
||||
height=profile.get("height", ""),
|
||||
weight=profile.get("weight", ""),
|
||||
goals=profile.get("goals", ""),
|
||||
is_admin=False,
|
||||
)
|
||||
self._session.add(user)
|
||||
self._session.commit()
|
||||
@@ -219,15 +168,10 @@ class SeedService:
|
||||
logger.info("seed_complete", table="user_exercise_programs", user=username)
|
||||
|
||||
def seed_all(self) -> None:
|
||||
"""Run all seed operations in the correct order.
|
||||
|
||||
Order matters: workout_days and exercises must exist before
|
||||
user_programs can reference them.
|
||||
"""
|
||||
"""Run all seed operations in the correct order."""
|
||||
logger.info("seed_all_started")
|
||||
self.seed_workout_days()
|
||||
self.seed_exercises()
|
||||
self.seed_warmups()
|
||||
self.seed_admin()
|
||||
self.seed_user_programs()
|
||||
logger.info("seed_all_complete")
|
||||
|
||||
@@ -27,77 +27,48 @@ class UserService:
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
password_hash: str,
|
||||
display_name: str,
|
||||
height: Optional[str] = None,
|
||||
weight: Optional[str] = None,
|
||||
goals: Optional[str] = None,
|
||||
is_admin: bool = False,
|
||||
) -> User:
|
||||
"""Create a new user profile.
|
||||
|
||||
Args:
|
||||
username: Unique login identifier.
|
||||
password_hash: Pre-hashed password string.
|
||||
username: Unique identifier.
|
||||
display_name: Human-readable name.
|
||||
height: User height as string.
|
||||
weight: User weight as string.
|
||||
goals: Free-text goals.
|
||||
is_admin: Whether user has admin privileges.
|
||||
|
||||
Returns:
|
||||
The newly created User record.
|
||||
"""
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
display_name=display_name,
|
||||
height=height,
|
||||
weight=weight,
|
||||
goals=goals,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
self._session.add(user)
|
||||
self._session.commit()
|
||||
self._session.refresh(user)
|
||||
logger.info("user_created", username=username, is_admin=is_admin)
|
||||
logger.info("user_created", username=username)
|
||||
return user
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""Retrieve a user by primary key.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID.
|
||||
|
||||
Returns:
|
||||
The User record, or None if not found.
|
||||
"""
|
||||
"""Retrieve a user by primary key."""
|
||||
return self._session.get(User, user_id)
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
"""Retrieve a user by username.
|
||||
|
||||
Args:
|
||||
username: The username to look up.
|
||||
|
||||
Returns:
|
||||
The User record, or None if not found.
|
||||
"""
|
||||
"""Retrieve a user by username."""
|
||||
statement = select(User).where(User.username == username)
|
||||
return self._session.exec(statement).first()
|
||||
|
||||
def list_users(self, exclude_admin: bool = False) -> list[User]:
|
||||
"""List all user profiles.
|
||||
|
||||
Args:
|
||||
exclude_admin: If True, omit admin users from the result.
|
||||
|
||||
Returns:
|
||||
List of User records.
|
||||
"""
|
||||
def list_users(self) -> list[User]:
|
||||
"""List all user profiles."""
|
||||
statement = select(User)
|
||||
if exclude_admin:
|
||||
statement = statement.where(User.is_admin == False) # noqa: E712
|
||||
return list(self._session.exec(statement).all())
|
||||
|
||||
def update_user(self, user_id: int, **kwargs) -> User:
|
||||
|
||||
@@ -8,5 +8,33 @@
|
||||
<p>Your open-source workout tracker</p>
|
||||
</hgroup>
|
||||
|
||||
<p>Welcome to SneakySwole. Get started by logging in.</p>
|
||||
{% if profiles %}
|
||||
<h2>Select Profile</h2>
|
||||
<div class="grid">
|
||||
{% for profile in profiles %}
|
||||
<article>
|
||||
<header>
|
||||
<strong>{{ profile.display_name }}</strong>
|
||||
</header>
|
||||
{% if profile.height or profile.weight %}
|
||||
<p>
|
||||
{% if profile.height %}Height: {{ profile.height }}{% endif %}
|
||||
{% if profile.height and profile.weight %} · {% endif %}
|
||||
{% if profile.weight %}Weight: {{ profile.weight }}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<footer>
|
||||
<form method="POST" action="/profiles/switch">
|
||||
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
||||
<button type="submit" class="contrast">Select</button>
|
||||
</form>
|
||||
</footer>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No profiles yet. Create one to get started.</p>
|
||||
{% endif %}
|
||||
|
||||
<a href="/profiles/create" role="button" class="secondary">Create New Profile</a>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{% 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 %}
|
||||
46
app/templates/pages/profile_create.html
Normal file
46
app/templates/pages/profile_create.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}SneakySwole — Create Profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<hgroup>
|
||||
<h1>Create Profile</h1>
|
||||
<p>Set up a new workout profile</p>
|
||||
</hgroup>
|
||||
|
||||
{% if error %}
|
||||
<article aria-label="Error" class="pico-background-red-500">
|
||||
<p>{{ error }}</p>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/profiles/create">
|
||||
<label for="display_name">
|
||||
Display Name <span aria-hidden="true">*</span>
|
||||
<input type="text" id="display_name" name="display_name" required
|
||||
placeholder="e.g. Phillip" value="{{ request.query_params.get('display_name', '') }}">
|
||||
</label>
|
||||
|
||||
<label for="height">
|
||||
Height
|
||||
<input type="text" id="height" name="height"
|
||||
placeholder="e.g. 6'0"">
|
||||
</label>
|
||||
|
||||
<label for="weight">
|
||||
Weight
|
||||
<input type="text" id="weight" name="weight"
|
||||
placeholder="e.g. 260 lbs">
|
||||
</label>
|
||||
|
||||
<label for="goals">
|
||||
Goals
|
||||
<textarea id="goals" name="goals" rows="3"
|
||||
placeholder="e.g. Build strength, improve mobility"></textarea>
|
||||
</label>
|
||||
|
||||
<button type="submit">Create Profile</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/">← Back to profiles</a></p>
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,5 @@
|
||||
{% set admin = request.state.admin %}
|
||||
{% set profiles = request.state.profiles %}
|
||||
{% set active_profile = request.state.active_profile %}
|
||||
{% if admin %}
|
||||
<li>
|
||||
<details class="dropdown">
|
||||
<summary>
|
||||
@@ -26,13 +24,10 @@
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/workouts">Workouts</a></li>
|
||||
<li><a href="/schedule">Schedule</a></li>
|
||||
<li><a href="/dashboard">Dashboard</a></li>
|
||||
<li><a href="/history">History</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 %}
|
||||
|
||||
@@ -1,69 +1,27 @@
|
||||
"""Authentication dependencies for FastAPI route protection.
|
||||
"""Profile selection utilities for FastAPI route protection.
|
||||
|
||||
Provides dependency functions that verify the admin session cookie
|
||||
and return the authenticated User, or redirect to /login.
|
||||
Provides dependency functions that check the active_profile_id cookie
|
||||
and return the selected User profile, or redirect to /.
|
||||
"""
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
class NotAuthenticatedError(Exception):
|
||||
"""Raised when a request lacks valid authentication."""
|
||||
|
||||
# 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
|
||||
class NoProfileSelectedError(Exception):
|
||||
"""Raised when a request lacks a valid profile selection."""
|
||||
|
||||
|
||||
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'.
|
||||
"""Extract the active profile ID from the cookie.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
@@ -77,11 +35,28 @@ def get_active_profile_id(request: Request) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _login_redirect():
|
||||
"""Create a redirect exception to the login page.
|
||||
def require_active_profile(request: Request, session: Session = Depends(get_db_session)) -> User:
|
||||
"""FastAPI dependency that requires a valid profile selection.
|
||||
|
||||
Reads the active_profile_id cookie, loads the profile from DB,
|
||||
and raises NoProfileSelectedError if missing or invalid.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
session: Database session (injected by FastAPI).
|
||||
|
||||
Returns:
|
||||
A NotAuthenticatedError handled by a registered exception handler
|
||||
in main.py that sends a 302 redirect to /login.
|
||||
The selected User profile.
|
||||
|
||||
Raises:
|
||||
NoProfileSelectedError: If no valid profile is selected.
|
||||
"""
|
||||
return NotAuthenticatedError()
|
||||
profile_id = get_active_profile_id(request)
|
||||
if profile_id is None:
|
||||
raise NoProfileSelectedError()
|
||||
|
||||
user = session.get(User, profile_id)
|
||||
if user is None:
|
||||
raise NoProfileSelectedError()
|
||||
|
||||
return user
|
||||
|
||||
Reference in New Issue
Block a user