318 lines
15 KiB
Python
318 lines
15 KiB
Python
from datetime import timedelta
|
|
from decimal import Decimal
|
|
|
|
from django.db.models import F, OuterRef, Q, Subquery, Value
|
|
from django.db.models.functions import Coalesce
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext as _
|
|
|
|
from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
|
|
SupermarketCategoryRelation)
|
|
|
|
|
|
def shopping_helper(qs, request):
|
|
supermarket = request.query_params.get('supermarket', None)
|
|
checked = request.query_params.get('checked', 'recent')
|
|
user = request.user
|
|
supermarket_order = [F('food__supermarket_category__name').asc(nulls_first=True), 'food__name']
|
|
|
|
# TODO created either scheduled task or startup task to delete very old shopping list entries
|
|
# TODO create user preference to define 'very old'
|
|
if supermarket:
|
|
supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category'))
|
|
qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999)))
|
|
supermarket_order = ['supermarket_order'] + supermarket_order
|
|
if checked in ['false', 0, '0']:
|
|
qs = qs.filter(checked=False)
|
|
elif checked in ['true', 1, '1']:
|
|
qs = qs.filter(checked=True)
|
|
elif checked in ['recent']:
|
|
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
|
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
|
|
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
|
|
supermarket_order = ['checked'] + supermarket_order
|
|
|
|
return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
|
|
|
|
|
|
class RecipeShoppingEditor():
|
|
def __init__(self, user, space, **kwargs):
|
|
self.created_by = user
|
|
self.space = space
|
|
self._kwargs = {**kwargs}
|
|
|
|
self.mealplan = self._kwargs.get('mealplan', None)
|
|
if type(self.mealplan) in [int, float]:
|
|
self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
|
|
if isinstance(self.mealplan, dict):
|
|
self.mealplan = MealPlan.objects.filter(id=self.mealplan['id'], space=self.space).first()
|
|
self.id = self._kwargs.get('id', None)
|
|
|
|
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
|
|
|
|
if self._shopping_list_recipe:
|
|
# created_by needs to be sticky to original creator as it is 'their' shopping list
|
|
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
|
|
self.created_by = getattr(self._shopping_list_recipe.entries.first(), 'created_by', self.created_by)
|
|
|
|
self.recipe = getattr(self._shopping_list_recipe, 'recipe', None) or self._kwargs.get('recipe', None) or getattr(self.mealplan, 'recipe', None)
|
|
if type(self.recipe) in [int, float]:
|
|
self.recipe = Recipe.objects.filter(id=self.recipe, space=self.space)
|
|
|
|
try:
|
|
self.servings = float(self._kwargs.get('servings', None))
|
|
except (ValueError, TypeError):
|
|
self.servings = getattr(self._shopping_list_recipe, 'servings', None) or getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', None)
|
|
|
|
@property
|
|
def _recipe_servings(self):
|
|
return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings',
|
|
None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None)
|
|
|
|
@property
|
|
def _servings_factor(self):
|
|
return Decimal(self.servings) / Decimal(self._recipe_servings)
|
|
|
|
@property
|
|
def _shared_users(self):
|
|
return [*list(self.created_by.get_shopping_share()), self.created_by]
|
|
|
|
@staticmethod
|
|
def get_shopping_list_recipe(id, user, space):
|
|
return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter(
|
|
Q(shoppinglist__created_by=user)
|
|
| Q(shoppinglist__shared=user)
|
|
| Q(entries__created_by=user)
|
|
| Q(entries__created_by__in=list(user.get_shopping_share()))
|
|
).prefetch_related('entries').first()
|
|
|
|
def get_recipe_ingredients(self, id, exclude_onhand=False):
|
|
if exclude_onhand:
|
|
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(
|
|
food__onhand_users__id__in=[x.id for x in self._shared_users])
|
|
else:
|
|
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
|
|
|
|
@property
|
|
def _include_related(self):
|
|
return self.created_by.userpreference.mealplan_autoinclude_related
|
|
|
|
@property
|
|
def _exclude_onhand(self):
|
|
return self.created_by.userpreference.mealplan_autoexclude_onhand
|
|
|
|
def create(self, **kwargs):
|
|
ingredients = kwargs.get('ingredients', None)
|
|
exclude_onhand = not ingredients and self._exclude_onhand
|
|
if servings := kwargs.get('servings', None):
|
|
self.servings = float(servings)
|
|
|
|
if mealplan := kwargs.get('mealplan', None):
|
|
if isinstance(mealplan, dict):
|
|
self.mealplan = MealPlan.objects.filter(id=mealplan['id'], space=self.space).first()
|
|
else:
|
|
self.mealplan = mealplan
|
|
self.recipe = mealplan.recipe
|
|
elif recipe := kwargs.get('recipe', None):
|
|
self.recipe = recipe
|
|
|
|
if not self.servings:
|
|
self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0)
|
|
|
|
self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings)
|
|
|
|
if ingredients:
|
|
self._add_ingredients(ingredients=ingredients)
|
|
else:
|
|
if self._include_related:
|
|
related = self.recipe.get_related_recipes()
|
|
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
|
|
for r in related:
|
|
self._add_ingredients(self.get_recipe_ingredients(r.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
|
|
else:
|
|
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand))
|
|
|
|
return True
|
|
|
|
def add(self, **kwargs):
|
|
return
|
|
|
|
def edit(self, servings=None, ingredients=None, **kwargs):
|
|
if servings:
|
|
self.servings = servings
|
|
|
|
self._delete_ingredients(ingredients=ingredients)
|
|
if self.servings != self._shopping_list_recipe.servings:
|
|
self.edit_servings()
|
|
self._add_ingredients(ingredients=ingredients)
|
|
return True
|
|
|
|
def edit_servings(self, servings=None, **kwargs):
|
|
if servings:
|
|
self.servings = servings
|
|
if id := kwargs.get('id', None):
|
|
self._shopping_list_recipe = self.get_shopping_list_recipe(id, self.created_by, self.space)
|
|
if not self.servings:
|
|
raise ValueError(_("You must supply a servings size"))
|
|
|
|
if self._shopping_list_recipe.servings == self.servings:
|
|
return True
|
|
|
|
for sle in ShoppingListEntry.objects.filter(list_recipe=self._shopping_list_recipe):
|
|
sle.amount = sle.ingredient.amount * Decimal(self._servings_factor)
|
|
sle.save()
|
|
self._shopping_list_recipe.servings = self.servings
|
|
self._shopping_list_recipe.save()
|
|
return True
|
|
|
|
def delete(self, **kwargs):
|
|
try:
|
|
self._shopping_list_recipe.delete()
|
|
return True
|
|
except BaseException:
|
|
return False
|
|
|
|
def _add_ingredients(self, ingredients=None):
|
|
if not ingredients:
|
|
return
|
|
elif isinstance(ingredients, list):
|
|
ingredients = Ingredient.objects.filter(id__in=ingredients)
|
|
existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
|
|
add_ingredients = ingredients.exclude(id__in=existing)
|
|
|
|
for i in [x for x in add_ingredients if x.food]:
|
|
ShoppingListEntry.objects.create(
|
|
list_recipe=self._shopping_list_recipe,
|
|
food=i.food,
|
|
unit=i.unit,
|
|
ingredient=i,
|
|
amount=i.amount * Decimal(self._servings_factor),
|
|
created_by=self.created_by,
|
|
space=self.space,
|
|
)
|
|
|
|
# deletes shopping list entries not in ingredients list
|
|
def _delete_ingredients(self, ingredients=None):
|
|
if not ingredients:
|
|
return
|
|
to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients)
|
|
ShoppingListEntry.objects.filter(id__in=to_delete).delete()
|
|
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
|
|
|
|
|
|
# # TODO refactor as class
|
|
# def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
|
|
# """
|
|
# Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
|
# :param list_recipe: Modify an existing ShoppingListRecipe
|
|
# :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
|
# :param mealplan: alternatively use a mealplan recipe as source of ingredients
|
|
# :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
|
# :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
|
# :param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
|
|
# """
|
|
# r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
|
# if not r:
|
|
# raise ValueError(_("You must supply a recipe or mealplan"))
|
|
|
|
# created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
|
|
# if not created_by:
|
|
# raise ValueError(_("You must supply a created_by"))
|
|
|
|
# try:
|
|
# servings = float(servings)
|
|
# except (ValueError, TypeError):
|
|
# servings = getattr(mealplan, 'servings', 1.0)
|
|
|
|
# servings_factor = servings / r.servings
|
|
|
|
# shared_users = list(created_by.get_shopping_share())
|
|
# shared_users.append(created_by)
|
|
# if list_recipe:
|
|
# created = False
|
|
# else:
|
|
# list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
|
# created = True
|
|
|
|
# related_step_ing = []
|
|
# if servings == 0 and not created:
|
|
# list_recipe.delete()
|
|
# return []
|
|
# elif ingredients:
|
|
# ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
|
# else:
|
|
# ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
|
|
|
|
# if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
|
|
# ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
|
|
|
# if related := created_by.userpreference.mealplan_autoinclude_related:
|
|
# # TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
|
|
# related_recipes = r.get_related_recipes()
|
|
|
|
# for x in related_recipes:
|
|
# # related recipe is a Step serving size is driven by recipe serving size
|
|
# # TODO once/if Steps can have a serving size this needs to be refactored
|
|
# if exclude_onhand:
|
|
# # if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
|
|
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
|
|
# else:
|
|
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
|
|
|
|
# x_ing = []
|
|
# if ingredients.filter(food__recipe=x).exists():
|
|
# for ing in ingredients.filter(food__recipe=x):
|
|
# if exclude_onhand:
|
|
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
|
# else:
|
|
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
|
|
# for i in [x for x in x_ing]:
|
|
# ShoppingListEntry.objects.create(
|
|
# list_recipe=list_recipe,
|
|
# food=i.food,
|
|
# unit=i.unit,
|
|
# ingredient=i,
|
|
# amount=i.amount * Decimal(servings_factor),
|
|
# created_by=created_by,
|
|
# space=space,
|
|
# )
|
|
# # dont' add food to the shopping list that are actually recipes that will be added as ingredients
|
|
# ingredients = ingredients.exclude(food__recipe=x)
|
|
|
|
# add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
|
|
# if not append:
|
|
# existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
|
# # delete shopping list entries not included in ingredients
|
|
# existing_list.exclude(ingredient__in=ingredients).delete()
|
|
# # add shopping list entries that did not previously exist
|
|
# add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
|
|
# add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
|
|
|
# # if servings have changed, update the ShoppingListRecipe and existing Entries
|
|
# if servings <= 0:
|
|
# servings = 1
|
|
|
|
# if not created and list_recipe.servings != servings:
|
|
# update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
|
# list_recipe.servings = servings
|
|
# list_recipe.save()
|
|
# for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
|
# sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
|
# sle.save()
|
|
|
|
# # add any missing Entries
|
|
# for i in [x for x in add_ingredients if x.food]:
|
|
|
|
# ShoppingListEntry.objects.create(
|
|
# list_recipe=list_recipe,
|
|
# food=i.food,
|
|
# unit=i.unit,
|
|
# ingredient=i,
|
|
# amount=i.amount * Decimal(servings_factor),
|
|
# created_by=created_by,
|
|
# space=space,
|
|
# )
|
|
|
|
# # return all shopping list items
|
|
# return list_recipe
|