Coverage for cookbook/helper/recipe_search.py: 71%

405 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2023-12-29 01:02 +0100

1import json 

2from datetime import date, timedelta 

3 

4from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity 

5from django.core.cache import cache 

6from django.db.models import Avg, Case, Count, Exists, F, Max, OuterRef, Q, Subquery, Value, When 

7from django.db.models.functions import Coalesce, Lower, Substr 

8from django.utils import timezone, translation 

9 

10from cookbook.helper.HelperFunctions import Round, str2bool 

11from cookbook.managers import DICTIONARY 

12from cookbook.models import (CookLog, CustomFilter, Food, Keyword, Recipe, SearchFields, 

13 SearchPreference, ViewLog) 

14from recipes import settings 

15 

16 

17# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering 

18class RecipeSearch(): 

19 _postgres = settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql' 

20 

21 def __init__(self, request, **params): 

22 self._request = request 

23 self._queryset = None 

24 if f := params.get('filter', None): 

25 custom_filter = ( 

26 CustomFilter.objects.filter(id=f, space=self._request.space) 

27 .filter(Q(created_by=self._request.user) | Q(shared=self._request.user) | Q(recipebook__shared=self._request.user)) 

28 .first() 

29 ) 

30 if custom_filter: 

31 self._params = {**json.loads(custom_filter.search)} 

32 self._original_params = {**(params or {})} 

33 # json.loads casts rating as an integer, expecting string 

34 if isinstance(self._params.get('rating', None), int): 

35 self._params['rating'] = str(self._params['rating']) 

36 else: 

37 self._params = {**(params or {})} 

38 else: 

39 self._params = {**(params or {})} 

40 if self._request.user.is_authenticated: 

41 CACHE_KEY = f"search_pref_{request.user.id}" 

42 cached_result = cache.get(CACHE_KEY, default=None) 

43 if cached_result is not None: 

44 self._search_prefs = cached_result 

45 else: 

46 self._search_prefs = request.user.searchpreference 

47 cache.set(CACHE_KEY, self._search_prefs, timeout=10) 

48 else: 

49 self._search_prefs = SearchPreference() 

50 self._string = self._params.get("query").strip() if self._params.get("query", None) else None 

51 self._rating = self._params.get("rating", None) 

52 self._keywords = { 

53 "or": self._params.get("keywords_or", None) or self._params.get("keywords", None), 

54 "and": self._params.get("keywords_and", None), 

55 "or_not": self._params.get("keywords_or_not", None), 

56 "and_not": self._params.get("keywords_and_not", None), 

57 } 

58 self._foods = { 

59 "or": self._params.get("foods_or", None) or self._params.get("foods", None), 

60 "and": self._params.get("foods_and", None), 

61 "or_not": self._params.get("foods_or_not", None), 

62 "and_not": self._params.get("foods_and_not", None), 

63 } 

64 self._books = { 

65 "or": self._params.get("books_or", None) or self._params.get("books", None), 

66 "and": self._params.get("books_and", None), 

67 "or_not": self._params.get("books_or_not", None), 

68 "and_not": self._params.get("books_and_not", None), 

69 } 

70 self._steps = self._params.get("steps", None) 

71 self._units = self._params.get("units", None) 

72 # TODO add created by 

73 # TODO image exists 

74 self._sort_order = self._params.get("sort_order", None) 

75 self._internal = str2bool(self._params.get("internal", None)) 

76 self._random = str2bool(self._params.get("random", False)) 

77 self._new = str2bool(self._params.get("new", False)) 

78 self._num_recent = int(self._params.get("num_recent", 0)) 

79 self._include_children = str2bool(self._params.get("include_children", None)) 

80 self._timescooked = self._params.get("timescooked", None) 

81 self._cookedon = self._params.get("cookedon", None) 

82 self._createdon = self._params.get("createdon", None) 

83 self._updatedon = self._params.get("updatedon", None) 

84 self._viewedon = self._params.get("viewedon", None) 

85 self._makenow = self._params.get("makenow", None) 

86 self._never_used_food = str2bool(self._params.get("never_used_food", False)) 

87 # this supports hidden feature to find recipes missing X ingredients 

