diff --git a/PROJECT.org b/PROJECT.org index 6053a3c..4ec8151 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -698,6 +698,30 @@ The Edit log form should have from top to bottom: *** Description ** TODO [#A] Add trends tests for concurrent trends :trends:tests:concurrent: +** TODO [#B] Add workouts app with exercise catalog and workout routines :workouts:feature: +:PROPERTIES: +:ID: ca613753-d203-4429-b062-36e3312bca82 +:END: + +*** Description + +Add a `workouts` Django app so we can scrobble gym sessions. + +- New `Exercise` model with the wrkout/exercises.json catalog, imported via + `python manage.py import_wrkout_exercises`. Image files come from inside the + tarball and are stored via ImageKit specs. +- New `WorkoutRoutine` model (ScrobblableMixin, media_type_label "Workout"), + scrobbled through the existing Scrobble system with a `workout_routine` FK and + `WorkoutLogData` storing sets/reps/weight per exercise plus duration, bodyweight + and RPE. +- Weights are stored canonically in kg; the profile's `weigh_in_units` setting + drives form input and display units. A generic + `apply_media_unit_conversions` helper in scrobbles/utils.py should handle both + drinks (`size_ml`) and workout weights, fixing the old GET-only drinks + conversion so imperial POSTs store kg. +- Templates, admin, DRF viewsets (`exercises`, `workout-routines`), MCP tools, + and `tests/workouts_tests/` (unit conversion, form round-trips, importer, + imperial POST) included. * Version 64.5 [1/1] ** DONE [#B] Clean up issues with org-mode notes :orgmode:notes:scrobbles: :PROPERTIES: diff --git a/tests/workouts_tests/__init__.py b/tests/workouts_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/workouts_tests/conftest.py b/tests/workouts_tests/conftest.py new file mode 100644 index 0000000..96644d2 --- /dev/null +++ b/tests/workouts_tests/conftest.py @@ -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() diff --git a/tests/workouts_tests/test_forms.py b/tests/workouts_tests/test_forms.py new file mode 100644 index 0000000..1849baf --- /dev/null +++ b/tests/workouts_tests/test_forms.py @@ -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"] diff --git a/tests/workouts_tests/test_importer.py b/tests/workouts_tests/test_importer.py new file mode 100644 index 0000000..3030555 --- /dev/null +++ b/tests/workouts_tests/test_importer.py @@ -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 diff --git a/tests/workouts_tests/test_models.py b/tests/workouts_tests/test_models.py new file mode 100644 index 0000000..305ea2a --- /dev/null +++ b/tests/workouts_tests/test_models.py @@ -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 diff --git a/tests/workouts_tests/test_views.py b/tests/workouts_tests/test_views.py new file mode 100644 index 0000000..14328ee --- /dev/null +++ b/tests/workouts_tests/test_views.py @@ -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 diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py index fb47524..20a9cc3 100644 --- a/vrobbler/apps/scrobbles/admin.py +++ b/vrobbler/apps/scrobbles/admin.py @@ -123,6 +123,7 @@ class ScrobbleAdmin(admin.ModelAdmin): "life_event", "birding_location", "disc_golf_course", + "workout_routine", "long_play_last_scrobble", ) list_filter = ( @@ -187,4 +188,5 @@ class FavoriteMediaAdmin(admin.ModelAdmin): "life_event", "birding_location", "disc_golf_course", + "workout_routine", ) diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.py index 4a0fbf9..7e06278 100644 --- a/vrobbler/apps/scrobbles/constants.py +++ b/vrobbler/apps/scrobbles/constants.py @@ -55,6 +55,7 @@ PLAY_AGAIN_MEDIA = { "birds": "BirdingLocation", "discgolf": "DiscGolfCourse", "nature": "SpeciesObservation", + "workouts": "Workout", } DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"] diff --git a/vrobbler/apps/scrobbles/mcp.py b/vrobbler/apps/scrobbles/mcp.py index f5f6371..a65b2d3 100644 --- a/vrobbler/apps/scrobbles/mcp.py +++ b/vrobbler/apps/scrobbles/mcp.py @@ -13,7 +13,8 @@ class ScrobbleToolset(MCPToolset): """List scrobbles from the last N days, optionally filtered by media type. Valid media_type values: Video, Track, PodcastEpisode, SportEvent, Book, Paper, VideoGame, BoardGame, GeoLocation, Trail, Beer, Puzzle, Food, Task, - WebPage, LifeEvent, Mood, BrickSet, Channel, BirdingLocation, DiscGolfCourse + WebPage, LifeEvent, Mood, BrickSet, Channel, BirdingLocation, DiscGolfCourse, + Workout """ qs = ( Scrobble.objects.filter(user=self.request.user) @@ -38,6 +39,7 @@ class ScrobbleToolset(MCPToolset): "birding_location", "disc_golf_course", "channel", + "workout_routine", ) .order_by("-timestamp") ) @@ -83,6 +85,7 @@ class ScrobbleToolset(MCPToolset): | Q(puzzle__title__icontains=query) | Q(brick_set__title__icontains=query) | Q(podcast_episode__title__icontains=query) + | Q(workout_routine__title__icontains=query) )[:limit] return [_scrobble_to_dict(s) for s in qs] @@ -591,6 +594,8 @@ def _scrobble_related_to_dict(s: Scrobble) -> dict | None: return _media_to_dict(s.disc_golf_course, fields=["title", "holes"]) if s.channel: return _media_to_dict(s.channel, fields=["title"]) + if s.workout_routine: + return _media_to_dict(s.workout_routine, fields=["title", "description"]) return None diff --git a/vrobbler/apps/scrobbles/migrations/0106_favoritemedia_workout_routine_and_more.py b/vrobbler/apps/scrobbles/migrations/0106_favoritemedia_workout_routine_and_more.py new file mode 100644 index 0000000..3b07481 --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0106_favoritemedia_workout_routine_and_more.py @@ -0,0 +1,108 @@ +# Generated by Django 4.2.29 on 2026-08-06 22:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("workouts", "0001_initial"), + ("scrobbles", "0105_scrobble_agent_session_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="favoritemedia", + name="workout_routine", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="workouts.workoutroutine", + ), + ), + migrations.AddField( + model_name="scrobble", + name="workout_routine", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="workouts.workoutroutine", + ), + ), + migrations.AlterField( + model_name="favoritemedia", + name="media_type", + field=models.CharField( + choices=[ + ("AgentSession", "Agent session"), + ("Video", "Video"), + ("Track", "Track"), + ("PodcastEpisode", "Podcast episode"), + ("SportEvent", "Sport event"), + ("Book", "Book"), + ("Paper", "Paper"), + ("VideoGame", "Video game"), + ("BoardGame", "Board game"), + ("GeoLocation", "GeoLocation"), + ("Trail", "Trail"), + ("Beer", "Beer"), + ("Wine", "Wine"), + ("Coffee", "Coffee"), + ("Drink", "Drink"), + ("Puzzle", "Puzzle"), + ("Food", "Food"), + ("Task", "Task"), + ("WebPage", "Web Page"), + ("LifeEvent", "Life event"), + ("Mood", "Mood"), + ("BrickSet", "Brick set"), + ("Channel", "Channel"), + ("BirdingLocation", "Birding location"), + ("DiscGolfCourse", "Disc golf"), + ("SpeciesObservation", "Species observation"), + ("Workout", "Workout"), + ], + max_length=20, + ), + ), + migrations.AlterField( + model_name="scrobble", + name="media_type", + field=models.CharField( + choices=[ + ("AgentSession", "Agent session"), + ("Video", "Video"), + ("Track", "Track"), + ("PodcastEpisode", "Podcast episode"), + ("SportEvent", "Sport event"), + ("Book", "Book"), + ("Paper", "Paper"), + ("VideoGame", "Video game"), + ("BoardGame", "Board game"), + ("GeoLocation", "GeoLocation"), + ("Trail", "Trail"), + ("Beer", "Beer"), + ("Wine", "Wine"), + ("Coffee", "Coffee"), + ("Drink", "Drink"), + ("Puzzle", "Puzzle"), + ("Food", "Food"), + ("Task", "Task"), + ("WebPage", "Web Page"), + ("LifeEvent", "Life event"), + ("Mood", "Mood"), + ("BrickSet", "Brick set"), + ("Channel", "Channel"), + ("BirdingLocation", "Birding location"), + ("DiscGolfCourse", "Disc golf"), + ("SpeciesObservation", "Species observation"), + ("Workout", "Workout"), + ], + default="Video", + max_length=20, + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 0fff2e5..6614930 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -58,7 +58,11 @@ from scrobbles.notifications import ( ScrobbleNtfyNotification, ) from scrobbles.sqids import encode_scrobble_share -from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key +from scrobbles.utils import ( + get_file_md5_hash, + media_class_to_foreign_key, + media_type_for_media_obj, +) from sports.models import SportEvent from taggit.managers import TaggableManager from tasks.models import Task @@ -701,6 +705,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = { "BirdingLocation": ("birding_location",), "DiscGolfCourse": ("disc_golf_course",), "SpeciesObservation": ("species_observation",), + "Workout": ("workout_routine",), } @@ -734,6 +739,7 @@ class ScrobbleQuerySet(models.QuerySet): "birding_location", "disc_golf_course", "species_observation", + "workout_routine", ) def with_related_for_types(self, media_types: list[str]): @@ -790,6 +796,7 @@ class Scrobble(TimeStampedModel): BIRDING_LOCATION = "BirdingLocation", "Birding location" DISC_GOLF = "DiscGolfCourse", "Disc golf" SPECIES_OBSERVATION = "SpeciesObservation", "Species observation" + WORKOUT = "Workout", "Workout" @classmethod def list(cls): @@ -832,6 +839,9 @@ class Scrobble(TimeStampedModel): species_observation = models.ForeignKey( SpeciesObservation, on_delete=models.DO_NOTHING, **BNULL ) + workout_routine = models.ForeignKey( + "workouts.WorkoutRoutine", on_delete=models.DO_NOTHING, **BNULL + ) media_type = models.CharField( max_length=20, choices=MediaType.choices, default=MediaType.VIDEO ) @@ -1011,7 +1021,9 @@ class Scrobble(TimeStampedModel): if self.timestamp: self.timestamp = self.timestamp.replace(microsecond=0) if self.media_obj: - self.media_type = self.MediaType(self.media_obj.__class__.__name__) + self.media_type = self.MediaType( + media_type_for_media_obj(self.media_obj) + ) if (self.timestamp and self.stop_timestamp) and ( not self.playback_position_seconds or self.playback_position_seconds <= 0 @@ -1420,6 +1432,8 @@ class Scrobble(TimeStampedModel): media_obj = self.disc_golf_course if self.species_observation: media_obj = self.species_observation + if self.workout_routine: + media_obj = self.workout_routine return media_obj def __str__(self): @@ -1984,6 +1998,9 @@ class FavoriteMedia(TimeStampedModel): disc_golf_course = models.ForeignKey( DiscGolfCourse, on_delete=models.CASCADE, **BNULL ) + workout_routine = models.ForeignKey( + "workouts.WorkoutRoutine", on_delete=models.CASCADE, **BNULL + ) media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices) sent_to_mopidy = models.BooleanField(default=False) @@ -2042,11 +2059,13 @@ class FavoriteMedia(TimeStampedModel): media_obj = self.birding_location if self.disc_golf_course: media_obj = self.disc_golf_course + if self.workout_routine: + media_obj = self.workout_routine return media_obj @classmethod def toggle(cls, media_obj, user): - media_type = media_obj.__class__.__name__ + media_type = media_type_for_media_obj(media_obj) if media_type not in Scrobble.MediaType.list(): raise ValueError(f"Unknown media type: {media_type}") @@ -2075,6 +2094,7 @@ class FavoriteMedia(TimeStampedModel): "BrickSet": "brick_set", "BirdingLocation": "birding_location", "DiscGolfCourse": "disc_golf_course", + "Workout": "workout_routine", } fk = fk_map.get(media_type) diff --git a/vrobbler/apps/scrobbles/templatetags/form_tags.py b/vrobbler/apps/scrobbles/templatetags/form_tags.py index af5d7a0..98ea3c4 100644 --- a/vrobbler/apps/scrobbles/templatetags/form_tags.py +++ b/vrobbler/apps/scrobbles/templatetags/form_tags.py @@ -48,3 +48,8 @@ def get_item(dictionary, key): @register.filter def agent_session_html(logdata, scrobble_id=None): return logdata.as_html(scrobble_id=scrobble_id) + + +@register.filter +def workout_html(logdata, units="metric"): + return logdata.as_html(units=units) diff --git a/vrobbler/apps/scrobbles/utils.py b/vrobbler/apps/scrobbles/utils.py index 02a1bbb..68ee5de 100644 --- a/vrobbler/apps/scrobbles/utils.py +++ b/vrobbler/apps/scrobbles/utils.py @@ -310,6 +310,82 @@ def media_class_to_foreign_key(media_class: str) -> str: return re.sub(r"(? str: + """Return the Scrobble.MediaType label for a media object. + + Defaults to the media class name (which matches MediaType for most models) + but honors an explicit media_type_label attribute when the class name and + MediaType value differ.""" + label = getattr(media_obj, "media_type_label", None) + if label: + return label + return media_obj.__class__.__name__ + + +def get_user_units(user) -> str: + """Return the weight units ("metric" or "imperial") a user prefers.""" + profile = getattr(user, "profile", None) + if profile: + return getattr(profile, "weigh_in_units", "metric") or "metric" + return "metric" + + +def apply_media_unit_conversions(form, media_type: str, user) -> str: + """Configure a scrobble log form's unit-sensitive fields based on a user's + profile preferences. + + Handles imperial drink sizes (oz <-> mL) and workout weights (lbs <-> kg). + Must be called on both the GET and POST form construction paths so that + submitted values are converted consistently with what is displayed. + + Returns the units string used ("metric" or "imperial"). + """ + units = get_user_units(user) + + if media_type == "Workout": + from workouts.models import WorkoutLogData + + WorkoutLogData.prepare_form(form, units) + + drink_types = ("Beer", "Wine", "Coffee") + if media_type in drink_types and "size_ml" in form.fields: + use_oz = getattr(getattr(user, "profile", None), "volume_unit", "metric") == "imperial" + if use_oz: + form.fields["size_ml"].label = "Size (oz)" + original_clean = form.fields["size_ml"].clean + + def clean_size_ml_oz(value): + val = original_clean(value) + if val is not None and val != "": + from drinks.models import ML_PER_OZ + + return round(float(val) * ML_PER_OZ) + return val + + form.fields["size_ml"].clean = clean_size_ml_oz + if form.initial.get("size_ml"): + from drinks.models import ML_PER_OZ + + form.fields["size_ml"].initial = round( + form.initial["size_ml"] / ML_PER_OZ, 1 + ) + else: + form.fields["size_ml"].label = "Size (mL)" + + return units + + +def normalize_log_data_for_units(data: dict, media_type: str, user) -> dict: + """Convert any imperial values in submitted log data into canonical + metric storage units before saving.""" + units = get_user_units(user) + if units == "imperial" and media_type == "Workout": + from workouts.models import WorkoutLogData + + WorkoutLogData.normalize_form_data(data, units) + return data + + def get_file_md5_hash(file_path: str) -> str: with open(file_path, "rb") as f: file_hash = hashlib.md5() diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index c3d44ac..52f62a0 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -101,9 +101,11 @@ from scrobbles.tasks import ( process_tsv_import, ) from scrobbles.utils import ( + apply_media_unit_conversions, get_daily_calories_for_user_by_day, get_long_plays_completed, get_long_plays_in_progress, + normalize_log_data_for_units, ) User = get_user_model() @@ -1391,32 +1393,7 @@ class ScrobbleDetailView(DetailView): form = FormClass(initial=log) self._update_board_game_widgets(form) - - drink_types = ("Beer", "Wine", "Coffee") - if self.object.media_type in drink_types and "size_ml" in form.fields: - user = self.request.user - use_oz = hasattr(user, "profile") and user.profile.volume_unit == "imperial" - if use_oz: - form.fields["size_ml"].label = "Size (oz)" - original_clean = form.fields["size_ml"].clean - - def clean_size_ml_oz(value): - val = original_clean(value) - if val is not None and val != "": - from drinks.models import ML_PER_OZ - - return round(float(val) * ML_PER_OZ) - return val - - form.fields["size_ml"].clean = clean_size_ml_oz - if log.get("size_ml"): - from drinks.models import ML_PER_OZ - - form.fields["size_ml"].initial = round( - log["size_ml"] / ML_PER_OZ, 1 - ) - else: - form.fields["size_ml"].label = "Size (mL)" + apply_media_unit_conversions(form, self.object.media_type, self.request.user) return form @@ -1425,6 +1402,7 @@ class ScrobbleDetailView(DetailView): FormClass = self.get_form_class() form = FormClass(request.POST) self._update_board_game_widgets(form) + apply_media_unit_conversions(form, self.object.media_type, self.request.user) if form.is_valid(): data = form.cleaned_data.copy() @@ -1452,6 +1430,10 @@ class ScrobbleDetailView(DetailView): if data.get("location_id", False): data["location_id"] = data["location_id"].id + normalize_log_data_for_units( + data, self.object.media_type, self.request.user + ) + self.object.log = data self.object.save(update_fields=["log"]) return redirect(self.object.get_absolute_url()) @@ -1478,6 +1460,7 @@ class ScrobbleDetailView(DetailView): "Mood": "mood", "BrickSet": "brick_set", "BirdingLocation": "birding_location", + "Workout": "workout_routine", } def get_context_data(self, **kwargs): @@ -1656,6 +1639,7 @@ class ScrobbleBlogView(ListView): "birding_location", "disc_golf_course", "species_observation", + "workout_routine", ) def get_context_data(self, **kwargs): @@ -2071,6 +2055,7 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView): "BrickSet": ["brick_set__title", None], "BirdingLocation": ["birding_location__title", "birding_location__description"], "AgentSession": ["agent_session__provider", "agent_session__model"], + "Workout": ["workout_routine__title", "workout_routine__description"], } def get(self, request, *args, **kwargs): diff --git a/vrobbler/apps/workouts/__init__.py b/vrobbler/apps/workouts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/workouts/admin.py b/vrobbler/apps/workouts/admin.py new file mode 100644 index 0000000..65f4c25 --- /dev/null +++ b/vrobbler/apps/workouts/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin +from scrobbles.admin import ScrobbleInline +from workouts.models import Exercise, WorkoutRoutine + + +@admin.register(Exercise) +class ExerciseAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ("uuid", "name", "level", "equipment", "category", "primary_muscles") + ordering = ("name",) + search_fields = ("name", "primary_muscles", "secondary_muscles") + + +@admin.register(WorkoutRoutine) +class WorkoutRoutineAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ("uuid", "title", "description") + ordering = ("-created",) + search_fields = ("title", "description") + inlines = [ + ScrobbleInline, + ] diff --git a/vrobbler/apps/workouts/api/__init__.py b/vrobbler/apps/workouts/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/workouts/api/serializers.py b/vrobbler/apps/workouts/api/serializers.py new file mode 100644 index 0000000..1f72621 --- /dev/null +++ b/vrobbler/apps/workouts/api/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers +from workouts.models import Exercise, WorkoutRoutine + + +class ExerciseSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Exercise + fields = "__all__" + + +class WorkoutRoutineSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = WorkoutRoutine + fields = "__all__" diff --git a/vrobbler/apps/workouts/api/views.py b/vrobbler/apps/workouts/api/views.py new file mode 100644 index 0000000..504a5b9 --- /dev/null +++ b/vrobbler/apps/workouts/api/views.py @@ -0,0 +1,15 @@ +from rest_framework import permissions, viewsets +from workouts import models +from workouts.api import serializers + + +class ExerciseViewSet(viewsets.ModelViewSet): + queryset = models.Exercise.objects.all().order_by("name") + serializer_class = serializers.ExerciseSerializer + permission_classes = [permissions.IsAuthenticated] + + +class WorkoutRoutineViewSet(viewsets.ModelViewSet): + queryset = models.WorkoutRoutine.objects.all().order_by("-created") + serializer_class = serializers.WorkoutRoutineSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/vrobbler/apps/workouts/apps.py b/vrobbler/apps/workouts/apps.py new file mode 100644 index 0000000..679c8df --- /dev/null +++ b/vrobbler/apps/workouts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WorkoutsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "workouts" diff --git a/vrobbler/apps/workouts/forms.py b/vrobbler/apps/workouts/forms.py new file mode 100644 index 0000000..932674b --- /dev/null +++ b/vrobbler/apps/workouts/forms.py @@ -0,0 +1,113 @@ +import json + +from django import forms +from workouts.models import Exercise, WorkoutSetEntry +from workouts.utils import display_value, normalize_weight, weight_label + + +class WorkoutSetsWidget(forms.Widget): + template_name = "workouts/workout_sets_widget.html" + + class Media: + js = ("workouts/workout_sets.js",) + + def __init__(self, *args, **kwargs): + self.units = kwargs.pop("units", "metric") + super().__init__(*args, **kwargs) + + def value_from_datadict(self, data, files, name): + exercise_ids = data.getlist(f"{name}_exercise_id") + sets_list = data.getlist(f"{name}_sets") + reps_list = data.getlist(f"{name}_reps") + weights = data.getlist(f"{name}_weight") + notes = data.getlist(f"{name}_notes") + return { + "exercise_id": exercise_ids, + "sets": sets_list, + "reps": reps_list, + "weight": weights, + "notes": notes, + } + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + rows = [] + if value: + if isinstance(value, str): + try: + value = json.loads(value) + except (json.JSONDecodeError, TypeError): + value = [] + for item in value or []: + if isinstance(item, dict): + entry = WorkoutSetEntry(**item) + rows.append( + { + "exercise_id": entry.exercise_id, + "sets": entry.sets, + "reps": entry.reps, + "weight": display_value(entry.weight_kg, self.units), + "notes": entry.notes, + } + ) + context["widget"]["rows"] = rows + context["widget"]["exercises"] = Exercise.objects.all().order_by("name") + context["widget"]["weight_label"] = weight_label(self.units) + return context + + +class WorkoutSetsField(forms.Field): + widget = WorkoutSetsWidget + + def __init__(self, *args, **kwargs): + self.units = kwargs.pop("units", "metric") + super().__init__(*args, **kwargs) + + def clean(self, value): + if not value: + return None + result = [] + if isinstance(value, dict): + exercise_ids = value.get("exercise_id", []) + sets_list = value.get("sets", []) + reps_list = value.get("reps", []) + weights = value.get("weight", []) + notes = value.get("notes", []) + else: + return None + + if not isinstance(exercise_ids, list): + return None + + for i, exercise_id in enumerate(exercise_ids): + if not exercise_id: + continue + try: + exercise_id = int(exercise_id) + except (ValueError, TypeError): + continue + sets_val = sets_list[i] if i < len(sets_list) else "" + reps_val = reps_list[i] if i < len(reps_list) else "" + weight_val = weights[i] if i < len(weights) else "" + note = notes[i] if i < len(notes) else "" + + try: + sets = int(sets_val) if sets_val else None + except (ValueError, TypeError): + sets = None + try: + reps = int(reps_val) if reps_val else None + except (ValueError, TypeError): + reps = None + weight_kg = normalize_weight(weight_val, self.units) + + entry = WorkoutSetEntry( + exercise_id=exercise_id, + sets=sets, + reps=reps, + weight_kg=weight_kg, + notes=note or None, + ) + result.append(entry.asdict) + + return result if result else None diff --git a/vrobbler/apps/workouts/importer.py b/vrobbler/apps/workouts/importer.py new file mode 100644 index 0000000..609666c --- /dev/null +++ b/vrobbler/apps/workouts/importer.py @@ -0,0 +1,144 @@ +import json +import logging +import os +import tarfile +import tempfile +import zipfile + +import requests +from django.core.files.base import ContentFile +from workouts.models import Exercise + +logger = logging.getLogger(__name__) + +TARBALL_URL = ( + "https://codeload.github.com/wrkout/exercises.json/tar.gz/refs/heads/master" +) +IMAGES_DIR_NAME = "images" + + +def download_tarball(url=TARBALL_URL, destination=None): + if not destination: + destination = os.path.join( + tempfile.gettempdir(), "wrkout-exercises.json.tar.gz" + ) + logger.info(f"Downloading {url}") + response = requests.get(url, timeout=120) + response.raise_for_status() + with open(destination, "wb") as f: + f.write(response.content) + return destination + + +def extract_tarball(tarball_path, destination=None): + if not destination: + destination = tempfile.mkdtemp(prefix="wrkout-") + if tarball_path.endswith(".zip"): + with zipfile.ZipFile(tarball_path) as zf: + zf.extractall(destination) + else: + with tarfile.open(tarball_path, "r:gz") as tf: + tf.extractall(destination, filter="data") + return destination + + +def _find_root(destination): + for entry in os.listdir(destination): + full = os.path.join(destination, entry) + if os.path.isdir(full): + if os.path.isdir(os.path.join(full, "exercises")): + return full + return destination + + +def load_exercises_data(root): + exercises_dir = os.path.join(root, "exercises") + if not os.path.isdir(exercises_dir): + raise FileNotFoundError(f"No exercises directory under {root}") + exercises = [] + for entry in os.listdir(exercises_dir): + exercise_dir = os.path.join(exercises_dir, entry) + if not os.path.isdir(exercise_dir): + continue + exercise_file = os.path.join(exercise_dir, "exercise.json") + if not os.path.isfile(exercise_file): + continue + with open(exercise_file, encoding="utf-8") as f: + data = json.load(f) + data["_dir"] = exercise_dir + exercises.append(data) + return exercises + + +def _image_paths_for(exercise_dir): + images_dir = os.path.join(exercise_dir, IMAGES_DIR_NAME) + if not os.path.isdir(images_dir): + return [] + return sorted( + os.path.join(images_dir, p) + for p in os.listdir(images_dir) + if p.lower().endswith((".jpg", ".jpeg", ".png")) + ) + + +def import_wrkout_exercises(tarball_path=None, import_images=True, dry_run=False): + tarball_path = tarball_path or download_tarball() + extracted = extract_tarball(tarball_path) + root = _find_root(extracted) + exercises = load_exercises_data(root) + logger.info(f"Found {len(exercises)} exercises in catalog") + + created = 0 + updated = 0 + skipped = 0 + image_failures = 0 + + for data in exercises: + name = data.get("name") + if not name: + skipped += 1 + continue + + defaults = { + "force": data.get("force"), + "level": data.get("level"), + "mechanic": data.get("mechanic"), + "equipment": data.get("equipment"), + "category": data.get("category"), + "primary_muscles": data.get("primaryMuscles") or [], + "secondary_muscles": data.get("secondaryMuscles") or [], + "instructions": data.get("instructions") or [], + } + if dry_run: + continue + + exercise, was_created = Exercise.objects.update_or_create( + name=name, defaults=defaults + ) + if was_created: + created += 1 + else: + updated += 1 + + if import_images: + image_paths = _image_paths_for(data.get("_dir")) + if image_paths: + try: + with open(image_paths[0], "rb") as f: + content = ContentFile(f.read()) + ext = os.path.splitext(image_paths[0])[1] or ".jpg" + exercise.photo.save(f"{name}{ext}", content, save=True) + except (OSError, ValueError) as exc: + logger.warning(f"Could not save image for {name}: {exc}") + image_failures += 1 + + logger.info( + f"Import finished: {created} created, {updated} updated, " + f"{skipped} skipped, {image_failures} image failures" + ) + return { + "created": created, + "updated": updated, + "skipped": skipped, + "image_failures": image_failures, + } diff --git a/vrobbler/apps/workouts/management/__init__.py b/vrobbler/apps/workouts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/workouts/management/commands/__init__.py b/vrobbler/apps/workouts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/workouts/management/commands/import_wrkout_exercises.py b/vrobbler/apps/workouts/management/commands/import_wrkout_exercises.py new file mode 100644 index 0000000..3475ecd --- /dev/null +++ b/vrobbler/apps/workouts/management/commands/import_wrkout_exercises.py @@ -0,0 +1,38 @@ +from django.core.management.base import BaseCommand +from workouts.importer import import_wrkout_exercises + + +class Command(BaseCommand): + help = "Import the exercise catalog from wrkout/exercises.json" + + def add_arguments(self, parser): + parser.add_argument( + "--tarball", + dest="tarball", + default=None, + help="Path to a local exercises.json tarball/zip to import from", + ) + parser.add_argument( + "--no-images", + action="store_true", + help="Do not download exercise images", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Parse the catalog but do not write anything to the database", + ) + + def handle(self, *args, **options): + result = import_wrkout_exercises( + tarball_path=options.get("tarball"), + import_images=not options.get("no_images"), + dry_run=options.get("dry_run"), + ) + self.stdout.write( + self.style.SUCCESS( + f"Import finished: {result['created']} created, " + f"{result['updated']} updated, {result['skipped']} skipped, " + f"{result['image_failures']} image failures" + ) + ) diff --git a/vrobbler/apps/workouts/mcp.py b/vrobbler/apps/workouts/mcp.py new file mode 100644 index 0000000..5b12cfb --- /dev/null +++ b/vrobbler/apps/workouts/mcp.py @@ -0,0 +1,69 @@ +from mcp_server import MCPToolset +from workouts.models import Exercise, WorkoutRoutine + + +class WorkoutToolset(MCPToolset): + def list_exercises( + self, + query: str | None = None, + muscle: str | None = None, + equipment: str | None = None, + limit: int = 20, + ) -> list[dict]: + """List exercises in the workout catalog, optionally filtered by + name, primary muscle, or equipment.""" + qs = Exercise.objects.all().order_by("name") + if query: + qs = qs.filter(name__icontains=query) + if muscle: + qs = qs.filter(primary_muscles__icontains=muscle) + if equipment: + qs = qs.filter(equipment__icontains=equipment) + return [_exercise_to_dict(e) for e in qs[:limit]] + + def get_exercise(self, uuid: str) -> dict | None: + """Get an exercise by UUID.""" + try: + e = Exercise.objects.get(uuid=uuid) + except Exercise.DoesNotExist: + return None + return _exercise_to_dict(e) + + def list_workout_routines( + self, query: str | None = None, limit: int = 20 + ) -> list[dict]: + """List workout routines, optionally filtered by title.""" + qs = WorkoutRoutine.objects.all().order_by("-created") + if query: + qs = qs.filter(title__icontains=query) + return [_routine_to_dict(r) for r in qs[:limit]] + + def get_workout_routine(self, uuid: str) -> dict | None: + """Get a workout routine by UUID.""" + try: + r = WorkoutRoutine.objects.get(uuid=uuid) + except WorkoutRoutine.DoesNotExist: + return None + return _routine_to_dict(r) + + +def _exercise_to_dict(e: Exercise) -> dict: + return { + "uuid": str(e.uuid), + "name": e.name, + "force": e.force, + "level": e.level, + "mechanic": e.mechanic, + "equipment": e.equipment, + "category": e.category, + "primary_muscles": e.primary_muscles or [], + "secondary_muscles": e.secondary_muscles or [], + "instructions": e.instructions or [], + } + + +def _routine_to_dict(r: WorkoutRoutine) -> dict: + result = {"uuid": str(r.uuid), "title": r.title} + if r.description: + result["description"] = r.description + return result diff --git a/vrobbler/apps/workouts/migrations/0001_initial.py b/vrobbler/apps/workouts/migrations/0001_initial.py new file mode 100644 index 0000000..a49a7a7 --- /dev/null +++ b/vrobbler/apps/workouts/migrations/0001_initial.py @@ -0,0 +1,135 @@ +# Generated by Django 4.2.29 on 2026-08-06 22:30 + +import uuid + +import django_extensions.db.fields +import taggit.managers +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("scrobbles", "0105_scrobble_agent_session_and_more"), + ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"), + ] + + operations = [ + migrations.CreateModel( + name="Exercise", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "uuid", + models.UUIDField( + blank=True, default=uuid.uuid4, editable=False, null=True + ), + ), + ("name", models.CharField(db_index=True, max_length=255, unique=True)), + ("force", models.CharField(blank=True, max_length=64, null=True)), + ("level", models.CharField(blank=True, max_length=64, null=True)), + ("mechanic", models.CharField(blank=True, max_length=64, null=True)), + ("equipment", models.CharField(blank=True, max_length=64, null=True)), + ("category", models.CharField(blank=True, max_length=64, null=True)), + ( + "primary_muscles", + models.JSONField(blank=True, default=list, null=True), + ), + ( + "secondary_muscles", + models.JSONField(blank=True, default=list, null=True), + ), + ("instructions", models.JSONField(blank=True, default=list, null=True)), + ( + "photo", + models.ImageField( + blank=True, null=True, upload_to="workouts/photos/" + ), + ), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + migrations.CreateModel( + name="WorkoutRoutine", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "uuid", + models.UUIDField( + blank=True, default=uuid.uuid4, editable=False, null=True + ), + ), + ("title", models.CharField(blank=True, max_length=255, null=True)), + ("base_run_time_seconds", models.IntegerField(blank=True, null=True)), + ("description", models.TextField(blank=True, null=True)), + ( + "genre", + taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="scrobbles.ObjectWithGenres", + to="scrobbles.Genre", + verbose_name="Genre", + ), + ), + ( + "tags", + taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/vrobbler/apps/workouts/migrations/__init__.py b/vrobbler/apps/workouts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/workouts/models.py b/vrobbler/apps/workouts/models.py new file mode 100644 index 0000000..625f2e0 --- /dev/null +++ b/vrobbler/apps/workouts/models.py @@ -0,0 +1,199 @@ +import logging +from dataclasses import dataclass +from functools import cached_property +from typing import Optional +from uuid import uuid4 + +from django.db import models +from django.urls import reverse +from django_extensions.db.models import TimeStampedModel +from imagekit.models import ImageSpecField +from imagekit.processors import ResizeToFit +from scrobbles.dataclasses import BaseLogData, WithPeopleLogData +from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin +from workouts.utils import display_weight + +logger = logging.getLogger(__name__) +BNULL = {"blank": True, "null": True} + + +@dataclass +class WorkoutSetEntry(BaseLogData): + exercise_id: Optional[int] = None + sets: Optional[int] = None + reps: Optional[int] = None + weight_kg: Optional[float] = None + notes: Optional[str] = None + + @property + def exercise(self) -> Optional["Exercise"]: + if not self.exercise_id: + return None + return Exercise.objects.filter(id=self.exercise_id).first() + + def __str__(self) -> str: + name = self.exercise.name if self.exercise else "Unknown" + parts = [name] + if self.sets and self.reps: + parts.append(f"{self.sets} x {self.reps}") + elif self.reps: + parts.append(f"{self.reps} reps") + if self.weight_kg: + parts.append(display_weight(self.weight_kg)) + if self.notes: + parts.append(f"({self.notes})") + return " ".join(parts) + + +@dataclass +class WorkoutLogData(BaseLogData, WithPeopleLogData): + workouts: Optional[list[WorkoutSetEntry]] = None + duration_minutes: Optional[int] = None + bodyweight_kg: Optional[float] = None + rpe: Optional[float] = None + + _excluded_fields = {} + + @cached_property + def workout_list(self) -> str: + if self.workouts: + return ", ".join([str(WorkoutSetEntry(**w)) for w in self.workouts]) + return "" + + def as_html(self, scrobble_id: Optional[int] = None, units: str = "metric") -> str: + if not self.workouts: + return "" + html_parts = [] + for workout_data in self.workouts: + entry = WorkoutSetEntry(**workout_data) + exercise = entry.exercise + name = exercise.name if exercise else "Unknown" + parts = [] + if entry.sets and entry.reps: + parts.append(f"{entry.sets} x {entry.reps}") + elif entry.reps: + parts.append(f"{entry.reps} reps") + if entry.weight_kg: + parts.append(display_weight(entry.weight_kg, units)) + info = " ".join(parts) + extra = f" \u2014 {entry.notes}" if entry.notes else "" + html_parts.append( + f'
{name}' + f'{f" {info}" if info else ""}{extra}
' + ) + return f'
{"".join(html_parts)}
' + + @classmethod + def override_fields(cls) -> dict: + from workouts.forms import WorkoutSetsField + + fields = {} + for base in cls.mro()[1:]: + if hasattr(base, "override_fields"): + base_fields = base.override_fields() + fields.update(base_fields) + custom_fields = { + "workouts": WorkoutSetsField(required=False), + } + fields.update(custom_fields) + return fields + + @classmethod + def prepare_form(cls, form, units: str = "metric"): + from workouts.utils import weight_label + + workouts_field = form.fields.get("workouts") + if workouts_field: + workouts_field.units = units + if hasattr(workouts_field.widget, "units"): + workouts_field.widget.units = units + + bodyweight_field = form.fields.get("bodyweight_kg") + if bodyweight_field is not None: + bodyweight_field.label = f"Bodyweight ({weight_label(units)})" + if units == "imperial" and form.initial.get("bodyweight_kg") is not None: + from workouts.utils import kg_to_lbs + + form.fields["bodyweight_kg"].initial = kg_to_lbs( + form.initial["bodyweight_kg"] + ) + + @classmethod + def normalize_form_data(cls, data: dict, units: str = "metric"): + from workouts.utils import lbs_to_kg + + if ( + units == "imperial" + and data.get("bodyweight_kg") is not None + and data.get("bodyweight_kg") != "" + ): + data["bodyweight_kg"] = lbs_to_kg(data["bodyweight_kg"]) + + +class Exercise(TimeStampedModel): + uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) + name = models.CharField(max_length=255, unique=True, db_index=True) + force = models.CharField(max_length=64, **BNULL) + level = models.CharField(max_length=64, **BNULL) + mechanic = models.CharField(max_length=64, **BNULL) + equipment = models.CharField(max_length=64, **BNULL) + category = models.CharField(max_length=64, **BNULL) + primary_muscles = models.JSONField(default=list, **BNULL) + secondary_muscles = models.JSONField(default=list, **BNULL) + instructions = models.JSONField(default=list, **BNULL) + photo = models.ImageField(upload_to="workouts/photos/", **BNULL) + photo_small = ImageSpecField( + source="photo", + processors=[ResizeToFit(100, 100)], + format="JPEG", + options={"quality": 60}, + ) + photo_medium = ImageSpecField( + source="photo", + processors=[ResizeToFit(300, 300)], + format="JPEG", + options={"quality": 75}, + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("workouts:exercise_detail", kwargs={"slug": self.uuid}) + + @classmethod + def find_or_create(cls, name: str) -> "Exercise": + exercise = cls.objects.filter(name__iexact=name).first() + if not exercise: + exercise = cls.objects.create(name=name) + return exercise + + +class WorkoutRoutine(ScrobblableMixin): + media_type_label = "Workout" + + description = models.TextField(**BNULL) + + def get_absolute_url(self): + return reverse("workouts:workout_routine_detail", kwargs={"slug": self.uuid}) + + @property + def strings(self) -> ScrobblableConstants: + return ScrobblableConstants(verb="Lifting", tags="workout") + + @property + def logdata_cls(self): + return WorkoutLogData + + def primary_image_url(self) -> str: + return "" + + def fix_metadata(self) -> None: + pass + + @classmethod + def find_or_create(cls, title: str) -> "WorkoutRoutine": + routine = cls.objects.filter(title__iexact=title).first() + if not routine: + routine = cls.objects.create(title=title) + return routine diff --git a/vrobbler/apps/workouts/static/workouts/workout_sets.js b/vrobbler/apps/workouts/static/workouts/workout_sets.js new file mode 100644 index 0000000..bbcdaec --- /dev/null +++ b/vrobbler/apps/workouts/static/workouts/workout_sets.js @@ -0,0 +1,30 @@ +document.addEventListener("DOMContentLoaded", function () { + function bindRowHandlers(widget) { + widget.querySelectorAll(".remove-workout-set").forEach(function (btn) { + btn.addEventListener("click", function () { + var row = btn.closest(".workout-set-row"); + if (row) row.remove(); + }); + }); + } + + document.querySelectorAll(".workout-sets-widget").forEach(function (widget) { + bindRowHandlers(widget); + var addBtn = widget.querySelector(".add-workout-set-row"); + if (!addBtn) return; + + addBtn.addEventListener("click", function () { + var template = widget.querySelector(".workout-set-row"); + if (!template) return; + var row = template.cloneNode(true); + row.querySelectorAll("input").forEach(function (input) { + input.value = ""; + }); + row.querySelectorAll("select").forEach(function (select) { + select.selectedIndex = 0; + }); + widget.querySelector(".workout-sets-list").appendChild(row); + bindRowHandlers(widget); + }); + }); +}); diff --git a/vrobbler/apps/workouts/templates/workouts/workout_sets_widget.html b/vrobbler/apps/workouts/templates/workouts/workout_sets_widget.html new file mode 100644 index 0000000..515f0a6 --- /dev/null +++ b/vrobbler/apps/workouts/templates/workouts/workout_sets_widget.html @@ -0,0 +1,58 @@ +
+
+ {% for row in widget.rows %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {% empty %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {% endfor %} +
+ +
diff --git a/vrobbler/apps/workouts/urls.py b/vrobbler/apps/workouts/urls.py new file mode 100644 index 0000000..96054aa --- /dev/null +++ b/vrobbler/apps/workouts/urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from workouts import views + +app_name = "workouts" + +urlpatterns = [ + path( + "routines/", + views.WorkoutRoutineListView.as_view(), + name="workout_routine_list", + ), + path( + "routines//", + views.WorkoutRoutineDetailView.as_view(), + name="workout_routine_detail", + ), + path("exercises/", views.ExerciseListView.as_view(), name="exercise_list"), + path( + "exercises//", + views.ExerciseDetailView.as_view(), + name="exercise_detail", + ), +] diff --git a/vrobbler/apps/workouts/utils.py b/vrobbler/apps/workouts/utils.py new file mode 100644 index 0000000..bc960a8 --- /dev/null +++ b/vrobbler/apps/workouts/utils.py @@ -0,0 +1,50 @@ +KG_PER_LB = 0.45359237 +LB_PER_KG = 1.0 / KG_PER_LB + +METRIC = "metric" +IMPERIAL = "imperial" + + +def lbs_to_kg(lbs: float) -> float: + return round(float(lbs) * KG_PER_LB, 2) + + +def kg_to_lbs(kg: float) -> float: + return round(float(kg) * LB_PER_KG, 1) + + +def normalize_weight(value, units: str = METRIC): + """Convert a user-provided weight into canonical kg for storage.""" + if value is None or value == "": + return None + try: + value = float(value) + except (ValueError, TypeError): + return None + if units == IMPERIAL: + return lbs_to_kg(value) + return round(value, 2) + + +def display_value(kg, units: str = METRIC): + """Return the weight value to present to a user in their preferred units.""" + if kg is None or kg == "": + return None + try: + kg = float(kg) + except (ValueError, TypeError): + return None + if units == IMPERIAL: + return kg_to_lbs(kg) + return round(kg, 1) + + +def weight_label(units: str = METRIC) -> str: + return "lbs" if units == IMPERIAL else "kg" + + +def display_weight(kg, units: str = METRIC) -> str: + value = display_value(kg, units) + if value is None: + return "" + return f"{value} {weight_label(units)}" diff --git a/vrobbler/apps/workouts/views.py b/vrobbler/apps/workouts/views.py new file mode 100644 index 0000000..5b69719 --- /dev/null +++ b/vrobbler/apps/workouts/views.py @@ -0,0 +1,23 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views import generic +from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView +from workouts.models import Exercise, WorkoutRoutine + + +class WorkoutRoutineListView(ScrobbleableListView): + model = WorkoutRoutine + + +class WorkoutRoutineDetailView(ScrobbleableDetailView): + model = WorkoutRoutine + + +class ExerciseListView(LoginRequiredMixin, generic.ListView): + model = Exercise + paginate_by = 100 + ordering = "name" + + +class ExerciseDetailView(LoginRequiredMixin, generic.DetailView): + model = Exercise + slug_field = "uuid" diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 2e22c9b..ad08d36 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -271,6 +271,7 @@ INSTALLED_APPS = [ "discgolf", "birds", "nature", + "workouts", "mathfilters", "drf_spectacular", "rest_framework", diff --git a/vrobbler/templates/scrobbles/_last_scrobbles.html b/vrobbler/templates/scrobbles/_last_scrobbles.html index 3ba0973..d81c786 100644 --- a/vrobbler/templates/scrobbles/_last_scrobbles.html +++ b/vrobbler/templates/scrobbles/_last_scrobbles.html @@ -90,6 +90,15 @@

No courses played today

{% endif %} +

Workouts

+ {% if Workout %} + {% with scrobbles=Workout count=Workout_count time=Workout_time %} + {% include "scrobbles/_scrobble_table.html" %} + {% endwith %} + {% else %} +

No workouts today

+ {% endif %} +
diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.html index d0ed40f..393c7fb 100644 --- a/vrobbler/templates/scrobbles/scrobble_detail.html +++ b/vrobbler/templates/scrobbles/scrobble_detail.html @@ -49,7 +49,7 @@

-{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% elif object.media_type == "AgentSession" %}🤖{% endif %} +{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% elif object.media_type == "AgentSession" %}🤖{% elif object.media_type == "Workout" %}🏋️{% endif %} {% if object.media_obj.get_absolute_url %} {% endif %} {{ object.media_obj.title }} @@ -279,6 +279,17 @@

{% endif %} +{% if object.media_type == "Workout" and object.logdata.workouts %} +
+
Workout Details
+ {% if user.is_authenticated %} + {{ object.logdata|workout_html:request.user.profile.weigh_in_units|safe }} + {% else %} + {{ object.logdata|workout_html:"metric"|safe }} + {% endif %} +
+{% endif %} + {% if object.media_type == "AgentSession" %}
{% include "scrobbles/_agent_session.html" %} diff --git a/vrobbler/templates/workouts/exercise_detail.html b/vrobbler/templates/workouts/exercise_detail.html new file mode 100644 index 0000000..22f341f --- /dev/null +++ b/vrobbler/templates/workouts/exercise_detail.html @@ -0,0 +1,35 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}{{object.name}}{% endblock %} + +{% block lists %} +
+
+

{{object.name}}

+ {% if object.photo %} + {{object.name}} + {% endif %} +
+
Level: {{object.level|default:"-"}}
+
Force: {{object.force|default:"-"}}
+
Equipment: {{object.equipment|default:"-"}}
+
Mechanic: {{object.mechanic|default:"-"}}
+
+

Primary muscles: + {% for muscle in object.primary_muscles %}{{muscle}}{% endfor %} +

+

Secondary muscles: + {% for muscle in object.secondary_muscles %}{{muscle}}{% endfor %} +

+ {% if object.instructions %} +

Instructions

+
    + {% for step in object.instructions %} +
  1. {{step}}
  2. + {% endfor %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/vrobbler/templates/workouts/exercise_list.html b/vrobbler/templates/workouts/exercise_list.html new file mode 100644 index 0000000..76cba5d --- /dev/null +++ b/vrobbler/templates/workouts/exercise_list.html @@ -0,0 +1,50 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}Exercises{% endblock %} + +{% block lists %} +
+
+
+ + + + + + + + + + + {% for exercise in object_list %} + + + + + + + {% endfor %} + +
ExerciseLevelEquipmentCategory
{{exercise.name}}{{exercise.level|default:""}}{{exercise.equipment|default:""}}{{exercise.category|default:""}}
+
+
+
+{% if is_paginated %} +
+{% endif %} +{% endblock %} diff --git a/vrobbler/templates/workouts/workoutroutine_detail.html b/vrobbler/templates/workouts/workoutroutine_detail.html new file mode 100644 index 0000000..69b9625 --- /dev/null +++ b/vrobbler/templates/workouts/workoutroutine_detail.html @@ -0,0 +1,43 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}{{object.title}}{% endblock %} + +{% block lists %} + +
+
+ {% if object.description %} +

{{object.description|safe|linebreaks|truncatewords:160}}

+
+ {% endif %} +

{{scrobbles|length}} scrobbles

+

+ Log a workout +

+
+
+
+
+

Last scrobbles

+
+ + + + + + + + + {% for scrobble in scrobbles %} + + + + + {% endfor %} + +
DateWorkout
{{scrobble.local_timestamp}}{{scrobble.logdata.workout_list}}
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/workouts/workoutroutine_list.html b/vrobbler/templates/workouts/workoutroutine_list.html new file mode 100644 index 0000000..b38d137 --- /dev/null +++ b/vrobbler/templates/workouts/workoutroutine_list.html @@ -0,0 +1,31 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}Workout Routines{% endblock %} + +{% block lists %} +
+
+
+ + + + + + + + + + {% for routine in object_list %} + + + + + + {% endfor %} + +
RoutineScrobblesLast Workout
{{routine.title}}{{routine.scrobble_count}}{{routine.last_scrobble|date}}
+
+
+
+{% endblock %} diff --git a/vrobbler/urls.py b/vrobbler/urls.py index d200561..47bdd18 100644 --- a/vrobbler/urls.py +++ b/vrobbler/urls.py @@ -103,6 +103,11 @@ from vrobbler.apps.videos.api.views import ( ) from vrobbler.apps.webpages import urls as webpages_urls from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet +from vrobbler.apps.workouts import urls as workouts_urls +from vrobbler.apps.workouts.api.views import ( + ExerciseViewSet, + WorkoutRoutineViewSet, +) # from vrobbler.apps.modern_ui import urls as modern_ui_urls @@ -166,6 +171,8 @@ router.register(r"birding-locations", BirdingLocationViewSet) router.register(r"disc-golf-courses", DiscGolfCourseViewSet) router.register(r"observations", SpeciesObservationViewSet) router.register(r"trails", TrailViewSet) +router.register(r"exercises", ExerciseViewSet) +router.register(r"workout-routines", WorkoutRoutineViewSet) urlpatterns = [ path("api/v1/", include(router.urls)), path("api/v1/auth", include("rest_framework.urls")), @@ -200,6 +207,7 @@ urlpatterns = [ path("", include(agents_urls, namespace="agents")), path("", include(birds_urls, namespace="birds")), path("", include(nature_urls, namespace="nature")), + path("", include(workouts_urls, namespace="workouts")), path("", include(scrobble_urls, namespace="scrobbles")), path("", include(profiles_urls, namespace="profiles")), path("", include(people_urls, namespace="people")),