Merge pull request #839 from smilerz/fix_search

Fix search
This commit is contained in:
vabene1111
2021-08-31 08:57:14 +02:00
committed by GitHub
18 changed files with 219 additions and 184 deletions

View File

@ -4,13 +4,15 @@ from recipes import settings
from django.contrib.postgres.search import ( from django.contrib.postgres.search import (
SearchQuery, SearchRank, TrigramSimilarity SearchQuery, SearchRank, TrigramSimilarity
) )
from django.db.models import Count, Q, Subquery, Case, When, Value from django.db.models import Count, Max, Q, Subquery, Case, When, Value
from django.utils import translation from django.utils import translation
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
from cookbook.models import Food, Keyword, ViewLog from cookbook.models import Food, Keyword, Recipe, ViewLog
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected
# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
def search_recipes(request, queryset, params): def search_recipes(request, queryset, params):
search_prefs = request.user.searchpreference search_prefs = request.user.searchpreference
search_string = params.get('query', '') search_string = params.get('query', '')
@ -19,6 +21,7 @@ def search_recipes(request, queryset, params):
search_foods = params.getlist('foods', []) search_foods = params.getlist('foods', [])
search_books = params.getlist('books', []) search_books = params.getlist('books', [])
# TODO I think default behavior should be 'AND' which is how most sites operate with facet/filters based on results
search_keywords_or = params.get('keywords_or', True) search_keywords_or = params.get('keywords_or', True)
search_foods_or = params.get('foods_or', True) search_foods_or = params.get('foods_or', True)
search_books_or = params.get('books_or', True) search_books_or = params.get('books_or', True)
@ -27,24 +30,30 @@ def search_recipes(request, queryset, params):
search_random = params.get('random', False) search_random = params.get('random', False)
search_new = params.get('new', False) search_new = params.get('new', False)
search_last_viewed = int(params.get('last_viewed', 0)) search_last_viewed = int(params.get('last_viewed', 0))
orderby = []
# TODO update this to concat with full search queryset qs1 | qs2 # TODO update this to concat with full search queryset qs1 | qs2
if search_last_viewed > 0: if search_last_viewed > 0:
last_viewed_recipes = ViewLog.objects.filter( last_viewed_recipes = ViewLog.objects.filter(
created_by=request.user, space=request.space, created_by=request.user, space=request.space,
created_at__gte=datetime.now() - timedelta(days=14) created_at__gte=datetime.now() - timedelta(days=14) # TODO make recent days a setting
).order_by('pk').values_list('recipe__pk', flat=True).distinct() ).order_by('-pk').values_list('recipe__pk', flat=True)
last_viewed_recipes = list(dict.fromkeys(last_viewed_recipes))[:search_last_viewed] # removes duplicates from list prior to slicing
return queryset.filter(pk__in=last_viewed_recipes[len(last_viewed_recipes) - min(len(last_viewed_recipes), search_last_viewed):]) # return queryset.annotate(last_view=Max('viewlog__pk')).annotate(new=Case(When(pk__in=last_viewed_recipes, then=('last_view')), default=Value(0))).filter(new__gt=0).order_by('-new')
# queryset that only annotates most recent view (higher pk = lastest view)
queryset = queryset.annotate(last_view=Max('viewlog__pk')).annotate(recent=Case(When(pk__in=last_viewed_recipes, then=('last_view')), default=Value(0)))
orderby += ['-recent']
orderby = [] # TODO create setting for default ordering - most cooked, rating,
# TODO create options for live sorting
# TODO make days of new recipe a setting
if search_new == 'true': if search_new == 'true':
queryset = queryset.annotate( queryset = (
new_recipe=Case(When(created_at__gte=(datetime.now() - timedelta(days=7)), then=Value(100)), queryset.annotate(new_recipe=Case(
default=Value(0), )) When(created_at__gte=(datetime.now() - timedelta(days=7)), then=('pk')), default=Value(0),))
orderby += ['new_recipe'] )
else: orderby += ['-new_recipe']
queryset = queryset
search_type = search_prefs.search or 'plain' search_type = search_prefs.search or 'plain'
if len(search_string) > 0: if len(search_string) > 0:
@ -124,12 +133,12 @@ def search_recipes(request, queryset, params):
queryset = queryset.filter(query_filter) queryset = queryset.filter(query_filter)
if len(search_keywords) > 0: if len(search_keywords) > 0:
# TODO creating setting to include descendants of keywords a setting
if search_keywords_or == 'true': if search_keywords_or == 'true':
# when performing an 'or' search all descendants are included in the OR condition # when performing an 'or' search all descendants are included in the OR condition
# so descendants are appended to filter all at once # so descendants are appended to filter all at once
for kw in Keyword.objects.filter(pk__in=search_keywords): # TODO creating setting to include descendants of keywords a setting
search_keywords += list(kw.get_descendants().values_list('pk', flat=True)) # 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) queryset = queryset.filter(keywords__id__in=search_keywords)
else: else:
# when performing an 'and' search returned recipes should include a parent OR any of its descedants # when performing an 'and' search returned recipes should include a parent OR any of its descedants
@ -160,32 +169,42 @@ def search_recipes(request, queryset, params):
queryset = queryset.order_by("?") queryset = queryset.order_by("?")
else: else:
# TODO add order by user settings # TODO add order by user settings
orderby += ['name'] # orderby += ['name']
queryset = queryset.order_by(*orderby) queryset = queryset.order_by(*orderby)
return queryset return queryset
def get_facet(qs, params): def get_facet(qs, request):
# NOTE facet counts for tree models include self AND descendants
facets = {} facets = {}
ratings = params.getlist('ratings', []) ratings = request.query_params.getlist('ratings', [])
keyword_list = params.getlist('keywords', []) keyword_list = request.query_params.getlist('keywords', [])
ingredient_list = params.getlist('ingredient', []) food_list = request.query_params.getlist('foods', [])
book_list = params.getlist('book', []) book_list = request.query_params.getlist('book', [])
search_keywords_or = request.query_params.get('keywords_or', True)
search_foods_or = request.query_params.get('foods_or', True)
search_books_or = request.query_params.get('books_or', True)
# this returns a list of keywords in the queryset and how many times it appears # if using an OR search, will annotate all keywords, otherwise, just those that appear in results
kws = Keyword.objects.filter(recipe__in=qs).annotate(kw_count=Count('recipe')) if search_keywords_or:
keywords = Keyword.objects.filter(space=request.space).annotate(recipe_count=Count('recipe'))
else:
keywords = Keyword.objects.filter(recipe__in=qs, space=request.space).annotate(recipe_count=Count('recipe'))
# custom django-tree function annotates a queryset to make building a tree easier. # 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 # 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) kw_a = annotated_qs(keywords, root=True, fill=True)
# TODO add rating facet # TODO add rating facet
facets['Ratings'] = [] facets['Ratings'] = []
facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list) facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list)
# TODO add food facet # TODO add food facet
facets['Ingredients'] = [] facets['Foods'] = []
# TODO add book facet # TODO add book facet
facets['Books'] = [] facets['Books'] = []
facets['Recent'] = ViewLog.objects.filter(
created_by=request.user, space=request.space,
created_at__gte=datetime.now() - timedelta(days=14) # TODO make days of recent recipe a setting
).values_list('recipe__pk', flat=True)
return facets return facets
@ -199,7 +218,7 @@ def fill_annotated_parents(annotation, filters):
annotation[i][1]['id'] = r[0].id annotation[i][1]['id'] = r[0].id
annotation[i][1]['name'] = r[0].name annotation[i][1]['name'] = r[0].name
annotation[i][1]['count'] = getattr(r[0], 'kw_count', 0) annotation[i][1]['count'] = getattr(r[0], 'recipe_count', 0)
annotation[i][1]['isDefaultExpanded'] = False annotation[i][1]['isDefaultExpanded'] = False
if str(r[0].id) in filters: if str(r[0].id) in filters:
@ -217,7 +236,7 @@ def fill_annotated_parents(annotation, filters):
while j < level: while j < level:
# this causes some double counting when a recipe has both a child and an ancestor # 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) annotation[parent[j]][1]['count'] += getattr(r[0], 'recipe_count', 0)
if expand: if expand:
annotation[parent[j]][1]['isDefaultExpanded'] = True annotation[parent[j]][1]['isDefaultExpanded'] = True
j += 1 j += 1

