From 56db5fe8b4e337c0c01fe1b12acd7bbfb29aff74 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Fri, 19 Jul 2024 16:53:29 +0200 Subject: [PATCH] Add some additional basic nutritional values validation for ingredients --- wger/nutrition/consts.py | 6 +- wger/nutrition/dataclasses.py | 40 ++++++++ wger/nutrition/models/ingredient.py | 48 +-------- wger/nutrition/models/plan.py | 4 +- wger/nutrition/tests/test_dataclass.py | 130 ++++++++++++++++++++++++ wger/nutrition/tests/test_ingredient.py | 43 ++------ wger/nutrition/tests/test_off.py | 16 +-- 7 files changed, 189 insertions(+), 98 deletions(-) create mode 100644 wger/nutrition/tests/test_dataclass.py diff --git a/wger/nutrition/consts.py b/wger/nutrition/consts.py index d5c4f2d95..27dac47e3 100644 --- a/wger/nutrition/consts.py +++ b/wger/nutrition/consts.py @@ -18,9 +18,9 @@ MEALITEM_WEIGHT_UNIT = '2' ENERGY_FACTOR = { - 'protein': {'kg': 4, 'lb': 113}, - 'carbohydrates': {'kg': 4, 'lb': 113}, - 'fat': {'kg': 9, 'lb': 225}, + 'protein': {'metric': 4, 'imperial': 113}, + 'carbohydrates': {'metric': 4, 'imperial': 113}, + 'fat': {'metric': 9, 'imperial': 225}, } """ Simple approximation of energy (kcal) provided per gram or ounce diff --git a/wger/nutrition/dataclasses.py b/wger/nutrition/dataclasses.py index d87408b9f..9f81f1095 100644 --- a/wger/nutrition/dataclasses.py +++ b/wger/nutrition/dataclasses.py @@ -19,6 +19,9 @@ ) from typing import Optional +# wger +from wger.nutrition.consts import ENERGY_FACTOR + @dataclass class IngredientData: @@ -50,6 +53,7 @@ def sanity_checks(self): self.brand = self.brand[:200] self.common_name = self.common_name[:200] + # Mass checks (not more than 100g of something per 100g of product etc) macros = [ 'protein', 'fat', @@ -64,8 +68,44 @@ def sanity_checks(self): if value and value > 100: raise ValueError(f'Value for {macro} is greater than 100: {value}') + if self.fat_saturated and self.fat_saturated > self.fat: + raise ValueError( + f'Saturated fat is greater than fat: {self.fat_saturated} > {self.fat}' + ) + + if self.carbohydrates_sugar and self.carbohydrates_sugar > self.carbohydrates: + raise ValueError( + f'Sugar is greater than carbohydrates: {self.carbohydrates_sugar} > {self.carbohydrates}' + ) + if self.carbohydrates + self.protein + self.fat > 100: raise ValueError(f'Total of carbohydrates, protein and fat is greater than 100!') + # Energy approximations + energy_protein = self.protein * ENERGY_FACTOR['protein']['metric'] + energy_carbohydrates = self.carbohydrates * ENERGY_FACTOR['carbohydrates']['metric'] + energy_fat = self.fat * ENERGY_FACTOR['fat']['metric'] + energy_calculated = energy_protein + energy_carbohydrates + energy_fat + + if energy_fat > self.energy: + raise ValueError( + f'Energy calculated from fat is greater than total energy: {energy_fat} > {self.energy}' + ) + + if energy_carbohydrates > self.energy: + raise ValueError( + f'Energy calculated from carbohydrates is greater than total energy: {energy_carbohydrates} > {self.energy}' + ) + + if energy_protein > self.energy: + raise ValueError( + f'Energy calculated from protein is greater than total energy: {energy_protein} > {self.energy}' + ) + + if energy_calculated > self.energy: + raise ValueError( + f'Total energy calculated is greater than energy: {energy_calculated} > {self.energy}' + ) + def dict(self): return asdict(self) diff --git a/wger/nutrition/models/ingredient.py b/wger/nutrition/models/ingredient.py index fbc325104..572bc038b 100644 --- a/wger/nutrition/models/ingredient.py +++ b/wger/nutrition/models/ingredient.py @@ -46,10 +46,7 @@ # wger from wger.core.models import Language -from wger.nutrition.consts import ( - ENERGY_FACTOR, - KJ_PER_KCAL, -) +from wger.nutrition.consts import KJ_PER_KCAL from wger.nutrition.managers import ApproximateCountManager from wger.nutrition.models.ingredient_category import IngredientCategory from wger.nutrition.models.sources import Source @@ -265,49 +262,6 @@ def get_absolute_url(self): else: return reverse('nutrition:ingredient:view', kwargs={'pk': self.id, 'slug': slug}) - def clean(self): - """ - Do a very broad sanity check on the nutritional values according to - the following rules: - - 1g of protein: 4kcal - - 1g of carbohydrates: 4kcal - - 1g of fat: 9kcal - - The sum is then compared to the given total energy, with ENERGY_APPROXIMATION - percent tolerance. - """ - - # Note: calculations in 100 grams, to save us the '/100' everywhere - energy_protein = 0 - if self.protein: - energy_protein = self.protein * ENERGY_FACTOR['protein']['kg'] - - energy_carbohydrates = 0 - if self.carbohydrates: - energy_carbohydrates = self.carbohydrates * ENERGY_FACTOR['carbohydrates']['kg'] - - energy_fat = 0 - if self.fat: - # TODO: for some reason, during the tests the fat value is not - # converted to decimal (django 1.9) - energy_fat = Decimal(self.fat * ENERGY_FACTOR['fat']['kg']) - - energy_calculated = energy_protein + energy_carbohydrates + energy_fat - - # Compare the values, but be generous - if self.energy: - energy_upper = self.energy * (1 + (self.ENERGY_APPROXIMATION / Decimal(100.0))) - energy_lower = self.energy * (1 - (self.ENERGY_APPROXIMATION / Decimal(100.0))) - - if not ((energy_upper > energy_calculated) and (energy_calculated > energy_lower)): - raise ValidationError( - _( - f'The total energy ({self.energy}kcal) is not the approximate sum of the ' - f'energy provided by protein, carbohydrates and fat ({energy_calculated}kcal' - f' +/-{self.ENERGY_APPROXIMATION}%)' - ) - ) - def save(self, *args, **kwargs): """ Reset the cache diff --git a/wger/nutrition/models/plan.py b/wger/nutrition/models/plan.py index 8cea374fb..8e884181e 100644 --- a/wger/nutrition/models/plan.py +++ b/wger/nutrition/models/plan.py @@ -17,7 +17,6 @@ # Standard Library import datetime import logging -from decimal import Decimal # Django from django.contrib.auth.models import User @@ -30,7 +29,6 @@ from wger.nutrition.consts import ENERGY_FACTOR from wger.nutrition.helpers import NutritionalValues from wger.utils.cache import cache_mapper -from wger.utils.constants import TWOPLACES from wger.weight.models import WeightEntry @@ -121,7 +119,7 @@ def get_nutritional_values(self): if not nutritional_representation: nutritional_values = NutritionalValues() use_metric = self.user.userprofile.use_metric - unit = 'kg' if use_metric else 'lb' + unit = 'metric' if use_metric else 'imperial' result = { 'total': NutritionalValues(), 'percent': {'protein': 0, 'carbohydrates': 0, 'fat': 0}, diff --git a/wger/nutrition/tests/test_dataclass.py b/wger/nutrition/tests/test_dataclass.py new file mode 100644 index 000000000..e838bce66 --- /dev/null +++ b/wger/nutrition/tests/test_dataclass.py @@ -0,0 +1,130 @@ +# 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 . + +# Django +from django.test import SimpleTestCase + +# wger +from wger.nutrition.dataclasses import IngredientData +from wger.utils.constants import CC_0_LICENSE_ID + + +class IngredientDataclassTestCase(SimpleTestCase): + """ + Test validation rules + """ + + ingredient_data: IngredientData + + def setUp(self): + self.ingredient_data = IngredientData( + name='Foo With Chocolate', + remote_id='1234567', + language_id=1, + energy=166.0, + protein=32.1, + carbohydrates=0.0, + carbohydrates_sugar=None, + fat=3.24, + fat_saturated=None, + fiber=None, + sodium=None, + code=None, + source_name='USDA', + source_url='', + common_name='', + brand='', + license_id=CC_0_LICENSE_ID, + license_author='', + license_title='', + license_object_url='', + ) + + def test_validation_ok(self): + """""" + self.assertEqual(self.ingredient_data.sanity_checks(), None) + + def test_validation_bigger_100(self): + """ + Test the validation for values bigger than 100 + """ + self.ingredient_data.protein = 101 + self.assertRaises(ValueError, self.ingredient_data.sanity_checks) + + def test_validation_saturated_fat(self): + """ + Test the validation for saturated fat + """ + self.ingredient_data.fat = 20 + self.ingredient_data.fat_saturated = 30 + self.assertRaises(ValueError, self.ingredient_data.sanity_checks) + + def test_validation_sugar(self): + """ + Test the validation for sugar + """ + self.ingredient_data.carbohydrates = 20 + self.ingredient_data.carbohydrates_sugar = 30 + self.assertRaises(ValueError, self.ingredient_data.sanity_checks) + + def test_validation_energy_fat(self): + """ + Test the validation for energy and fat + """ + self.ingredient_data.energy = 200 + self.ingredient_data.fat = 30 # generates 30 * 9 = 270 kcal + self.assertRaisesRegex( + ValueError, + 'Energy calculated from fat', + self.ingredient_data.sanity_checks, + ) + + def test_validation_energy_protein(self): + """ + Test the validation for energy and protein + """ + self.ingredient_data.energy = 100 + self.ingredient_data.protein = 30 # generates 30 * 4 = 120 kcal + self.assertRaisesRegex( + ValueError, + 'Energy calculated from protein', + self.ingredient_data.sanity_checks, + ) + + def test_validation_energy_carbohydrates(self): + """ + Test the validation for energy and carbohydrates + """ + self.ingredient_data.energy = 100 + self.ingredient_data.carbohydrates = 30 # generates 30 * 4 = 120 kcal + self.assertRaisesRegex( + ValueError, + 'Energy calculated from carbohydrates', + self.ingredient_data.sanity_checks, + ) + + def test_validation_energy_total(self): + """ + Test the validation for energy total + """ + self.ingredient_data.energy = 200 # less than 120 + 80 + 90 + self.ingredient_data.protein = 30 # generates 30 * 4 = 120 kcal + self.ingredient_data.carbohydrates = 20 # generates 20 * 4 = 80 kcal + self.ingredient_data.fat = 10 # generates 10 * 9 = 90 kcal + self.assertRaisesRegex( + ValueError, + 'Total energy calculated', + self.ingredient_data.sanity_checks, + ) diff --git a/wger/nutrition/tests/test_ingredient.py b/wger/nutrition/tests/test_ingredient.py index 5176d1749..dbd39ea0b 100644 --- a/wger/nutrition/tests/test_ingredient.py +++ b/wger/nutrition/tests/test_ingredient.py @@ -396,38 +396,6 @@ def test_compare(self): meal = Meal.objects.get(pk=1) self.assertFalse(ingredient1 == meal) - def test_total_energy(self): - """ - Tests the custom clean() method - """ - self.user_login('admin') - - # Values OK - ingredient = Ingredient() - ingredient.name = 'FooBar, cooked, with salt' - ingredient.energy = 50 - ingredient.protein = 0.5 - ingredient.carbohydrates = 12 - ingredient.fat = Decimal('0.1') - ingredient.language_id = 1 - self.assertFalse(ingredient.full_clean()) - - # Values wrong - ingredient.protein = 20 - self.assertRaises(ValidationError, ingredient.full_clean) - - ingredient.protein = 0.5 - ingredient.fat = 5 - self.assertRaises(ValidationError, ingredient.full_clean) - - ingredient.fat = 0.1 - ingredient.carbohydrates = 20 - self.assertRaises(ValidationError, ingredient.full_clean) - - ingredient.fat = 5 - ingredient.carbohydrates = 20 - self.assertRaises(ValidationError, ingredient.full_clean) - class IngredientApiTestCase(api_base_test.ApiBaseResourceTestCase): """ @@ -451,15 +419,16 @@ def setUp(self): self.off_response = { 'code': '1234', 'lang': 'de', + 'name': 'Foo with chocolate', 'product_name': 'Foo with chocolate', 'generic_name': 'Foo with chocolate, 250g package', 'brands': 'The bar company', 'editors_tags': ['open food facts', 'MrX'], 'nutriments': { - 'energy-kcal_100g': 120, + 'energy-kcal_100g': 600, 'proteins_100g': 10, - 'carbohydrates_100g': 20, - 'sugars_100g': 30, + 'carbohydrates_100g': 30, + 'sugars_100g': 20, 'fat_100g': 40, 'saturated-fat_100g': 11, 'sodium_100g': 5, @@ -480,9 +449,9 @@ def test_fetch_from_off_success(self, mock_api): self.assertEqual(ingredient.name, 'Foo with chocolate') self.assertEqual(ingredient.code, '1234') - self.assertEqual(ingredient.energy, 120) + self.assertEqual(ingredient.energy, 600) self.assertEqual(ingredient.protein, 10) - self.assertEqual(ingredient.carbohydrates, 20) + self.assertEqual(ingredient.carbohydrates, 30) self.assertEqual(ingredient.fat, 40) self.assertEqual(ingredient.fat_saturated, 11) self.assertEqual(ingredient.sodium, 5) diff --git a/wger/nutrition/tests/test_off.py b/wger/nutrition/tests/test_off.py index a4a5fe71c..6125aa139 100644 --- a/wger/nutrition/tests/test_off.py +++ b/wger/nutrition/tests/test_off.py @@ -38,10 +38,10 @@ def setUp(self): 'brands': 'The bar company', 'editors_tags': ['open food facts', 'MrX'], 'nutriments': { - 'energy-kcal_100g': 120, + 'energy-kcal_100g': 600, 'proteins_100g': 10, - 'carbohydrates_100g': 20, - 'sugars_100g': 30, + 'carbohydrates_100g': 30, + 'sugars_100g': 20, 'fat_100g': 40, 'saturated-fat_100g': 11, 'sodium_100g': 5, @@ -59,10 +59,10 @@ def test_regular_response(self): name='Foo with chocolate', remote_id='1234', language_id=1, - energy=120, + energy=600, protein=10, - carbohydrates=20, - carbohydrates_sugar=30, + carbohydrates=30, + carbohydrates_sugar=20, fat=40, fat_saturated=11, fiber=None, @@ -86,12 +86,12 @@ def test_convert_kj(self): we convert it to kcal per 100 g """ del self.off_data1['nutriments']['energy-kcal_100g'] - self.off_data1['nutriments']['energy-kj_100g'] = 120 + self.off_data1['nutriments']['energy-kj_100g'] = 2510.4 result = extract_info_from_off(self.off_data1, 1) # 120 / KJ_PER_KCAL - self.assertAlmostEqual(result.energy, 28.6806, 3) + self.assertAlmostEqual(result.energy, 600, 3) def test_no_energy(self): """