88 if isinstance(self._makenow, bool) and self._makenow == True: 

89 self._makenow = 0 

90 elif isinstance(self._makenow, str) and self._makenow in ["yes", "true"]: 

91 self._makenow = 0 

92 else: 

93 try: 

94 self._makenow = int(self._makenow) 

95 except (ValueError, TypeError): 

96 self._makenow = None 

97 

98 self._search_type = self._search_prefs.search or "plain" 

99 if self._string: 

100 if self._postgres: 

101 self._unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True) 

102 else: 

103 self._unaccent_include = [] 

104 self._icontains_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)] 

105 self._istartswith_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)] 

106 self._trigram_include = None 

107 self._fulltext_include = None 

108 self._trigram = False 

109 if self._postgres and self._string: 

110 self._language = DICTIONARY.get(translation.get_language(), 'simple') 

111 self._trigram_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)] 

112 self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True) or None 

113 

114 if self._search_type not in ['websearch', 'raw'] and self._trigram_include: 

115 self._trigram = True 

116 self.search_query = SearchQuery( 

117 self._string, 

118 search_type=self._search_type, 

119 config=self._language, 

120 ) 

121 self.search_rank = None 

122 self.orderby = [] 

123 self._filters = None 

124 self._fuzzy_match = None 

125 

126 def get_queryset(self, queryset): 

127 self._queryset = queryset 

128 self._queryset = self._queryset.prefetch_related('keywords') 

129 self._build_sort_order() 

130 self._recently_viewed(num_recent=self._num_recent) 

131 self._cooked_on_filter(cooked_date=self._cookedon) 

132 self._created_on_filter(created_date=self._createdon) 

133 self._updated_on_filter(updated_date=self._updatedon) 

134 self._viewed_on_filter(viewed_date=self._viewedon) 

135 self._favorite_recipes(times_cooked=self._timescooked) 

136 self._new_recipes() 

137 self.keyword_filters(**self._keywords) 

138 self.food_filters(**self._foods) 

139 self.book_filters(**self._books) 

140 self.rating_filter(rating=self._rating) 

141 self.internal_filter(internal=self._internal) 

142 self.step_filters(steps=self._steps) 

143 self.unit_filters(units=self._units) 

144 self._makenow_filter(missing=self._makenow) 

145 self._never_used_food_filter() 

146 self.string_filters(string=self._string) 

147 return self._queryset.filter(space=self._request.space).order_by(*self.orderby) 

148 

149 def _sort_includes(self, *args): 

150 for x in args: 

151 if x in self.orderby: 

152 return True 

153 elif "-" + x in self.orderby: 

154 return True 

155 return False 

156 

157 def _build_sort_order(self): 

158 if self._random: 

159 self.orderby = ["?"] 

160 else: 

161 order = [] 

162 # TODO add userpreference for default sort order and replace '-favorite' 

163 default_order = ["name"] 

164 # recent and new_recipe are always first; they float a few recipes to the top 

165 if self._num_recent: 

166 order += ["-recent"] 

167 if self._new: 

168 order += ["-new_recipe"] 

169 

170 # if a sort order is provided by user - use that order 

171 if self._sort_order: 

172 if not isinstance(self._sort_order, list): 

173 order += [self._sort_order] 

174 else: 

175 order += self._sort_order 

176 if not self._postgres or not self._string: 

177 if "score" in order: 

178 order.remove("score") 

179 if "-score" in order: 

180 order.remove("-score") 

181 # if no sort order provided prioritize text search, followed by the default search 

182 elif self._postgres and self._string and (self._trigram or self._fulltext_include): 

183 order += ["-score", *default_order] 

184 # otherwise sort by the remaining order_by attributes or favorite by default 

185 else: 

186 order += default_order 

187 order[:] = [Lower("name").asc() if x == "name" else x for x in order] 

188 order[:] = [Lower("name").desc() if x == "-name" else x for x in order] 

189 self.orderby = order 

190 

191 def string_filters(self, string=None): 

192 if not string: 

193 return 

194 

195 self.build_text_filters(self._string) 

196 if self._postgres: 

197 self.build_fulltext_filters(self._string) 

