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

View File

@ -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
# },
# }

View File

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

View File

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

View File

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