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