improved converters and helpers

This commit is contained in:
vabene1111
2023-04-02 10:54:57 +02:00
parent 44cb2d9807
commit 25c914606e
6 changed files with 198 additions and 102 deletions

View File

@ -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'

View File

@ -1,34 +1,51 @@
from cookbook.models import FoodPropertyType from cookbook.models import FoodPropertyType
def calculate_recipe_properties(recipe): class FoodPropertyHelper:
ingredients = [] space = None
computed_properties = {}
property_types = FoodPropertyType.objects.filter(space=recipe.space).all()
for s in recipe.steps.all(): def __init__(self, space):
ingredients += s.ingredients.all() """
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? def calculate_recipe_properties(self, recipe):
computed_properties[fpt.id] = {'name': fpt.name, 'food_values': {}, 'total_value': 0} """
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 fpt in property_types: # TODO is this safe or should I require the request context?
for pt in property_types: computed_properties[fpt.id] = {'name': fpt.name, 'food_values': {}, 'total_value': 0}
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
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 for i in ingredients:
def add_or_create(d, key, value): for pt in property_types:
if key in d: p = i.food.foodproperty_set.filter(space=self.space, property_type=pt).first()
d[key] += value if p:
else: computed_properties[p.property_type.id]['total_value'] += (i.amount / p.food_amount) * p.property_amount
d[key] = value 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)
return d 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

View File

@ -1,7 +1,11 @@
from django.core.cache import caches
from pint import UnitRegistry, UndefinedUnitError, PintError from pint import UnitRegistry, UndefinedUnitError, PintError
from cookbook.helper.cache_helper import CacheHelper
from cookbook.models import Ingredient, Unit 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 = { CONVERT_TO_UNITS = {
'metric': ['g', 'kg', 'ml', 'l'], 'metric': ['g', 'kg', 'ml', 'l'],
'us': ['ounce', 'pound', 'fluid_ounce', 'pint', 'quart', 'gallon'], 'us': ['ounce', 'pound', 'fluid_ounce', 'pint', 'quart', 'gallon'],
@ -9,69 +13,102 @@ CONVERT_TO_UNITS = {
} }
def base_conversions(ingredient_list): class UnitConversionHelper:
ureg = UnitRegistry() space = None
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}')
# TODO allow setting which units to convert to def __init__(self, space):
units = Unit.objects.filter(base_unit__in=(CONVERT_TO_UNITS['metric'] + CONVERT_TO_UNITS['us'] + CONVERT_TO_UNITS['uk'])).all() """
Initializes unit conversion helper
:param space: space to perform conversions on
"""
self.space = space
for u in units: def base_conversions(self, ingredient_list):
try: """
converted = quantitiy.to(u.base_unit) Calculates all possible base unit conversions for each ingredient give.
ingredient = Ingredient(amount=converted.m, unit=u, food=ingredient_list[0].food, ) Converts to all common base units IF they exist in the unit database of the space.
if not any((x.unit.name == ingredient.unit.name or x.unit.base_unit == ingredient.unit.name) for x in pint_converted_list): For useful results all ingredients passed should be of the same food, otherwise filtering afterwards might be required.
pint_converted_list.append(ingredient) :param ingredient_list: list of ingredients to convert
except PintError: :return: ingredient list with appended conversions
pass """
except PintError: ureg = UnitRegistry()
pass 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): return pint_converted_list
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)
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): def _uc_convert(self, uc, amount, unit, food):
if uc.food is None or uc.food == food: """
if unit == uc.base_unit: Helper to calculate values for custom unit conversions.
return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food) Converts given base values using the passed UnitConversion object into a converted Ingredient
# return { :param uc: UnitConversion object
# 'amount': amount * (uc.converted_amount / uc.base_amount), :param amount: base amount
# 'unit': { :param unit: base unit
# 'id': uc.converted_unit.id, :param food: base food
# 'name': uc.converted_unit.name, :return: converted ingredient object from base amount/unit/food
# 'plural_name': uc.converted_unit.plural_name """
# }, if uc.food is None or uc.food == food:
# } if unit == uc.base_unit:
else: return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space)
return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food) # return {
# return { # 'amount': amount * (uc.converted_amount / uc.base_amount),
# 'amount': amount * (uc.base_amount / uc.converted_amount), # 'unit': {
# 'unit': { # 'id': uc.converted_unit.id,
# 'id': uc.base_unit.id, # 'name': uc.converted_unit.name,
# 'name': uc.base_unit.name, # 'plural_name': uc.converted_unit.plural_name
# 'plural_name': uc.base_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
# },
# }

View File

@ -4,15 +4,17 @@ from functools import wraps
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.postgres.search import SearchVector from django.contrib.postgres.search import SearchVector
from django.core.cache import caches
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import translation from django.utils import translation
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from cookbook.helper.cache_helper import CacheHelper
from cookbook.helper.shopping_helper import RecipeShoppingEditor from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe, from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
ShoppingListEntry, Step, UserPreference, SearchPreference, SearchFields) ShoppingListEntry, Step, UserPreference, SearchPreference, SearchFields, Unit)
SQLITE = True SQLITE = True
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 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") print("MEAL_AUTO_ADD Created SLR")
except AttributeError: except AttributeError:
pass 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)