View File

@ -5,7 +5,6 @@ import uuid
from datetime import date, timedelta from datetime import date, timedelta
from annoying.fields import AutoOneToOneField from annoying.fields import AutoOneToOneField
from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
@ -745,7 +744,7 @@ class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionMo
return self.recipe.name return self.recipe.name
class Meta(): class Meta():
indexes = (Index(fields=['id', 'recipe', '-created_at', 'rating']),) indexes = (Index(fields=['id', 'recipe', '-created_at', 'rating', 'created_by']),)
class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionModelMixin): class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionModelMixin):
@ -760,7 +759,7 @@ class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionMo
return self.recipe.name return self.recipe.name
class Meta(): class Meta():
indexes = (Index(fields=['recipe', '-created_at']),) indexes = (Index(fields=['recipe', '-created_at', 'created_by']),)
class ImportLog(models.Model, PermissionModelMixin): class ImportLog(models.Model, PermissionModelMixin):

View File

@ -1,14 +1,14 @@
import random import random
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from gettext import gettext as _ from gettext import gettext as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import QuerySet, Sum, Avg from django.db.models import QuerySet, Sum, Avg
from django.utils import timezone
from drf_writable_nested import (UniqueFieldsMixin, from drf_writable_nested import (UniqueFieldsMixin,
WritableNestedModelSerializer) WritableNestedModelSerializer)
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError, NotFound from rest_framework.exceptions import ValidationError, NotFound
from treebeard.mp_tree import MP_NodeQuerySet
from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword, from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword,
MealPlan, MealType, NutritionInformation, Recipe, MealPlan, MealType, NutritionInformation, Recipe,
@ -287,7 +287,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
def create(self, validated_data): def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip() validated_data['name'] = validated_data['name'].strip()
validated_data['space'] = self.context['request'].space validated_data['space'] = self.context['request'].space
obj, created = Food.objects.get_or_create(validated_data) obj, created = Food.objects.get_or_create(**validated_data)
return obj return obj
def update(self, instance, validated_data): def update(self, instance, validated_data):
@ -387,11 +387,19 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
pass pass
return None return None
# TODO make days of new recipe a setting
def is_recipe_new(self, obj):
if obj.created_at > (timezone.now() - timedelta(days=7)):
return True
else:
return False
class RecipeOverviewSerializer(RecipeBaseSerializer): class RecipeOverviewSerializer(RecipeBaseSerializer):
keywords = KeywordLabelSerializer(many=True) keywords = KeywordLabelSerializer(many=True)
rating = serializers.SerializerMethodField('get_recipe_rating') rating = serializers.SerializerMethodField('get_recipe_rating')
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked') last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
new = serializers.SerializerMethodField('is_recipe_new')
def create(self, validated_data): def create(self, validated_data):
pass pass
@ -404,7 +412,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
fields = ( fields = (
'id', 'name', 'description', 'image', 'keywords', 'working_time', 'id', 'name', 'description', 'image', 'keywords', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at', 'waiting_time', 'created_by', 'created_at', 'updated_at',
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new'
) )
read_only_fields = ['image', 'created_by', 'created_at'] read_only_fields = ['image', 'created_by', 'created_at']

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,10 +1,11 @@
import json import json
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from cookbook.models import Recipe from cookbook.models import Recipe
from cookbook.tests.conftest import get_random_json_recipe, validate_recipe
LIST_URL = 'api:recipe-list' LIST_URL = 'api:recipe-list'
DETAIL_URL = 'api:recipe-detail' DETAIL_URL = 'api:recipe-detail'
@ -49,18 +50,19 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
def test_update(arg, request, recipe_1_s1): def test_update(arg, request, recipe_1_s1):
with scopes_disabled(): with scopes_disabled():
c = request.getfixturevalue(arg[0]) c = request.getfixturevalue(arg[0])
j = get_random_json_recipe()
r = c.patch( r = c.patch(
reverse( reverse(
DETAIL_URL, DETAIL_URL,
args={recipe_1_s1.id} args={recipe_1_s1.id}
), ),
{'name': 'new'}, j,
content_type='application/json' content_type='application/json'
) )
response = json.loads(r.content) response = json.loads(r.content)
assert r.status_code == arg[1] assert r.status_code == arg[1]
if r.status_code == 200: if r.status_code == 200:
assert response['name'] == 'new' validate_recipe(j, json.loads(r.content))
@pytest.mark.parametrize("arg", [ @pytest.mark.parametrize("arg", [
@ -70,22 +72,24 @@ def test_update(arg, request, recipe_1_s1):
['a1_s1', 201], ['a1_s1', 201],
]) ])
def test_add(arg, request, u1_s2): def test_add(arg, request, u1_s2):
c = request.getfixturevalue(arg[0]) x = 0
r = c.post( while x < 2:
reverse(LIST_URL), c = request.getfixturevalue(arg[0])
{'name': 'test', 'waiting_time': 0, 'working_time': 0, 'keywords': [], 'steps': []}, j = get_random_json_recipe()
content_type='application/json' r = c.post(
) reverse(LIST_URL), j, content_type='application/json'
response = json.loads(r.content) )
print(r.content) response = json.loads(r.content)
assert r.status_code == arg[1] print(r.content)
if r.status_code == 201: assert r.status_code == arg[1]
# id can change when running multiple tests, changed to validate name if r.status_code == 201:
assert response['name'] == 'test' # id can change when running multiple tests, changed to validate name
r = c.get(reverse(DETAIL_URL, args={response['id']})) validate_recipe(j, json.loads(r.content))
assert r.status_code == 200 r = c.get(reverse(DETAIL_URL, args={response['id']}))
r = u1_s2.get(reverse(DETAIL_URL, args={response['id']})) assert r.status_code == 200
assert r.status_code == 404 r = u1_s2.get(reverse(DETAIL_URL, args={response['id']}))
assert r.status_code == 404
x += 1
def test_delete(u1_s1, u1_s2, recipe_1_s1): def test_delete(u1_s1, u1_s2, recipe_1_s1):