198 self.build_trigram(self._string) 

199 

200 query_filter = Q() 

201 if self._filters: 

202 for f in self._filters: 

203 query_filter |= f 

204 

205 # this creates duplicate records which can screw up other aggregates, see makenow for workaround 

206 self._queryset = self._queryset.filter(query_filter).distinct() 

207 if self._fulltext_include: 

208 if self._fuzzy_match is None: 

209 self._queryset = self._queryset.annotate(score=Coalesce(Max(self.search_rank), 0.0)) 

210 else: 

211 self._queryset = self._queryset.annotate(rank=Coalesce(Max(self.search_rank), 0.0)) 

212 

213 if self._fuzzy_match is not None: 

214 simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity') 

215 if not self._fulltext_include: 

216 self._queryset = self._queryset.annotate(score=Coalesce(Subquery(simularity), 0.0)) 

217 else: 

218 self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0)) 

219 if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None: 

220 self._queryset = self._queryset.annotate(score=F('rank') + F('simularity')) 

221 else: 

222 query_filter = Q() 

223 for f in [x + "__unaccent__iexact" if x in self._unaccent_include else x + "__iexact" for x in SearchFields.objects.all().values_list("field", flat=True)]: 

224 query_filter |= Q(**{"%s" % f: self._string}) 

225 self._queryset = self._queryset.filter(query_filter).distinct() 

226 

227 def _cooked_on_filter(self, cooked_date=None): 

228 if self._sort_includes('lastcooked') or cooked_date: 

229 lessthan = self._sort_includes('-lastcooked') or '-' in (cooked_date or [])[:1] 

230 if lessthan: 

231 default = timezone.now() - timedelta(days=100000) 

232 else: 

233 default = timezone.now() 

234 self._queryset = self._queryset.annotate( 

235 lastcooked=Coalesce(Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default)) 

236 ) 

237 if cooked_date is None: 

238 return 

239 

240 cooked_date = date(*[int(x)for x in cooked_date.split('-') if x != '']) 

241 

242 if lessthan: 

243 self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=default) 

244 else: 

245 self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=default) 

246 

247 def _created_on_filter(self, created_date=None): 

248 if created_date is None: 

249 return 

250 lessthan = '-' in created_date[:1] 

251 created_date = date(*[int(x) for x in created_date.split('-') if x != '']) 

252 if lessthan: 

253 self._queryset = self._queryset.filter(created_at__date__lte=created_date) 

254 else: 

255 self._queryset = self._queryset.filter(created_at__date__gte=created_date) 

256 

257 def _updated_on_filter(self, updated_date=None): 

258 if updated_date is None: 

259 return 

260 lessthan = '-' in updated_date[:1] 

261 updated_date = date(*[int(x)for x in updated_date.split('-') if x != '']) 

262 if lessthan: 

263 self._queryset = self._queryset.filter(updated_at__date__lte=updated_date) 

264 else: 

265 self._queryset = self._queryset.filter(updated_at__date__gte=updated_date) 

266 

267 def _viewed_on_filter(self, viewed_date=None): 

268 if self._sort_includes("lastviewed") or viewed_date: 

269 longTimeAgo = timezone.now() - timedelta(days=100000) 

270 self._queryset = self._queryset.annotate( 

271 lastviewed=Coalesce(Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo)) 

272 ) 

273 if viewed_date is None: 

274 return 

275 lessthan = '-' in viewed_date[:1] 

276 viewed_date = date(*[int(x)for x in viewed_date.split('-') if x != '']) 

277 

278 if lessthan: 

279 self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo) 

280 else: 

281 self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo) 

282 

283 def _new_recipes(self, new_days=7): 

284 # TODO make new days a user-setting 

285 if not self._new: 

286 return 

287 self._queryset = self._queryset.annotate( 

288 new_recipe=Case( 

289 When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')), 

290 default=Value(0), 

291 ) 

292 ) 

293 

294 def _recently_viewed(self, num_recent=None): 

295 if not num_recent: 

296 if self._sort_includes("lastviewed"): 

297 self._queryset = self._queryset.annotate( 

298 lastviewed=Coalesce(Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then="viewlog__pk"))), Value(0)) 