View File

@ -1,9 +1,8 @@
from django.contrib import auth from django.contrib import auth
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from cookbook.helper.food_property_helper import calculate_recipe_properties from cookbook.helper.food_property_helper import FoodPropertyHelper
from cookbook.helper.unit_conversion_helper import get_conversions from cookbook.models import Unit, Food, FoodPropertyType, FoodProperty, Recipe, Step
from cookbook.models import Unit, Food, Ingredient, UnitConversion, FoodPropertyType, FoodProperty, Recipe, Step
def test_food_property(space_1, u1_s1): 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) step_2.ingredients.create(amount=50, unit=unit_gram, food=food_1, space=space_1)
recipe_1.steps.add(step_2) 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]['name'] == property_fat.name
assert property_values[property_fat.id]['total_value'] == 525 # TODO manually validate those numbers 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 assert property_values[property_fat.id]['food_values'][food_2.id] == 250 # TODO manually validate those numbers
print(property_values) print(property_values)
# TODO more property tests # TODO more property tests
# TODO test space separation

View File

@ -3,12 +3,15 @@ from _decimal import Decimal
from django.contrib import auth from django.contrib import auth
from django_scopes import scopes_disabled 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 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(): 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_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_kg = Unit.objects.create(name='kg', base_unit='kg', space=space_1)
unit_pcs = Unit.objects.create(name='pcs', base_unit='', 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, space=space_1,
) )
conversions = get_conversions(ingredient_food_1_gram) conversions = uch.get_conversions(ingredient_food_1_gram)
print(conversions) print(conversions)
assert len(conversions) == 2 assert len(conversions) == 2
assert next(x for x in conversions if x.unit == unit_kg) is not None 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, space=space_1,
) )
conversions = get_conversions(ingredient_food_1_floz1) conversions = uch.get_conversions(ingredient_food_1_floz1)
assert len(conversions) == 2 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) is not None
assert next(x for x in conversions if x.unit == unit_floz2).amount == 96.07599404038842 # TODO validate value 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) print(conversions)
unit_pint = Unit.objects.create(name='pint', base_unit='pint', space=space_1) 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 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) is not None
assert next(x for x in conversions if x.unit == unit_pint).amount == 6.004749627524276 # TODO validate value 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, space=space_1,
created_by=auth.get_user(u1_s1), 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 len(conversions) == 3
assert next(x for x in conversions if x.unit == unit_fantasy) is not None 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, space=space_1,
) )
assert len(get_conversions(ingredient_food_1_pcs)) == 1 assert len(uch.get_conversions(ingredient_food_1_pcs)) == 1
assert len(get_conversions(ingredient_food_2_pcs)) == 1 assert len(uch.get_conversions(ingredient_food_2_pcs)) == 1
print(get_conversions(ingredient_food_1_pcs)) print(uch.get_conversions(ingredient_food_1_pcs))
print(get_conversions(ingredient_food_2_pcs)) print(uch.get_conversions(ingredient_food_2_pcs))
print('\n----------- TEST CUSTOM CONVERSION - PCS TO MULTIPLE BASE ---------------') print('\n----------- TEST CUSTOM CONVERSION - PCS TO MULTIPLE BASE ---------------')
uc1 = UnitConversion.objects.create( uc1 = UnitConversion.objects.create(
@ -105,14 +108,14 @@ def test_unit_conversions(space_1, u1_s1):
created_by=auth.get_user(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 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_gram).amount == 1000
assert next(x for x in conversions if x.unit == unit_kg).amount == 1 assert next(x for x in conversions if x.unit == unit_kg).amount == 1
print(conversions) print(conversions)
assert len(get_conversions(ingredient_food_2_pcs)) == 1 assert len(uch.get_conversions(ingredient_food_2_pcs)) == 1
print(get_conversions(ingredient_food_2_pcs)) print(uch.get_conversions(ingredient_food_2_pcs))
print('\n----------- TEST CUSTOM CONVERSION - REVERSE CONVERSION ---------------') print('\n----------- TEST CUSTOM CONVERSION - REVERSE CONVERSION ---------------')
uc2 = UnitConversion.objects.create( uc2 = UnitConversion.objects.create(
@ -125,14 +128,35 @@ def test_unit_conversions(space_1, u1_s1):
created_by=auth.get_user(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 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_gram).amount == 1000
assert next(x for x in conversions if x.unit == unit_kg).amount == 1 assert next(x for x in conversions if x.unit == unit_kg).amount == 1
print(conversions) print(conversions)
conversions = get_conversions(ingredient_food_2_pcs) conversions = uch.get_conversions(ingredient_food_2_pcs)
assert len(conversions) == 3 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_gram).amount == 1000
assert next(x for x in conversions if x.unit == unit_kg).amount == 1 assert next(x for x in conversions if x.unit == unit_kg).amount == 1
print(conversions) 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)