hierarchical keyword filtering in recipe search
This commit is contained in:
parent
1f21631c5a
commit
170673f467
@ -1,5 +1,7 @@
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
@ -32,7 +32,7 @@ def rescale_image_png(image_object, base_width=720):
|
||||
def get_filetype(name):
|
||||
try:
|
||||
return os.path.splitext(name)[1]
|
||||
except:
|
||||
except Exception:
|
||||
return '.jpeg'
|
||||
|
||||
|
||||
|
@ -3,8 +3,6 @@ Source: https://djangosnippets.org/snippets/1703/
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.cache import caches
|
||||
from django.views.generic.detail import SingleObjectTemplateResponseMixin
|
||||
from django.views.generic.edit import ModelFormMixin
|
||||
|
||||
from cookbook.models import ShareLink
|
||||
from django.contrib import messages
|
||||
@ -64,7 +62,7 @@ def is_object_owner(user, obj):
|
||||
return False
|
||||
try:
|
||||
return obj.get_owner() == user
|
||||
except:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
|
@ -4,7 +4,7 @@ from recipes import settings
|
||||
from django.contrib.postgres.search import (
|
||||
SearchQuery, SearchRank, TrigramSimilarity
|
||||
)
|
||||
from django.db.models import Max, Q, Subquery, Case, When, Value
|
||||
from django.db.models import Count, Q, Subquery, Case, When, Value
|
||||
from django.utils import translation
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
@ -14,6 +14,7 @@ from cookbook.models import Food, Keyword, ViewLog
|
||||
def search_recipes(request, queryset, params):
|
||||
search_prefs = request.user.searchpreference
|
||||
search_string = params.get('query', '')
|
||||
search_ratings = params.getlist('ratings', [])
|
||||
search_keywords = params.getlist('keywords', [])
|
||||
search_foods = params.getlist('foods', [])
|
||||
search_books = params.getlist('books', [])
|
||||
@ -27,6 +28,7 @@ def search_recipes(request, queryset, params):
|
||||
search_new = params.get('new', False)
|
||||
search_last_viewed = int(params.get('last_viewed', 0))
|
||||
|
||||
# TODO update this to concat with full search queryset qs1 | qs2
|
||||
if search_last_viewed > 0:
|
||||
last_viewed_recipes = ViewLog.objects.filter(
|
||||
created_by=request.user, space=request.space,
|
||||
@ -122,11 +124,18 @@ def search_recipes(request, queryset, params):
|
||||
queryset = queryset.filter(query_filter)
|
||||
|
||||
if len(search_keywords) > 0:
|
||||
# TODO creating setting to include descendants of keywords a setting
|
||||
if search_keywords_or == 'true':
|
||||
# when performing an 'or' search all descendants are included in the OR condition
|
||||
# so descendants are appended to
|
||||
for kw in Keyword.objects.filter(pk__in=search_keywords):
|
||||
search_keywords += list(kw.get_descendants().values_list('pk', flat=True))
|
||||
queryset = queryset.filter(keywords__id__in=search_keywords)
|
||||
else:
|
||||
for k in search_keywords:
|
||||
queryset = queryset.filter(keywords__id=k)
|
||||
# when performing an 'and' search returned recipes should include a parent OR any of its descedants
|
||||
# AND other keywords selected so filters are appended using keyword__id__in the list of keywords and descendants
|
||||
for kw in Keyword.objects.filter(pk__in=search_keywords):
|
||||
queryset = queryset.filter(keywords__id__in=list(kw.get_descendants_and_self().values_list('pk', flat=True)))
|
||||
|
||||
if len(search_foods) > 0:
|
||||
if search_foods_or == 'true':
|
||||
@ -153,9 +162,118 @@ def search_recipes(request, queryset, params):
|
||||
# TODO add order by user settings
|
||||
orderby += ['name']
|
||||
queryset = queryset.order_by(*orderby)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
# this returns a list of keywords in the queryset and how many times it appears
|
||||
# Keyword.objects.filter(recipe__in=queryset).annotate(kw_count=Count('recipe'))
|
||||
def get_facet(qs, params):
|
||||
facets = {}
|
||||
ratings = params.getlist('ratings', [])
|
||||
keyword_list = params.getlist('keywords', [])
|
||||
ingredient_list = params.getlist('ingredient', [])
|
||||
book_list = params.getlist('book', [])
|
||||
|
||||
# this returns a list of keywords in the queryset and how many times it appears
|
||||
kws = Keyword.objects.filter(recipe__in=qs).annotate(kw_count=Count('recipe'))
|
||||
# custom django-tree function annotates a queryset to make building a tree easier.
|
||||
# see https://django-treebeard.readthedocs.io/en/latest/api.html#treebeard.models.Node.get_annotated_list_qs for details
|
||||
kw_a = annotated_qs(kws, root=True, fill=True)
|
||||
|
||||
# TODO add rating facet
|
||||
facets['Ratings'] = []
|
||||
facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list)
|
||||
# TODO add food facet
|
||||
facets['Ingredients'] = []
|
||||
# TODO add book facet
|
||||
facets['Books'] = []
|
||||
|
||||
return facets
|
||||
|
||||
|
||||
def fill_annotated_parents(annotation, filters):
|
||||
tree_list = []
|
||||
parent = []
|
||||
i = 0
|
||||
level = -1
|
||||
for r in annotation:
|
||||
expand = False
|
||||
|
||||
annotation[i][1]['id'] = r[0].id
|
||||
annotation[i][1]['name'] = r[0].name
|
||||
annotation[i][1]['count'] = getattr(r[0], 'kw_count', 0)
|
||||
annotation[i][1]['isDefaultExpanded'] = False
|
||||
|
||||
if str(r[0].id) in filters:
|
||||
expand = True
|
||||
if r[1]['level'] < level:
|
||||
parent = parent[:r[1]['level'] - level]
|
||||
parent[-1] = i
|
||||
level = r[1]['level']
|
||||
elif r[1]['level'] > level:
|
||||
parent.extend([i])
|
||||
level = r[1]['level']
|
||||
else:
|
||||
parent[-1] = i
|
||||
j = 0
|
||||
|
||||
while j < level:
|
||||
# this causes some double counting when a recipe has both a child and an ancestor
|
||||
annotation[parent[j]][1]['count'] += getattr(r[0], 'kw_count', 0)
|
||||
if expand:
|
||||
annotation[parent[j]][1]['isDefaultExpanded'] = True
|
||||
j += 1
|
||||
if level == 0:
|
||||
tree_list.append(annotation[i][1])
|
||||
elif level > 0:
|
||||
annotation[parent[level - 1]][1].setdefault('children', []).append(annotation[i][1])
|
||||
i += 1
|
||||
return tree_list
|
||||
|
||||
|
||||
def annotated_qs(qs, root=False, fill=False):
|
||||
"""
|
||||
Gets an annotated list from a queryset.
|
||||
|
||||
:param root:
|
||||
|
||||
Will backfill in annotation to include all parents to root node.
|
||||
|
||||
:param fill:
|
||||
|
||||
Will fill in gaps in annotation where nodes between children
|
||||
and ancestors are not included in the queryset.
|
||||
"""
|
||||
|
||||
result, info = [], {}
|
||||
start_depth, prev_depth = (None, None)
|
||||
nodes_list = list(qs.values_list('pk', flat=True))
|
||||
for node in qs.order_by('path'):
|
||||
node_queue = [node]
|
||||
while len(node_queue) > 0:
|
||||
dirty = False
|
||||
current_node = node_queue[-1]
|
||||
depth = current_node.get_depth()
|
||||
parent_id = current_node.parent
|
||||
if root and depth > 1 and parent_id not in nodes_list:
|
||||
parent_id = current_node.parent
|
||||
nodes_list.append(parent_id)
|
||||
node_queue.append(current_node.__class__.objects.get(pk=parent_id))
|
||||
dirty = True
|
||||
|
||||
if fill and depth > 1 and prev_depth and depth > prev_depth and parent_id not in nodes_list:
|
||||
nodes_list.append(parent_id)
|
||||
node_queue.append(current_node.__class__.objects.get(pk=parent_id))
|
||||
dirty = True
|
||||
|
||||
if not dirty:
|
||||
working_node = node_queue.pop()
|
||||
if start_depth is None:
|
||||
start_depth = depth
|
||||
open = (depth and (prev_depth is None or depth > prev_depth))
|
||||
if prev_depth is not None and depth < prev_depth:
|
||||
info['close'] = list(range(0, prev_depth - depth))
|
||||
info = {'open': open, 'close': [], 'level': depth - start_depth}
|
||||
result.append((working_node, info,))
|
||||
prev_depth = depth
|
||||
if start_depth and start_depth > 0:
|
||||
info['close'] = list(range(0, prev_depth - start_depth + 1))
|
||||
return result
|
||||
|
@ -1,4 +1,3 @@
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
|
@ -6,6 +6,7 @@ from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from jinja2 import Template, TemplateSyntaxError, UndefinedError
|
||||
from gettext import gettext as _
|
||||
|
||||
|
||||
class IngredientObject(object):
|
||||
amount = ""
|
||||
unit = ""
|
||||
|
@ -1,11 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
|
||||
|
||||
class Pepperplate(Integration):
|
||||
|
@ -1,10 +1,8 @@
|
||||
import re
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
|
||||
|
||||
class ChefTap(Integration):
|
||||
|
@ -1,4 +1,3 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
@ -6,7 +5,7 @@ from zipfile import ZipFile
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
from cookbook.models import Recipe, Step, Ingredient, Keyword
|
||||
|
||||
|
||||
class Chowdown(Integration):
|
||||
|
@ -1,7 +1,5 @@
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
from io import BytesIO, StringIO
|
||||
|
@ -6,7 +6,7 @@ from zipfile import ZipFile
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
|
||||
|
||||
class Mealie(Integration):
|
||||
@ -50,7 +50,7 @@ class Mealie(Integration):
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
recipe.steps.add(step)
|
||||
|
||||
@ -59,7 +59,7 @@ class Mealie(Integration):
|
||||
import_zip = ZipFile(f['file'])
|
||||
try:
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(f'recipes/{recipe_json["slug"]}/images/min-original.webp')), filetype=get_filetype(f'recipes/{recipe_json["slug"]}/images/original'))
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return recipe
|
||||
|
@ -1,11 +1,8 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
from cookbook.models import Recipe, Step, Ingredient, Keyword
|
||||
|
||||
|
||||
class MealMaster(Integration):
|
||||
|
@ -6,7 +6,7 @@ from zipfile import ZipFile
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
|
||||
|
||||
class NextcloudCookbook(Integration):
|
||||
|
@ -1,11 +1,8 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.helper.ingredient_parser import get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
|
||||
|
||||
class OpenEats(Integration):
|
||||
|
@ -16,7 +16,7 @@ class Paprika(Integration):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
with gzip.open(file, 'r') as recipe_zip:
|
||||
with gzip.open(file, 'r') as recipe_zip:
|
||||
recipe_json = json.loads(recipe_zip.read().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
|
@ -1,16 +1,14 @@
|
||||
import re
|
||||
import json
|
||||
import base64
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
import imghdr
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
from cookbook.models import Recipe, Step, Ingredient, Keyword
|
||||
|
||||
|
||||
class RecetteTek(Integration):
|
||||
@ -108,7 +106,7 @@ class RecetteTek(Integration):
|
||||
recipe.keywords.add(k)
|
||||
recipe.save()
|
||||
except Exception as e:
|
||||
pass
|
||||
print(recipe.name, ': failed to parse keywords ', str(e))
|
||||
|
||||
# TODO: Parse Nutritional Information
|
||||
|
||||
@ -123,7 +121,7 @@ class RecetteTek(Integration):
|
||||
else:
|
||||
if file['originalPicture'] != '':
|
||||
response = requests.get(file['originalPicture'])
|
||||
if imghdr.what(BytesIO(response.content)) != None:
|
||||
if imghdr.what(BytesIO(response.content)) is not None:
|
||||
self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture']))
|
||||
else:
|
||||
raise Exception("Original image failed to download.")
|
||||
|
@ -3,12 +3,10 @@ from bs4 import BeautifulSoup
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.helper.recipe_url_import import parse_servings, iso_duration_to_minutes
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
from cookbook.models import Recipe, Step, Ingredient, Keyword
|
||||
|
||||
|
||||
class RecipeKeeper(Integration):
|
||||
@ -61,7 +59,6 @@ class RecipeKeeper(Integration):
|
||||
if file.find("span", {"itemprop": "recipeSource"}).text != '':
|
||||
step.instruction += "\n\nImported from: " + file.find("span", {"itemprop": "recipeSource"}).text
|
||||
step.save()
|
||||
source_url_added = True
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
@ -72,7 +69,7 @@ class RecipeKeeper(Integration):
|
||||
import_zip = ZipFile(f['file'])
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(file.find("img", class_="recipe-photo").get("src"))), filetype='.jpeg')
|
||||
except Exception as e:
|
||||
pass
|
||||
print(recipe.name, ': failed to import image ', str(e))
|
||||
|
||||
return recipe
|
||||
|
||||
|
@ -1,9 +1,7 @@
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
|
@ -1,11 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
from cookbook.models import Recipe, Step, Ingredient, Keyword
|
||||
|
||||
|
||||
class RezKonv(Integration):
|
||||
|
@ -2,7 +2,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
|
||||
|
||||
class Safron(Integration):
|
||||
|
@ -27,5 +27,5 @@ class Command(BaseCommand):
|
||||
Step.objects.all().update(search_vector=SearchVector('instruction__unaccent', weight='B', config=language))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(_('Recipe index rebuild complete.')))
|
||||
except:
|
||||
except Exception:
|
||||
self.stdout.write(self.style.ERROR(_('Recipe index rebuild failed.')))
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.contrib.postgres.aggregates import StringAgg
|
||||
from django.contrib.postgres.search import (
|
||||
SearchQuery, SearchRank, SearchVector, TrigramSimilarity,
|
||||
SearchQuery, SearchRank, SearchVector,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
@ -5,7 +5,6 @@ import uuid
|
||||
from datetime import date, timedelta
|
||||
|
||||
from annoying.fields import AutoOneToOneField
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
@ -19,7 +18,7 @@ from django.utils.translation import gettext as _
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from django_prometheus.models import ExportModelOperationsMixin
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, DATABASES, FRACTION_PREF_DEFAULT,
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
|
||||
STICKY_NAV_PREF_DEFAULT)
|
||||
|
||||
|
||||
@ -277,6 +276,7 @@ class SyncLog(models.Model, PermissionModelMixin):
|
||||
|
||||
|
||||
class Keyword(ExportModelOperationsMixin('keyword'), MP_Node, PermissionModelMixin):
|
||||
# TODO add find and fix problem functions
|
||||
node_order_by = ['name']
|
||||
name = models.CharField(max_length=64)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
|
@ -1,6 +1,5 @@
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from os import listdir
|
||||
from os.path import isfile, join
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -9,6 +9,10 @@ from cookbook.models import Food, Ingredient, Step, Recipe
|
||||
LIST_URL = 'api:recipe-list'
|
||||
DETAIL_URL = 'api:recipe-detail'
|
||||
|
||||
# TODO need to add extensive tests against recipe search to go through all of the combinations of parameters
|
||||
# probably needs to include a far more extensive set of initial recipes to effectively test results
|
||||
# and to ensure that all parts of the code are exercised.
|
||||
# TODO should probably consider adding code coverage plugin to the test suite
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
|
@ -4,9 +4,9 @@ import re
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from annoying.decorators import ajax_request
|
||||
from annoying.functions import get_object_or_None
|
||||
from collections import OrderedDict
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
@ -37,7 +37,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest,
|
||||
group_required)
|
||||
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
||||
|
||||
from cookbook.helper.recipe_search import search_recipes
|
||||
from cookbook.helper.recipe_search import search_recipes, get_facet
|
||||
from cookbook.helper.recipe_url_import import get_from_scraper
|
||||
from cookbook.models import (CookLog, Food, Ingredient, Keyword, MealPlan,
|
||||
MealType, Recipe, RecipeBook, ShoppingList,
|
||||
@ -326,6 +326,7 @@ class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMi
|
||||
queryset = SupermarketCategoryRelation.objects
|
||||
serializer_class = SupermarketCategoryRelationSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(supermarket__space=self.request.space)
|
||||
@ -446,6 +447,19 @@ class RecipePagination(PageNumberPagination):
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
def paginate_queryset(self, queryset, request, view=None):
|
||||
self.facets = get_facet(queryset, request.query_params)
|
||||
return super().paginate_queryset(queryset, request, view)
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
return Response(OrderedDict([
|
||||
('count', self.page.paginator.count),
|
||||
('next', self.get_next_link()),
|
||||
('previous', self.get_previous_link()),
|
||||
('results', data),
|
||||
('facets', self.facets)
|
||||
]))
|
||||
|
||||
|
||||
class RecipeViewSet(viewsets.ModelViewSet):
|
||||
queryset = Recipe.objects
|
||||
|
@ -13,7 +13,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import ngettext
|
||||
from django_tables2 import RequestConfig
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL import UnidentifiedImageError
|
||||
from requests.exceptions import MissingSchema
|
||||
|
||||
from cookbook.forms import BatchEditForm, SyncForm
|
||||
|
@ -63,7 +63,7 @@ def get_integration(request, export_type):
|
||||
|
||||
@group_required('user')
|
||||
def import_recipe(request):
|
||||
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
|
||||
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
|
||||
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
|
@ -3,12 +3,12 @@ import json
|
||||
import requests
|
||||
from django.db.models import Q
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_unit, get_food
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.models import TelegramBot, ShoppingList, ShoppingListEntry, Food, Unit
|
||||
from cookbook.models import TelegramBot, ShoppingList, ShoppingListEntry
|
||||
|
||||
|
||||
@group_required('user')
|
||||
@ -58,7 +58,7 @@ def hook(request, token):
|
||||
)
|
||||
)
|
||||
return JsonResponse({'data': data['message']['text']})
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JsonResponse({})
|
||||
|
@ -25,7 +25,7 @@ from cookbook.filters import RecipeFilter
|
||||
from cookbook.forms import (CommentForm, Recipe, User,
|
||||
UserCreateForm, UserNameForm, UserPreference,
|
||||
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm,
|
||||
SearchPreferenceForm, AllAuthSignupForm)
|
||||
SearchPreferenceForm)
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission
|
||||
from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
|
||||
|
@ -15,11 +15,9 @@ import os
|
||||
import re
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dotenv import load_dotenv
|
||||
from webpack_loader.loader import WebpackLoader
|
||||
load_dotenv()
|
||||
# from dotenv import load_dotenv
|
||||
# load_dotenv()
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Get vars from .env files
|
||||
|
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@babel/eslint-parser": "^7.13.14",
|
||||
"@kevinfaguiar/vue-twemoji-picker": "^5.7.4",
|
||||
"@riophae/vue-treeselect": "^0.4.0",
|
||||
"axios": "^0.21.1",
|
||||
"bootstrap-vue": "^2.21.2",
|
||||
"core-js": "^3.14.0",
|
||||
|
@ -10,16 +10,20 @@
|
||||
<b-input class="form-control form-control-lg form-control-borderless form-control-search" v-model="settings.search_input"
|
||||
v-bind:placeholder="$t('Search')"></b-input>
|
||||
<b-input-group-append>
|
||||
<b-button class="shadow-none btn btn-light" @click="openRandom()">
|
||||
<b-button variant="light"
|
||||
v-b-tooltip.hover :title="$t('Random Recipes')"
|
||||
@click="openRandom()">
|
||||
<i class="fas fa-dice-five" style="font-size: 1.5em"></i>
|
||||
</b-button>
|
||||
<b-button v-b-toggle.collapse_advanced_search
|
||||
v-bind:class="{'btn-primary': !isAdvancedSettingsSet(), 'btn-danger': isAdvancedSettingsSet()}"
|
||||
class="shadow-none btn"><i
|
||||
class="fas fa-caret-down" v-if="!settings.advanced_search_visible"></i><i
|
||||
class="fas fa-caret-up"
|
||||
v-if="settings.advanced_search_visible"></i>
|
||||
v-b-tooltip.hover :title="$t('Advanced Settings')"
|
||||
v-bind:variant="!isAdvancedSettingsSet() ? 'primary' : 'danger'"
|
||||
>
|
||||
<!-- consider changing this icon to a filter -->
|
||||
<i class="fas fa-caret-down" v-if="!settings.advanced_search_visible"></i>
|
||||
<i class="fas fa-caret-up" v-if="settings.advanced_search_visible"></i>
|
||||
</b-button>
|
||||
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</div>
|
||||
@ -134,12 +138,16 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<b-input-group class="mt-2">
|
||||
<generic-multiselect @change="genericSelectChanged" parent_variable="search_keywords"
|
||||
<!-- <generic-multiselect @change="genericSelectChanged" parent_variable="search_keywords"
|
||||
:initial_selection="settings.search_keywords"
|
||||
search_function="listKeywords" label="label"
|
||||
:tree_api="true"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Keywords')"></generic-multiselect>
|
||||
v-bind:placeholder="$t('Keywords')"></generic-multiselect> -->
|
||||
<treeselect v-model="settings.search_keywords" :options="facets.Keywords" :flat="true"
|
||||
searchNested multiple :placeholder="$t('Keywords')" :normalizer="normalizer"
|
||||
@input="refreshData(false)"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"/>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox v-model="settings.search_keywords_or" name="check-button"
|
||||
@ -254,8 +262,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -277,6 +283,8 @@ import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||
import RecipeCard from "@/components/RecipeCard";
|
||||
import GenericMultiselect from "@/components/GenericMultiselect";
|
||||
import Treeselect from '@riophae/vue-treeselect'
|
||||
import '@riophae/vue-treeselect/dist/vue-treeselect.css'
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
@ -285,10 +293,11 @@ let SETTINGS_COOKIE_NAME = 'search_settings'
|
||||
export default {
|
||||
name: 'RecipeSearchView',
|
||||
mixins: [ResolveUrlMixin],
|
||||
components: {GenericMultiselect, RecipeCard},
|
||||
components: {GenericMultiselect, RecipeCard, Treeselect},
|
||||
data() {
|
||||
return {
|
||||
recipes: [],
|
||||
facets: [],
|
||||
meal_plans: [],
|
||||
last_viewed_recipes: [],
|
||||
|
||||
@ -383,9 +392,7 @@ export default {
|
||||
|
||||
apiClient.listRecipes(
|
||||
this.settings.search_input,
|
||||
this.settings.search_keywords.map(function (A) {
|
||||
return A["id"];
|
||||
}),
|
||||
this.settings.search_keywords,
|
||||
this.settings.search_foods.map(function (A) {
|
||||
return A["id"];
|
||||
}),
|
||||
@ -405,6 +412,7 @@ export default {
|
||||
window.scrollTo(0, 0);
|
||||
this.pagination_count = result.data.count
|
||||
this.recipes = result.data.results
|
||||
this.facets = result.data.facets
|
||||
})
|
||||
},
|
||||
openRandom: function () {
|
||||
@ -458,6 +466,14 @@ export default {
|
||||
},
|
||||
isAdvancedSettingsSet() {
|
||||
return ((this.settings.search_keywords.length + this.settings.search_foods.length + this.settings.search_books.length) > 0)
|
||||
},
|
||||
normalizer(node) {
|
||||
return {
|
||||
id: node.id,
|
||||
label: node.name + ' (' + node.count + ')',
|
||||
children: node.children,
|
||||
isDefaultExpanded: node.isDefaultExpanded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -79,7 +79,10 @@ module.exports = {
|
||||
priority: 1
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
// TODO make this conditional on .env DEBUG = TRUE
|
||||
// config.optimization.minimize(false)
|
||||
);
|
||||
|
||||
//TODO somehow remov them as they are also added to the manifest config of the service worker
|
||||
/*
|
||||
|
@ -1063,6 +1063,20 @@
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353"
|
||||
integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==
|
||||
|
||||
"@riophae/vue-treeselect@^0.4.0":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@riophae/vue-treeselect/-/vue-treeselect-0.4.0.tgz#0baed5a794cffc580b63591f35c125e51c0df241"
|
||||
integrity sha512-J4atYmBqXQmiPFK/0B5sXKjtnGc21mBJEiyKIDZwk0Q9XuynVFX6IJ4EpaLmUgL5Tve7HAS7wkiGGSti6Uaxcg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.3.1"
|
||||
babel-helper-vue-jsx-merge-props "^2.0.3"
|
||||
easings-css "^1.0.0"
|
||||
fuzzysearch "^1.0.3"
|
||||
is-promise "^2.1.0"
|
||||
lodash "^4.0.0"
|
||||
material-colors "^1.2.6"
|
||||
watch-size "^2.0.0"
|
||||
|
||||
"@rollup/plugin-babel@^5.2.0":
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879"
|
||||
@ -2255,6 +2269,11 @@ babel-extract-comments@^1.0.0:
|
||||
dependencies:
|
||||
babylon "^6.18.0"
|
||||
|
||||
babel-helper-vue-jsx-merge-props@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-2.0.3.tgz#22aebd3b33902328e513293a8e4992b384f9f1b6"
|
||||
integrity sha512-gsLiKK7Qrb7zYJNgiXKpXblxbV5ffSwR0f5whkPAaBAR4fhi6bwRZxX9wBlIc5M/v8CCkXUbXZL4N/nSE97cqg==
|
||||
|
||||
babel-loader@^8.1.0:
|
||||
version "8.2.2"
|
||||
resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.2.2.tgz#9363ce84c10c9a40e6c753748e1441b60c8a0b81"
|
||||
@ -3804,6 +3823,11 @@ duplexify@^3.4.2, duplexify@^3.6.0:
|
||||
readable-stream "^2.0.0"
|
||||
stream-shift "^1.0.0"
|
||||
|
||||
easings-css@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/easings-css/-/easings-css-1.0.0.tgz#dde569003bb7a4a0c0b77878f5db3e0be5679c81"
|
||||
integrity sha512-7Uq7NdazNfVtr0RNmPAys8it0zKCuaqxJStYKEl72D3j4gbvXhhaM7iWNbqhA4C94ygCye6VuyhzBRQC4szeBg==
|
||||
|
||||
easy-stack@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/easy-stack/-/easy-stack-1.0.1.tgz#8afe4264626988cabb11f3c704ccd0c835411066"
|
||||
@ -4674,6 +4698,11 @@ functional-red-black-tree@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
|
||||
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
|
||||
|
||||
fuzzysearch@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/fuzzysearch/-/fuzzysearch-1.0.3.tgz#dffc80f6d6b04223f2226aa79dd194231096d008"
|
||||
integrity sha1-3/yA9tawQiPyImqnndGUIxCW0Ag=
|
||||
|
||||
generic-names@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-2.0.1.tgz#f8a378ead2ccaa7a34f0317b05554832ae41b872"
|
||||
@ -5579,6 +5608,11 @@ is-plain-object@^2.0.3, is-plain-object@^2.0.4:
|
||||
dependencies:
|
||||
isobject "^3.0.1"
|
||||
|
||||
is-promise@^2.1.0:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1"
|
||||
integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==
|
||||
|
||||
is-regex@^1.0.4, is-regex@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.3.tgz#d029f9aff6448b93ebbe3f33dac71511fdcbef9f"
|
||||
@ -6052,7 +6086,7 @@ lodash.uniq@^4.5.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
|
||||
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
|
||||
|
||||
lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.3:
|
||||
lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.3:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
@ -6137,6 +6171,11 @@ map-visit@^1.0.0:
|
||||
dependencies:
|
||||
object-visit "^1.0.0"
|
||||
|
||||
material-colors@^1.2.6:
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46"
|
||||
integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==
|
||||
|
||||
md5.js@^1.3.4:
|
||||
version "1.3.5"
|
||||
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
|
||||
@ -9509,6 +9548,11 @@ vuex@^3.6.0:
|
||||
resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71"
|
||||
integrity sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==
|
||||
|
||||
watch-size@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/watch-size/-/watch-size-2.0.0.tgz#096ee28d0365bd7ea03d9c8bf1f2f50a73be1474"
|
||||
integrity sha512-M92R89dNoTPWyCD+HuUEDdhaDnh9jxPGOwlDc0u51jAgmjUvzqaEMynXSr3BaWs+QdHYk4KzibPy1TFtjLmOZQ==
|
||||
|
||||
watchpack-chokidar2@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz#38500072ee6ece66f3769936950ea1771be1c957"
|
||||
|
Loading…
Reference in New Issue
Block a user