299 ) 

300 return 

301 

302 num_recent_recipes = ( 

303 ViewLog.objects.filter(created_by=self._request.user, space=self._request.space) 

304 .values('recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent] 

305 ) 

306 self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0))) 

307 

308 def _favorite_recipes(self, times_cooked=None): 

309 if self._sort_includes('favorite') or times_cooked: 

310 less_than = '-' in (str(times_cooked) or []) and not self._sort_includes('-favorite') 

311 if less_than: 

312 default = 1000 

313 else: 

314 default = 0 

315 favorite_recipes = ( 

316 CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk')) 

317 .values('recipe') 

318 .annotate(count=Count('pk', distinct=True)) 

319 .values('count') 

320 ) 

321 self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default)) 

322 if times_cooked is None: 

323 return 

324 

325 if times_cooked == "0": 

326 self._queryset = self._queryset.filter(favorite=0) 

327 elif less_than: 

328 self._queryset = self._queryset.filter(favorite__lte=int(times_cooked.replace('-', ''))).exclude(favorite=0) 

329 else: 

330 self._queryset = self._queryset.filter(favorite__gte=int(times_cooked)) 

331 

332 def keyword_filters(self, **kwargs): 

333 if all([kwargs[x] is None for x in kwargs]): 

334 return 

335 for kw_filter in kwargs: 

336 if not kwargs[kw_filter]: 

337 continue 

338 if not isinstance(kwargs[kw_filter], list): 

339 kwargs[kw_filter] = [kwargs[kw_filter]] 

340 

341 keywords = Keyword.objects.filter(pk__in=kwargs[kw_filter]) 

342 if "or" in kw_filter: 

343 if self._include_children: 

344 f_or = Q(keywords__in=Keyword.include_descendants(keywords)) 

345 else: 

346 f_or = Q(keywords__in=keywords) 

347 if "not" in kw_filter: 

348 self._queryset = self._queryset.exclude(f_or) 

349 else: 

350 self._queryset = self._queryset.filter(f_or) 

351 elif "and" in kw_filter: 

352 recipes = Recipe.objects.all() 

353 for kw in keywords: 

354 if self._include_children: 

355 f_and = Q(keywords__in=kw.get_descendants_and_self()) 

356 else: 

357 f_and = Q(keywords=kw) 

358 if "not" in kw_filter: 

359 recipes = recipes.filter(f_and) 

360 else: 

361 self._queryset = self._queryset.filter(f_and) 

362 if 'not' in kw_filter: 

363 self._queryset = self._queryset.exclude(id__in=recipes.values('id')) 

364 

365 def food_filters(self, **kwargs): 

366 if all([kwargs[x] is None for x in kwargs]): 

367 return 

368 for fd_filter in kwargs: 

369 if not kwargs[fd_filter]: 

370 continue 

371 if not isinstance(kwargs[fd_filter], list): 

372 kwargs[fd_filter] = [kwargs[fd_filter]] 

373 

374 foods = Food.objects.filter(pk__in=kwargs[fd_filter]) 

375 if "or" in fd_filter: 

376 if self._include_children: 

377 f_or = Q(steps__ingredients__food__in=Food.include_descendants(foods)) 

378 else: 

379 f_or = Q(steps__ingredients__food__in=foods) 

380 

381 if "not" in fd_filter: 

382 self._queryset = self._queryset.exclude(f_or) 

383 else: 

384 self._queryset = self._queryset.filter(f_or) 

385 elif "and" in fd_filter: 

386 recipes = Recipe.objects.all() 

387 for food in foods: 

388 if self._include_children: 

389 f_and = Q(steps__ingredients__food__in=food.get_descendants_and_self()) 

390 else: 

391 f_and = Q(steps__ingredients__food=food) 

392 if "not" in fd_filter: 

393 recipes = recipes.filter(f_and) 

394 else: 

395 self._queryset = self._queryset.filter(f_and) 

396 if 'not' in fd_filter: 

397 self._queryset = self._queryset.exclude(id__in=recipes.values('id')) 

398 

399 def unit_filters(self, units=None, operator=True): 

400 if operator != True: 

