Skip to content

Commit

Permalink
Convert energy in kJ to kcal when importing data from OFF
Browse files Browse the repository at this point in the history
See #1506
  • Loading branch information
rolandgeider committed Nov 26, 2023
1 parent d050767 commit 6923461
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 38 deletions.
3 changes: 1 addition & 2 deletions extras/docker/development/README.md
Expand Up @@ -5,8 +5,7 @@ you manage your personal workouts, weight and diet plans and can also be used
as a simple gym management utility. It offers a REST API as well, for easy
integration with other projects and tools.

Please note that this image is intended for development, if you want to
host your own instance, take a look at the provided docker compose file:
If you want to host your own instance, take a look at the provided docker compose file:

<https://github.com/wger-project/docker>

Expand Down
2 changes: 2 additions & 0 deletions wger/nutrition/consts.py
Expand Up @@ -34,3 +34,5 @@
"""
Simple approximation of energy (kcal) provided per gram or ounce
"""

KJ_PER_KCAL = 4.184
13 changes: 6 additions & 7 deletions wger/nutrition/helpers.py
Expand Up @@ -26,9 +26,8 @@
from wger.nutrition.consts import (
MEALITEM_WEIGHT_GRAM,
MEALITEM_WEIGHT_UNIT,
KJ_PER_KCAL,
)
from wger.utils.constants import TWOPLACES
from wger.utils.units import AbstractWeight


class BaseMealItem:
Expand Down Expand Up @@ -116,7 +115,7 @@ class NutritionalValues:

@property
def energy_kilojoule(self):
return self.energy * 4.184
return self.energy * KJ_PER_KCAL

def __add__(self, other: 'NutritionalValues'):
"""
Expand All @@ -127,15 +126,15 @@ def __add__(self, other: 'NutritionalValues'):
protein=self.protein + other.protein,
carbohydrates=self.carbohydrates + other.carbohydrates,
carbohydrates_sugar=self.carbohydrates_sugar +
other.carbohydrates_sugar if self.carbohydrates_sugar and other.carbohydrates_sugar else
other.carbohydrates_sugar if self.carbohydrates_sugar and other.carbohydrates_sugar else
self.carbohydrates_sugar or other.carbohydrates_sugar,
fat=self.fat + other.fat,
fat_saturated=self.fat_saturated + other.fat_saturated if self.fat_saturated
and other.fat_saturated else self.fat_saturated or other.fat_saturated,
and other.fat_saturated else self.fat_saturated or other.fat_saturated,
fibres=self.fibres +
other.fibres if self.fibres and other.fibres else self.fibres or other.fibres,
other.fibres if self.fibres and other.fibres else self.fibres or other.fibres,
sodium=self.sodium +
other.sodium if self.sodium and other.sodium else self.sodium or other.sodium,
other.sodium if self.sodium and other.sodium else self.sodium or other.sodium,
)

@property
Expand Down
11 changes: 6 additions & 5 deletions wger/nutrition/management/commands/import-off-products.py
Expand Up @@ -25,7 +25,6 @@
from wger.nutrition.models import Ingredient
from wger.nutrition.off import extract_info_from_off


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -57,8 +56,8 @@ def add_arguments(self, parser):
dest='mode',
type=str,
help='Script mode, "insert" or "update". Insert will insert the ingredients as new '
'entries in the database, while update will try to update them if they are '
'already present. Deault: insert'
'entries in the database, while update will try to update them if they are '
'already present. Deault: insert'
)
parser.add_argument(
'--completeness',
Expand All @@ -67,10 +66,11 @@ def add_arguments(self, parser):
dest='completeness',
type=float,
help='Completeness threshold for importing the products. Products in OFF have '
'completeness score that ranges from 0 to 1.1. Default: 0.7'
'completeness score that ranges from 0 to 1.1. Default: 0.7'
)

def handle(self, **options):

try:
# Third Party
from pymongo import MongoClient
Expand All @@ -94,7 +94,7 @@ def handle(self, **options):
client = MongoClient('mongodb://off:off-wger@127.0.0.1', port=27017)
db = client.admin

languages = {l.short_name: l for l in Language.objects.all()}
languages = {l.short_name: l.pk for l in Language.objects.all()}

bulk_update_bucket = []
counter = Counter()
Expand All @@ -114,6 +114,7 @@ def handle(self, **options):
ingredient_data = extract_info_from_off(product, languages[product['lang']])
except KeyError as e:
# self.stdout.write(f'--> KeyError while extracting info from OFF: {e}')
# self.stdout.write(f'--> Product: {product}')
counter['skipped'] += 1
continue

Expand Down
10 changes: 6 additions & 4 deletions wger/nutrition/models/ingredient.py
Expand Up @@ -45,7 +45,10 @@

# wger
from wger.core.models import Language
from wger.nutrition.consts import ENERGY_FACTOR
from wger.nutrition.consts import (
ENERGY_FACTOR,
KJ_PER_KCAL,
)
from wger.nutrition.models.sources import Source
from wger.utils.cache import cache_mapper
from wger.utils.constants import (
Expand All @@ -62,7 +65,6 @@
# Local
from .ingredient_category import IngredientCategory


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -423,7 +425,7 @@ def energy_kilojoule(self):
returns kilojoules for current ingredient, 0 if energy is uninitialized
"""
if self.energy:
return Decimal(self.energy * 4.184).quantize(TWOPLACES)
return Decimal(self.energy * KJ_PER_KCAL).quantize(TWOPLACES)
else:
return 0