View File

@ -1,5 +1,6 @@
import copy import copy
import inspect import inspect
import random
import uuid import uuid
import pytest import pytest
@ -81,6 +82,74 @@ def get_random_recipe(space_1, u1_s1):
return r return r
def get_random_json_recipe():
return {
"name": str(uuid.uuid4()),
"description": str(uuid.uuid4()),
"keywords": [{"name": str(uuid.uuid4())}, {"name": str(uuid.uuid4())}],
"steps": [
{
"instruction": str(uuid.uuid4()),
"ingredients": [
{"food": {"name": str(uuid.uuid4())}, "unit": {"name": str(uuid.uuid4())}, "amount": random.randint(0, 10)},
{"food": {"name": str(uuid.uuid4())}, "unit": {"name": str(uuid.uuid4())}, "amount": random.randint(0, 10)},
],
}
],
"working_time": random.randint(0, 120),
"waiting_time": random.randint(0, 120),
}
def validate_recipe(expected, recipe):
expected_lists = {}
target_lists = {}
# file and url are metadata not related to the recipe
[expected.pop(k) for k in ['file', 'url'] if k in expected]
# if a key is a list remove it to deal with later
lists = [k for k, v in expected.items() if type(v) == list]
for k in lists:
expected_lists[k] = expected.pop(k)
target_lists[k] = recipe.pop(k)
try:
# recipe dicts will have additional keys (IDs, default values, etc)
# this will check for an exact match from expected key:value to a superset of key:value pairs
assert expected.items() <= recipe.items()
except AssertionError:
for key in expected:
if expected[key] != recipe[key]:
print('Expected : ', expected[key], ' got: ', recipe[key])
# this is later, it may or may not work with keys that have list values
# it also may or may not work on complex nested dicts
for key in expected_lists:
for k in expected_lists[key]:
try:
assert any([dict_compare(k, i) for i in target_lists[key]])
except AssertionError:
for result in [dict_compare(k, i, details=True) for i in target_lists[key]]:
print('Added Keys: ', result[0])
print('Removed Keys', result[1])
print('Modified Value Keys', result[2])
print('Modified Dictionary Keys', result[3])
def dict_compare(d1, d2, details=False):
d1_keys = set(d1.keys())
d2_keys = set(d2.keys())
shared = d1_keys.intersection(d2_keys)
sub_dicts = [i for i, j in d1.items() if type(j) == dict]
not_dicts = shared - set(sub_dicts)
added = d1_keys - d2_keys
removed = d2_keys - d1_keys
modified = {o: (d1[o], d2[o]) for o in not_dicts if d1[o] != d2[o]}
modified_dicts = {o: (d1[o], d2[o]) for o in sub_dicts if not d1[o].items() <= d2[o].items()}
if details:
return added, removed, modified, modified_dicts
else:
return any([not added, not removed, not modified, not modified_dicts])
@pytest.fixture @pytest.fixture
def recipe_1_s1(space_1, u1_s1): def recipe_1_s1(space_1, u1_s1):
return get_random_recipe(space_1, u1_s1) return get_random_recipe(space_1, u1_s1)

