diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef9d2e888..41de1359b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ alternatives. This replaces the default DRF UniqueValidator with a custom validator that includes suggested usernames in the error response. Suggestions are supported in both the serializer and web registration form validation. - + +* Add support for dynamic measurement categories. The (`/api/v2/measurement/`) endpoint now + intercepts requests for dynamic categories (such as BMI) and auto-calculates the + results on-the-fly using existing user data (e.g., profile height and weight entries). + A new endpoint at (`/api/v2/measurement-category/dynamic-types/`) has been added to + expose the available dynamic options. + An enum is used for the different options and can be extended for more dynamic types. + * Exercise language are now also checked when performing edits, instead of only during submission. diff --git a/wger/measurements/api/serializers.py b/wger/measurements/api/serializers.py index c965148d9e..c07c33d71d 100644 --- a/wger/measurements/api/serializers.py +++ b/wger/measurements/api/serializers.py @@ -17,6 +17,7 @@ from decimal import Decimal # Third Party +import jsonschema from rest_framework import serializers # wger @@ -33,7 +34,46 @@ class UnitSerializer(serializers.ModelSerializer): class Meta: model = Category - fields = ('id', 'name', 'unit') + fields = ('id', 'name', 'unit', 'dynamic_type', 'dynamic_params') + + def validate(self, data): + """ + Validate the dynamic_params JSON matches the required schema for the selected dynamic_type. + """ + # get type and params + dynamic_type = data.get( + 'dynamic_type', getattr(self.instance, 'dynamic_type', Category.DynamicType.NONE) + ) + dynamic_params = data.get('dynamic_params', getattr(self.instance, 'dynamic_params', {})) + + # if dynamic type is none, just clear params to avoid excessive validation + if dynamic_type == Category.DynamicType.NONE: + data['dynamic_params'] = {} + return super().validate(data) + + # define the allowed JSON structures + schemas = { + Category.DynamicType.BMI: { + 'type': 'object', + 'additionalProperties': False, + }, + # when one rep max is added it can go here + # Category.DynamicType.ONE_REP_MAX: { + # "type": "object", + # "properties": {"exercise_id": {"type": "integer"}, "max_reps": {"type": "integer"}}, + # "required": ["exercise_id"], + # "additionalProperties": False + # } + } + + schema = schemas.get(dynamic_type) + if schema: + try: + jsonschema.validate(instance=dynamic_params, schema=schema) + except jsonschema.exceptions.ValidationError as e: + raise serializers.ValidationError({'dynamic_params': e.message}) + + return super().validate(data) class MeasurementSerializer(serializers.ModelSerializer): diff --git a/wger/measurements/api/views.py b/wger/measurements/api/views.py index 2dd3fe340d..09d4dba974 100644 --- a/wger/measurements/api/views.py +++ b/wger/measurements/api/views.py @@ -23,7 +23,9 @@ from django.core.exceptions import PermissionDenied # Third Party +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response # wger from wger.measurements.api.filtersets import MeasurementEntryFilterSet @@ -35,12 +37,20 @@ Category, Measurement, ) +from wger.measurements.utils.bmi import calculate_bmi from wger.utils.viewsets import WgerOwnerObjectModelViewSet logger = logging.getLogger(__name__) +# Map the dynamic_type enum to the math function +DYNAMIC_REGISTRY = { + Category.DynamicType.BMI: calculate_bmi, + # add squat 1rm later +} + + class CategoryViewSet(WgerOwnerObjectModelViewSet): """ API endpoint for measurement units @@ -74,6 +84,18 @@ def get_owner_objects(self): """ return [(User, 'user')] + @action(detail=False, methods=['get'], url_path='dynamic-types') + def dynamic_types(self, request): + """ + Dedicated route for virtual/calculated categories + Returns a list of available dynamic calculation types from the model Enum. + URL: /api/v2/measurement-category/dynamic-types/ + """ + choices = [ + {'value': choice.value, 'label': choice.label} for choice in Category.DynamicType + ] + return Response(choices) + class MeasurementViewSet(WgerOwnerObjectModelViewSet): """ @@ -101,3 +123,34 @@ def get_queryset(self): return Measurement.objects.none() return Measurement.objects.filter(category__user=self.request.user) + + def list(self, request, *args, **kwargs): + """ + Intercept requests for dynamic categories before the filterset blocks them + """ + category_id = request.query_params.get('category') + + if category_id: + try: + # look up the category and check its enum value + category = Category.objects.get(id=category_id, user=request.user) + + if category.dynamic_type != Category.DynamicType.NONE: + calc_func = DYNAMIC_REGISTRY.get(category.dynamic_type) + + if calc_func: + # get the raw list of calculated dictionaries + raw_data = calc_func(request.user, category_id) + + # paginate the list + page = self.paginate_queryset(raw_data) + if page is not None: + return self.get_paginated_response(page) + + # fallback + return Response(raw_data) + except (Category.DoesNotExist, ValueError): + # fallback to standard behavior + pass + + return super().list(request, *args, **kwargs) diff --git a/wger/measurements/migrations/0004_category_dynamic_params_category_dynamic_type.py b/wger/measurements/migrations/0004_category_dynamic_params_category_dynamic_type.py new file mode 100644 index 0000000000..92ecc96790 --- /dev/null +++ b/wger/measurements/migrations/0004_category_dynamic_params_category_dynamic_type.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.14 on 2026-05-15 08:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('measurements', '0003_alter_measurement_unique_together_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='category', + name='dynamic_params', + field=models.JSONField( + blank=True, + default=dict, + help_text='Configuration parameters for dynamic calculations', + ), + ), + migrations.AddField( + model_name='category', + name='dynamic_type', + field=models.CharField( + choices=[('NONE', 'None'), ('BMI', 'Bmi')], + db_index=True, + default='NONE', + max_length=20, + ), + ), + ] diff --git a/wger/measurements/models/category.py b/wger/measurements/models/category.py index c20bab84f7..cc6a064814 100644 --- a/wger/measurements/models/category.py +++ b/wger/measurements/models/category.py @@ -41,6 +41,19 @@ class Meta: max_length=30, ) + class DynamicType(models.TextChoices): + NONE = ('NONE',) + BMI = ('BMI',) + # ONE_REP_MAX = ('ONE_REP_MAX'), can be added in future + + dynamic_type = models.CharField( + max_length=20, choices=DynamicType.choices, default=DynamicType.NONE, db_index=True + ) + + dynamic_params = models.JSONField( + default=dict, blank=True, help_text='Configuration parameters for dynamic calculations' + ) + def get_owner_object(self): """ Returns the object that has owner information diff --git a/wger/measurements/tests/test_dynamic_measurements.py b/wger/measurements/tests/test_dynamic_measurements.py new file mode 100644 index 0000000000..b2556e81fa --- /dev/null +++ b/wger/measurements/tests/test_dynamic_measurements.py @@ -0,0 +1,78 @@ +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Workout Manager. If not, see . + +# Standard Library +import datetime +import unittest +from unittest.mock import ( + MagicMock, + patch, +) + +# wger +from wger.measurements.utils.bmi import calculate_bmi + + +class BMILogicTest(unittest.TestCase): + """ + Pure unit test for BMI math logic. + Bypasses Django database and wger signals. + """ + + @patch('wger.weight.models.WeightEntry.objects.filter') + def test_calculate_bmi_math(self, mock_filter): + user = MagicMock() + user.userprofile.height = 180 # 1.8m + + weight_entry = MagicMock() + weight_entry.weight = 80.0 + weight_entry.date = datetime.date(2026, 5, 12) + + mock_filter.return_value.order_by.return_value = [weight_entry] + + results = calculate_bmi(user, category_id=99) + + # 80 / (1.8 * 1.8) = 24.69 + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['value'], 24.69) + self.assertEqual(results[0]['category'], 99) + + def test_calculate_bmi_missing_height(self): + user = MagicMock() + user.userprofile.height = None + + results = calculate_bmi(user, category_id=99) + self.assertEqual(results, []) + + @patch('wger.weight.models.WeightEntry.objects.filter') + def test_calculate_bmi_multiple_entries(self, mock_filter): + user = MagicMock() + user.userprofile.height = 175 # 1.75m + + w1 = MagicMock(weight=70.0, date=datetime.date(2026, 5, 1)) + w2 = MagicMock(weight=75.0, date=datetime.date(2026, 5, 10)) + + mock_filter.return_value.order_by.return_value = [w1, w2] + + results = calculate_bmi(user, category_id=99) + + # 70 / 1.75^2 = 22.86 + self.assertEqual(results[0]['value'], 22.86) + # 75 / 1.75^2 = 24.49 + self.assertEqual(results[1]['value'], 24.49) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/wger/measurements/utils/__init__.py b/wger/measurements/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/wger/measurements/utils/bmi.py b/wger/measurements/utils/bmi.py new file mode 100644 index 0000000000..fc32181a41 --- /dev/null +++ b/wger/measurements/utils/bmi.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Workout Manager. If not, see . + + +def calculate_bmi(user, category_id): + # wger + from wger.weight.models import WeightEntry + + profile = user.userprofile + if not profile or not profile.height or profile.height <= 0: + return [] + + # height_sq will be a float + height_sq = (profile.height / 100) ** 2 + + weights = WeightEntry.objects.filter(user=user).order_by('date') + + return [ + { + 'id': w.id, + 'category': int(category_id), # link it to the requested category + 'date': w.date.isoformat(), + 'value': round(float(w.weight) / height_sq, 2), + 'notes': 'Auto-calculated from weight entry', + } + for w in weights + ]