Expand Down Expand Up @@ -474,7 +476,7 @@ def fetch_ingredient_from_off(cls, code: str):
product = result['product']

try:
ingredient_data = extract_info_from_off(product, load_language(product['lang']))
ingredient_data = extract_info_from_off(product, load_language(product['lang']).pk)
except KeyError:
return None

Expand Down
45 changes: 25 additions & 20 deletions wger/nutrition/off.py
Expand Up @@ -14,18 +14,17 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

# wger
from wger.nutrition.consts import KJ_PER_KCAL
from wger.nutrition.models import Source
from wger.utils.constants import ODBL_LICENSE_ID
from wger.utils.models import AbstractSubmissionModel


OFF_REQUIRED_TOP_LEVEL = [
'product_name',
'code',
'nutriments',
]
OFF_REQUIRED_NUTRIMENTS = [
'energy-kcal_100g',
'proteins_100g',
'carbohydrates_100g',
'sugars_100g',
Expand All @@ -34,47 +33,53 @@
]


def extract_info_from_off(product, language):

if not all(req in product for req in OFF_REQUIRED_TOP_LEVEL):
def extract_info_from_off(product_data, language: int):
if not all(req in product_data for req in OFF_REQUIRED_TOP_LEVEL):
raise KeyError('Missing required top-level key')

if not all(req in product['nutriments'] for req in OFF_REQUIRED_NUTRIMENTS):
if not all(req in product_data['nutriments'] for req in OFF_REQUIRED_NUTRIMENTS):
raise KeyError('Missing required nutrition key')

# Basics
name = product['product_name']
name = product_data['product_name']
if name is None:
raise KeyError('Product name is None')
if len(name) > 200:
name = name[:200]

common_name = product.get('generic_name', '')
common_name = product_data.get('generic_name', '')
if len(common_name) > 200:
common_name = common_name[:200]

code = product['code']
energy = product['nutriments']['energy-kcal_100g']
protein = product['nutriments']['proteins_100g']
carbs = product['nutriments']['carbohydrates_100g']
sugars = product['nutriments']['sugars_100g']
fat = product['nutriments']['fat_100g']
saturated = product['nutriments']['saturated-fat_100g']
# If the energy is not available in kcal, convert from kJ
if 'energy-kcal_100g' in product_data['nutriments']:
energy = product_data['nutriments']['energy-kcal_100g']
elif 'energy-kj_100g' in product_data['nutriments']:
energy = product_data['nutriments']['energy-kj_100g'] / KJ_PER_KCAL
else:
raise KeyError('Energy is not available')