View File

@ -9,6 +9,7 @@ from ._recipes import (
ALLRECIPES, AMERICAS_TEST_KITCHEN, CHEF_KOCH, CHEF_KOCH2, COOKPAD, ALLRECIPES, AMERICAS_TEST_KITCHEN, CHEF_KOCH, CHEF_KOCH2, COOKPAD,
COOKS_COUNTRY, DELISH, FOOD_NETWORK, GIALLOZAFFERANO, JOURNAL_DES_FEMMES, COOKS_COUNTRY, DELISH, FOOD_NETWORK, GIALLOZAFFERANO, JOURNAL_DES_FEMMES,
MADAME_DESSERT, MARMITON, TASTE_OF_HOME, THE_SPRUCE_EATS, TUDOGOSTOSO) MADAME_DESSERT, MARMITON, TASTE_OF_HOME, THE_SPRUCE_EATS, TUDOGOSTOSO)
from cookbook.tests.conftest import validate_recipe
IMPORT_SOURCE_URL = 'api_recipe_from_source' IMPORT_SOURCE_URL = 'api_recipe_from_source'
DATA_DIR = "cookbook/tests/other/test_data/" DATA_DIR = "cookbook/tests/other/test_data/"
@ -56,7 +57,8 @@ def test_import_permission(arg, request):
TUDOGOSTOSO, TUDOGOSTOSO,
]) ])
def test_recipe_import(arg, u1_s1): def test_recipe_import(arg, u1_s1):
for f in arg['file']: url = arg['url']
for f in list(arg['file']) : # url and files get popped later
if 'cookbook' in os.getcwd(): if 'cookbook' in os.getcwd():
test_file = os.path.join(os.getcwd(), 'other', 'test_data', f) test_file = os.path.join(os.getcwd(), 'other', 'test_data', f)
else: else:
@ -66,33 +68,10 @@ def test_recipe_import(arg, u1_s1):
reverse(IMPORT_SOURCE_URL), reverse(IMPORT_SOURCE_URL),
{ {
'data': d.read(), 'data': d.read(),
'url': arg['url'], 'url': url,
'mode': 'source' 'mode': 'source'
}, },
files={'foo': 'bar'} files={'foo': 'bar'}
) )
recipe = json.loads(response.content)['recipe_json'] recipe = json.loads(response.content)['recipe_json']
for key in list(set(arg) - set(['file', 'url'])): validate_recipe(arg, recipe)
if type(arg[key]) == list:
assert len(recipe[key]) == len(arg[key])
if key == 'keywords':
valid_keywords = [i['text'] for i in arg[key]]
for k in recipe[key]:
assert k['text'] in valid_keywords
elif key == 'recipeIngredient':
valid_ing = ["{:g}{}{}{}{}".format(
i['amount'],
i['unit']['text'],
i['ingredient']['text'],
i['note'],
i['original'])
for i in arg[key]]
for i in recipe[key]:
assert "{:g}{}{}{}{}".format(
i['amount'],
i['unit']['text'],
i['ingredient']['text'],
i['note'],
i['original']) in valid_ing
else:
assert recipe[key] == arg[key]

