diff --git a/extras/docker/development/README.md b/extras/docker/development/README.md index 55a382e43..cdb7c1d71 100644 --- a/extras/docker/development/README.md +++ b/extras/docker/development/README.md @@ -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: diff --git a/wger/nutrition/consts.py b/wger/nutrition/consts.py index 0088236c5..12be31d3e 100644 --- a/wger/nutrition/consts.py +++ b/wger/nutrition/consts.py @@ -34,3 +34,5 @@ """ Simple approximation of energy (kcal) provided per gram or ounce """ + +KJ_PER_KCAL = 4.184 diff --git a/wger/nutrition/helpers.py b/wger/nutrition/helpers.py index 9927542bd..74e7e2e95 100644 --- a/wger/nutrition/helpers.py +++ b/wger/nutrition/helpers.py @@ -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: @@ -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'): """ @@ -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 diff --git a/wger/nutrition/management/commands/import-off-products.py b/wger/nutrition/management/commands/import-off-products.py index ecabb14dc..a72c52650 100644 --- a/wger/nutrition/management/commands/import-off-products.py +++ b/wger/nutrition/management/commands/import-off-products.py @@ -25,7 +25,6 @@ from wger.nutrition.models import Ingredient from wger.nutrition.off import extract_info_from_off - logger = logging.getLogger(__name__) @@ -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', @@ -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 @@ -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() @@ -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 diff --git a/wger/nutrition/models/ingredient.py b/wger/nutrition/models/ingredient.py index 1bdd9636b..84cba0098 100644 --- a/wger/nutrition/models/ingredient.py +++ b/wger/nutrition/models/ingredient.py @@ -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 ( @@ -62,7 +65,6 @@ # Local from .ingredient_category import IngredientCategory - logger = logging.getLogger(__name__) @@ -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 @@ -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 diff --git a/wger/nutrition/off.py b/wger/nutrition/off.py index 36ef4e392..daaea6852 100644 --- a/wger/nutrition/off.py +++ b/wger/nutrition/off.py @@ -14,18 +14,17 @@ # along with this program. If not, see . # 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', @@ -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, diff --git a/wger/nutrition/tests/test_off.py b/wger/nutrition/tests/test_off.py new file mode 100644 index 000000000..ab413d9c0 --- /dev/null +++ b/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 . + +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)