fixes keyword filter on OR search

This commit is contained in:
smilerz
2021-08-24 22:01:02 -05:00
parent 16e8b24947
commit 78ebd18ed8
25 changed files with 54 additions and 102 deletions

View File

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

View File

@ -11,6 +11,8 @@ from cookbook.managers import DICTIONARY
from cookbook.models import Food, Keyword, 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):
search_prefs = request.user.searchpreference
search_string = params.get('query', '')
@ -19,6 +21,7 @@ def search_recipes(request, queryset, params):
search_foods = params.getlist('foods', [])
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_foods_or = params.get('foods_or', True)
search_books_or = params.get('books_or', True)
@ -124,12 +127,12 @@ 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 filter all at once
for kw in Keyword.objects.filter(pk__in=search_keywords):
search_keywords += list(kw.get_descendants().values_list('pk', flat=True))
# TODO creating setting to include descendants of keywords a setting
# 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:
# when performing an 'and' search returned recipes should include a parent OR any of its descedants
@ -165,24 +168,31 @@ def search_recipes(request, queryset, params):
return queryset
def get_facet(qs, params):
def get_facet(qs, params, space):
# NOTE facet counts for tree models include self AND descendants
facets = {}
ratings = params.getlist('ratings', [])
keyword_list = params.getlist('keywords', [])
ingredient_list = params.getlist('ingredient', [])
ingredient_list = params.getlist('foods', [])
book_list = params.getlist('book', [])
search_keywords_or = params.get('keywords_or', True)
search_foods_or = params.get('foods_or', True)
search_books_or = params.get('books_or', True)
# 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'))
# if using an OR search, will annotate all keywords, otherwise, just those that appear in results
if search_keywords_or:
keywords = Keyword.objects.filter(space=space).annotate(recipe_count=Count('recipe'))
else:
keywords = Keyword.objects.filter(recipe__in=qs, space=space).annotate(recipe_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)
kw_a = annotated_qs(keywords, 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'] = []
facets['Foods'] = []
# TODO add book facet
facets['Books'] = []
@ -199,7 +209,7 @@ def fill_annotated_parents(annotation, filters):
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]['count'] = getattr(r[0], 'recipe_count', 0)
annotation[i][1]['isDefaultExpanded'] = False
if str(r[0].id) in filters:
@ -217,7 +227,7 @@ def fill_annotated_parents(annotation, filters):
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)
annotation[parent[j]][1]['count'] += getattr(r[0], 'recipe_count', 0)
if expand:
annotation[parent[j]][1]['isDefaultExpanded'] = True
j += 1

View File

@ -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
@ -38,16 +37,14 @@ def get_model_name(model):
class TreeManager(MP_NodeManager):
def get_or_create(self, **kwargs):
# model.Manager get_or_create() is not compatible with MP_Tree
def get_or_create(self, **kwargs):
kwargs['name'] = kwargs['name'].strip()
q = self.filter(name__iexact=kwargs['name'], space=kwargs['space'])
if len(q) != 0:
return q[0], False
else:
try:
return self.get(name__iexact=kwargs['name'], space=kwargs['space']), False
except self.model.DoesNotExist:
with scopes_disabled():
node = self.model.add_root(**kwargs)
return node, True
return self.model.add_root(**kwargs), True
class PermissionModelMixin:

View File

@ -63,7 +63,6 @@ class RecipeSchema(AutoSchema):
class TreeSchema(AutoSchema):
def get_path_parameters(self, path, method):
if not is_list_view(path, method, self.view):
return super(TreeSchema, self).get_path_parameters(path, method)
@ -85,5 +84,4 @@ class TreeSchema(AutoSchema):
"description": 'Return all self and children of {} with ID [int].'.format(api_name),
'schema': {'type': 'int', },
})
return parameters

View File