View File

@ -12,7 +12,7 @@ from django.contrib.auth.models import User
from django.contrib.postgres.search import TrigramSimilarity from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.core.files import File from django.core.files import File
from django.db.models import Q from django.db.models import Case, Q, Value, When
from django.http import FileResponse, HttpResponse, JsonResponse from django.http import FileResponse, HttpResponse, JsonResponse
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from django.shortcuts import redirect, get_object_or_404 from django.shortcuts import redirect, get_object_or_404
@ -98,7 +98,6 @@ class DefaultPagination(PageNumberPagination):
class FuzzyFilterMixin(ViewSetMixin): class FuzzyFilterMixin(ViewSetMixin):
def get_queryset(self): def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space) self.queryset = self.queryset.filter(space=self.request.space)
query = self.request.query_params.get('query', None) query = self.request.query_params.get('query', None)
@ -106,10 +105,19 @@ class FuzzyFilterMixin(ViewSetMixin):
if query is not None and query not in ["''", '']: if query is not None and query not in ["''", '']:
if fuzzy: if fuzzy:
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2).order_by("-trigram") self.queryset = (
self.queryset
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set
.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2)
.order_by('-exact').order_by("-trigram")
)
else: else:
# TODO have this check unaccent search settings? # TODO have this check unaccent search settings or other search preferences?
self.queryset = self.queryset.filter(name__icontains=query) self.queryset = (
self.queryset
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set
.filter(name__icontains=query).order_by('-exact')
)
updated_at = self.request.query_params.get('updated_at', None) updated_at = self.request.query_params.get('updated_at', None)
if updated_at is not None: if updated_at is not None:
@ -356,10 +364,6 @@ class FoodViewSet(viewsets.ModelViewSet, FuzzyFilterMixin):
serializer_class = FoodSerializer serializer_class = FoodSerializer
permission_classes = [CustomIsUser] permission_classes = [CustomIsUser]
def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space)
return super().get_queryset()
class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin): class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
queryset = RecipeBook.objects queryset = RecipeBook.objects
@ -466,7 +470,7 @@ class RecipePagination(PageNumberPagination):
max_page_size = 100 max_page_size = 100
def paginate_queryset(self, queryset, request, view=None): def paginate_queryset(self, queryset, request, view=None):
self.facets = get_facet(queryset, request.query_params) self.facets = get_facet(queryset, request)
return super().paginate_queryset(queryset, request, view) return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data): def get_paginated_response(self, data):

