Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
42 changes: 41 additions & 1 deletion wger/measurements/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from decimal import Decimal

# Third Party
import jsonschema
from rest_framework import serializers

# wger
Expand All @@ -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):
Expand Down
53 changes: 53 additions & 0 deletions wger/measurements/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
13 changes: 13 additions & 0 deletions wger/measurements/models/category.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
rolandgeider marked this conversation as resolved.
)

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
Expand Down
78 changes: 78 additions & 0 deletions wger/measurements/tests/test_dynamic_measurements.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

# 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()
Empty file.
41 changes: 41 additions & 0 deletions wger/measurements/utils/bmi.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.


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
]