from datetime import timedelta from decimal import Decimal from django.contrib.postgres.aggregates import ArrayAgg 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.helper.HelperFunctions import Round, str2bool from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe, SupermarketCategoryRelation) from recipes import settings 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 type(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 type(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: return False def _add_ingredients(self, ingredients=None): if not ingredients: return elif type(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