feat: add SeedService for YAML-based database seeding
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
233
app/services/seed_service.py
Normal file
233
app/services/seed_service.py
Normal 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")
|
||||
94
tests/test_seed_service.py
Normal file
94
tests/test_seed_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user