401 raise NotImplementedError 

402 if not units: 

403 return 

404 if not isinstance(units, list): 

405 units = [units] 

406 self._queryset = self._queryset.filter(steps__ingredients__unit__in=units) 

407 

408 def rating_filter(self, rating=None): 

409 if rating or self._sort_includes('rating'): 

410 lessthan = '-' in (rating or []) 

411 reverse = 'rating' in (self._sort_order or []) and '-rating' not in (self._sort_order or []) 

412 if lessthan or reverse: 

413 default = 100 

414 else: 

415 default = 0 

416 # TODO make ratings a settings user-only vs all-users 

417 self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then="cooklog__rating"), default=default)))) 

418 if rating is None: 

419 return 

420 

421 if rating == "0": 

422 self._queryset = self._queryset.filter(rating=0) 

423 elif lessthan: 

424 self._queryset = self._queryset.filter(rating__lte=int(rating[1:])).exclude(rating=0) 

425 else: 

426 self._queryset = self._queryset.filter(rating__gte=int(rating)) 

427 

428 def internal_filter(self, internal=None): 

429 if not internal: 

430 return 

431 self._queryset = self._queryset.filter(internal=internal) 

432 

433 def book_filters(self, **kwargs): 

434 if all([kwargs[x] is None for x in kwargs]): 

435 return 

436 for bk_filter in kwargs: 

437 if not kwargs[bk_filter]: 

438 continue 

439 if not isinstance(kwargs[bk_filter], list): 

440 kwargs[bk_filter] = [kwargs[bk_filter]] 

441 

442 if "or" in bk_filter: 

443 f = Q(recipebookentry__book__id__in=kwargs[bk_filter]) 

444 if "not" in bk_filter: 

445 self._queryset = self._queryset.exclude(f) 

446 else: 

447 self._queryset = self._queryset.filter(f) 

448 elif "and" in bk_filter: 

449 recipes = Recipe.objects.all() 

450 for book in kwargs[bk_filter]: 

451 if 'not' in bk_filter: 

452 recipes = recipes.filter(recipebookentry__book__id=book) 

453 else: 

454 self._queryset = self._queryset.filter(recipebookentry__book__id=book) 

455 if 'not' in bk_filter: 

456 self._queryset = self._queryset.exclude(id__in=recipes.values('id')) 

457 

458 def step_filters(self, steps=None, operator=True): 

459 if operator != True: 

460 raise NotImplementedError 

461 if not steps: 

462 return 

463 if not isinstance(steps, list): 

464 steps = [steps] 

465 self._queryset = self._queryset.filter(steps__id__in=steps) 

466 

467 def build_fulltext_filters(self, string=None): 

468 if not string: 

469 return 

470 if self._fulltext_include: 

471 vectors = [] 

472 rank = [] 

473 if 'name' in self._fulltext_include: 

474 vectors.append('name_search_vector') 

475 rank.append(SearchRank('name_search_vector', self.search_query, cover_density=True)) 

476 if 'description' in self._fulltext_include: 

477 vectors.append('desc_search_vector') 

478 rank.append(SearchRank('desc_search_vector', self.search_query, cover_density=True)) 

479 if 'steps__instruction' in self._fulltext_include: 

480 vectors.append('steps__search_vector') 

481 rank.append(SearchRank('steps__search_vector', self.search_query, cover_density=True)) 

482 if 'keywords__name' in self._fulltext_include: 

483 # explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields 

484 vectors.append('keywords__name__unaccent') 

485 rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True)) 

486 if 'steps__ingredients__food__name' in self._fulltext_include: 

487 vectors.append('steps__ingredients__food__name__unaccent') 

488 rank.append(SearchRank('steps__ingredients__food__name', self.search_query, cover_density=True)) 

489 

490 for r in rank: 

491 if self.search_rank is None: 

492 self.search_rank = r 

493 else: 

494 self.search_rank += r 

495 # modifying queryset will annotation creates duplicate results 

496 self._filters.append(Q(id__in=Recipe.objects.annotate(vector=SearchVector(*vectors)).filter(Q(vector=self.search_query)))) 

497 

498 def build_text_filters(self, string=None): 

