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:
2026-02-24 12:12:23 -06:00
parent 23754ea239
commit e35b78ae87
17 changed files with 1071 additions and 1 deletions

View File

@@ -24,9 +24,11 @@ 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
from app.routes.pages import router as pages_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.services.seed_service import SeedService
from app.services.auth_service import AuthService
@@ -104,6 +106,8 @@ def create_app() -> FastAPI:
app.include_router(auth_router)
app.include_router(exercises_router)
app.include_router(health_router)
app.include_router(history_router)
app.include_router(logging_router)
app.include_router(pages_router)
app.include_router(profiles_router)
app.include_router(workouts_router)

113
app/routes/history.py Normal file
View 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
View 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("")

View File

@@ -4,6 +4,8 @@ Displays the warmup routine and main exercises for each workout day,
with the active profile's programming targets.
"""
from datetime import date
import structlog
from fastapi import APIRouter, Depends, Request
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_exercise_program import UserExerciseProgram
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__)
@@ -77,6 +81,7 @@ async def workout_day_detail(
active_profile_id = get_active_profile_id(request)
programs = {}
active_profile = None
existing_logs = {}
if active_profile_id:
active_profile = session.get(User, active_profile_id)
if active_profile:
@@ -86,6 +91,27 @@ async def workout_day_detail(
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()
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
return templates.TemplateResponse("pages/workout_day.html", {
"request": request,
@@ -94,5 +120,7 @@ async def workout_day_detail(
"exercises": exercises,
"programs": programs,
"active_profile": active_profile,
"existing_logs": existing_logs,
"workout_day_id": workout_day_id,
"admin": admin,
})

195
app/services/log_service.py Normal file
View 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())

View 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

View 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 %}

View 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 %}

View File

@@ -24,5 +24,18 @@
<p>{{ exercise.form_cues }}</p>
</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>

View 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" %}

View 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>

View File

@@ -27,6 +27,7 @@
</details>
</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="/profiles">Profiles</a></li>
<li><a href="/logout">Logout</a></li>

View 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>

View 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
View 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()

View 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)

View 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()