View File

@ -126,11 +126,6 @@
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{ $t('Advanced Search Settings') }}</a> <a :href="resolveDjangoUrl('view_settings') + '#search'">{{ $t('Advanced Search Settings') }}</a>
</div> </div>
</div> </div>
<div class="row" style="margin-top: 1vh">
<div class="col-12">
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{ $t('Advanced Search Settings') }}</a>
</div>
</div>
<div class="row" style="margin-top: 1vh"> <div class="row" style="margin-top: 1vh">
<div class="col-12" style="text-align: right"> <div class="col-12" style="text-align: right">
<b-button size="sm" variant="secondary" style="margin-right:8px" <b-button size="sm" variant="secondary" style="margin-right:8px"
@ -143,12 +138,6 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<b-input-group class="mt-2"> <b-input-group class="mt-2">
<!-- <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> -->
<treeselect v-model="settings.search_keywords" :options="facets.Keywords" :flat="true" <treeselect v-model="settings.search_keywords" :options="facets.Keywords" :flat="true"
searchNested multiple :placeholder="$t('Keywords')" :normalizer="normalizer" searchNested multiple :placeholder="$t('Keywords')" :normalizer="normalizer"
@input="refreshData(false)" @input="refreshData(false)"
@ -238,12 +227,11 @@
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe" <recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe"
:meal_plan="m" :footer_text="m.meal_type_name" :meal_plan="m" :footer_text="m.meal_type_name"
footer_icon="far fa-calendar-alt"></recipe-card> footer_icon="far fa-calendar-alt"></recipe-card>
<recipe-card v-for="r in last_viewed_recipes" v-bind:key="`rv_${r.id}`" :recipe="r"
v-bind:footer_text="$t('Recently_Viewed')" footer_icon="fas fa-eye"></recipe-card>
</template> </template>
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r"
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r"></recipe-card> :footer_text="isRecentOrNew(r)[0]"
:footer_icon="isRecentOrNew(r)[1]">
</recipe-card>
</div> </div>
</div> </div>
</div> </div>
@ -333,22 +321,9 @@ export default {
mounted() { mounted() {
this.$nextTick(function () { this.$nextTick(function () {
if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) { if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) {
let cookie_val = this.$cookies.get(SETTINGS_COOKIE_NAME) this.settings = Object.assign({}, this.settings, this.$cookies.get(SETTINGS_COOKIE_NAME))
for (let i of Object.keys(cookie_val)) {
this.$set(this.settings, i, cookie_val[i])
}
//TODO i have no idea why the above code does not suffice to update the
//TODO pagination UI element as $set should update all values reactively but it does not
setTimeout(function () {
this.$set(this.settings, 'pagination_page', 0)
}.bind(this), 50)
setTimeout(function () {
this.$set(this.settings, 'pagination_page', cookie_val['pagination_page'])
}.bind(this), 51)
} }
let urlParams = new URLSearchParams(window.location.search); let urlParams = new URLSearchParams(window.location.search);
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
@ -364,8 +339,8 @@ export default {
} }
this.loadMealPlan() this.loadMealPlan()
this.loadRecentlyViewed() // this.loadRecentlyViewed()
this.refreshData(false) // this.refreshData(false) // this gets triggered when the cookies get loaded
}) })
this.$i18n.locale = window.CUSTOM_LOCALE this.$i18n.locale = window.CUSTOM_LOCALE
@ -381,9 +356,12 @@ export default {
this.loadMealPlan() this.loadMealPlan()
}, },
'settings.recently_viewed': function () { 'settings.recently_viewed': function () {
this.loadRecentlyViewed() // this.loadRecentlyViewed()
this.refreshData(false)
}, },
'settings.search_input': _debounce(function () { 'settings.search_input': _debounce(function () {
this.settings.pagination_page = 1
this.pagination_count = 0
this.refreshData(false) this.refreshData(false)
}, 300), }, 300),
'settings.page_count': _debounce(function () { 'settings.page_count': _debounce(function () {
@ -412,12 +390,15 @@ export default {
random, random,
this.settings.sort_by_new, this.settings.sort_by_new,
this.settings.pagination_page, this.settings.pagination_page,
this.settings.page_count this.settings.page_count,
{query: {last_viewed: this.settings.recently_viewed}}
).then(result => { ).then(result => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
this.pagination_count = result.data.count this.pagination_count = result.data.count
this.recipes = result.data.results this.recipes = result.data.results
this.facets = result.data.facets this.facets = result.data.facets
console.log(this.recipes)
}) })
}, },
openRandom: function () { openRandom: function () {
@ -438,20 +419,19 @@ export default {
} else { } else {
this.meal_plans = [] this.meal_plans = []
} }
},
loadRecentlyViewed: function () {
let apiClient = new ApiApiFactory()
if (this.settings.recently_viewed > 0) {
apiClient.listRecipes(undefined, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, this.settings.sort_by_new, 1, this.settings.recently_viewed, {query: {last_viewed: this.settings.recently_viewed}}).then(result => {
this.last_viewed_recipes = result.data.results
})
} else {
this.last_viewed_recipes = []
}
}, },
// DEPRECATED: intergrated into standard FTS queryset
// loadRecentlyViewed: function () {
// let apiClient = new ApiApiFactory()
// if (this.settings.recently_viewed > 0) {
// apiClient.listRecipes(undefined, undefined, undefined, undefined, undefined, undefined,
// undefined, undefined, undefined, this.settings.sort_by_new, 1, this.settings.recently_viewed, {query: {last_viewed: this.settings.recently_viewed}}).then(result => {
// this.last_viewed_recipes = result.data.results
// })
// } else {
// this.last_viewed_recipes = []
// }
// },
genericSelectChanged: function (obj) { genericSelectChanged: function (obj) {
this.settings[obj.var] = obj.val this.settings[obj.var] = obj.val
this.refreshData(false) this.refreshData(false)
@ -479,6 +459,17 @@ export default {
children: node.children, children: node.children,
isDefaultExpanded: node.isDefaultExpanded isDefaultExpanded: node.isDefaultExpanded
} }
},
isRecentOrNew: function(x) {
let recent_recipe = [this.$t('Recently_Viewed'), "fas fa-eye"]
let new_recipe = [this.$t('New_Recipe'), "fas fa-splotch"]
if (x.new) {
return new_recipe
} else if (this.facets.Recent.includes(x.id)) {
return recent_recipe
} else {
return [undefined, undefined]
}
} }
} }
} }

