[workouts] Add workouts app with exercise catalog and workout routines (ca613753)

This commit is contained in:
2026-08-06 20:36:24 -04:00
parent 550e17c5c5
commit 5def87e7f1
43 changed files with 1892 additions and 31 deletions

View File

View File

@ -0,0 +1,55 @@
import pytest
from django.contrib.auth import get_user_model
from django.utils import timezone
from scrobbles.models import Scrobble
from workouts.models import Exercise, WorkoutLogData, WorkoutRoutine
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create(email="lifter@example.com")
@pytest.fixture
def exercise(db):
return Exercise.objects.create(
name="Deadlift",
force="pull",
level="intermediate",
mechanic="compound",
equipment="barbell",
category="strength",
primary_muscles=["lower back"],
secondary_muscles=["glutes"],
instructions=["Step 1", "Step 2"],
)
@pytest.fixture
def workout_routine(db):
return WorkoutRoutine.objects.create(title="Test Routine")
@pytest.fixture
def workout_scrobble(user, workout_routine, exercise):
return Scrobble.objects.create(
user=user,
workout_routine=workout_routine,
media_type=Scrobble.MediaType.WORKOUT,
timestamp=timezone.now(),
played_to_completion=True,
log={
"workouts": [
{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight_kg": 50.0}
],
"duration_minutes": 45,
"bodyweight_kg": 80.0,
},
)
@pytest.fixture
def logdata():
return WorkoutLogData()

View File

@ -0,0 +1,114 @@
from django.http import QueryDict
from workouts.forms import WorkoutSetsField, WorkoutSetsWidget
from workouts.models import WorkoutLogData
from workouts.utils import lbs_to_kg
def build_post_data(exercise_id, rows):
qd = QueryDict("", mutable=True)
qd.setlist("workouts_exercise_id", [str(r["exercise_id"]) for r in rows])
qd.setlist("workouts_sets", [str(r.get("sets") or "") for r in rows])
qd.setlist("workouts_reps", [str(r.get("reps") or "") for r in rows])
qd.setlist("workouts_weight", [str(r.get("weight") or "") for r in rows])
qd.setlist("workouts_notes", [r.get("notes") or "" for r in rows])
return qd
class TestWorkoutSetsField:
def field_value(self, field, qd):
return field.widget.value_from_datadict(qd, {}, "workouts")
def test_clean_metric(self, exercise):
field = WorkoutSetsField(units="metric")
qd = build_post_data(
exercise.id,
[{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight": 50}],
)
result = field.clean(self.field_value(field, qd))
assert result[0]["exercise_id"] == exercise.id
assert result[0]["sets"] == 3
assert result[0]["reps"] == 10
assert result[0]["weight_kg"] == 50.0
def test_clean_imperial(self, exercise):
field = WorkoutSetsField(units="imperial")
qd = build_post_data(
exercise.id,
[{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight": 45}],
)
result = field.clean(self.field_value(field, qd))
assert result[0]["weight_kg"] == lbs_to_kg(45)
def test_clean_empty(self, exercise):
field = WorkoutSetsField(units="metric")
qd = build_post_data(
exercise.id, [{"exercise_id": "", "sets": "", "reps": "", "weight": ""}]
)
assert field.clean(self.field_value(field, qd)) is None
def test_clean_missing_exercise(self, exercise):
field = WorkoutSetsField(units="metric")
qd = build_post_data(
exercise.id,
[{"exercise_id": "", "sets": 3, "reps": 10, "weight": 50}],
)
assert field.clean(self.field_value(field, qd)) is None
class TestWorkoutLogDataForm:
def test_form_valid_metric(self, exercise):
form = WorkoutLogData.form()(
build_post_data(
exercise.id,
[
{"exercise_id": exercise.id, "sets": 4, "reps": 8, "weight": 45},
{
"exercise_id": exercise.id,
"sets": 3,
"reps": 12,
"weight": 30,
"notes": "drop set",
},
],
)
)
WorkoutLogData.prepare_form(form, "metric")
assert form.is_valid(), form.errors
data = form.cleaned_data
assert data["workouts"][0]["sets"] == 4
assert data["workouts"][0]["weight_kg"] == 45.0
assert data["workouts"][1]["notes"] == "drop set"
def test_form_valid_imperial(self, exercise):
form = WorkoutLogData.form()(
build_post_data(
exercise.id,
[{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight": 100}],
)
)
WorkoutLogData.prepare_form(form, "imperial")
assert form.is_valid(), form.errors
data = form.cleaned_data
assert data["workouts"][0]["weight_kg"] == lbs_to_kg(100)
def test_form_bodyweight_imperial(self):
qd = QueryDict("", mutable=True)
qd["bodyweight_kg"] = "180.5"
form = WorkoutLogData.form()(qd)
WorkoutLogData.prepare_form(form, "imperial")
assert form.is_valid(), form.errors
data = form.cleaned_data.copy()
WorkoutLogData.normalize_form_data(data, "imperial")
assert data["bodyweight_kg"] == lbs_to_kg(180.5)
class TestWorkoutSetsWidget:
def test_value_from_datadict(self, exercise):
widget = WorkoutSetsWidget()
qd = build_post_data(
exercise.id,
[{"exercise_id": exercise.id, "sets": 3, "reps": 10, "weight": 50}],
)
value = widget.value_from_datadict(qd, {}, "workouts")
assert value["exercise_id"] == [str(exercise.id)]
assert value["weight"] == ["50"]

View File

@ -0,0 +1,114 @@
import json
import os
import tarfile
import tempfile
import pytest
from workouts.importer import (
_find_root,
_image_paths_for,
import_wrkout_exercises,
load_exercises_data,
)
from workouts.models import Exercise
SAMPLE_EXERCISE = {
"name": "3/4 Sit-Up",
"force": "pull",
"level": "beginner",
"mechanic": "compound",
"equipment": "body only",
"primaryMuscles": ["abdominals"],
"secondaryMuscles": [],
"instructions": ["Lie down.", "Sit up."],
"category": "strength",
}
@pytest.fixture
def sample_tarball():
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as f:
tarball_path = f.name
with tempfile.TemporaryDirectory() as src:
root = os.path.join(src, "exercises.json-master")
exercise_dir = os.path.join(root, "exercises", "3_4_Sit-Up")
images_dir = os.path.join(exercise_dir, "images")
os.makedirs(images_dir)
with open(os.path.join(exercise_dir, "exercise.json"), "w") as f:
json.dump(SAMPLE_EXERCISE, f)
with open(os.path.join(images_dir, "0.jpg"), "wb") as f:
f.write(b"\xff\xd8\xff\xe0fakejpeg")
with tarfile.open(tarball_path, "w:gz") as tf:
for dirpath, dirnames, filenames in os.walk(src):
for filename in filenames:
full = os.path.join(dirpath, filename)
arcname = os.path.relpath(full, src)
tf.add(full, arcname=arcname)
return tarball_path
class TestHelpers:
def test_find_root(self, sample_tarball):
import tempfile
dest = tempfile.mkdtemp()
with tarfile.open(sample_tarball, "r:gz") as tf:
tf.extractall(dest)
root = _find_root(dest)
assert root.endswith("exercises.json-master")
def test_load_exercises_data(self, sample_tarball):
import tempfile
dest = tempfile.mkdtemp()
with tarfile.open(sample_tarball, "r:gz") as tf:
tf.extractall(dest)
root = _find_root(dest)
data = load_exercises_data(root)
assert len(data) == 1
assert data[0]["name"] == "3/4 Sit-Up"
assert os.path.isdir(data[0]["_dir"])
def test_image_paths_for(self, sample_tarball):
import tempfile
dest = tempfile.mkdtemp()
with tarfile.open(sample_tarball, "r:gz") as tf:
tf.extractall(dest)
root = _find_root(dest)
data = load_exercises_data(root)
paths = _image_paths_for(data[0]["_dir"])
assert len(paths) == 1
assert paths[0].endswith("0.jpg")
class TestImport:
def test_import_creates_exercises(self, db, sample_tarball):
result = import_wrkout_exercises(tarball_path=sample_tarball)
assert result["created"] == 1
exercise = Exercise.objects.get(name="3/4 Sit-Up")
assert exercise.level == "beginner"
assert exercise.primary_muscles == ["abdominals"]
assert exercise.instructions == ["Lie down.", "Sit up."]
assert exercise.photo
def test_import_dry_run(self, db, sample_tarball):
result = import_wrkout_exercises(tarball_path=sample_tarball, dry_run=True)
assert result["created"] == 0
assert Exercise.objects.count() == 0
def test_import_no_images(self, db, sample_tarball):
result = import_wrkout_exercises(
tarball_path=sample_tarball, import_images=False
)
assert result["created"] == 1
exercise = Exercise.objects.get(name="3/4 Sit-Up")
assert not exercise.photo
def test_import_updates_existing(self, db, sample_tarball):
Exercise.objects.create(name="3/4 Sit-Up", level="beginner")
result = import_wrkout_exercises(
tarball_path=sample_tarball, import_images=False
)
assert result["created"] == 0
assert result["updated"] == 1

View File

@ -0,0 +1,120 @@
from workouts.models import (
Exercise,
WorkoutLogData,
WorkoutRoutine,
WorkoutSetEntry,
)
from workouts.utils import (
display_value,
display_weight,
kg_to_lbs,
lbs_to_kg,
normalize_weight,
)
class TestWeightUtils:
def test_lbs_to_kg(self):
assert lbs_to_kg(1) == 0.45
assert lbs_to_kg(100) == 45.36
def test_kg_to_lbs(self):
assert kg_to_lbs(1) == 2.2
assert kg_to_lbs(45.36) == 100.0
def test_normalize_weight_metric(self):
assert normalize_weight("50", "metric") == 50.0
def test_normalize_weight_imperial(self):
assert normalize_weight("45", "imperial") == lbs_to_kg(45)
def test_normalize_weight_empty(self):
assert normalize_weight("", "metric") is None
assert normalize_weight(None, "metric") is None
def test_display_value(self):
assert display_value(50, "metric") == 50.0
assert display_value(45.36, "imperial") == 100.0
def test_display_weight(self):
assert display_weight(50, "metric") == "50.0 kg"
assert display_weight(45.36, "imperial") == "100.0 lbs"
class TestExerciseModel:
def test_str(self, exercise):
assert str(exercise) == "Deadlift"
def test_find_or_create(self, exercise):
found = Exercise.find_or_create("deadlift")
assert found.id == exercise.id
new = Exercise.find_or_create("New Exercise")
assert new.id != exercise.id
class TestWorkoutRoutineModel:
def test_media_type_label(self, workout_routine):
assert workout_routine.media_type_label == "Workout"
def test_find_or_create(self, workout_routine):
found = WorkoutRoutine.find_or_create("test routine")
assert found.id == workout_routine.id
def test_logdata_cls(self, workout_routine):
assert workout_routine.logdata_cls is WorkoutLogData
class TestWorkoutLogData:
def test_as_html_metric(self, workout_scrobble, exercise):
html = workout_scrobble.logdata.as_html()
assert "Deadlift" in html
assert "3 x 10" in html
assert "50.0 kg" in html
def test_as_html_imperial(self, workout_scrobble, exercise):
html = workout_scrobble.logdata.as_html(units="imperial")
assert "110.2 lbs" in html
def test_workout_list(self, workout_scrobble):
assert "Deadlift" in workout_scrobble.logdata.workout_list
def test_from_log_dict_roundtrip(self, workout_scrobble):
log_dict = workout_scrobble.log
data = WorkoutLogData.from_log_dict(log_dict)
assert (
data["workouts"][0]["exercise_id"]
== workout_scrobble.log["workouts"][0]["exercise_id"]
)
restored = WorkoutLogData(**data)
assert restored.workouts[0]["sets"] == 3
assert restored.workouts[0]["weight_kg"] == 50.0
entry = WorkoutSetEntry(**restored.workouts[0])
assert entry.sets == 3
assert entry.weight_kg == 50.0
def test_override_fields(self):
fields = WorkoutLogData.override_fields()
assert "workouts" in fields
assert "with_people_ids" in fields
def test_prepare_form_metric(self, logdata):
form = WorkoutLogData.form()()
WorkoutLogData.prepare_form(form, "metric")
assert form.fields["workouts"].units == "metric"
assert form.fields["bodyweight_kg"].label == "Bodyweight (kg)"
def test_prepare_form_imperial(self, logdata):
form = WorkoutLogData.form()()
WorkoutLogData.prepare_form(form, "imperial")
assert form.fields["workouts"].units == "imperial"
assert form.fields["bodyweight_kg"].label == "Bodyweight (lbs)"
def test_normalize_form_data_imperial(self, logdata):
data = {"bodyweight_kg": 180.5}
WorkoutLogData.normalize_form_data(data, "imperial")
assert data["bodyweight_kg"] == lbs_to_kg(180.5)
def test_normalize_form_data_metric_no_change(self, logdata):
data = {"bodyweight_kg": 80.0}
WorkoutLogData.normalize_form_data(data, "metric")
assert data["bodyweight_kg"] == 80.0

View File

@ -0,0 +1,105 @@
from django.test import Client
from django.urls import reverse
from scrobbles.models import Scrobble
class TestExerciseViews:
def test_exercise_list_anonymous_redirects(self, db):
client = Client()
response = client.get(reverse("workouts:exercise_list"))
assert response.status_code == 302
def test_exercise_list_authenticated(self, db, user, exercise):
client = Client()
client.force_login(user)
response = client.get(reverse("workouts:exercise_list"))
assert response.status_code == 200
assert "Deadlift" in response.content.decode()
def test_exercise_detail(self, db, user, exercise):
client = Client()
client.force_login(user)
response = client.get(
reverse("workouts:exercise_detail", kwargs={"slug": exercise.uuid})
)
assert response.status_code == 200
assert "Deadlift" in response.content.decode()
class TestWorkoutRoutineViews:
def test_routine_list(self, db, user, workout_routine, workout_scrobble):
client = Client()
client.force_login(user)
response = client.get(reverse("workouts:workout_routine_list"))
assert response.status_code == 200
def test_routine_detail(self, db, user, workout_routine, workout_scrobble):
client = Client()
client.force_login(user)
response = client.get(
reverse(
"workouts:workout_routine_detail",
kwargs={"slug": workout_routine.uuid},
)
)
assert response.status_code == 200
assert "Test Routine" in response.content.decode()
class TestWorkoutScrobbleViews:
def test_scrobble_detail_shows_workouts(self, db, user, workout_scrobble):
client = Client()
client.force_login(user)
response = client.get(workout_scrobble.get_absolute_url())
assert response.status_code == 200
assert b"Workout Details" in response.content
def test_scrobble_post_imperial_conversion(
self, db, user, workout_scrobble, exercise
):
user.profile.weigh_in_units = "imperial"
user.profile.save()
client = Client()
client.force_login(user)
response = client.post(
workout_scrobble.get_absolute_url(),
{
"duration_minutes": "45",
"bodyweight_kg": "180.5",
"workouts_exercise_id": [str(exercise.id)],
"workouts_sets": ["4"],
"workouts_reps": ["8"],
"workouts_weight": ["100"],
"workouts_notes": [""],
},
)
assert response.status_code == 302
updated = Scrobble.objects.get(pk=workout_scrobble.pk)
log = updated.log
assert log["bodyweight_kg"] == round(180.5 * 0.45359237, 2)
assert log["workouts"][0]["weight_kg"] == round(100 * 0.45359237, 2)
def test_scrobble_post_metric_no_conversion(
self, db, user, workout_scrobble, exercise
):
user.profile.weigh_in_units = "metric"
user.profile.save()
client = Client()
client.force_login(user)
response = client.post(
workout_scrobble.get_absolute_url(),
{
"duration_minutes": "45",
"bodyweight_kg": "80.5",
"workouts_exercise_id": [str(exercise.id)],
"workouts_sets": ["4"],
"workouts_reps": ["8"],
"workouts_weight": ["50"],
"workouts_notes": [""],
},
)
assert response.status_code == 302
updated = Scrobble.objects.get(pk=workout_scrobble.pk)
log = updated.log
assert log["bodyweight_kg"] == 80.5
assert log["workouts"][0]["weight_kg"] == 50.0