From 5fcf39fb53fb9259d0e0c8f284c9f426927be5c1 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Mon, 11 May 2026 17:00:03 +0930 Subject: [PATCH 1/9] Add serializer and migration for is_dynamic field --- wger/measurements/api/serializers.py | 2 +- .../migrations/0004_category_is_dynamic.py | 18 ++++++++++++++++++ wger/measurements/models/category.py | 6 ++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 wger/measurements/migrations/0004_category_is_dynamic.py diff --git a/wger/measurements/api/serializers.py b/wger/measurements/api/serializers.py index c965148d9e..9643b4fe48 100644 --- a/wger/measurements/api/serializers.py +++ b/wger/measurements/api/serializers.py @@ -33,7 +33,7 @@ class UnitSerializer(serializers.ModelSerializer): class Meta: model = Category - fields = ('id', 'name', 'unit') + fields = ('id', 'name', 'unit', 'is_dynamic') class MeasurementSerializer(serializers.ModelSerializer): diff --git a/wger/measurements/migrations/0004_category_is_dynamic.py b/wger/measurements/migrations/0004_category_is_dynamic.py new file mode 100644 index 0000000000..f5d40d81e8 --- /dev/null +++ b/wger/measurements/migrations/0004_category_is_dynamic.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-11 06:22 + +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='is_dynamic', + field=models.BooleanField(default=False, help_text='Indicates if this category is automatically calculated.', verbose_name='Is Dynamic'), + ), + ] diff --git a/wger/measurements/models/category.py b/wger/measurements/models/category.py index c20bab84f7..845f9a9fb2 100644 --- a/wger/measurements/models/category.py +++ b/wger/measurements/models/category.py @@ -41,6 +41,12 @@ class Meta: max_length=30, ) + is_dynamic = models.BooleanField( + verbose_name='Is Dynamic', + default=False, + help_text='Indicates if this category is automatically calculated.', + ) + def get_owner_object(self): """ Returns the object that has owner information From 64303e2980f56067f7e3c094f0467cbb1a3fa0b3 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Mon, 11 May 2026 19:08:57 +0930 Subject: [PATCH 2/9] Add path to get preprogrammed dynamic measurements --- wger/measurements/api/views.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/wger/measurements/api/views.py b/wger/measurements/api/views.py index 2dd3fe340d..2164199dc6 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 @@ -74,6 +76,23 @@ def get_owner_objects(self): """ return [(User, 'user')] + @action(detail=False, methods=['get']) + def dynamic(self, request): + """ + Dedicated route for virtual/calculated categories + URL: /api/v2/measurement-category/dynamic/ + """ + data = [ + { + 'id': -1, + 'name': 'BMI', + 'unit': 'kg/m²', + 'is_dynamic': True + } + # easy to append more objects here later + ] + return Response(data) + class MeasurementViewSet(WgerOwnerObjectModelViewSet): """ From 96391366aeeae7bd1353b15d9a90ad62f479f0f6 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Mon, 11 May 2026 20:28:52 +0930 Subject: [PATCH 3/9] Hardcode category ID for now --- wger/measurements/api/views.py | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/wger/measurements/api/views.py b/wger/measurements/api/views.py index 2164199dc6..d62a5d20c0 100644 --- a/wger/measurements/api/views.py +++ b/wger/measurements/api/views.py @@ -42,6 +42,29 @@ logger = logging.getLogger(__name__) +def calculate_bmi(user): + from wger.weight.models import WeightEntry + + profile = getattr(user, 'userprofile', None) + 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': f'bmi-{w.id}', + 'category': -1, + 'date': w.date, + # Cast w.weight to float to avoid the Decimal/Float TypeError + 'value': round(float(w.weight) / height_sq, 2), + 'notes': 'Auto-calculated from weight entry' + } + for w in weights + ] class CategoryViewSet(WgerOwnerObjectModelViewSet): """ @@ -120,3 +143,24 @@ 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') + is_dynamic = request.query_params.get('is_dynamic') + + print(request.query_params) + print("is_dynamic", is_dynamic) + + if category_id == '19': + bmi_data = calculate_bmi(request.user) + return Response({ + "count": len(bmi_data), + "next": None, + "previous": None, + "results": bmi_data + }) + + return super().list(request, *args, **kwargs) \ No newline at end of file From 87ba14c29cc9fb50639991b89daafdc4991d5391 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Tue, 12 May 2026 18:15:27 +0930 Subject: [PATCH 4/9] Add dynamic type enums and extendable dynamic measurement behaviour --- wger/measurements/api/serializers.py | 2 +- wger/measurements/api/views.py | 69 +++++++++++-------- ...tegory_is_dynamic_category_dynamic_type.py | 22 ++++++ wger/measurements/models/category.py | 14 ++-- 4 files changed, 72 insertions(+), 35 deletions(-) create mode 100644 wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py diff --git a/wger/measurements/api/serializers.py b/wger/measurements/api/serializers.py index 9643b4fe48..3dd10b56f7 100644 --- a/wger/measurements/api/serializers.py +++ b/wger/measurements/api/serializers.py @@ -33,7 +33,7 @@ class UnitSerializer(serializers.ModelSerializer): class Meta: model = Category - fields = ('id', 'name', 'unit', 'is_dynamic') + fields = ('id', 'name', 'unit', 'dynamic_type') class MeasurementSerializer(serializers.ModelSerializer): diff --git a/wger/measurements/api/views.py b/wger/measurements/api/views.py index d62a5d20c0..b628458d40 100644 --- a/wger/measurements/api/views.py +++ b/wger/measurements/api/views.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) -def calculate_bmi(user): +def calculate_bmi(user, category_id): from wger.weight.models import WeightEntry profile = getattr(user, 'userprofile', None) @@ -56,16 +56,21 @@ def calculate_bmi(user): return [ { - 'id': f'bmi-{w.id}', - 'category': -1, - 'date': w.date, - # Cast w.weight to float to avoid the Decimal/Float TypeError + 'id': w.id, # Use integer IDs so the frontend state manager doesn't complain + 'category': int(category_id), # Link it to the requested category + 'date': w.date.isoformat() if hasattr(w.date, 'isoformat') else w.date, 'value': round(float(w.weight) / height_sq, 2), 'notes': 'Auto-calculated from weight entry' } for w in weights ] +# 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 @@ -99,22 +104,18 @@ def get_owner_objects(self): """ return [(User, 'user')] - @action(detail=False, methods=['get']) - def dynamic(self, request): + @action(detail=False, methods=['get'], url_path='dynamic-types') + def dynamic_types(self, request): """ Dedicated route for virtual/calculated categories - URL: /api/v2/measurement-category/dynamic/ + Returns a list of available dynamic calculation types from the model Enum. + URL: /api/v2/measurement-category/dynamic-types/ """ - data = [ - { - 'id': -1, - 'name': 'BMI', - 'unit': 'kg/m²', - 'is_dynamic': True - } - # easy to append more objects here later + choices = [ + {"value": choice.value, "label": choice.label} + for choice in Category.DynamicType ] - return Response(data) + return Response(choices) class MeasurementViewSet(WgerOwnerObjectModelViewSet): @@ -149,18 +150,26 @@ def list(self, request, *args, **kwargs): Intercept requests for dynamic categories before the filterset blocks them """ category_id = request.query_params.get('category') - is_dynamic = request.query_params.get('is_dynamic') - - print(request.query_params) - print("is_dynamic", is_dynamic) - - if category_id == '19': - bmi_data = calculate_bmi(request.user) - return Response({ - "count": len(bmi_data), - "next": None, - "previous": None, - "results": bmi_data - }) + + 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: + # execute the math function and wrap it in the DRF pagination envelope + data = calc_func(request.user, category_id) + return Response({ + "count": len(data), + "next": None, + "previous": None, + "results": data + }) + except (Category.DoesNotExist, ValueError): + # fallback to standard behavior + pass return super().list(request, *args, **kwargs) \ No newline at end of file diff --git a/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py b/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py new file mode 100644 index 0000000000..e0b3c0dd17 --- /dev/null +++ b/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.14 on 2026-05-12 08:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('measurements', '0004_category_is_dynamic'), + ] + + operations = [ + migrations.RemoveField( + model_name='category', + name='is_dynamic', + ), + migrations.AddField( + model_name='category', + name='dynamic_type', + field=models.CharField(choices=[('NONE', 'None'), ('BMI', 'Bmi'), ('SQUAT_1RM', 'Squat 1Rm')], db_index=True, default='NONE', max_length=20), + ), + ] diff --git a/wger/measurements/models/category.py b/wger/measurements/models/category.py index 845f9a9fb2..dd001788e3 100644 --- a/wger/measurements/models/category.py +++ b/wger/measurements/models/category.py @@ -41,10 +41,16 @@ class Meta: max_length=30, ) - is_dynamic = models.BooleanField( - verbose_name='Is Dynamic', - default=False, - help_text='Indicates if this category is automatically calculated.', + class DynamicType(models.TextChoices): + NONE = 'NONE', + BMI = 'BMI', + # SQUAT_1RM = 'SQUAT_1RM', can be added in future + + dynamic_type = models.CharField( + max_length=20, + choices=DynamicType.choices, + default=DynamicType.NONE, + db_index=True ) def get_owner_object(self): From ba75480f924f9308827c642e299d166ead5a3aab Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Tue, 12 May 2026 19:59:00 +0930 Subject: [PATCH 5/9] Add bmi tests --- wger/measurements/api/views.py | 42 +++---------- .../migrations/0004_category_is_dynamic.py | 7 ++- ...tegory_is_dynamic_category_dynamic_type.py | 8 ++- wger/measurements/models/category.py | 9 +-- .../tests/test_dynamic_measurements.py | 62 +++++++++++++++++++ wger/measurements/utils/__init__.py | 0 wger/measurements/utils/bmi.py | 41 ++++++++++++ 7 files changed, 126 insertions(+), 43 deletions(-) create mode 100644 wger/measurements/tests/test_dynamic_measurements.py create mode 100644 wger/measurements/utils/__init__.py create mode 100644 wger/measurements/utils/bmi.py diff --git a/wger/measurements/api/views.py b/wger/measurements/api/views.py index b628458d40..4ab4d4f194 100644 --- a/wger/measurements/api/views.py +++ b/wger/measurements/api/views.py @@ -37,33 +37,12 @@ Category, Measurement, ) +from wger.measurements.utils.bmi import calculate_bmi from wger.utils.viewsets import WgerOwnerObjectModelViewSet logger = logging.getLogger(__name__) -def calculate_bmi(user, category_id): - from wger.weight.models import WeightEntry - - profile = getattr(user, 'userprofile', None) - 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, # Use integer IDs so the frontend state manager doesn't complain - 'category': int(category_id), # Link it to the requested category - 'date': w.date.isoformat() if hasattr(w.date, 'isoformat') else w.date, - 'value': round(float(w.weight) / height_sq, 2), - 'notes': 'Auto-calculated from weight entry' - } - for w in weights - ] # Map the dynamic_type enum to the math function DYNAMIC_REGISTRY = { @@ -71,6 +50,7 @@ def calculate_bmi(user, category_id): # add squat 1rm later } + class CategoryViewSet(WgerOwnerObjectModelViewSet): """ API endpoint for measurement units @@ -112,8 +92,7 @@ def dynamic_types(self, request): URL: /api/v2/measurement-category/dynamic-types/ """ choices = [ - {"value": choice.value, "label": choice.label} - for choice in Category.DynamicType + {'value': choice.value, 'label': choice.label} for choice in Category.DynamicType ] return Response(choices) @@ -155,21 +134,18 @@ def list(self, request, *args, **kwargs): 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: # execute the math function and wrap it in the DRF pagination envelope data = calc_func(request.user, category_id) - return Response({ - "count": len(data), - "next": None, - "previous": None, - "results": data - }) + return Response( + {'count': len(data), 'next': None, 'previous': None, 'results': data} + ) except (Category.DoesNotExist, ValueError): # fallback to standard behavior pass - return super().list(request, *args, **kwargs) \ No newline at end of file + return super().list(request, *args, **kwargs) diff --git a/wger/measurements/migrations/0004_category_is_dynamic.py b/wger/measurements/migrations/0004_category_is_dynamic.py index f5d40d81e8..16f7f3ab8c 100644 --- a/wger/measurements/migrations/0004_category_is_dynamic.py +++ b/wger/measurements/migrations/0004_category_is_dynamic.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('measurements', '0003_alter_measurement_unique_together_and_more'), ] @@ -13,6 +12,10 @@ class Migration(migrations.Migration): migrations.AddField( model_name='category', name='is_dynamic', - field=models.BooleanField(default=False, help_text='Indicates if this category is automatically calculated.', verbose_name='Is Dynamic'), + field=models.BooleanField( + default=False, + help_text='Indicates if this category is automatically calculated.', + verbose_name='Is Dynamic', + ), ), ] diff --git a/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py b/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py index e0b3c0dd17..3a129e59e3 100644 --- a/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py +++ b/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('measurements', '0004_category_is_dynamic'), ] @@ -17,6 +16,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='category', name='dynamic_type', - field=models.CharField(choices=[('NONE', 'None'), ('BMI', 'Bmi'), ('SQUAT_1RM', 'Squat 1Rm')], db_index=True, default='NONE', max_length=20), + field=models.CharField( + choices=[('NONE', 'None'), ('BMI', 'Bmi'), ('SQUAT_1RM', 'Squat 1Rm')], + db_index=True, + default='NONE', + max_length=20, + ), ), ] diff --git a/wger/measurements/models/category.py b/wger/measurements/models/category.py index dd001788e3..bf57f595f3 100644 --- a/wger/measurements/models/category.py +++ b/wger/measurements/models/category.py @@ -42,15 +42,12 @@ class Meta: ) class DynamicType(models.TextChoices): - NONE = 'NONE', - BMI = 'BMI', + NONE = ('NONE',) + BMI = ('BMI',) # SQUAT_1RM = 'SQUAT_1RM', can be added in future dynamic_type = models.CharField( - max_length=20, - choices=DynamicType.choices, - default=DynamicType.NONE, - db_index=True + max_length=20, choices=DynamicType.choices, default=DynamicType.NONE, db_index=True ) def get_owner_object(self): diff --git a/wger/measurements/tests/test_dynamic_measurements.py b/wger/measurements/tests/test_dynamic_measurements.py new file mode 100644 index 0000000000..d725f02668 --- /dev/null +++ b/wger/measurements/tests/test_dynamic_measurements.py @@ -0,0 +1,62 @@ +# Standard Library +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 = '2026-05-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='2026-05-01') + w2 = MagicMock(weight=75.0, date='2026-05-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() 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..8f4e1bc9e0 --- /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 = getattr(user, 'userprofile', None) + 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, # Use integer IDs so the frontend state manager doesn't complain + 'category': int(category_id), # Link it to the requested category + 'date': w.date.isoformat() if hasattr(w.date, 'isoformat') else w.date, + 'value': round(float(w.weight) / height_sq, 2), + 'notes': 'Auto-calculated from weight entry', + } + for w in weights + ] From d95cf3cb24d9f9eea95d373fb9839bd1ec327347 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Tue, 12 May 2026 20:07:21 +0930 Subject: [PATCH 6/9] Update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00659285f0..559c3be29c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,10 @@ this makes refresh tokens single-use and lets a leaked token live only until the next refresh call. It's recommended to update your `prod.env` and set `REFRESH_TOKEN_LIFETIME` to a value like 3000. + +* 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. \ No newline at end of file From bdff4715c2ef786f04d55cc15723e11de6e30175 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Fri, 15 May 2026 18:04:54 +0930 Subject: [PATCH 7/9] Use pagination, add params for dynamic calculations, squash migrations --- wger/measurements/api/serializers.py | 2 +- wger/measurements/api/views.py | 15 ++++++--- ...ry_dynamic_params_category_dynamic_type.py | 31 +++++++++++++++++++ .../migrations/0004_category_is_dynamic.py | 21 ------------- ...tegory_is_dynamic_category_dynamic_type.py | 26 ---------------- wger/measurements/models/category.py | 4 +++ wger/measurements/utils/bmi.py | 8 ++--- 7 files changed, 50 insertions(+), 57 deletions(-) create mode 100644 wger/measurements/migrations/0004_category_dynamic_params_category_dynamic_type.py delete mode 100644 wger/measurements/migrations/0004_category_is_dynamic.py delete mode 100644 wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py diff --git a/wger/measurements/api/serializers.py b/wger/measurements/api/serializers.py index 3dd10b56f7..92d3629583 100644 --- a/wger/measurements/api/serializers.py +++ b/wger/measurements/api/serializers.py @@ -33,7 +33,7 @@ class UnitSerializer(serializers.ModelSerializer): class Meta: model = Category - fields = ('id', 'name', 'unit', 'dynamic_type') + fields = ('id', 'name', 'unit', 'dynamic_type', 'dynamic_params') class MeasurementSerializer(serializers.ModelSerializer): diff --git a/wger/measurements/api/views.py b/wger/measurements/api/views.py index 4ab4d4f194..09d4dba974 100644 --- a/wger/measurements/api/views.py +++ b/wger/measurements/api/views.py @@ -139,11 +139,16 @@ def list(self, request, *args, **kwargs): calc_func = DYNAMIC_REGISTRY.get(category.dynamic_type) if calc_func: - # execute the math function and wrap it in the DRF pagination envelope - data = calc_func(request.user, category_id) - return Response( - {'count': len(data), 'next': None, 'previous': None, 'results': data} - ) + # 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 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/migrations/0004_category_is_dynamic.py b/wger/measurements/migrations/0004_category_is_dynamic.py deleted file mode 100644 index 16f7f3ab8c..0000000000 --- a/wger/measurements/migrations/0004_category_is_dynamic.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.2.14 on 2026-05-11 06:22 - -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='is_dynamic', - field=models.BooleanField( - default=False, - help_text='Indicates if this category is automatically calculated.', - verbose_name='Is Dynamic', - ), - ), - ] diff --git a/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py b/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py deleted file mode 100644 index 3a129e59e3..0000000000 --- a/wger/measurements/migrations/0005_remove_category_is_dynamic_category_dynamic_type.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.2.14 on 2026-05-12 08:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('measurements', '0004_category_is_dynamic'), - ] - - operations = [ - migrations.RemoveField( - model_name='category', - name='is_dynamic', - ), - migrations.AddField( - model_name='category', - name='dynamic_type', - field=models.CharField( - choices=[('NONE', 'None'), ('BMI', 'Bmi'), ('SQUAT_1RM', 'Squat 1Rm')], - db_index=True, - default='NONE', - max_length=20, - ), - ), - ] diff --git a/wger/measurements/models/category.py b/wger/measurements/models/category.py index bf57f595f3..5bb8f5b8f5 100644 --- a/wger/measurements/models/category.py +++ b/wger/measurements/models/category.py @@ -50,6 +50,10 @@ class DynamicType(models.TextChoices): 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/utils/bmi.py b/wger/measurements/utils/bmi.py index 8f4e1bc9e0..fc32181a41 100644 --- a/wger/measurements/utils/bmi.py +++ b/wger/measurements/utils/bmi.py @@ -20,7 +20,7 @@ def calculate_bmi(user, category_id): # wger from wger.weight.models import WeightEntry - profile = getattr(user, 'userprofile', None) + profile = user.userprofile if not profile or not profile.height or profile.height <= 0: return [] @@ -31,9 +31,9 @@ def calculate_bmi(user, category_id): return [ { - 'id': w.id, # Use integer IDs so the frontend state manager doesn't complain - 'category': int(category_id), # Link it to the requested category - 'date': w.date.isoformat() if hasattr(w.date, 'isoformat') else w.date, + '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', } From d81ec7c494f62d60957b9f48bef19fb12a7d64c6 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Fri, 15 May 2026 18:31:35 +0930 Subject: [PATCH 8/9] Add validation for jsonschema of dynamic params --- wger/measurements/api/serializers.py | 40 ++++++++++++++++++++++++++++ wger/measurements/models/category.py | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/wger/measurements/api/serializers.py b/wger/measurements/api/serializers.py index 92d3629583..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 @@ -35,6 +36,45 @@ class Meta: model = Category 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/models/category.py b/wger/measurements/models/category.py index 5bb8f5b8f5..cc6a064814 100644 --- a/wger/measurements/models/category.py +++ b/wger/measurements/models/category.py @@ -44,7 +44,7 @@ class Meta: class DynamicType(models.TextChoices): NONE = ('NONE',) BMI = ('BMI',) - # SQUAT_1RM = 'SQUAT_1RM', can be added in future + # 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 From e978f141b62ec0abe95821e403433c03ad9e2855 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Mon, 18 May 2026 19:40:27 +0930 Subject: [PATCH 9/9] Repair tests for previous changes --- .../tests/test_dynamic_measurements.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/wger/measurements/tests/test_dynamic_measurements.py b/wger/measurements/tests/test_dynamic_measurements.py index d725f02668..b2556e81fa 100644 --- a/wger/measurements/tests/test_dynamic_measurements.py +++ b/wger/measurements/tests/test_dynamic_measurements.py @@ -1,4 +1,20 @@ +# 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, @@ -22,7 +38,7 @@ def test_calculate_bmi_math(self, mock_filter): weight_entry = MagicMock() weight_entry.weight = 80.0 - weight_entry.date = '2026-05-12' + weight_entry.date = datetime.date(2026, 5, 12) mock_filter.return_value.order_by.return_value = [weight_entry] @@ -45,8 +61,8 @@ def test_calculate_bmi_multiple_entries(self, mock_filter): user = MagicMock() user.userprofile.height = 175 # 1.75m - w1 = MagicMock(weight=70.0, date='2026-05-01') - w2 = MagicMock(weight=75.0, date='2026-05-10') + 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] @@ -59,4 +75,4 @@ def test_calculate_bmi_multiple_entries(self, mock_filter): if __name__ == '__main__': - unittest.main() + unittest.main() \ No newline at end of file