@ -3,12 +3,11 @@ from decimal import Decimal
from gettext import gettext as _
from django.contrib.auth.models import User
from django.db.models import QuerySet, Sum, Avg
from django.db.models import Avg, QuerySet, Sum
from drf_writable_nested import (UniqueFieldsMixin,
WritableNestedModelSerializer)
from rest_framework import serializers
from rest_framework.exceptions import ValidationError, NotFound
from treebeard.mp_tree import MP_NodeQuerySet
from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword,
MealPlan, MealType, NutritionInformation, Recipe,
@ -210,8 +209,8 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
def get_image(self, obj):
recipes = obj.recipe_set.all().filter(space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
if len(recipes) == 0:
recipes = Recipe.objects.filter(keywords__in=obj.get_tree(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
if len(recipes) == 0 and obj.has_children():
recipes = Recipe.objects.filter(keywords__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
if len(recipes) != 0:
return random.choice(recipes).image.url
else:
@ -229,7 +228,6 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
return obj
class Meta:
# list_serializer_class = SpaceFilterSerializer
model = Keyword
fields = ('id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at', 'updated_at')
read_only_fields = ('id', 'numchild', 'parent', 'image')

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

@ -22,7 +22,6 @@ MOVE_URL = 'api:keyword-move'
MERGE_URL = 'api:keyword-merge'
# TODO are there better ways to manage these fixtures?
@pytest.fixture()
def obj_1(space_1):
return Keyword.objects.get_or_create(name='test_1', space=space_1)[0]
@ -352,7 +351,6 @@ def test_merge(
def test_root_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
# should return root objects in the space (obj_1, obj_2), ignoring query filters
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root=0').content)
assert len(response['results']) == 2
@ -367,7 +365,6 @@ def test_root_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
def test_tree_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
with scopes_disabled():
obj_2.move(obj_1, 'sorted-child')
# should return full tree starting at obj_1 (obj_1_1_1, obj_2), ignoring query filters

View File

@ -109,9 +109,9 @@ urlpatterns = [
path('api/ingredient-from-string/', api.ingredient_from_string, name='api_ingredient_from_string'),
path('api/share-link/<int:pk>', api.share_link, name='api_share_link'),
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'),
path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'),
path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'),
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'), # TODO is this deprecated?
path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'), # TODO is this deprecated?
path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'), # TODO is this deprecated?
path('telegram/setup/<int:pk>', telegram.setup_bot, name='telegram_setup'),
path('telegram/remove/<int:pk>', telegram.remove_bot, name='telegram_remove'),
@ -137,7 +137,7 @@ urlpatterns = [
generic_models = (
Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync,
Comment, RecipeBookEntry, Keyword, Food, ShoppingList, InviteLink
Comment, RecipeBookEntry, Food, ShoppingList, InviteLink
)
for m in generic_models:

View File

@ -108,8 +108,9 @@ class FuzzyFilterMixin(ViewSetMixin):
if fuzzy:
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2).order_by("-trigram")
else:
# TODO have this check unaccent search settings?
self.queryset = self.queryset.filter(name__icontains=query)
# TODO have this check unaccent search settings or other search preferences?
# TODO for some querysets exact matches are sorted beyond pagesize, need to find better solution
self.queryset = self.queryset.filter(name__istartswith=query) | self.queryset.filter(name__icontains=query)
updated_at = self.request.query_params.get('updated_at', None)
if updated_at is not None:
@ -144,14 +145,14 @@ class TreeMixin(FuzzyFilterMixin):
except self.model.DoesNotExist:
self.queryset = self.model.objects.none()
if root == 0:
self.queryset = self.model.get_root_nodes() | self.model.objects.filter(depth=0)
self.queryset = self.model.get_root_nodes()
else:
self.queryset = self.model.objects.get(id=root).get_children()
elif tree:
if tree.isnumeric():
try:
self.queryset = self.model.objects.get(id=int(tree)).get_descendants_and_self()
except Keyword.DoesNotExist:
except self.model.DoesNotExist:
self.queryset = self.model.objects.none()
else:
return super().get_queryset()
@ -466,7 +467,7 @@ class RecipePagination(PageNumberPagination):
max_page_size = 100
def paginate_queryset(self, queryset, request, view=None):
self.facets = get_facet(queryset, request.query_params)
self.facets = get_facet(queryset, request.query_params, request.space)
return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data):

View File

@ -148,7 +148,6 @@ def import_url(request):
recipe.steps.add(step)
all_keywords = Keyword.get_tree()
for kw in data['keywords']:
k, created = Keyword.objects.get_or_create(name=kw['text'], space=request.space)
recipe.keywords.add(k)

View File

@ -56,9 +56,7 @@ def index(request):
return HttpResponseRedirect(reverse('view_search'))
# faceting
# unaccent / likely will perform full table scan
# create tests
# TODO need to deprecate
def search(request):
if has_group_permission(request.user, ('guest',)):
if request.user.userpreference.search_style == UserPreference.NEW:

View File

@ -8,7 +8,6 @@
<div class="col-xl-8 col-12">
<!-- TODO only show scollbars in split mode, but this doesn't interact well with infinite scroll, maybe a different component? -->
<div class="container-fluid d-flex flex-column flex-grow-1" :class="{'vh-100' : show_split}">
<!-- <div class="container-fluid d-flex flex-column flex-grow-1 vh-100"> -->
<!-- expanded options box -->
<div class="row flex-shrink-0">
<div class="col col-md-12">

View File

@ -143,12 +143,6 @@
<div class="row">
<div class="col-12">
<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"
searchNested multiple :placeholder="$t('Keywords')" :normalizer="normalizer"
@input="refreshData(false)"

View File

@ -27,7 +27,7 @@
<script>
export default {
name: 'KeywordContextMenu',
name: 'GenericContextMenu',
props: {
show_edit: {type: Boolean, default: true},
show_delete: {type: Boolean, default: true},

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

@ -36,10 +36,7 @@ export default {
search_function: String,
label: String,
parent_variable: {type: String, default: undefined},
limit: {
type: Number,
default: 10,
},
limit: {type: Number, default: 10,},
sticky_options: {type:Array, default(){return []}},
initial_selection: {type:Array, default(){return []}},
multiple: {type: Boolean, default: true},

View File

@ -80,7 +80,7 @@ module.exports = {
},
},
},
// TODO make this conditional on .env DEBUG = TRUE
// TODO make this conditional on .env DEBUG = FALSE
config.optimization.minimize(true)
);

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