Merge branch 'feat/phase2-data-layer'

This commit is contained in:
2026-02-24 10:15:10 -06:00
24 changed files with 1753 additions and 0 deletions

149
alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite:///data/sneakyswole.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

57
alembic/env.py Normal file
View File

@@ -0,0 +1,57 @@
"""Alembic environment configuration.
Imports all SQLModel models so autogenerate detects schema changes.
"""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel
# Import all models so SQLModel.metadata knows about them
from app.models import ( # noqa: F401
User, Exercise, Warmup, WorkoutDay,
UserExerciseProgram, WorkoutSession, WorkoutLog, ProgressLog,
)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode (SQL script generation)."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode (direct DB connection)."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,155 @@
"""initial schema - 8 tables
Revision ID: 1855836abf6c
Revises:
Create Date: 2026-02-24 09:35:47.297957
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '1855836abf6c'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('exercises',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('muscle_group', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('workout_day', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('sets', sa.Integer(), nullable=False),
sa.Column('tempo', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('form_cues', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_exercises_name'), 'exercises', ['name'], unique=False)
op.create_index(op.f('ix_exercises_workout_day'), 'exercises', ['workout_day'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('height', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('weight', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('goals', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('warmups',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('reps', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('form_cues', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_warmups_name'), 'warmups', ['name'], unique=False)
op.create_table('workout_days',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('day_number', sa.Integer(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('day_number')
)
op.create_index(op.f('ix_workout_days_name'), 'workout_days', ['name'], unique=True)
op.create_table('progress_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('suggested_reps', sa.Integer(), nullable=True),
sa.Column('suggested_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('actual_reps', sa.Integer(), nullable=True),
sa.Column('actual_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('progression_applied', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_progress_log_user_id'), 'progress_log', ['user_id'], unique=False)
op.create_table('user_exercise_programs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('wk1_reps', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('wk4_reps', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('wk1_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('wk4_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_exercise_programs_exercise_id'), 'user_exercise_programs', ['exercise_id'], unique=False)
op.create_index(op.f('ix_user_exercise_programs_user_id'), 'user_exercise_programs', ['user_id'], unique=False)
op.create_table('workout_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('workout_day_id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['workout_day_id'], ['workout_days.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_workout_sessions_user_id'), 'workout_sessions', ['user_id'], unique=False)
op.create_table('workout_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('set_number', sa.Integer(), nullable=False),
sa.Column('reps_completed', sa.Integer(), nullable=False),
sa.Column('weight_used', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('felt_easy', sa.Boolean(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ),
sa.ForeignKeyConstraint(['session_id'], ['workout_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_workout_logs_session_id'), 'workout_logs', ['session_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_workout_logs_session_id'), table_name='workout_logs')
op.drop_table('workout_logs')
op.drop_index(op.f('ix_workout_sessions_user_id'), table_name='workout_sessions')
op.drop_table('workout_sessions')
op.drop_index(op.f('ix_user_exercise_programs_user_id'), table_name='user_exercise_programs')
op.drop_index(op.f('ix_user_exercise_programs_exercise_id'), table_name='user_exercise_programs')
op.drop_table('user_exercise_programs')
op.drop_index(op.f('ix_progress_log_user_id'), table_name='progress_log')
op.drop_table('progress_log')
op.drop_index(op.f('ix_workout_days_name'), table_name='workout_days')
op.drop_table('workout_days')
op.drop_index(op.f('ix_warmups_name'), table_name='warmups')
op.drop_table('warmups')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_exercises_workout_day'), table_name='exercises')
op.drop_index(op.f('ix_exercises_name'), table_name='exercises')
op.drop_table('exercises')
# ### end Alembic commands ###

60
app/database.py Normal file
View File

@@ -0,0 +1,60 @@
"""Database engine and session management.
Provides a SQLModel engine factory and a session dependency
for use with FastAPI's dependency injection.
"""
from pathlib import Path
from typing import Generator
import structlog
from sqlmodel import Session, create_engine
logger = structlog.get_logger(__name__)
def get_engine(database_url: str):
"""Create a SQLAlchemy engine for the given database URL.
For SQLite, ensures the parent directory exists and sets
appropriate connection arguments.
Args:
database_url: SQLAlchemy-compatible connection string.
Returns:
A configured SQLAlchemy Engine.
"""
connect_args = {}
if database_url.startswith("sqlite"):
# Ensure the data directory exists
db_path = database_url.replace("sqlite:///", "")
if db_path and db_path != ":memory:":
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
connect_args = {"check_same_thread": False}
engine = create_engine(
database_url,
connect_args=connect_args,
echo=False,
)
logger.info("database_engine_created", url=database_url)
return engine
def get_db_session(engine) -> Generator[Session, None, None]:
"""Yield a SQLModel session for dependency injection.
Usage in FastAPI routes:
@router.get("/example")
def example(session: Session = Depends(get_db_session)):
...
Args:
engine: The SQLAlchemy engine to bind the session to.
Yields:
A SQLModel Session that auto-closes after use.
"""
with Session(engine) as session:
yield session

View File

@@ -10,11 +10,14 @@ import structlog
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlmodel import SQLModel, Session
from app.config import get_settings
from app.database import get_engine, get_db_session
from app.logging_config import setup_logging
from app.routes.health import router as health_router
from app.routes.pages import router as pages_router
from app.services.seed_service import SeedService
logger = structlog.get_logger(__name__)
@@ -50,6 +53,24 @@ def create_app() -> FastAPI:
app.include_router(health_router)
app.include_router(pages_router)
# Database setup
engine = get_engine(settings.database_url)
SQLModel.metadata.create_all(engine)
app.state.engine = engine
# DB session dependency for routes
def _get_session():
yield from get_db_session(engine)
app.dependency_overrides[get_db_session] = _get_session
# Seed database on startup
@app.on_event("startup")
async def on_startup():
with Session(engine) as session:
seed_service = SeedService(session)
seed_service.seed_all()
logger.info("app_started", environment=settings.app_env)
return app

View File

@@ -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",
]

35
app/models/exercise.py Normal file
View File

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

View File

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

39
app/models/user.py Normal file
View File

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

View File

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

33
app/models/warmup.py Normal file
View File

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

26
app/models/workout_day.py Normal file
View File

@@ -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="")

37
app/models/workout_log.py Normal file
View File

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

View File

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

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,233 @@
"""Service for seeding the database from YAML config files.
Reads config/exercises.yaml and config/user_programs.yaml to populate
the exercise library, warmups, workout days, and user programs.
"""
import os
from pathlib import Path
from typing import Optional
import bcrypt
import structlog
import yaml
from sqlmodel import Session, select
from app.models.exercise import Exercise
from app.models.user import User
from app.models.user_exercise_program import UserExerciseProgram
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
logger = structlog.get_logger(__name__)
# Default workout day definitions
WORKOUT_DAYS = [
{"name": "Push", "day_number": 1, "description": "Chest, shoulders, triceps"},
{"name": "Pull", "day_number": 2, "description": "Back, biceps, traps"},
{"name": "Lower", "day_number": 3, "description": "Quads, hamstrings, glutes, calves"},
{"name": "Full Body", "day_number": 4, "description": "Compound full-body movements"},
]
class SeedService:
"""Seeds the database from YAML configuration files.
Args:
session: An active SQLModel Session.
config_dir: Path to the config directory containing YAML files.
"""
def __init__(self, session: Session, config_dir: Optional[Path] = None) -> None:
self._session = session
self._config_dir = config_dir or Path(__file__).resolve().parent.parent.parent / "config"
def _load_yaml(self, filename: str) -> dict:
"""Load and parse a YAML file from the config directory.
Args:
filename: Name of the YAML file to load.
Returns:
Parsed YAML content as a dictionary.
"""
filepath = self._config_dir / filename
with open(filepath, "r") as f:
return yaml.safe_load(f)
def seed_workout_days(self) -> None:
"""Seed the 4 workout day records if they don't already exist."""
existing = self._session.exec(select(WorkoutDay)).first()
if existing:
logger.info("seed_skipped", table="workout_days", reason="already seeded")
return
for day_data in WORKOUT_DAYS:
day = WorkoutDay(**day_data)
self._session.add(day)
self._session.commit()
logger.info("seed_complete", table="workout_days", count=len(WORKOUT_DAYS))
def seed_exercises(self) -> None:
"""Seed exercises from config/exercises.yaml if table is empty."""
existing = self._session.exec(select(Exercise)).first()
if existing:
logger.info("seed_skipped", table="exercises", reason="already seeded")
return
data = self._load_yaml("exercises.yaml")
exercises = data.get("exercises", [])
for ex in exercises:
exercise = Exercise(
name=ex["name"],
muscle_group=ex["muscle_group"],
workout_day=ex["workout_day"],
sets=ex.get("sets", 3),
tempo=ex.get("tempo", ""),
form_cues=ex.get("form_cues", "").strip(),
)
self._session.add(exercise)
self._session.commit()
logger.info("seed_complete", table="exercises", count=len(exercises))
def seed_warmups(self) -> None:
"""Seed warmups from config/exercises.yaml if table is empty."""
existing = self._session.exec(select(Warmup)).first()
if existing:
logger.info("seed_skipped", table="warmups", reason="already seeded")
return
data = self._load_yaml("exercises.yaml")
warmups = data.get("warmup", [])
for i, wu in enumerate(warmups, start=1):
warmup = Warmup(
name=wu["name"],
type=wu.get("type", ""),
reps=wu.get("reps", ""),
form_cues=wu.get("form_cues", "").strip(),
sort_order=i,
)
self._session.add(warmup)
self._session.commit()
logger.info("seed_complete", table="warmups", count=len(warmups))
def seed_admin(self) -> None:
"""Create admin user from environment variables if not exists.
Reads ADMIN_USERNAME and ADMIN_PASSWORD from env,
hashes the password with bcrypt, and creates the admin user.
"""
admin_username = os.environ.get("ADMIN_USERNAME", "admin")
admin_password = os.environ.get("ADMIN_PASSWORD", "")
existing = self._session.exec(
select(User).where(User.username == admin_username)
).first()
if existing:
logger.info("seed_skipped", table="users", reason="admin already exists")
return
if not admin_password:
logger.warning("seed_skipped", table="users", reason="ADMIN_PASSWORD not set")
return
# Hash password with bcrypt
password_hash = bcrypt.hashpw(
admin_password.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8")
admin = User(
username=admin_username,
password_hash=password_hash,
display_name="Admin",
is_admin=True,
)
self._session.add(admin)
self._session.commit()
logger.info("seed_complete", table="users", user="admin")
def seed_user_programs(self) -> None:
"""Seed user profiles and their exercise programs from user_programs.yaml.
Creates non-admin user profiles and links them to exercises
with week 1/4 rep and weight targets.
"""
data = self._load_yaml("user_programs.yaml")
programs = data.get("programs", [])
for program in programs:
username = program["user"].lower().replace(" ", "_")
display_name = program["user"]
profile = program.get("profile", {})
# Check if user already exists
existing_user = self._session.exec(
select(User).where(User.username == username)
).first()
if existing_user:
user = existing_user
logger.info("seed_skipped", table="users", user=username, reason="already exists")
else:
user = User(
username=username,
password_hash="", # Non-admin users don't log in initially
display_name=display_name,
height=profile.get("height", ""),
weight=profile.get("weight", ""),
goals=profile.get("goals", ""),
is_admin=False,
)
self._session.add(user)
self._session.commit()
self._session.refresh(user)
logger.info("seed_complete", table="users", user=username)
# Link exercises to user
for ex_data in program.get("exercises", []):
exercise = self._session.exec(
select(Exercise).where(Exercise.name == ex_data["name"])
).first()
if exercise is None:
logger.warning("seed_exercise_not_found", name=ex_data["name"])
continue
# Check if program already exists
existing_program = self._session.exec(
select(UserExerciseProgram).where(
UserExerciseProgram.user_id == user.id,
UserExerciseProgram.exercise_id == exercise.id,
)
).first()
if existing_program:
continue
uep = UserExerciseProgram(
user_id=user.id,
exercise_id=exercise.id,
wk1_reps=str(ex_data.get("wk1_reps", "")),
wk4_reps=str(ex_data.get("wk4_reps", "")),
wk1_weight=str(ex_data.get("wk1_weight", "")),
wk4_weight=str(ex_data.get("wk4_weight", "")),
)
self._session.add(uep)
self._session.commit()
logger.info("seed_complete", table="user_exercise_programs", user=username)
def seed_all(self) -> None:
"""Run all seed operations in the correct order.
Order matters: workout_days and exercises must exist before
user_programs can reference them.
"""
logger.info("seed_all_started")
self.seed_workout_days()
self.seed_exercises()
self.seed_warmups()
self.seed_admin()
self.seed_user_programs()
logger.info("seed_all_complete")

View File

@@ -0,0 +1,128 @@
"""Service layer for user profile management.
All user database operations go through this service.
Routes should never query the users table directly.
"""
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.user import User
logger = structlog.get_logger(__name__)
class UserService:
"""Handles CRUD operations for User records.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def create_user(
self,
username: str,
password_hash: str,
display_name: str,
height: Optional[str] = None,
weight: Optional[str] = None,
goals: Optional[str] = None,
is_admin: bool = False,
) -> User:
"""Create a new user profile.
Args:
username: Unique login identifier.
password_hash: Pre-hashed password string.
display_name: Human-readable name.
height: User height as string.
weight: User weight as string.
goals: Free-text goals.
is_admin: Whether user has admin privileges.
Returns:
The newly created User record.
"""
user = User(
username=username,
password_hash=password_hash,
display_name=display_name,
height=height,
weight=weight,
goals=goals,
is_admin=is_admin,
)
self._session.add(user)
self._session.commit()
self._session.refresh(user)
logger.info("user_created", username=username, is_admin=is_admin)
return user
def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Retrieve a user by primary key.
Args:
user_id: The user's ID.
Returns:
The User record, or None if not found.
"""
return self._session.get(User, user_id)
def get_user_by_username(self, username: str) -> Optional[User]:
"""Retrieve a user by username.
Args:
username: The username to look up.
Returns:
The User record, or None if not found.
"""
statement = select(User).where(User.username == username)
return self._session.exec(statement).first()
def list_users(self, exclude_admin: bool = False) -> list[User]:
"""List all user profiles.
Args:
exclude_admin: If True, omit admin users from the result.
Returns:
List of User records.
"""
statement = select(User)
if exclude_admin:
statement = statement.where(User.is_admin == False) # noqa: E712
return list(self._session.exec(statement).all())
def update_user(self, user_id: int, **kwargs) -> User:
"""Update fields on an existing user.
Args:
user_id: The user's ID.
**kwargs: Field names and new values to update.
Returns:
The updated User record.
Raises:
ValueError: If the user is not found.
"""
user = self.get_user_by_id(user_id)
if user is None:
raise ValueError(f"User with id {user_id} not found")
for key, value in kwargs.items():
if hasattr(user, key):
setattr(user, key, value)
self._session.add(user)
self._session.commit()
self._session.refresh(user)
logger.info("user_updated", user_id=user_id, fields=list(kwargs.keys()))
return user

26
tests/test_database.py Normal file
View File

@@ -0,0 +1,26 @@
"""Tests for database engine and session management."""
from sqlmodel import Session
from app.database import get_engine, get_db_session
class TestDatabase:
"""Tests for database connection management."""
def test_get_engine_returns_engine(self) -> None:
"""get_engine should return a SQLAlchemy engine instance."""
engine = get_engine("sqlite:///data/test.db")
assert engine is not None
assert "sqlite" in str(engine.url)
def test_get_db_session_yields_session(self) -> None:
"""get_db_session should yield a usable SQLModel Session."""
engine = get_engine("sqlite:///:memory:")
session_gen = get_db_session(engine)
session = next(session_gen)
assert isinstance(session, Session)
try:
next(session_gen)
except StopIteration:
pass

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

228
tests/test_models.py Normal file
View File

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

View File

@@ -0,0 +1,94 @@
"""Tests for the SeedService class."""
from pathlib import Path
from unittest.mock import patch
import bcrypt
from sqlmodel import SQLModel, Session, create_engine, select
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.services.seed_service import SeedService
class TestSeedService:
"""Tests for YAML-based database seeding."""
def _setup(self):
"""Create an in-memory DB and seed service."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
# Use the real config files from the project
config_dir = Path(__file__).resolve().parent.parent / "config"
service = SeedService(session, config_dir=config_dir)
return session, service
def test_seed_workout_days(self) -> None:
"""seed_workout_days should create 4 workout day records."""
session, service = self._setup()
service.seed_workout_days()
days = session.exec(select(WorkoutDay)).all()
assert len(days) == 4
names = {d.name for d in days}
assert names == {"Push", "Pull", "Lower", "Full Body"}
session.close()
def test_seed_exercises_from_yaml(self) -> None:
"""seed_exercises should load all exercises from exercises.yaml."""
session, service = self._setup()
service.seed_exercises()
exercises = session.exec(select(Exercise)).all()
assert len(exercises) == 20 # 5 Push + 5 Pull + 5 Lower + 5 Full Body
session.close()
def test_seed_warmups_from_yaml(self) -> None:
"""seed_warmups should load all warmups from exercises.yaml."""
session, service = self._setup()
service.seed_warmups()
warmups = session.exec(select(Warmup)).all()
assert len(warmups) == 6
session.close()
def test_seed_admin_user(self) -> None:
"""seed_admin should create admin user with hashed password."""
session, service = self._setup()
with patch.dict("os.environ", {
"ADMIN_USERNAME": "admin",
"ADMIN_PASSWORD": "testpass",
}):
service.seed_admin()
admin = session.exec(select(User).where(User.is_admin == True)).first() # noqa: E712
assert admin is not None
assert admin.username == "admin"
assert bcrypt.checkpw(b"testpass", admin.password_hash.encode())
session.close()
def test_seed_user_programs(self) -> None:
"""seed_user_programs should create user profiles and link exercises."""
session, service = self._setup()
service.seed_exercises()
service.seed_user_programs()
programs = session.exec(select(UserExerciseProgram)).all()
assert len(programs) > 0
users = session.exec(select(User).where(User.is_admin == False)).all() # noqa: E712
assert len(users) == 2 # Phillip and Daughter
session.close()
def test_seed_is_idempotent(self) -> None:
"""Running seed_all twice should not create duplicate records."""
session, service = self._setup()
with patch.dict("os.environ", {
"ADMIN_USERNAME": "admin",
"ADMIN_PASSWORD": "testpass",
}):
service.seed_all()
service.seed_all()
users = session.exec(select(User)).all()
exercises = session.exec(select(Exercise)).all()
# Should not be doubled
assert len(exercises) == 20
session.close()

View File

@@ -0,0 +1,89 @@
"""Tests for the UserService class."""
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.services.user_service import UserService
class TestUserService:
"""Tests for user CRUD operations."""
def _setup(self):
"""Create an in-memory DB and service instance."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
service = UserService(session)
return session, service
def test_create_user(self) -> None:
"""create_user should insert a new user and return it."""
session, service = self._setup()
user = service.create_user(
username="phil",
password_hash="hashed",
display_name="Phillip",
height="6'0\"",
weight="260 lbs",
goals="Muscle build",
is_admin=False,
)
assert user.id is not None
assert user.username == "phil"
session.close()
def test_get_user_by_id(self) -> None:
"""get_user_by_id should return the correct user."""
session, service = self._setup()
created = service.create_user(
username="test", password_hash="h", display_name="Test"
)
found = service.get_user_by_id(created.id)
assert found is not None
assert found.username == "test"
session.close()
def test_get_user_by_username(self) -> None:
"""get_user_by_username should return the correct user."""
session, service = self._setup()
service.create_user(username="admin", password_hash="h", display_name="Admin")
found = service.get_user_by_username("admin")
assert found is not None
assert found.display_name == "Admin"
session.close()
def test_get_user_by_username_not_found(self) -> None:
"""get_user_by_username should return None for missing users."""
session, service = self._setup()
found = service.get_user_by_username("nonexistent")
assert found is None
session.close()
def test_list_users(self) -> None:
"""list_users should return all users."""
session, service = self._setup()
service.create_user(username="a", password_hash="h", display_name="A")
service.create_user(username="b", password_hash="h", display_name="B")
users = service.list_users()
assert len(users) == 2
session.close()
def test_list_non_admin_users(self) -> None:
"""list_users with exclude_admin=True should skip admin users."""
session, service = self._setup()
service.create_user(username="admin", password_hash="h", display_name="Admin", is_admin=True)
service.create_user(username="user", password_hash="h", display_name="User", is_admin=False)
users = service.list_users(exclude_admin=True)
assert len(users) == 1
assert users[0].username == "user"
session.close()
def test_update_user(self) -> None:
"""update_user should modify the specified fields."""
session, service = self._setup()
user = service.create_user(username="u", password_hash="h", display_name="Old")
updated = service.update_user(user.id, display_name="New", weight="250 lbs")
assert updated.display_name == "New"
assert updated.weight == "250 lbs"
session.close()