View File

@ -1,38 +0,0 @@
<template>
<div>
<b-dropdown variant="link" toggle-class="text-decoration-none" no-caret>
<template #button-content>
<i class="fas fa-ellipsis-v" ></i>
</template>
<b-dropdown-item v-on:click="$emit('item-action', 'edit')" v-if="show_edit">
<i class="fas fa-pencil-alt fa-fw"></i> {{ $t('Edit') }}
</b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'delete')" v-if="show_delete">
<i class="fas fa-trash-alt fa-fw"></i> {{ $t('Delete') }}
</b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'move')" v-if="show_move">
<i class="fas fa-trash-alt fa-fw"></i> {{ $t('Move') }}
</b-dropdown-item>
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge')">
<i class="fas fa-trash-alt fa-fw"></i> {{ $t('Merge') }}
</b-dropdown-item>
</b-dropdown>
</div>
</template>
<script>
export default {
name: 'KeywordContextMenu',
props: {
show_edit: {type: Boolean, default: true},
show_delete: {type: Boolean, default: true},
show_move: {type: Boolean, default: false},
show_merge: {type: Boolean, default: false},
}
}
</script>

View File

@ -1 +1 @@
{"status":"done","chunks":{"recipe_search_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_search_view.js"],"recipe_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_view.js"],"offline_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/offline_view.js"],"import_response_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/import_response_view.js"],"supermarket_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/supermarket_view.js"],"user_file_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/user_file_view.js"],"keyword_list_view":["css/chunk-vendors.css","js/chunk-vendors.js","css/keyword_list_view.css","js/keyword_list_view.js"]},"assets":{"../../templates/sw.js":{"name":"../../templates/sw.js","path":"..\\..\\templates\\sw.js"},"css/chunk-vendors.css":{"name":"css/chunk-vendors.css","path":"css\\chunk-vendors.css"},"js/chunk-vendors.js":{"name":"js/chunk-vendors.js","path":"js\\chunk-vendors.js"},"js/import_response_view.js":{"name":"js/import_response_view.js","path":"js\\import_response_view.js"},"css/keyword_list_view.css":{"name":"css/keyword_list_view.css","path":"css\\keyword_list_view.css"},"js/keyword_list_view.js":{"name":"js/keyword_list_view.js","path":"js\\keyword_list_view.js"},"js/offline_view.js":{"name":"js/offline_view.js","path":"js\\offline_view.js"},"js/recipe_search_view.js":{"name":"js/recipe_search_view.js","path":"js\\recipe_search_view.js"},"js/recipe_view.js":{"name":"js/recipe_view.js","path":"js\\recipe_view.js"},"js/supermarket_view.js":{"name":"js/supermarket_view.js","path":"js\\supermarket_view.js"},"js/user_file_view.js":{"name":"js/user_file_view.js","path":"js\\user_file_view.js"},"recipe_search_view.html":{"name":"recipe_search_view.html","path":"recipe_search_view.html"},"recipe_view.html":{"name":"recipe_view.html","path":"recipe_view.html"},"offline_view.html":{"name":"offline_view.html","path":"offline_view.html"},"import_response_view.html":{"name":"import_response_view.html","path":"import_response_view.html"},"supermarket_view.html":{"name":"supermarket_view.html","path":"supermarket_view.html"},"user_file_view.html":{"name":"user_file_view.html","path":"user_file_view.html"},"keyword_list_view.html":{"name":"keyword_list_view.html","path":"keyword_list_view.html"},"manifest.json":{"name":"manifest.json","path":"manifest.json"}}} {"status":"done","chunks":{"recipe_search_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_search_view.js"],"recipe_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_view.js"],"offline_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/offline_view.js"],"import_response_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/import_response_view.js"],"supermarket_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/supermarket_view.js"],"user_file_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/user_file_view.js"],"keyword_list_view":["css/chunk-vendors.css","js/chunk-vendors.js","css/keyword_list_view.css","js/keyword_list_view.js"]},"assets":{"../../templates/sw.js":{"name":"../../templates/sw.js","path":"../../templates/sw.js"},"css/chunk-vendors.css":{"name":"css/chunk-vendors.css","path":"css/chunk-vendors.css"},"js/chunk-vendors.js":{"name":"js/chunk-vendors.js","path":"js/chunk-vendors.js"},"js/import_response_view.js":{"name":"js/import_response_view.js","path":"js/import_response_view.js"},"css/keyword_list_view.css":{"name":"css/keyword_list_view.css","path":"css/keyword_list_view.css"},"js/keyword_list_view.js":{"name":"js/keyword_list_view.js","path":"js/keyword_list_view.js"},"js/offline_view.js":{"name":"js/offline_view.js","path":"js/offline_view.js"},"js/recipe_search_view.js":{"name":"js/recipe_search_view.js","path":"js/recipe_search_view.js"},"js/recipe_view.js":{"name":"js/recipe_view.js","path":"js/recipe_view.js"},"js/supermarket_view.js":{"name":"js/supermarket_view.js","path":"js/supermarket_view.js"},"js/user_file_view.js":{"name":"js/user_file_view.js","path":"js/user_file_view.js"},"recipe_search_view.html":{"name":"recipe_search_view.html","path":"recipe_search_view.html"},"recipe_view.html":{"name":"recipe_view.html","path":"recipe_view.html"},"offline_view.html":{"name":"offline_view.html","path":"offline_view.html"},"import_response_view.html":{"name":"import_response_view.html","path":"import_response_view.html"},"supermarket_view.html":{"name":"supermarket_view.html","path":"supermarket_view.html"},"user_file_view.html":{"name":"user_file_view.html","path":"user_file_view.html"},"keyword_list_view.html":{"name":"keyword_list_view.html","path":"keyword_list_view.html"},"manifest.json":{"name":"manifest.json","path":"manifest.json"}}}