feat: add Phase 4 Logging & Tracking — inline set logging, history views
Add workout logging so users can track sets, reps, weight, and a
"felt easy?" toggle inline from the workout day view via HTMX.
Sessions auto-create on first log. History page shows past sessions
with detailed per-exercise breakdowns.
New services: WorkoutSessionService, LogService
New routes: POST /log, /log/{id}/edit, /log/{id}/delete, GET /history, /history/{id}
New templates: log_form, log_entry, session_card, log_history, session_detail
Modified: exercise_card (inline logging), nav (History link), workouts route (session context)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,9 +24,11 @@ from app.database import get_engine, get_db_session
|
|||||||
from app.logging_config import setup_logging
|
from app.logging_config import setup_logging
|
||||||
from app.routes.auth import router as auth_router
|
from app.routes.auth import router as auth_router
|
||||||
from app.routes.exercises import router as exercises_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
|
from app.routes.health import router as health_router
|
||||||
from app.routes.pages import router as pages_router
|
from app.routes.pages import router as pages_router
|
||||||
from app.routes.profiles import router as profiles_router
|
from app.routes.profiles import router as profiles_router
|
||||||
|
from app.routes.logging import router as logging_router
|
||||||
from app.routes.workouts import router as workouts_router
|
from app.routes.workouts import router as workouts_router
|
||||||
from app.services.seed_service import SeedService
|
from app.services.seed_service import SeedService
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
@@ -104,6 +106,8 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(exercises_router)
|
app.include_router(exercises_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
app.include_router(history_router)
|
||||||
|
app.include_router(logging_router)
|
||||||
app.include_router(pages_router)
|
app.include_router(pages_router)
|
||||||
app.include_router(profiles_router)
|
app.include_router(profiles_router)
|
||||||
app.include_router(workouts_router)
|
app.include_router(workouts_router)
|
||||||
|
|||||||
113
app/routes/history.py
Normal file
113
app/routes/history.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Log history routes for viewing past workout sessions.
|
||||||
|
|
||||||
|
Displays a list of past sessions and detailed logs per session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, 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.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
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/history", tags=["history"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def log_history(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""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)
|
||||||
|
|
||||||
|
# Resolve workout day names for display
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
days_by_id = {d.id: d for d in exercise_service.list_workout_days()}
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/log_history.html", {
|
||||||
|
"request": request,
|
||||||
|
"sessions": sessions_list,
|
||||||
|
"days_by_id": days_by_id,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}", response_class=HTMLResponse)
|
||||||
|
async def session_detail(
|
||||||
|
session_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
ws_service = WorkoutSessionService(session)
|
||||||
|
ws = ws_service.get_session_by_id(session_id)
|
||||||
|
|
||||||
|
log_service = LogService(session)
|
||||||
|
logs = log_service.list_logs_for_session(session_id)
|
||||||
|
|
||||||
|
# Group logs by exercise
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
exercises_by_id = {}
|
||||||
|
logs_by_exercise = {}
|
||||||
|
for log in logs:
|
||||||
|
if log.exercise_id not in exercises_by_id:
|
||||||
|
exercises_by_id[log.exercise_id] = (
|
||||||
|
exercise_service.get_exercise_by_id(log.exercise_id)
|
||||||
|
)
|
||||||
|
logs_by_exercise.setdefault(log.exercise_id, []).append(log)
|
||||||
|
|
||||||
|
# Resolve workout day name
|
||||||
|
days_by_id = {d.id: d for d in exercise_service.list_workout_days()}
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/session_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"workout_session": ws,
|
||||||
|
"logs_by_exercise": logs_by_exercise,
|
||||||
|
"exercises_by_id": exercises_by_id,
|
||||||
|
"days_by_id": days_by_id,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
177
app/routes/logging.py
Normal file
177
app/routes/logging.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""Workout logging routes for inline set tracking.
|
||||||
|
|
||||||
|
Handles creating, editing, and deleting individual set logs.
|
||||||
|
All responses are HTMX partials that update in place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, 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.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
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/log", tags=["logging"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_class=HTMLResponse)
|
||||||
|
async def log_set(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""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))
|
||||||
|
workout_day_id = int(form.get("workout_day_id", 0))
|
||||||
|
set_number = int(form.get("set_number", 1))
|
||||||
|
reps = int(form.get("reps", 0))
|
||||||
|
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,
|
||||||
|
workout_day_id=workout_day_id,
|
||||||
|
session_date=date.today(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the log entry
|
||||||
|
log_service = LogService(session)
|
||||||
|
log_service.create_log(
|
||||||
|
session_id=ws.id,
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
set_number=set_number,
|
||||||
|
reps_completed=reps,
|
||||||
|
weight_used=weight,
|
||||||
|
felt_easy=felt_easy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return updated logs for this exercise
|
||||||
|
logs = log_service.list_logs_for_exercise(ws.id, exercise_id)
|
||||||
|
next_set = len(logs) + 1
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/log_entry.html", {
|
||||||
|
"request": request,
|
||||||
|
"logs": logs,
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"workout_day_id": workout_day_id,
|
||||||
|
"next_set": next_set,
|
||||||
|
"session_id": ws.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{log_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def edit_log(
|
||||||
|
log_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
form = await request.form()
|
||||||
|
log_service = LogService(session)
|
||||||
|
|
||||||
|
log_service.update_log(
|
||||||
|
log_id,
|
||||||
|
reps_completed=int(form.get("reps", 0)),
|
||||||
|
weight_used=form.get("weight", ""),
|
||||||
|
felt_easy=form.get("felt_easy") == "on",
|
||||||
|
notes=form.get("notes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
log = log_service.get_log_by_id(log_id)
|
||||||
|
logs = log_service.list_logs_for_exercise(log.session_id, log.exercise_id)
|
||||||
|
next_set = len(logs) + 1
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/log_entry.html", {
|
||||||
|
"request": request,
|
||||||
|
"logs": logs,
|
||||||
|
"exercise_id": log.exercise_id,
|
||||||
|
"workout_day_id": 0,
|
||||||
|
"next_set": next_set,
|
||||||
|
"session_id": log.session_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{log_id}/delete", response_class=HTMLResponse)
|
||||||
|
async def delete_log(
|
||||||
|
log_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
log_service = LogService(session)
|
||||||
|
log = log_service.get_log_by_id(log_id)
|
||||||
|
|
||||||
|
if log:
|
||||||
|
exercise_id = log.exercise_id
|
||||||
|
session_id = log.session_id
|
||||||
|
log_service.delete_log(log_id)
|
||||||
|
|
||||||
|
logs = log_service.list_logs_for_exercise(session_id, exercise_id)
|
||||||
|
next_set = len(logs) + 1
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/log_entry.html", {
|
||||||
|
"request": request,
|
||||||
|
"logs": logs,
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"workout_day_id": 0,
|
||||||
|
"next_set": next_set,
|
||||||
|
"session_id": session_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return HTMLResponse("")
|
||||||
@@ -4,6 +4,8 @@ Displays the warmup routine and main exercises for each workout day,
|
|||||||
with the active profile's programming targets.
|
with the active profile's programming targets.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
@@ -13,6 +15,8 @@ from app.database import get_db_session
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.user_exercise_program import UserExerciseProgram
|
from app.models.user_exercise_program import UserExerciseProgram
|
||||||
from app.services.exercise_service import ExerciseService
|
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 get_current_admin_user, get_active_profile_id
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
@@ -77,6 +81,7 @@ async def workout_day_detail(
|
|||||||
active_profile_id = get_active_profile_id(request)
|
active_profile_id = get_active_profile_id(request)
|
||||||
programs = {}
|
programs = {}
|
||||||
active_profile = None
|
active_profile = None
|
||||||
|
existing_logs = {}
|
||||||
if active_profile_id:
|
if active_profile_id:
|
||||||
active_profile = session.get(User, active_profile_id)
|
active_profile = session.get(User, active_profile_id)
|
||||||
if active_profile:
|
if active_profile:
|
||||||
@@ -86,6 +91,27 @@ async def workout_day_detail(
|
|||||||
for prog in session.exec(statement).all():
|
for prog in session.exec(statement).all():
|
||||||
programs[prog.exercise_id] = prog
|
programs[prog.exercise_id] = prog
|
||||||
|
|
||||||
|
# Look up the workout day ID for logging forms
|
||||||
|
days = exercise_service.list_workout_days()
|
||||||
|
workout_day_id = 0
|
||||||
|
for d in days:
|
||||||
|
if d.name == day_display:
|
||||||
|
workout_day_id = d.id
|
||||||
|
break
|
||||||
|
|
||||||
|
# Load existing logs for today's session (if any)
|
||||||
|
if active_profile_id and workout_day_id:
|
||||||
|
ws_service = WorkoutSessionService(session)
|
||||||
|
ws = ws_service.get_or_create_session(
|
||||||
|
user_id=active_profile_id,
|
||||||
|
workout_day_id=workout_day_id,
|
||||||
|
session_date=date.today(),
|
||||||
|
)
|
||||||
|
log_service = LogService(session)
|
||||||
|
all_logs = log_service.list_logs_for_session(ws.id)
|
||||||
|
for log in all_logs:
|
||||||
|
existing_logs.setdefault(log.exercise_id, []).append(log)
|
||||||
|
|
||||||
templates = request.app.state.templates
|
templates = request.app.state.templates
|
||||||
return templates.TemplateResponse("pages/workout_day.html", {
|
return templates.TemplateResponse("pages/workout_day.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
@@ -94,5 +120,7 @@ async def workout_day_detail(
|
|||||||
"exercises": exercises,
|
"exercises": exercises,
|
||||||
"programs": programs,
|
"programs": programs,
|
||||||
"active_profile": active_profile,
|
"active_profile": active_profile,
|
||||||
|
"existing_logs": existing_logs,
|
||||||
|
"workout_day_id": workout_day_id,
|
||||||
"admin": admin,
|
"admin": admin,
|
||||||
})
|
})
|
||||||
|
|||||||
195
app/services/log_service.py
Normal file
195
app/services/log_service.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Service layer for workout log (set-level) data access.
|
||||||
|
|
||||||
|
Handles CRUD for individual set logs within workout sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LogService:
|
||||||
|
"""Handles CRUD operations for WorkoutLog records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def create_log(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
exercise_id: int,
|
||||||
|
set_number: int,
|
||||||
|
reps_completed: int,
|
||||||
|
weight_used: str,
|
||||||
|
felt_easy: bool,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> WorkoutLog:
|
||||||
|
"""Create a new set log entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: FK to workout_sessions.
|
||||||
|
exercise_id: FK to exercises.
|
||||||
|
set_number: Which set (1, 2, 3...).
|
||||||
|
reps_completed: Actual reps performed.
|
||||||
|
weight_used: Weight as string (e.g., "30 lbs").
|
||||||
|
felt_easy: Whether the set felt easy.
|
||||||
|
notes: Optional notes for this set.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created WorkoutLog record.
|
||||||
|
"""
|
||||||
|
log = WorkoutLog(
|
||||||
|
session_id=session_id,
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
set_number=set_number,
|
||||||
|
reps_completed=reps_completed,
|
||||||
|
weight_used=weight_used,
|
||||||
|
felt_easy=felt_easy,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
self._session.add(log)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(log)
|
||||||
|
logger.info(
|
||||||
|
"log_created",
|
||||||
|
session_id=session_id,
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
set=set_number,
|
||||||
|
)
|
||||||
|
return log
|
||||||
|
|
||||||
|
def list_logs_for_session(self, session_id: int) -> list[WorkoutLog]:
|
||||||
|
"""List all log entries for a workout session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The workout session ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WorkoutLog records ordered by exercise and set number.
|
||||||
|
"""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(WorkoutLog.session_id == session_id)
|
||||||
|
.order_by(WorkoutLog.exercise_id, WorkoutLog.set_number)
|
||||||
|
)
|
||||||
|
return list(self._session.exec(statement).all())
|
||||||
|
|
||||||
|
def list_logs_for_exercise(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
exercise_id: int,
|
||||||
|
) -> list[WorkoutLog]:
|
||||||
|
"""List log entries for a specific exercise within a session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The workout session ID.
|
||||||
|
exercise_id: The exercise ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WorkoutLog records for this exercise, ordered by set.
|
||||||
|
"""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(
|
||||||
|
WorkoutLog.session_id == session_id,
|
||||||
|
WorkoutLog.exercise_id == exercise_id,
|
||||||
|
)
|
||||||
|
.order_by(WorkoutLog.set_number)
|
||||||
|
)
|
||||||
|
return list(self._session.exec(statement).all())
|
||||||
|
|
||||||
|
def get_log_by_id(self, log_id: int) -> Optional[WorkoutLog]:
|
||||||
|
"""Retrieve a log entry by primary key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_id: The log entry ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The WorkoutLog record, or None if not found.
|
||||||
|
"""
|
||||||
|
return self._session.get(WorkoutLog, log_id)
|
||||||
|
|
||||||
|
def update_log(self, log_id: int, **kwargs) -> WorkoutLog:
|
||||||
|
"""Update fields on an existing log entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_id: The log entry ID.
|
||||||
|
**kwargs: Field names and new values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated WorkoutLog record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the log is not found.
|
||||||
|
"""
|
||||||
|
log = self.get_log_by_id(log_id)
|
||||||
|
if log is None:
|
||||||
|
raise ValueError(f"WorkoutLog with id {log_id} not found")
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(log, key):
|
||||||
|
setattr(log, key, value)
|
||||||
|
|
||||||
|
self._session.add(log)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(log)
|
||||||
|
logger.info("log_updated", log_id=log_id, fields=list(kwargs.keys()))
|
||||||
|
return log
|
||||||
|
|
||||||
|
def delete_log(self, log_id: int) -> None:
|
||||||
|
"""Delete a log entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_id: The log entry ID.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the log is not found.
|
||||||
|
"""
|
||||||
|
log = self.get_log_by_id(log_id)
|
||||||
|
if log is None:
|
||||||
|
raise ValueError(f"WorkoutLog with id {log_id} not found")
|
||||||
|
|
||||||
|
self._session.delete(log)
|
||||||
|
self._session.commit()
|
||||||
|
logger.info("log_deleted", log_id=log_id)
|
||||||
|
|
||||||
|
def get_latest_logs_for_exercise(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
exercise_id: int,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[WorkoutLog]:
|
||||||
|
"""Get the most recent log entries for an exercise across sessions.
|
||||||
|
|
||||||
|
Used by the progression engine (Phase 5) to determine
|
||||||
|
what the user last did for this exercise.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's ID.
|
||||||
|
exercise_id: The exercise ID.
|
||||||
|
limit: Maximum number of logs to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of recent WorkoutLog records, newest first.
|
||||||
|
"""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutLog)
|
||||||
|
.join(WorkoutSession, WorkoutLog.session_id == WorkoutSession.id)
|
||||||
|
.where(
|
||||||
|
WorkoutSession.user_id == user_id,
|
||||||
|
WorkoutLog.exercise_id == exercise_id,
|
||||||
|
)
|
||||||
|
.order_by(WorkoutSession.date.desc(), WorkoutLog.set_number)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(self._session.exec(statement).all())
|
||||||
131
app/services/workout_session_service.py
Normal file
131
app/services/workout_session_service.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Service layer for workout session management.
|
||||||
|
|
||||||
|
Handles creation, retrieval, and updates for workout sessions.
|
||||||
|
A session represents a single workout on a specific date for a user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkoutSessionService:
|
||||||
|
"""Handles CRUD operations for WorkoutSession records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def get_or_create_session(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
workout_day_id: int,
|
||||||
|
session_date: date,
|
||||||
|
) -> WorkoutSession:
|
||||||
|
"""Get an existing session or create a new one.
|
||||||
|
|
||||||
|
If a session already exists for this user + day + date combo,
|
||||||
|
return it. Otherwise, create a new one. This allows logging
|
||||||
|
to start automatically without an explicit "start session" step.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's ID.
|
||||||
|
workout_day_id: The workout day's ID.
|
||||||
|
session_date: The date of the workout.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The existing or newly created WorkoutSession.
|
||||||
|
"""
|
||||||
|
statement = select(WorkoutSession).where(
|
||||||
|
WorkoutSession.user_id == user_id,
|
||||||
|
WorkoutSession.workout_day_id == workout_day_id,
|
||||||
|
WorkoutSession.date == session_date,
|
||||||
|
)
|
||||||
|
existing = self._session.exec(statement).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
ws = WorkoutSession(
|
||||||
|
user_id=user_id,
|
||||||
|
workout_day_id=workout_day_id,
|
||||||
|
date=session_date,
|
||||||
|
)
|
||||||
|
self._session.add(ws)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(ws)
|
||||||
|
logger.info(
|
||||||
|
"workout_session_created",
|
||||||
|
user_id=user_id,
|
||||||
|
day_id=workout_day_id,
|
||||||
|
date=str(session_date),
|
||||||
|
)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
def list_sessions(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[WorkoutSession]:
|
||||||
|
"""List workout sessions for a user, most recent first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's ID.
|
||||||
|
limit: Maximum number of sessions to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WorkoutSession records, ordered by date descending.
|
||||||
|
"""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(self._session.exec(statement).all())
|
||||||
|
|
||||||
|
def get_session_by_id(self, session_id: int) -> Optional[WorkoutSession]:
|
||||||
|
"""Retrieve a workout session by primary key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The session ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The WorkoutSession record, or None if not found.
|
||||||
|
"""
|
||||||
|
return self._session.get(WorkoutSession, session_id)
|
||||||
|
|
||||||
|
def update_session(self, session_id: int, **kwargs) -> WorkoutSession:
|
||||||
|
"""Update fields on an existing workout session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The session ID.
|
||||||
|
**kwargs: Field names and new values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated WorkoutSession record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the session is not found.
|
||||||
|
"""
|
||||||
|
ws = self.get_session_by_id(session_id)
|
||||||
|
if ws is None:
|
||||||
|
raise ValueError(f"WorkoutSession with id {session_id} not found")
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(ws, key):
|
||||||
|
setattr(ws, key, value)
|
||||||
|
|
||||||
|
self._session.add(ws)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(ws)
|
||||||
|
return ws
|
||||||
23
app/templates/pages/log_history.html
Normal file
23
app/templates/pages/log_history.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Workout History -- SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>Workout History</h1>
|
||||||
|
{% if active_profile %}
|
||||||
|
<p>History for: <strong>{{ active_profile.display_name }}</strong></p>
|
||||||
|
{% else %}
|
||||||
|
<p>No profile selected -- <a href="/profiles">select one</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
{% if sessions %}
|
||||||
|
{% for ws in sessions %}
|
||||||
|
{% set day = days_by_id.get(ws.workout_day_id) %}
|
||||||
|
{% include "partials/session_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p>No workout sessions recorded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
51
app/templates/pages/session_detail.html
Normal file
51
app/templates/pages/session_detail.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Session Detail -- SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
{% set day = days_by_id.get(workout_session.workout_day_id) %}
|
||||||
|
<h1>
|
||||||
|
{{ day.name if day else "Workout" }}
|
||||||
|
-- {{ workout_session.date.strftime('%B %d, %Y') }}
|
||||||
|
</h1>
|
||||||
|
{% if workout_session.notes %}
|
||||||
|
<p>{{ workout_session.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
{% for exercise_id, logs in logs_by_exercise.items() %}
|
||||||
|
{% set exercise = exercises_by_id[exercise_id] %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h3>{{ exercise.name }}</h3>
|
||||||
|
</header>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Set</th>
|
||||||
|
<th>Reps</th>
|
||||||
|
<th>Weight</th>
|
||||||
|
<th>Easy?</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.set_number }}</td>
|
||||||
|
<td>{{ log.reps_completed }}</td>
|
||||||
|
<td>{{ log.weight_used }}</td>
|
||||||
|
<td>{{ "Yes" if log.felt_easy else "No" }}</td>
|
||||||
|
<td>{{ log.notes or "" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p>No exercises logged in this session.</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<a href="/history" role="button" class="outline">Back to History</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -24,5 +24,18 @@
|
|||||||
<p>{{ exercise.form_cues }}</p>
|
<p>{{ exercise.form_cues }}</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Phase 4 adds: inline logging form here -->
|
<!-- Inline logging (Phase 4) -->
|
||||||
|
{% if active_profile %}
|
||||||
|
<div id="logs-exercise-{{ exercise.id }}">
|
||||||
|
{% if existing_logs and existing_logs[exercise.id] %}
|
||||||
|
{% set logs = existing_logs[exercise.id] %}
|
||||||
|
{% set exercise_id = exercise.id %}
|
||||||
|
{% set next_set = logs|length + 1 %}
|
||||||
|
{% include "partials/log_entry.html" %}
|
||||||
|
{% else %}
|
||||||
|
{% set next_set = 1 %}
|
||||||
|
{% include "partials/log_form.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
39
app/templates/partials/log_entry.html
Normal file
39
app/templates/partials/log_entry.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!-- Displays logged sets for a single exercise within a session -->
|
||||||
|
{% if logs %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Set</th>
|
||||||
|
<th>Reps</th>
|
||||||
|
<th>Weight</th>
|
||||||
|
<th>Easy?</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.set_number }}</td>
|
||||||
|
<td>{{ log.reps_completed }}</td>
|
||||||
|
<td>{{ log.weight_used }}</td>
|
||||||
|
<td>{{ "Yes" if log.felt_easy else "No" }}</td>
|
||||||
|
<td>
|
||||||
|
<form hx-post="/log/{{ log.id }}/delete"
|
||||||
|
hx-target="#logs-exercise-{{ exercise_id }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Delete this set?"
|
||||||
|
style="display:inline; margin:0;">
|
||||||
|
<button type="submit" class="outline secondary"
|
||||||
|
style="padding:0.2rem 0.5rem; font-size:0.8rem;">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Next set form -->
|
||||||
|
{% include "partials/log_form.html" %}
|
||||||
24
app/templates/partials/log_form.html
Normal file
24
app/templates/partials/log_form.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- Inline logging form, included inside each exercise_card.html -->
|
||||||
|
<form hx-post="/log"
|
||||||
|
hx-target="#logs-exercise-{{ exercise.id }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
style="margin-bottom:0;">
|
||||||
|
<input type="hidden" name="exercise_id" value="{{ exercise.id }}">
|
||||||
|
<input type="hidden" name="workout_day_id" value="{{ workout_day_id }}">
|
||||||
|
<input type="hidden" name="set_number" value="{{ next_set|default(1) }}">
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<small style="white-space:nowrap; opacity:0.7;">Set {{ next_set|default(1) }}</small>
|
||||||
|
<input type="number" name="reps" placeholder="Reps"
|
||||||
|
min="0" max="100" required
|
||||||
|
style="width:5rem; margin-bottom:0;">
|
||||||
|
<input type="text" name="weight" placeholder="Weight (lbs)"
|
||||||
|
required
|
||||||
|
style="width:8rem; margin-bottom:0;">
|
||||||
|
<label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;">
|
||||||
|
<input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;">
|
||||||
|
Easy?
|
||||||
|
</label>
|
||||||
|
<button type="submit" style="margin-bottom:0; width:auto; white-space:nowrap;">Log Set</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="/workouts">Workouts</a></li>
|
<li><a href="/workouts">Workouts</a></li>
|
||||||
|
<li><a href="/history">History</a></li>
|
||||||
<li><a href="/exercises">Exercises</a></li>
|
<li><a href="/exercises">Exercises</a></li>
|
||||||
<li><a href="/profiles">Profiles</a></li>
|
<li><a href="/profiles">Profiles</a></li>
|
||||||
<li><a href="/logout">Logout</a></li>
|
<li><a href="/logout">Logout</a></li>
|
||||||
|
|||||||
16
app/templates/partials/session_card.html
Normal file
16
app/templates/partials/session_card.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h3>{{ day.name if day else "Unknown" }} Day</h3>
|
||||||
|
<p>{{ ws.date.strftime('%A, %B %d, %Y') }}</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
{% if ws.notes %}
|
||||||
|
<p>{{ ws.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<footer>
|
||||||
|
<a href="/history/{{ ws.id }}" role="button" class="outline">
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
21
tests/test_history_routes.py
Normal file
21
tests/test_history_routes.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Tests for log history routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogHistory:
|
||||||
|
"""Tests for GET /history."""
|
||||||
|
|
||||||
|
def test_history_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /history should require admin login."""
|
||||||
|
response = client.get("/history", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionDetail:
|
||||||
|
"""Tests for GET /history/<session_id>."""
|
||||||
|
|
||||||
|
def test_session_detail_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /history/1 should require admin login."""
|
||||||
|
response = client.get("/history/1", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 303)
|
||||||
107
tests/test_log_service.py
Normal file
107
tests/test_log_service.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Tests for the LogService class."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.exercise import Exercise
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.services.log_service import LogService
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogService:
|
||||||
|
"""Tests for workout log CRUD operations."""
|
||||||
|
|
||||||
|
def _setup(self):
|
||||||
|
"""Create an in-memory DB with prerequisite data."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
|
||||||
|
user = User(username="phil", password_hash="h", display_name="Phillip")
|
||||||
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
|
exercise = Exercise(
|
||||||
|
name="DB Chest Press", muscle_group="Chest",
|
||||||
|
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
|
||||||
|
)
|
||||||
|
session.add_all([user, day, exercise])
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
session.refresh(day)
|
||||||
|
session.refresh(exercise)
|
||||||
|
|
||||||
|
ws = WorkoutSession(
|
||||||
|
user_id=user.id, workout_day_id=day.id, date=date.today(),
|
||||||
|
)
|
||||||
|
session.add(ws)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
|
||||||
|
service = LogService(session)
|
||||||
|
return session, service, ws, exercise
|
||||||
|
|
||||||
|
def test_create_log_entry(self) -> None:
|
||||||
|
"""create_log should insert a new log entry."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
log = service.create_log(
|
||||||
|
session_id=ws.id,
|
||||||
|
exercise_id=exercise.id,
|
||||||
|
set_number=1,
|
||||||
|
reps_completed=8,
|
||||||
|
weight_used="30 lbs",
|
||||||
|
felt_easy=False,
|
||||||
|
)
|
||||||
|
assert log.id is not None
|
||||||
|
assert log.reps_completed == 8
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_list_logs_for_session(self) -> None:
|
||||||
|
"""list_logs_for_session should return all logs for a session."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
service.create_log(ws.id, exercise.id, 2, 8, "30 lbs", False)
|
||||||
|
service.create_log(ws.id, exercise.id, 3, 7, "30 lbs", True)
|
||||||
|
logs = service.list_logs_for_session(ws.id)
|
||||||
|
assert len(logs) == 3
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_list_logs_for_exercise_in_session(self) -> None:
|
||||||
|
"""list_logs_for_exercise should filter by exercise within a session."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
logs = service.list_logs_for_exercise(ws.id, exercise.id)
|
||||||
|
assert len(logs) == 1
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_update_log(self) -> None:
|
||||||
|
"""update_log should modify an existing log entry."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
log = service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
updated = service.update_log(log.id, reps_completed=10, felt_easy=True)
|
||||||
|
assert updated.reps_completed == 10
|
||||||
|
assert updated.felt_easy is True
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_delete_log(self) -> None:
|
||||||
|
"""delete_log should remove a log entry."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
log = service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
service.delete_log(log.id)
|
||||||
|
logs = service.list_logs_for_session(ws.id)
|
||||||
|
assert len(logs) == 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_latest_logs_for_exercise(self) -> None:
|
||||||
|
"""get_latest_logs should return the most recent logs for an exercise."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
service.create_log(ws.id, exercise.id, 2, 8, "30 lbs", True)
|
||||||
|
logs = service.get_latest_logs_for_exercise(
|
||||||
|
user_id=ws.user_id,
|
||||||
|
exercise_id=exercise.id,
|
||||||
|
)
|
||||||
|
assert len(logs) >= 1
|
||||||
|
session.close()
|
||||||
44
tests/test_logging_routes.py
Normal file
44
tests/test_logging_routes.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Tests for workout logging routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogSet:
|
||||||
|
"""Tests for POST /log."""
|
||||||
|
|
||||||
|
def test_log_set_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""POST /log should require admin login."""
|
||||||
|
response = client.post(
|
||||||
|
"/log",
|
||||||
|
data={
|
||||||
|
"exercise_id": "1",
|
||||||
|
"workout_day_id": "1",
|
||||||
|
"set_number": "1",
|
||||||
|
"reps": "8",
|
||||||
|
"weight": "30 lbs",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code in (401, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogEdit:
|
||||||
|
"""Tests for POST /log/<id>/edit."""
|
||||||
|
|
||||||
|
def test_edit_log_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""POST /log/1/edit should require admin login."""
|
||||||
|
response = client.post(
|
||||||
|
"/log/1/edit",
|
||||||
|
data={"reps": "10", "weight": "35 lbs"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code in (401, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogDelete:
|
||||||
|
"""Tests for POST /log/<id>/delete."""
|
||||||
|
|
||||||
|
def test_delete_log_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""POST /log/1/delete should require admin login."""
|
||||||
|
response = client.post("/log/1/delete", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 303)
|
||||||
83
tests/test_workout_session_service.py
Normal file
83
tests/test_workout_session_service.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Tests for the WorkoutSessionService class."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
from app.services.workout_session_service import WorkoutSessionService
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkoutSessionService:
|
||||||
|
"""Tests for workout session CRUD operations."""
|
||||||
|
|
||||||
|
def _setup(self):
|
||||||
|
"""Create an in-memory DB with prerequisite data."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
|
||||||
|
user = User(username="phil", password_hash="h", display_name="Phillip")
|
||||||
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
|
session.add_all([user, day])
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
session.refresh(day)
|
||||||
|
|
||||||
|
service = WorkoutSessionService(session)
|
||||||
|
return session, service, user, day
|
||||||
|
|
||||||
|
def test_get_or_create_session_creates_new(self) -> None:
|
||||||
|
"""get_or_create_session should create a new session if none exists."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
ws = service.get_or_create_session(
|
||||||
|
user_id=user.id,
|
||||||
|
workout_day_id=day.id,
|
||||||
|
session_date=date.today(),
|
||||||
|
)
|
||||||
|
assert ws.id is not None
|
||||||
|
assert ws.user_id == user.id
|
||||||
|
assert ws.workout_day_id == day.id
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_or_create_session_returns_existing(self) -> None:
|
||||||
|
"""get_or_create_session should return existing session for same day."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
ws1 = service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
ws2 = service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
assert ws1.id == ws2.id
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_list_sessions_for_user(self) -> None:
|
||||||
|
"""list_sessions should return all sessions for a user."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
sessions = service.list_sessions(user_id=user.id)
|
||||||
|
assert len(sessions) == 1
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_list_sessions_empty(self) -> None:
|
||||||
|
"""list_sessions should return empty list for user with no sessions."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
sessions = service.list_sessions(user_id=user.id)
|
||||||
|
assert len(sessions) == 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_session_by_id(self) -> None:
|
||||||
|
"""get_session_by_id should return the correct session."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
ws = service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
found = service.get_session_by_id(ws.id)
|
||||||
|
assert found is not None
|
||||||
|
assert found.id == ws.id
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_update_session_notes(self) -> None:
|
||||||
|
"""update_session should allow updating notes."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
ws = service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
updated = service.update_session(ws.id, notes="Great workout!")
|
||||||
|
assert updated.notes == "Great workout!"
|
||||||
|
session.close()
|
||||||
Reference in New Issue
Block a user