499 if not string: 

500 return 

501 

502 if not self._filters: 

503 self._filters = [] 

504 # dynamically build array of filters that will be applied 

505 for f in self._icontains_include: 

506 self._filters += [Q(**{"%s__icontains" % f: self._string})] 

507 

508 for f in self._istartswith_include: 

509 self._filters += [Q(**{"%s__istartswith" % f: self._string})] 

510 

511 def build_trigram(self, string=None): 

512 if not string: 

513 return 

514 if self._trigram: 

515 trigram = None 

516 for f in self._trigram_include: 

517 if trigram: 

518 trigram += TrigramSimilarity(f, self._string) 

519 else: 

520 trigram = TrigramSimilarity(f, self._string) 

521 self._fuzzy_match = ( 

522 Recipe.objects.annotate(trigram=trigram) 

523 .distinct() 

524 .annotate(simularity=Max('trigram')) 

525 .values('id', 'simularity') 

526 .filter(simularity__gt=self._search_prefs.trigram_threshold) 

527 ) 

528 self._filters += [Q(pk__in=self._fuzzy_match.values('pk'))] 

529 

530 def _makenow_filter(self, missing=None): 

531 if missing is None or (isinstance(missing, bool) and missing == False): 

532 return 

533 shopping_users = [*self._request.user.get_shopping_share(), self._request.user] 

534 onhand_filter = Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand 

535 # ignore substitutions when also using the never_used_food filter 

536 if not self._never_used_food: 

537 # or substitute food onhand 

538 onhand_filter |= (Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users) 

539 | Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users)) 

540 | Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users)) 

541 ) 

542 makenow_recipes = ( 

543 Recipe.objects.annotate( 

544 count_food=Count("steps__ingredients__food__pk", filter=Q(steps__ingredients__food__isnull=False), distinct=True), 

545 count_onhand=Count("steps__ingredients__food__pk", filter=onhand_filter, distinct=True), 

546 count_ignore_shopping=Count( 

547 "steps__ingredients__food__pk", filter=Q(steps__ingredients__food__ignore_shopping=True, steps__ingredients__food__recipe__isnull=True), distinct=True 

548 ), 

549 has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users), then=Value(1)), default=Value(0)), 

550 has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users), then=Value(1)), default=Value(0)), 

551 ) 

552 .annotate(missingfood=F("count_food") - F("count_onhand") - F("count_ignore_shopping")) 

553 .filter(missingfood=missing) 

554 ) 

555 self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values("id")) 

556 

557 def _never_used_food_filter(self): 

558 # filters recipes to include foods that have never been used 

559 if not self._never_used_food: 

560 return 

561 self._queryset = self._queryset.filter(steps__ingredients__food__in=Food.objects.filter(~Q(ingredient__step__recipe__cooklog__isnull=False)).distinct()) 

562 

563 @staticmethod 

564 def __children_substitute_filter(shopping_users=None): 

565 children_onhand_subquery = Food.objects.filter(path__startswith=OuterRef('path'), depth__gt=OuterRef('depth'), onhand_users__in=shopping_users) 

566 return ( 

567 Food.objects.exclude( # list of foods that are onhand and children of: foods that are not onhand and are set to use children as substitutes 

568 Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users) 

569 ) 

570 .exclude(depth=1, numchild=0) 

571 .filter(substitute_children=True) 

572 .annotate(child_onhand_count=Exists(children_onhand_subquery)) 

573 .filter(child_onhand_count=True) 

574 ) 

575 

576 @staticmethod 

577 def __sibling_substitute_filter(shopping_users=None): 

578 sibling_onhand_subquery = Food.objects.filter( 

579 path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)), depth=OuterRef('depth'), onhand_users__in=shopping_users 

580 ) 

581 return ( 

582 Food.objects.exclude( # list of foods that are onhand and siblings of: foods that are not onhand and are set to use siblings as substitutes 

583 Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users) 

584 ) 

585 .exclude(depth=1, numchild=0) 

586 .filter(substitute_siblings=True) 

587 .annotate(sibling_onhand=Exists(sibling_onhand_subquery)) 

588 .filter(sibling_onhand=True) 

589 )