Coverage for cookbook/helper/shopping_helper.py: 87%
139 statements
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-29 01:02 +0100
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-29 01:02 +0100
1from datetime import timedelta
2from decimal import Decimal
4from django.db.models import F, OuterRef, Q, Subquery, Value
5from django.db.models.functions import Coalesce
6from django.utils import timezone
7from django.utils.translation import gettext as _
9from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
10 SupermarketCategoryRelation)
13def shopping_helper(qs, request):
14 supermarket = request.query_params.get('supermarket', None)
15 checked = request.query_params.get('checked', 'recent')
16 user = request.user
17 supermarket_order = [F('food__supermarket_category__name').asc(nulls_first=True), 'food__name']
19 # TODO created either scheduled task or startup task to delete very old shopping list entries
20 # TODO create user preference to define 'very old'
21 if supermarket:
22 supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category'))
23 qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999)))
24 supermarket_order = ['supermarket_order'] + supermarket_order
25 if checked in ['false', 0, '0']:
26 qs = qs.filter(checked=False)
27 elif checked in ['true', 1, '1']:
28 qs = qs.filter(checked=True)
29 elif checked in ['recent']:
30 today_start = timezone.now().replace(hour=0, minute=0, second=0)
31 week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
32 qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
33 supermarket_order = ['checked'] + supermarket_order
35 return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
38class RecipeShoppingEditor():
39 def __init__(self, user, space, **kwargs):
40 self.created_by = user
41 self.space = space
42 self._kwargs = {**kwargs}
44 self.mealplan = self._kwargs.get('mealplan', None)
45 if type(self.mealplan) in [int, float]:
46 self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
47 if isinstance(self.mealplan, dict):
48 self.mealplan = MealPlan.objects.filter(id=self.mealplan['id'], space=self.space).first()
49 self.id = self._kwargs.get('id', None)
51 self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
53 if self._shopping_list_recipe:
54 # created_by needs to be sticky to original creator as it is 'their' shopping list
55 # changing shopping list created_by can shift some items to new owner which may not share in the other direction
56 self.created_by = getattr(self._shopping_list_recipe.entries.first(), 'created_by', self.created_by)
58 self.recipe = getattr(self._shopping_list_recipe, 'recipe', None) or self._kwargs.get('recipe', None) or getattr(self.mealplan, 'recipe', None)
59 if type(self.recipe) in [int, float]:
60 self.recipe = Recipe.objects.filter(id=self.recipe, space=self.space)
62 try:
63 self.servings = float(self._kwargs.get('servings', None))
64 except (ValueError, TypeError):
65 self.servings = getattr(self._shopping_list_recipe, 'servings', None) or getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', None)
67 @property
68 def _recipe_servings(self):
69 return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings',
70 None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None)
72 @property
73 def _servings_factor(self):
74 return Decimal(self.servings) / Decimal(self._recipe_servings)
76 @property
77 def _shared_users(self):
78 return [*list(self.created_by.get_shopping_share()), self.created_by]
80 @staticmethod
81 def get_shopping_list_recipe(id, user, space):
82 return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter(
83 Q(shoppinglist__created_by=user)
84 | Q(shoppinglist__shared=user)
85 | Q(entries__created_by=user)
86 | Q(entries__created_by__in=list(user.get_shopping_share()))
87 ).prefetch_related('entries').first()
89 def get_recipe_ingredients(self, id, exclude_onhand=False):
90 if exclude_onhand:
91 return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(
92 food__onhand_users__id__in=[x.id for x in self._shared_users])
93 else:
94 return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
96 @property
97 def _include_related(self):
98 return self.created_by.userpreference.mealplan_autoinclude_related
100 @property
101 def _exclude_onhand(self):
102 return self.created_by.userpreference.mealplan_autoexclude_onhand
104 def create(self, **kwargs):
105 ingredients = kwargs.get('ingredients', None)
106 exclude_onhand = not ingredients and self._exclude_onhand
107 if servings := kwargs.get('servings', None):
108 self.servings = float(servings)
110 if mealplan := kwargs.get('mealplan', None):
111 if isinstance(mealplan, dict):
112 self.mealplan = MealPlan.objects.filter(id=mealplan['id'], space=self.space).first()
113 else:
114 self.mealplan = mealplan
115 self.recipe = mealplan.recipe
116 elif recipe := kwargs.get('recipe', None):
117 self.recipe = recipe
119 if not self.servings:
120 self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0)
122 self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings)
124 if ingredients:
125 self._add_ingredients(ingredients=ingredients)
126 else:
127 if self._include_related:
128 related = self.recipe.get_related_recipes()
129 self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
130 for r in related:
131 self._add_ingredients(self.get_recipe_ingredients(r.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
132 else:
133 self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand))
135 return True
137 def add(self, **kwargs):
138 return
140 def edit(self, servings=None, ingredients=None, **kwargs):
141 if servings:
142 self.servings = servings
144 self._delete_ingredients(ingredients=ingredients)
145 if self.servings != self._shopping_list_recipe.servings:
146 self.edit_servings()
147 self._add_ingredients(ingredients=ingredients)
148 return True
150 def edit_servings(self, servings=None, **kwargs):
151 if servings:
152 self.servings = servings
153 if id := kwargs.get('id', None):
154 self._shopping_list_recipe = self.get_shopping_list_recipe(id, self.created_by, self.space)
155 if not self.servings:
156 raise ValueError(_("You must supply a servings size"))
158 if self._shopping_list_recipe.servings == self.servings:
159 return True
161 for sle in ShoppingListEntry.objects.filter(list_recipe=self._shopping_list_recipe):
162 sle.amount = sle.ingredient.amount * Decimal(self._servings_factor)
163 sle.save()
164 self._shopping_list_recipe.servings = self.servings
165 self._shopping_list_recipe.save()
166 return True
168 def delete(self, **kwargs):
169 try:
170 self._shopping_list_recipe.delete()
171 return True
172 except BaseException:
173 return False
175 def _add_ingredients(self, ingredients=None):
176 if not ingredients:
177 return
178 elif isinstance(ingredients, list):
179 ingredients = Ingredient.objects.filter(id__in=ingredients, food__ignore_shopping=False)
180 existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
181 add_ingredients = ingredients.exclude(id__in=existing)
183 for i in [x for x in add_ingredients if x.food]:
184 ShoppingListEntry.objects.create(
185 list_recipe=self._shopping_list_recipe,
186 food=i.food,
187 unit=i.unit,
188 ingredient=i,
189 amount=i.amount * Decimal(self._servings_factor),
190 created_by=self.created_by,
191 space=self.space,
192 )
194 # deletes shopping list entries not in ingredients list
195 def _delete_ingredients(self, ingredients=None):
196 if not ingredients:
197 return
198 to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients)
199 ShoppingListEntry.objects.filter(id__in=to_delete).delete()
200 self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)