diff --git a/app/models/__init__.py b/app/models/__init__.py index e69de29..d482f26 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -0,0 +1,24 @@ +"""SQLModel model definitions for SneakySwole. + +All 8 tables are defined here and re-exported for convenient imports. +""" + +from app.models.user import User +from app.models.exercise import Exercise +from app.models.warmup import Warmup +from app.models.workout_day import WorkoutDay +from app.models.user_exercise_program import UserExerciseProgram +from app.models.workout_session import WorkoutSession +from app.models.workout_log import WorkoutLog +from app.models.progress_log import ProgressLog + +__all__ = [ + "User", + "Exercise", + "Warmup", + "WorkoutDay", + "UserExerciseProgram", + "WorkoutSession", + "WorkoutLog", + "ProgressLog", +] diff --git a/app/models/exercise.py b/app/models/exercise.py new file mode 100644 index 0000000..7ed3c5a --- /dev/null +++ b/app/models/exercise.py @@ -0,0 +1,35 @@ +"""Exercise model for the exercise library catalog. + +Each exercise belongs to a workout day and includes form cues. +""" + +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class Exercise(SQLModel, table=True): + """An exercise in the workout library. + + Attributes: + id: Primary key, auto-incremented. + name: Exercise name (e.g., "DB Chest Press (Floor)"). + muscle_group: Target muscle group (e.g., "Chest", "Shoulders"). + workout_day: Which day this exercise belongs to (Push/Pull/Lower/Full Body). + sets: Default number of sets. + tempo: Tempo notation (e.g., "3-1-2" = 3s eccentric, 1s pause, 2s concentric). + form_cues: Detailed form instructions. + created_at: Timestamp when the record was created. + """ + + __tablename__ = "exercises" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + muscle_group: str = Field(default="") + workout_day: str = Field(index=True) + sets: int = Field(default=3) + tempo: str = Field(default="") + form_cues: str = Field(default="") + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/app/models/progress_log.py b/app/models/progress_log.py new file mode 100644 index 0000000..b1bad35 --- /dev/null +++ b/app/models/progress_log.py @@ -0,0 +1,39 @@ +"""ProgressLog model for tracking progression suggestions and outcomes. + +Records what the progression engine suggested vs what the user actually did. +""" + +import datetime as dt +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class ProgressLog(SQLModel, table=True): + """A progression tracking entry for a specific exercise. + + Attributes: + id: Primary key, auto-incremented. + user_id: FK to users table. + exercise_id: FK to exercises table. + date: The date this progression entry applies to. + suggested_reps: What the engine recommended. + suggested_weight: What the engine recommended. + actual_reps: What the user actually did. + actual_weight: What the user actually used. + progression_applied: Type of progression (e.g., "reps_increase", "weight_increase", "deload"). + created_at: Timestamp when the record was created. + """ + + __tablename__ = "progress_log" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", index=True) + exercise_id: int = Field(foreign_key="exercises.id") + date: dt.date = Field(default_factory=dt.date.today) + suggested_reps: Optional[int] = Field(default=None) + suggested_weight: Optional[str] = Field(default=None) + actual_reps: Optional[int] = Field(default=None) + actual_weight: Optional[str] = Field(default=None) + progression_applied: Optional[str] = Field(default=None) + created_at: dt.datetime = Field(default_factory=dt.datetime.utcnow) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..41f831b --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,39 @@ +"""User model for profile management. + +Stores admin and regular user profiles with physical stats and goals. +""" + +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class User(SQLModel, table=True): + """A user profile in the system. + + Attributes: + id: Primary key, auto-incremented. + username: Unique login identifier. + password_hash: bcrypt-hashed password (admin only initially). + 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. + """ + + __tablename__ = "users" + + 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) diff --git a/app/models/user_exercise_program.py b/app/models/user_exercise_program.py new file mode 100644 index 0000000..eb46dd5 --- /dev/null +++ b/app/models/user_exercise_program.py @@ -0,0 +1,37 @@ +"""UserExerciseProgram model for per-user exercise programming. + +Links a user to an exercise with week 1 and week 4 rep/weight targets. +""" + +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class UserExerciseProgram(SQLModel, table=True): + """Per-user programming for a specific exercise. + + Attributes: + id: Primary key, auto-incremented. + user_id: FK to users table. + exercise_id: FK to exercises table. + wk1_reps: Week 1 target reps (string to support "30 sec" style). + wk4_reps: Week 4 target reps. + wk1_weight: Week 1 target weight (e.g., "30 lbs", "BW"). + wk4_weight: Week 4 target weight. + created_at: Timestamp when the record was created. + updated_at: Timestamp of the last update. + """ + + __tablename__ = "user_exercise_programs" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", index=True) + exercise_id: int = Field(foreign_key="exercises.id", index=True) + wk1_reps: str = Field(default="") + wk4_reps: str = Field(default="") + wk1_weight: str = Field(default="") + wk4_weight: str = Field(default="") + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/app/models/warmup.py b/app/models/warmup.py new file mode 100644 index 0000000..b4389db --- /dev/null +++ b/app/models/warmup.py @@ -0,0 +1,33 @@ +"""Warmup model for the standardized warmup routine. + +Warmups are displayed before every workout in a fixed order. +""" + +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class Warmup(SQLModel, table=True): + """A warmup exercise in the standardized routine. + + Attributes: + id: Primary key, auto-incremented. + name: Warmup name (e.g., "Cat / Cow"). + type: Category (e.g., "Thoracic Mob", "Hip Mobility"). + reps: Rep scheme as a string (e.g., "8 reps", "8 each side"). + form_cues: Detailed form instructions. + sort_order: Display order in the warmup sequence. + created_at: Timestamp when the record was created. + """ + + __tablename__ = "warmups" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + type: str = Field(default="") + reps: str = Field(default="") + form_cues: str = Field(default="") + sort_order: int = Field(default=0) + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/app/models/workout_day.py b/app/models/workout_day.py new file mode 100644 index 0000000..a3aab1c --- /dev/null +++ b/app/models/workout_day.py @@ -0,0 +1,26 @@ +"""WorkoutDay model for the 4-day training split. + +Defines the named workout days and their order. +""" + +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class WorkoutDay(SQLModel, table=True): + """A named workout day in the training program. + + Attributes: + id: Primary key, auto-incremented. + name: Day name (Push, Pull, Lower, Full Body). + day_number: Order in the weekly rotation (1-4). + description: Brief description of the day's focus. + """ + + __tablename__ = "workout_days" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True, unique=True) + day_number: int = Field(unique=True) + description: str = Field(default="") diff --git a/app/models/workout_log.py b/app/models/workout_log.py new file mode 100644 index 0000000..0a39cad --- /dev/null +++ b/app/models/workout_log.py @@ -0,0 +1,37 @@ +"""WorkoutLog model for per-exercise set logging. + +Each log entry records one set of one exercise within a session. +""" + +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class WorkoutLog(SQLModel, table=True): + """A single set log for an exercise within a workout session. + + Attributes: + id: Primary key, auto-incremented. + session_id: FK to workout_sessions table. + exercise_id: FK to exercises table. + set_number: Which set this is (1, 2, 3...). + reps_completed: Actual reps performed. + weight_used: Weight used as string (e.g., "30 lbs", "BW"). + felt_easy: Whether the user felt the set was easy (progression signal). + notes: Optional notes about this specific set. + created_at: Timestamp when the record was created. + """ + + __tablename__ = "workout_logs" + + id: Optional[int] = Field(default=None, primary_key=True) + session_id: int = Field(foreign_key="workout_sessions.id", index=True) + exercise_id: int = Field(foreign_key="exercises.id") + set_number: int = Field(default=1) + reps_completed: int = Field(default=0) + weight_used: str = Field(default="") + felt_easy: bool = Field(default=False) + notes: Optional[str] = Field(default=None) + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/app/models/workout_session.py b/app/models/workout_session.py new file mode 100644 index 0000000..acf1606 --- /dev/null +++ b/app/models/workout_session.py @@ -0,0 +1,31 @@ +"""WorkoutSession model for tracking completed workout sessions. + +Each session ties a user to a workout day on a specific date. +""" + +import datetime as dt +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class WorkoutSession(SQLModel, table=True): + """A completed workout session. + + Attributes: + id: Primary key, auto-incremented. + user_id: FK to users table. + workout_day_id: FK to workout_days table. + date: The date the workout was performed. + notes: Optional free-text notes about the session. + created_at: Timestamp when the record was created. + """ + + __tablename__ = "workout_sessions" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", index=True) + workout_day_id: int = Field(foreign_key="workout_days.id") + date: dt.date = Field(default_factory=dt.date.today) + notes: Optional[str] = Field(default=None) + created_at: dt.datetime = Field(default_factory=dt.datetime.utcnow) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ac28cd0 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,228 @@ +"""Tests for SQLModel model definitions.""" + +from datetime import datetime + +from sqlmodel import SQLModel, Session, create_engine + +from app.models.user import User +from app.models.exercise import Exercise +from app.models.warmup import Warmup +from app.models.workout_day import WorkoutDay +from app.models.user_exercise_program import UserExerciseProgram +from app.models.workout_session import WorkoutSession +from app.models.workout_log import WorkoutLog +from app.models.progress_log import ProgressLog + + +class TestModels: + """Tests that all models can be instantiated and persisted.""" + + def _create_engine(self): + """Create an in-memory SQLite engine with all tables.""" + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + return engine + + def test_user_model_roundtrip(self) -> None: + """User model should persist and retrieve correctly.""" + engine = self._create_engine() + user = User( + username="testuser", + password_hash="fakehash", + display_name="Test User", + height="6'0\"", + weight="200 lbs", + goals="Get strong", + is_admin=False, + ) + with Session(engine) as session: + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + assert user.username == "testuser" + assert user.is_admin is False + assert isinstance(user.created_at, datetime) + + def test_exercise_model_roundtrip(self) -> None: + """Exercise model should persist and retrieve correctly.""" + engine = self._create_engine() + exercise = Exercise( + name="DB Chest Press (Floor)", + muscle_group="Chest", + workout_day="Push", + sets=3, + tempo="3-1-2", + form_cues="Lie on the floor...", + ) + with Session(engine) as session: + session.add(exercise) + session.commit() + session.refresh(exercise) + assert exercise.id is not None + assert exercise.name == "DB Chest Press (Floor)" + + def test_warmup_model_roundtrip(self) -> None: + """Warmup model should persist and retrieve correctly.""" + engine = self._create_engine() + warmup = Warmup( + name="Cat / Cow", + type="Thoracic Mob", + reps="8 reps", + form_cues="Start on hands and knees...", + sort_order=1, + ) + with Session(engine) as session: + session.add(warmup) + session.commit() + session.refresh(warmup) + assert warmup.id is not None + + def test_workout_day_model_roundtrip(self) -> None: + """WorkoutDay model should persist and retrieve correctly.""" + engine = self._create_engine() + day = WorkoutDay( + name="Push", + day_number=1, + description="Chest, shoulders, triceps", + ) + with Session(engine) as session: + session.add(day) + session.commit() + session.refresh(day) + assert day.id is not None + assert day.day_number == 1 + + def test_user_exercise_program_model_roundtrip(self) -> None: + """UserExerciseProgram model should persist with FK references.""" + engine = self._create_engine() + with Session(engine) as session: + user = User(username="u", password_hash="h", display_name="U") + exercise = Exercise( + name="Test Ex", muscle_group="Test", + workout_day="Push", sets=3, tempo="3-1-2", form_cues="..." + ) + session.add(user) + session.add(exercise) + session.commit() + session.refresh(user) + session.refresh(exercise) + + program = UserExerciseProgram( + user_id=user.id, + exercise_id=exercise.id, + wk1_reps="8", + wk4_reps="12", + wk1_weight="30 lbs", + wk4_weight="40 lbs", + ) + session.add(program) + session.commit() + session.refresh(program) + assert program.id is not None + assert program.user_id == user.id + + def test_workout_session_model_roundtrip(self) -> None: + """WorkoutSession model should persist correctly.""" + engine = self._create_engine() + with Session(engine) as session: + user = User(username="u", password_hash="h", display_name="U") + day = WorkoutDay(name="Push", day_number=1, description="Push day") + session.add(user) + session.add(day) + session.commit() + session.refresh(user) + session.refresh(day) + + ws = WorkoutSession( + user_id=user.id, + workout_day_id=day.id, + date=datetime.utcnow().date(), + ) + session.add(ws) + session.commit() + session.refresh(ws) + assert ws.id is not None + + def test_workout_log_model_roundtrip(self) -> None: + """WorkoutLog model should persist correctly.""" + engine = self._create_engine() + with Session(engine) as session: + user = User(username="u", password_hash="h", display_name="U") + day = WorkoutDay(name="Push", day_number=1, description="Push day") + exercise = Exercise( + name="Ex", muscle_group="Test", + 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=datetime.utcnow().date(), + ) + session.add(ws) + session.commit() + session.refresh(ws) + + log = WorkoutLog( + session_id=ws.id, + exercise_id=exercise.id, + set_number=1, + reps_completed=8, + weight_used="30 lbs", + felt_easy=False, + ) + session.add(log) + session.commit() + session.refresh(log) + assert log.id is not None + assert log.felt_easy is False + + def test_progress_log_model_roundtrip(self) -> None: + """ProgressLog model should persist correctly.""" + engine = self._create_engine() + with Session(engine) as session: + user = User(username="u", password_hash="h", display_name="U") + exercise = Exercise( + name="Ex", muscle_group="Test", + workout_day="Push", sets=3, tempo="3-1-2", form_cues="..." + ) + session.add_all([user, exercise]) + session.commit() + session.refresh(user) + session.refresh(exercise) + + pl = ProgressLog( + user_id=user.id, + exercise_id=exercise.id, + date=datetime.utcnow().date(), + suggested_reps=10, + suggested_weight="35 lbs", + actual_reps=10, + actual_weight="35 lbs", + progression_applied="reps_increase", + ) + session.add(pl) + session.commit() + session.refresh(pl) + assert pl.id is not None + + def test_all_models_importable_from_init(self) -> None: + """All models should be importable from app.models.""" + from app.models import ( + User, Exercise, Warmup, WorkoutDay, + UserExerciseProgram, WorkoutSession, WorkoutLog, ProgressLog, + ) + assert User is not None + assert Exercise is not None + assert Warmup is not None + assert WorkoutDay is not None + assert UserExerciseProgram is not None + assert WorkoutSession is not None + assert WorkoutLog is not None + assert ProgressLog is not None