Add some additional basic nutritional values validation for ingredients

This commit is contained in:
Roland Geider
2024-07-19 16:53:29 +02:00
parent cbfec01955
commit 56db5fe8b4
7 changed files with 189 additions and 98 deletions

View File

@@ -18,9 +18,9 @@ MEALITEM_WEIGHT_GRAM = '1'
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

View File

@@ -19,6 +19,9 @@ from dataclasses import (
)
from typing import Optional
# wger
from wger.nutrition.consts import ENERGY_FACTOR
@dataclass
class IngredientData:
@@ -50,6 +53,7 @@ class IngredientData:
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 @@ class IngredientData:
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)

View File

@@ -46,10 +46,7 @@ from requests import (
# 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 @@ class Ingredient(AbstractLicenseModel, models.Model):
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

View File

@@ -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 django.utils.translation import gettext_lazy as _
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 @@ class NutritionPlan(models.Model):
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},

View File

@@ -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 <http://www.gnu.org/licenses/>.
# 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,
)

View File

@@ -396,38 +396,6 @@ class IngredientTestCase(WgerTestCase):
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 @@ class IngredientModelTestCase(WgerTestCase):
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 @@ class IngredientModelTestCase(WgerTestCase):
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)

View File

@@ -38,10 +38,10 @@ class ExtractInfoFromOffTestCase(SimpleTestCase):
'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 @@ class ExtractInfoFromOffTestCase(SimpleTestCase):
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 @@ class ExtractInfoFromOffTestCase(SimpleTestCase):
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):
"""