feat: add ExerciseService with filtering and warmup support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 10:07:33 -06:00
parent afb2cdf308
commit 42f6667b23
2 changed files with 183 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
"""Service layer for exercise, warmup, and workout day data access.
All exercise-related database operations go through this service.
"""
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.exercise import Exercise
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
logger = structlog.get_logger(__name__)
class ExerciseService:
"""Handles read operations for exercises, warmups, and workout days.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def list_exercises(
self,
workout_day: Optional[str] = None,
muscle_group: Optional[str] = None,
) -> list[Exercise]:
"""List exercises with optional filtering.
Args:
workout_day: Filter by workout day name (e.g., "Push").
muscle_group: Filter by muscle group (e.g., "Chest").
Returns:
List of matching Exercise records.
"""
statement = select(Exercise)
if workout_day:
statement = statement.where(Exercise.workout_day == workout_day)
if muscle_group:
statement = statement.where(Exercise.muscle_group == muscle_group)
return list(self._session.exec(statement).all())
def get_exercise_by_id(self, exercise_id: int) -> Optional[Exercise]:
"""Retrieve an exercise by primary key.
Args:
exercise_id: The exercise ID.
Returns:
The Exercise record, or None if not found.
"""
return self._session.get(Exercise, exercise_id)
def get_exercise_by_name(self, name: str) -> Optional[Exercise]:
"""Retrieve an exercise by exact name match.
Args:
name: The exercise name to look up.
Returns:
The Exercise record, or None if not found.
"""
statement = select(Exercise).where(Exercise.name == name)
return self._session.exec(statement).first()
def list_warmups(self) -> list[Warmup]:
"""List all warmups in display order.
Returns:
List of Warmup records sorted by sort_order.
"""
statement = select(Warmup).order_by(Warmup.sort_order)
return list(self._session.exec(statement).all())
def list_workout_days(self) -> list[WorkoutDay]:
"""List all workout days in order.
Returns:
List of WorkoutDay records sorted by day_number.
"""
statement = select(WorkoutDay).order_by(WorkoutDay.day_number)
return list(self._session.exec(statement).all())

View File

@@ -0,0 +1,95 @@
"""Tests for the ExerciseService class."""
from sqlmodel import SQLModel, Session, create_engine
from app.models.exercise import Exercise
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
from app.services.exercise_service import ExerciseService
class TestExerciseService:
"""Tests for exercise and warmup data access."""
def _setup(self):
"""Create an in-memory DB with seed data and return the service."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
service = ExerciseService(session)
# Seed workout days
for i, (name, desc) in enumerate([
("Push", "Chest, shoulders, triceps"),
("Pull", "Back, biceps, traps"),
("Lower", "Quads, hamstrings, glutes, calves"),
("Full Body", "Compound movements"),
], start=1):
session.add(WorkoutDay(name=name, day_number=i, description=desc))
# Seed a few exercises
session.add(Exercise(name="DB Chest Press", muscle_group="Chest", workout_day="Push", sets=3, tempo="3-1-2", form_cues="..."))
session.add(Exercise(name="DB Row", muscle_group="Back", workout_day="Pull", sets=3, tempo="3-1-2", form_cues="..."))
# Seed warmups
session.add(Warmup(name="Cat / Cow", type="Thoracic Mob", reps="8 reps", form_cues="...", sort_order=1))
session.add(Warmup(name="Fire Hydrant", type="Hip Mobility", reps="8 each", form_cues="...", sort_order=2))
session.commit()
return session, service
def test_list_exercises_all(self) -> None:
"""list_exercises should return all exercises."""
session, service = self._setup()
exercises = service.list_exercises()
assert len(exercises) == 2
session.close()
def test_list_exercises_by_workout_day(self) -> None:
"""list_exercises should filter by workout day."""
session, service = self._setup()
exercises = service.list_exercises(workout_day="Push")
assert len(exercises) == 1
assert exercises[0].name == "DB Chest Press"
session.close()
def test_list_exercises_by_muscle_group(self) -> None:
"""list_exercises should filter by muscle group."""
session, service = self._setup()
exercises = service.list_exercises(muscle_group="Back")
assert len(exercises) == 1
session.close()
def test_get_exercise_by_id(self) -> None:
"""get_exercise_by_id should return the correct exercise."""
session, service = self._setup()
exercises = service.list_exercises()
found = service.get_exercise_by_id(exercises[0].id)
assert found is not None
session.close()
def test_get_exercise_by_name(self) -> None:
"""get_exercise_by_name should return the correct exercise."""
session, service = self._setup()
found = service.get_exercise_by_name("DB Chest Press")
assert found is not None
assert found.muscle_group == "Chest"
session.close()
def test_list_warmups_ordered(self) -> None:
"""list_warmups should return warmups in sort_order."""
session, service = self._setup()
warmups = service.list_warmups()
assert len(warmups) == 2
assert warmups[0].name == "Cat / Cow"
assert warmups[1].name == "Fire Hydrant"
session.close()
def test_list_workout_days(self) -> None:
"""list_workout_days should return all 4 days in order."""
session, service = self._setup()
days = service.list_workout_days()
assert len(days) == 4
assert days[0].name == "Push"
assert days[3].name == "Full Body"
session.close()