code = product_data['code']
protein = product_data['nutriments']['proteins_100g']
carbs = product_data['nutriments']['carbohydrates_100g']
sugars = product_data['nutriments']['sugars_100g']
fat = product_data['nutriments']['fat_100g']
saturated = product_data['nutriments']['saturated-fat_100g']

# these are optional
sodium = product['nutriments'].get('sodium_100g', None)
fibre = product['nutriments'].get('fiber_100g', None)
brand = product.get('brands', None)
sodium = product_data['nutriments'].get('sodium_100g', None)
fibre = product_data['nutriments'].get('fiber_100g', None)
brand = product_data.get('brands', None)

# License and author info
source_name = Source.OPEN_FOOD_FACTS.value
source_url = f'https://world.openfoodfacts.org/api/v2/product/{code}.json'
authors = ', '.join(product.get('editors_tags', ['open food facts']))
authors = ', '.join(product_data.get('editors_tags', ['open food facts']))
object_url = f'https://world.openfoodfacts.org/product/{code}/'

return {
'name': name,
'language': language,
'language_id': language,
'energy': energy,
'protein': protein,
'carbohydrates': carbs,
Expand Down
100 changes: 100 additions & 0 deletions wger/nutrition/tests/test_off.py
@@ -0,0 +1,100 @@
# 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/>.

from django.test import SimpleTestCase

# wger
from wger.nutrition.off import extract_info_from_off
from wger.utils.constants import ODBL_LICENSE_ID
from wger.utils.models import AbstractSubmissionModel


class ExtractInfoFromOffTestCase(SimpleTestCase):
"""
Test the extract_info_from_off function
"""
off_data1 = {}

def setUp(self):
self.off_data1 = {
'code': '1234',
'lang': 'de',
'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,
'proteins_100g': 10,
'carbohydrates_100g': 20,
'sugars_100g': 30,
'fat_100g': 40,
'saturated-fat_100g': 11,
'sodium_100g': 5,
'fiber_100g': None,
'other_stuff': 'is ignored'
}
}

def test_regular_response(self):
"""
Test that the function can read the regular case
"""
result = extract_info_from_off(self.off_data1, 1)
test = {
'name': 'Foo with chocolate',
'language_id': 1,
'energy': 120,
'protein': 10,
'carbohydrates': 20,
'carbohydrates_sugar': 30,
'fat': 40,
'fat_saturated': 11,
'fibres': None,
'sodium': 5,
'code': '1234',
'source_name': 'Open Food Facts',
'source_url': 'https://world.openfoodfacts.org/api/v2/product/1234.json',
'common_name': 'Foo with chocolate, 250g package',
'brand': 'The bar company',
'status': AbstractSubmissionModel.STATUS_ACCEPTED,
'license_id': ODBL_LICENSE_ID,
'license_author': 'open food facts, MrX',
'license_title': 'Foo with chocolate',
'license_object_url': 'https://world.openfoodfacts.org/product/1234/'
}

self.assertDictEqual(result, test)

def test_convert_kj(self):
"""
If the energy is not available in kcal per 100 g, but is in kj per 100 g,
we convert it to kcal per 100 g
"""
del self.off_data1['nutriments']['energy-kcal_100g']
self.off_data1['nutriments']['energy-kj_100g'] = 120

result = extract_info_from_off(self.off_data1, 1)

# 120 / KJ_PER_KCAL
self.assertAlmostEqual(result['energy'], 28.6806, 3)

def test_no_energy(self):
"""
No energy available
"""
del self.off_data1['nutriments']['energy-kcal_100g']

self.assertRaises(KeyError, extract_info_from_off, self.off_data1, 1)

0 comments on commit 6923461

Please sign in to comment.