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
+ ]