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

1from datetime import timedelta 

2from decimal import Decimal 

3 

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 _ 

8 

9from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe, 

10 SupermarketCategoryRelation) 

11 

12 

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

18 

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 

34 

35 return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe') 

36 

37 

38class RecipeShoppingEditor(): 

39 def __init__(self, user, space, **kwargs): 

40 self.created_by = user 

41 self.space = space 

42 self._kwargs = {**kwargs} 

43 

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) 

50 

51 self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space) 

52 

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) 

57 

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) 

61 

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) 

66 

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) 

71 

72 @property 

73 def _servings_factor(self): 

74 return Decimal(self.servings) / Decimal(self._recipe_servings) 

75 

76 @property 

77 def _shared_users(self): 

78 return [*list(self.created_by.get_shopping_share()), self.created_by] 

79 

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

88 

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) 

95 

96 @property 

97 def _include_related(self): 

98 return self.created_by.userpreference.mealplan_autoinclude_related 

99 

100 @property 

101 def _exclude_onhand(self): 

102 return self.created_by.userpreference.mealplan_autoexclude_onhand 

103 

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) 

109 

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 

118 

119 if not self.servings: 

120 self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0) 

121 

122 self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings) 

123 

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

134 

135 return True 

136 

137 def add(self, **kwargs): 

138 return 

139 

140 def edit(self, servings=None, ingredients=None, **kwargs): 

141 if servings: 

142 self.servings = servings 

143 

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 

149 

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

157 

158 if self._shopping_list_recipe.servings == self.servings: 

159 return True 

160 

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 

167 

168 def delete(self, **kwargs): 

169 try: 

170 self._shopping_list_recipe.delete() 

171 return True 

172 except BaseException: 

173 return False 

174 

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) 

182 

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 ) 

193 

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)