mirror of
https://github.com/wger-project/wger.git
synced 2026-02-18 00:17:51 +01:00
Add some additional basic nutritional values validation for ingredients
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
130
wger/nutrition/tests/test_dataclass.py
Normal file
130
wger/nutrition/tests/test_dataclass.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user