Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add some additional basic nutritional values validation for ingredients #1726

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
6 changes: 3 additions & 3 deletions wger/nutrition/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we replacing clear explicit units with abstract opaque terms?

'carbohydrates': {'metric': 4, 'imperial': 113},
'fat': {'metric': 9, 'imperial': 225},
}
"""
Simple approximation of energy (kcal) provided per gram or ounce
Expand Down
40 changes: 40 additions & 0 deletions wger/nutrition/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
)
from typing import Optional

# wger
from wger.nutrition.consts import ENERGY_FACTOR


@dataclass
class IngredientData:
Expand Down Expand Up @@ -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',
Expand All @@ -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}'
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The protein, carb and fat energy values are rough. Checking this too strictly might trigger false positives , especially for ingredients that only contain 1 macro (eg egg whites)

For sanity checking maybe allow 20% error or so ? I would be curious to have a look at ingredients for which we trigger errors here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also all these checks are quite verbose. Do we need to raise ValueErrors explicitly? Could we replace these with simple, single line assert statements ? If the code is more obvious, it also reduces the need for unit testing


if energy_calculated > self.energy:
raise ValueError(
f'Total energy calculated is greater than energy: {energy_calculated} > {self.energy}'
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also this is not fool proof. Btw I thought we already had such a check somewhere

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note also that in current form, if this triggers, one of the previous ones will have triggered too.

def dict(self):
return asdict(self)
48 changes: 1 addition & 47 deletions wger/nutrition/models/ingredient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions wger/nutrition/models/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
# Standard Library
import datetime
import logging
from decimal import Decimal

# Django
from django.contrib.auth.models import User
Expand All @@ -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


Expand Down Expand Up @@ -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},
Expand Down
130 changes: 130 additions & 0 deletions wger/nutrition/tests/test_dataclass.py
Original file line number Diff line number Diff line change
@@ -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,
)
43 changes: 6 additions & 37 deletions wger/nutrition/tests/test_ingredient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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,
Expand All @@ -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)
Expand Down
Loading
Loading