diff --git a/cookbook/helper/cache_helper.py b/cookbook/helper/cache_helper.py new file mode 100644 index 000000000..3cd7900f9 --- /dev/null +++ b/cookbook/helper/cache_helper.py @@ -0,0 +1,9 @@ +class CacheHelper: + space = None + + BASE_UNITS_CACHE_KEY = None + + def __init__(self, space): + self.space = space + + self.BASE_UNITS_CACHE_KEY = f'SPACE_{space.id}_BASE_UNITS' diff --git a/cookbook/helper/food_property_helper.py b/cookbook/helper/food_property_helper.py index 58f08af8f..1bed81c0a 100644 --- a/cookbook/helper/food_property_helper.py +++ b/cookbook/helper/food_property_helper.py @@ -1,34 +1,51 @@ from cookbook.models import FoodPropertyType -def calculate_recipe_properties(recipe): - ingredients = [] - computed_properties = {} - property_types = FoodPropertyType.objects.filter(space=recipe.space).all() +class FoodPropertyHelper: + space = None - for s in recipe.steps.all(): - ingredients += s.ingredients.all() + def __init__(self, space): + """ + Helper to perform food property calculations + :param space: space to limit scope to + """ + self.space = space - for fpt in property_types: # TODO is this safe or should I require the request context? - computed_properties[fpt.id] = {'name': fpt.name, 'food_values': {}, 'total_value': 0} + def calculate_recipe_properties(self, recipe): + """ + Calculate all food properties for a given recipe. + :param recipe: recipe to calculate properties for + :return: dict of with property keys and total/food values for each property available + """ + ingredients = [] + computed_properties = {} + property_types = FoodPropertyType.objects.filter(space=self.space).all() - # TODO unit conversion support + for s in recipe.steps.all(): + ingredients += s.ingredients.all() - for i in ingredients: - for pt in property_types: - p = i.food.foodproperty_set.filter(property_type=pt).first() - if p: - computed_properties[p.property_type.id]['total_value'] += (i.amount / p.food_amount) * p.property_amount - computed_properties[p.property_type.id]['food_values'] = add_or_create(computed_properties[p.property_type.id]['food_values'], i.food.id, (i.amount / p.food_amount) * p.property_amount) - else: - computed_properties[pt.id]['food_values'][i.food.id] = None + for fpt in property_types: # TODO is this safe or should I require the request context? + computed_properties[fpt.id] = {'name': fpt.name, 'food_values': {}, 'total_value': 0} - return computed_properties + # TODO unit conversion support -# small dict helper to add to existing key or create new, probably a better way of doing this -def add_or_create(d, key, value): - if key in d: - d[key] += value - else: - d[key] = value - return d + for i in ingredients: + for pt in property_types: + p = i.food.foodproperty_set.filter(space=self.space, property_type=pt).first() + if p: + computed_properties[p.property_type.id]['total_value'] += (i.amount / p.food_amount) * p.property_amount + computed_properties[p.property_type.id]['food_values'] = self.add_or_create(computed_properties[p.property_type.id]['food_values'], i.food.id, (i.amount / p.food_amount) * p.property_amount) + else: + computed_properties[pt.id]['food_values'][i.food.id] = None + + return computed_properties + + # small dict helper to add to existing key or create new, probably a better way of doing this + # TODO move to central helper ? + @staticmethod + def add_or_create(d, key, value): + if key in d: + d[key] += value + else: + d[key] = value + return d diff --git a/cookbook/helper/unit_conversion_helper.py b/cookbook/helper/unit_conversion_helper.py index ca466cc3a..c664b03e9 100644 --- a/cookbook/helper/unit_conversion_helper.py +++ b/cookbook/helper/unit_conversion_helper.py @@ -1,7 +1,11 @@ +from django.core.cache import caches from pint import UnitRegistry, UndefinedUnitError, PintError +from cookbook.helper.cache_helper import CacheHelper from cookbook.models import Ingredient, Unit +# basic units that should be considered for "to" conversions +# TODO possible remove this hardcoded units and just add a flag to the unit CONVERT_TO_UNITS = { 'metric': ['g', 'kg', 'ml', 'l'], 'us': ['ounce', 'pound', 'fluid_ounce', 'pint', 'quart', 'gallon'], @@ -9,69 +13,102 @@ CONVERT_TO_UNITS = { } -def base_conversions(ingredient_list): - ureg = UnitRegistry() - pint_converted_list = ingredient_list.copy() - for i in ingredient_list: - try: - conversion_unit = i.unit.name - if i.unit.base_unit: - conversion_unit = i.unit.base_unit - quantitiy = ureg.Quantity(f'{i.amount} {conversion_unit}') +class UnitConversionHelper: + space = None - # TODO allow setting which units to convert to - units = Unit.objects.filter(base_unit__in=(CONVERT_TO_UNITS['metric'] + CONVERT_TO_UNITS['us'] + CONVERT_TO_UNITS['uk'])).all() + def __init__(self, space): + """ + Initializes unit conversion helper + :param space: space to perform conversions on + """ + self.space = space - for u in units: - try: - converted = quantitiy.to(u.base_unit) - ingredient = Ingredient(amount=converted.m, unit=u, food=ingredient_list[0].food, ) - if not any((x.unit.name == ingredient.unit.name or x.unit.base_unit == ingredient.unit.name) for x in pint_converted_list): - pint_converted_list.append(ingredient) - except PintError: - pass - except PintError: - pass + def base_conversions(self, ingredient_list): + """ + Calculates all possible base unit conversions for each ingredient give. + Converts to all common base units IF they exist in the unit database of the space. + For useful results all ingredients passed should be of the same food, otherwise filtering afterwards might be required. + :param ingredient_list: list of ingredients to convert + :return: ingredient list with appended conversions + """ + ureg = UnitRegistry() + pint_converted_list = ingredient_list.copy() + for i in ingredient_list: + try: + conversion_unit = i.unit.name + if i.unit.base_unit: + conversion_unit = i.unit.base_unit + quantitiy = ureg.Quantity(f'{i.amount} {conversion_unit}') - return pint_converted_list + # TODO allow setting which units to convert to? possibly only once conversions become visible + units = caches['default'].get(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, None) + if not units: + units = Unit.objects.filter(space=self.space, base_unit__in=(CONVERT_TO_UNITS['metric'] + CONVERT_TO_UNITS['us'] + CONVERT_TO_UNITS['uk'])).all() + caches['default'].set(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, units, 60 * 60) # cache is cleared on unit save signal so long duration is fine + for u in units: + try: + converted = quantitiy.to(u.base_unit) + ingredient = Ingredient(amount=converted.m, unit=u, food=ingredient_list[0].food, ) + if not any((x.unit.name == ingredient.unit.name or x.unit.base_unit == ingredient.unit.name) for x in pint_converted_list): + pint_converted_list.append(ingredient) + except PintError: + pass + except PintError: + pass -def get_conversions(ingredient): - conversions = [ingredient] - if ingredient.unit: - for c in ingredient.unit.unit_conversion_base_relation.all(): - r = _uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food) - if r and r not in conversions: - conversions.append(r) - for c in ingredient.unit.unit_conversion_converted_relation.all(): - r = _uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food) - if r and r not in conversions: - conversions.append(r) + return pint_converted_list - conversions = base_conversions(conversions) + def get_conversions(self, ingredient): + """ + Converts an ingredient to all possible conversions based on the custom unit conversion database. + After that passes conversion to UnitConversionHelper.base_conversions() to get all base conversions possible. + :param ingredient: Ingredient object + :return: list of ingredients with all possible custom and base conversions + """ + conversions = [ingredient] + if ingredient.unit: + for c in ingredient.unit.unit_conversion_base_relation.filter(space=self.space).all(): + r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food) + if r and r not in conversions: + conversions.append(r) + for c in ingredient.unit.unit_conversion_converted_relation.filter(space=self.space).all(): + r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food) + if r and r not in conversions: + conversions.append(r) - return conversions + conversions = self.base_conversions(conversions) + return conversions -def _uc_convert(uc, amount, unit, food): - if uc.food is None or uc.food == food: - if unit == uc.base_unit: - return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food) - # return { - # 'amount': amount * (uc.converted_amount / uc.base_amount), - # 'unit': { - # 'id': uc.converted_unit.id, - # 'name': uc.converted_unit.name, - # 'plural_name': uc.converted_unit.plural_name - # }, - # } - else: - return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food) - # return { - # 'amount': amount * (uc.base_amount / uc.converted_amount), - # 'unit': { - # 'id': uc.base_unit.id, - # 'name': uc.base_unit.name, - # 'plural_name': uc.base_unit.plural_name - # }, - # } + def _uc_convert(self, uc, amount, unit, food): + """ + Helper to calculate values for custom unit conversions. + Converts given base values using the passed UnitConversion object into a converted Ingredient + :param uc: UnitConversion object + :param amount: base amount + :param unit: base unit + :param food: base food + :return: converted ingredient object from base amount/unit/food + """ + if uc.food is None or uc.food == food: + if unit == uc.base_unit: + return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space) + # return { + # 'amount': amount * (uc.converted_amount / uc.base_amount), + # 'unit': { + # 'id': uc.converted_unit.id, + # 'name': uc.converted_unit.name, + # 'plural_name': uc.converted_unit.plural_name + # }, + # } + else: + return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food, space=self.space) + # return { + # 'amount': amount * (uc.base_amount / uc.converted_amount), + # 'unit': { + # 'id': uc.base_unit.id, + # 'name': uc.base_unit.name, + # 'plural_name': uc.base_unit.plural_name + # }, + # } diff --git a/cookbook/signals.py b/cookbook/signals.py index 624ffa536..bce361ea9 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -4,15 +4,17 @@ from functools import wraps from django.conf import settings from django.contrib.auth.models import User from django.contrib.postgres.search import SearchVector +from django.core.cache import caches from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import translation from django_scopes import scope, scopes_disabled +from cookbook.helper.cache_helper import CacheHelper from cookbook.helper.shopping_helper import RecipeShoppingEditor from cookbook.managers import DICTIONARY from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe, - ShoppingListEntry, Step, UserPreference, SearchPreference, SearchFields) + ShoppingListEntry, Step, UserPreference, SearchPreference, SearchFields, Unit) SQLITE = True if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', @@ -149,3 +151,9 @@ def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs print("MEAL_AUTO_ADD Created SLR") except AttributeError: pass + + +@receiver(post_save, sender=Unit) +def create_search_preference(sender, instance=None, created=False, **kwargs): + if instance: + caches['default'].delete(CacheHelper(instance.space).BASE_UNITS_CACHE_KEY) diff --git a/cookbook/tests/other/test_food_property.py b/cookbook/tests/other/test_food_property.py index 024233c1f..a93459575 100644 --- a/cookbook/tests/other/test_food_property.py +++ b/cookbook/tests/other/test_food_property.py @@ -1,9 +1,8 @@ from django.contrib import auth from django_scopes import scopes_disabled -from cookbook.helper.food_property_helper import calculate_recipe_properties -from cookbook.helper.unit_conversion_helper import get_conversions -from cookbook.models import Unit, Food, Ingredient, UnitConversion, FoodPropertyType, FoodProperty, Recipe, Step +from cookbook.helper.food_property_helper import FoodPropertyHelper +from cookbook.models import Unit, Food, FoodPropertyType, FoodProperty, Recipe, Step def test_food_property(space_1, u1_s1): @@ -41,7 +40,7 @@ def test_food_property(space_1, u1_s1): step_2.ingredients.create(amount=50, unit=unit_gram, food=food_1, space=space_1) recipe_1.steps.add(step_2) - property_values = calculate_recipe_properties(recipe_1) + property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_1) assert property_values[property_fat.id]['name'] == property_fat.name assert property_values[property_fat.id]['total_value'] == 525 # TODO manually validate those numbers @@ -49,3 +48,5 @@ def test_food_property(space_1, u1_s1): assert property_values[property_fat.id]['food_values'][food_2.id] == 250 # TODO manually validate those numbers print(property_values) # TODO more property tests + + # TODO test space separation diff --git a/cookbook/tests/other/test_unit_conversion.py b/cookbook/tests/other/test_unit_conversion.py index d17e6b707..adc9d15cc 100644 --- a/cookbook/tests/other/test_unit_conversion.py +++ b/cookbook/tests/other/test_unit_conversion.py @@ -3,12 +3,15 @@ from _decimal import Decimal from django.contrib import auth from django_scopes import scopes_disabled -from cookbook.helper.unit_conversion_helper import get_conversions +from cookbook.helper.unit_conversion_helper import UnitConversionHelper from cookbook.models import Unit, Food, Ingredient, UnitConversion -def test_unit_conversions(space_1, u1_s1): +def test_unit_conversions(space_1, space_2, u1_s1): with scopes_disabled(): + uch = UnitConversionHelper(space_1) + uch_space_2 = UnitConversionHelper(space_2) + unit_gram = Unit.objects.create(name='gram', base_unit='g', space=space_1) unit_kg = Unit.objects.create(name='kg', base_unit='kg', space=space_1) unit_pcs = Unit.objects.create(name='pcs', base_unit='', space=space_1) @@ -27,7 +30,7 @@ def test_unit_conversions(space_1, u1_s1): space=space_1, ) - conversions = get_conversions(ingredient_food_1_gram) + conversions = uch.get_conversions(ingredient_food_1_gram) print(conversions) assert len(conversions) == 2 assert next(x for x in conversions if x.unit == unit_kg) is not None @@ -42,7 +45,7 @@ def test_unit_conversions(space_1, u1_s1): space=space_1, ) - conversions = get_conversions(ingredient_food_1_floz1) + conversions = uch.get_conversions(ingredient_food_1_floz1) assert len(conversions) == 2 assert next(x for x in conversions if x.unit == unit_floz2) is not None assert next(x for x in conversions if x.unit == unit_floz2).amount == 96.07599404038842 # TODO validate value @@ -50,7 +53,7 @@ def test_unit_conversions(space_1, u1_s1): print(conversions) unit_pint = Unit.objects.create(name='pint', base_unit='pint', space=space_1) - conversions = get_conversions(ingredient_food_1_floz1) + conversions = uch.get_conversions(ingredient_food_1_floz1) assert len(conversions) == 3 assert next(x for x in conversions if x.unit == unit_pint) is not None assert next(x for x in conversions if x.unit == unit_pint).amount == 6.004749627524276 # TODO validate value @@ -66,7 +69,7 @@ def test_unit_conversions(space_1, u1_s1): space=space_1, created_by=auth.get_user(u1_s1), ) - conversions = get_conversions(ingredient_food_1_gram) + conversions = uch.get_conversions(ingredient_food_1_gram) assert len(conversions) == 3 assert next(x for x in conversions if x.unit == unit_fantasy) is not None @@ -89,10 +92,10 @@ def test_unit_conversions(space_1, u1_s1): space=space_1, ) - assert len(get_conversions(ingredient_food_1_pcs)) == 1 - assert len(get_conversions(ingredient_food_2_pcs)) == 1 - print(get_conversions(ingredient_food_1_pcs)) - print(get_conversions(ingredient_food_2_pcs)) + assert len(uch.get_conversions(ingredient_food_1_pcs)) == 1 + assert len(uch.get_conversions(ingredient_food_2_pcs)) == 1 + print(uch.get_conversions(ingredient_food_1_pcs)) + print(uch.get_conversions(ingredient_food_2_pcs)) print('\n----------- TEST CUSTOM CONVERSION - PCS TO MULTIPLE BASE ---------------') uc1 = UnitConversion.objects.create( @@ -105,14 +108,14 @@ def test_unit_conversions(space_1, u1_s1): created_by=auth.get_user(u1_s1), ) - conversions = get_conversions(ingredient_food_1_pcs) + conversions = uch.get_conversions(ingredient_food_1_pcs) assert len(conversions) == 3 assert next(x for x in conversions if x.unit == unit_gram).amount == 1000 assert next(x for x in conversions if x.unit == unit_kg).amount == 1 print(conversions) - assert len(get_conversions(ingredient_food_2_pcs)) == 1 - print(get_conversions(ingredient_food_2_pcs)) + assert len(uch.get_conversions(ingredient_food_2_pcs)) == 1 + print(uch.get_conversions(ingredient_food_2_pcs)) print('\n----------- TEST CUSTOM CONVERSION - REVERSE CONVERSION ---------------') uc2 = UnitConversion.objects.create( @@ -125,14 +128,35 @@ def test_unit_conversions(space_1, u1_s1): created_by=auth.get_user(u1_s1), ) - conversions = get_conversions(ingredient_food_1_pcs) + conversions = uch.get_conversions(ingredient_food_1_pcs) assert len(conversions) == 3 assert next(x for x in conversions if x.unit == unit_gram).amount == 1000 assert next(x for x in conversions if x.unit == unit_kg).amount == 1 print(conversions) - conversions = get_conversions(ingredient_food_2_pcs) + conversions = uch.get_conversions(ingredient_food_2_pcs) assert len(conversions) == 3 assert next(x for x in conversions if x.unit == unit_gram).amount == 1000 assert next(x for x in conversions if x.unit == unit_kg).amount == 1 print(conversions) + + print('\n----------- TEST SPACE SEPARATION ---------------') + uc2.space = space_2 + uc2.save() + conversions = uch.get_conversions(ingredient_food_2_pcs) + assert len(conversions) == 1 + print(conversions) + + conversions = uch_space_2.get_conversions(ingredient_food_1_gram) + assert len(conversions) == 1 + assert not any(x for x in conversions if x.unit == unit_kg) + print(conversions) + + unit_kg_space_2 = Unit.objects.create(name='kg', base_unit='kg', space=space_2) + conversions = uch_space_2.get_conversions(ingredient_food_1_gram) + assert len(conversions) == 2 + assert not any(x for x in conversions if x.unit == unit_kg) + assert next(x for x in conversions if x.unit == unit_kg_space_2) is not None + assert next(x for x in conversions if x.unit == unit_kg_space_2).amount == 0.1 + print(conversions) +