diff --git a/app/services/exercise_service.py b/app/services/exercise_service.py new file mode 100644 index 0000000..34bd791 --- /dev/null +++ b/app/services/exercise_service.py @@ -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()) diff --git a/tests/test_exercise_service.py b/tests/test_exercise_service.py new file mode 100644 index 0000000..086d57d --- /dev/null +++ b/tests/test_exercise_service.py @@ -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()