Simplifies the progression model to a universal rep ladder: every exercise follows 6→8→10→12 reps at current weight, then +5 lbs and reset to 6. Replaces per-user wk1/wk4 rep and weight targets with a single starting_weight field. - Add Alembic migration to drop wk1_reps/wk4_reps/wk1_weight/wk4_weight, add starting_weight (migrated from wk1_weight) - Run Alembic migrations on app startup instead of create_all, with auto-detection and stamping for legacy databases - Include alembic/ and alembic.ini in Docker image - Rewrite progression_service.get_suggestion() with ladder logic: climb, hold, weight_increase, hold_at_top, deload - Replace wk1/wk4 grid in exercise cards with rep ladder progress bar - Add color-coded progression badges by type - Change weight log input from text to number with pre-filled suggestion - Normalize weight input in routes (0→BW, bare number→N lbs) - Remove schedule page (route, template, nav link, tests) - Simplify user_programs.yaml from 4 fields to 1 per exercise - Update all tests for new schema and progression logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
175 lines
6.5 KiB
Python
175 lines
6.5 KiB
Python
"""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.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
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."""
|
|
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_user_programs(self) -> None:
|
|
"""Seed user profiles and their exercise programs from user_programs.yaml."""
|
|
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,
|
|
display_name=display_name,
|
|
height=profile.get("height", ""),
|
|
weight=profile.get("weight", ""),
|
|
goals=profile.get("goals", ""),
|
|
)
|
|
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,
|
|
starting_weight=str(ex_data.get("starting_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."""
|
|
logger.info("seed_all_started")
|
|
self.seed_workout_days()
|
|
self.seed_exercises()
|
|
self.seed_warmups()
|
|
self.seed_user_programs()
|
|
logger.info("seed_all_complete")
|