From 6887068cb1d2bd194977fe39d607274116938e19 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Fri, 12 Jul 2024 14:46:13 +0200 Subject: [PATCH] Replace old search function Using the existing filter mechanisms from django-filter --- wger/nutrition/api/filtersets.py | 76 ++++++++++++- wger/nutrition/api/views.py | 138 +----------------------- wger/nutrition/tests/test_ingredient.py | 43 -------- wger/nutrition/tests/test_search_api.py | 66 +++++------- wger/urls.py | 1 - 5 files changed, 102 insertions(+), 222 deletions(-) diff --git a/wger/nutrition/api/filtersets.py b/wger/nutrition/api/filtersets.py index 601f6c8d9..2cdeaebd3 100644 --- a/wger/nutrition/api/filtersets.py +++ b/wger/nutrition/api/filtersets.py @@ -1,3 +1,24 @@ +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Workout Manager. If not, see . + +# Standard Library +import logging + +# Django +from django.contrib.postgres.search import TrigramSimilarity + # Third Party from django_filters import rest_framework as filters @@ -6,6 +27,11 @@ Ingredient, LogItem, ) +from wger.utils.db import is_postgres_db +from wger.utils.language import load_language + + +logger = logging.getLogger(__name__) class LogItemFilterSet(filters.FilterSet): @@ -21,6 +47,54 @@ class Meta: class IngredientFilterSet(filters.FilterSet): + code = filters.CharFilter(method='search_code') + name__search = filters.CharFilter(method='search_name_fulltext') + language__code = filters.CharFilter(method='search_languagecode') + + def search_code(self, queryset, name, value): + """ + 'exact' search for the barcode. + + It this is not known locally, try fetching the result from OFF + """ + + if not value: + return queryset + + queryset = queryset.filter(code=value) + if queryset.count() == 0: + logger.debug('code not found locally, fetching code from off') + Ingredient.fetch_ingredient_from_off(value) + + return queryset + + def search_name_fulltext(self, queryset, name, value): + """ + Perform a fulltext search when postgres is available + """ + + if is_postgres_db(): + return ( + queryset.annotate(similarity=TrigramSimilarity('name', value)) + .filter(similarity__gt=0.15) + .order_by('-similarity', 'name') + ) + else: + return queryset.filter(name__icontains=value) + + def search_languagecode(self, queryset, name, value): + """ + Filter based on language codes, not IDs + + Also accepts a comma separated list of codes. Unknown codes are ignored + """ + + languages = [load_language(l) for l in value.split(',')] + if languages: + queryset = queryset.filter(language__in=languages) + + return queryset + class Meta: model = Ingredient fields = { @@ -40,7 +114,7 @@ class Meta: 'created': ['exact', 'gt', 'lt'], 'last_update': ['exact', 'gt', 'lt'], 'last_imported': ['exact', 'gt', 'lt'], - 'language': ['exact'], + 'language': ['exact', 'in'], 'license': ['exact'], 'license_author': ['exact'], } diff --git a/wger/nutrition/api/views.py b/wger/nutrition/api/views.py index 8883bf10a..2e11bdf59 100644 --- a/wger/nutrition/api/views.py +++ b/wger/nutrition/api/views.py @@ -20,28 +20,12 @@ # Django from django.conf import settings -from django.contrib.postgres.search import TrigramSimilarity from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page # Third Party -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import ( - OpenApiParameter, - extend_schema, - inline_serializer, -) -from easy_thumbnails.alias import aliases -from easy_thumbnails.files import get_thumbnailer from rest_framework import viewsets -from rest_framework.decorators import ( - action, - api_view, -) -from rest_framework.fields import ( - CharField, - IntegerField, -) +from rest_framework.decorators import action from rest_framework.response import Response # wger @@ -73,12 +57,6 @@ NutritionPlan, WeightUnit, ) -from wger.utils.constants import ( - ENGLISH_SHORT_NAME, - SEARCH_ALL_LANGUAGES, -) -from wger.utils.db import is_postgres_db -from wger.utils.language import load_language from wger.utils.viewsets import WgerOwnerObjectModelViewSet @@ -94,26 +72,12 @@ class IngredientViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = IngredientSerializer ordering_fields = '__all__' filterset_class = IngredientFilterSet + queryset = Ingredient.objects.all() @method_decorator(cache_page(settings.WGER_SETTINGS['INGREDIENT_CACHE_TTL'])) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - def get_queryset(self): - """H""" - qs = Ingredient.objects.all() - - code = self.request.query_params.get('code') - if not code: - return qs - - qs = qs.filter(code=code) - if qs.count() == 0: - logger.debug('code not found locally, fetching code from off') - Ingredient.fetch_ingredient_from_off(code) - - return qs - @action(detail=True) def get_values(self, request, pk): """ @@ -168,104 +132,6 @@ class IngredientInfoViewSet(IngredientViewSet): serializer_class = IngredientInfoSerializer -@extend_schema( - parameters=[ - OpenApiParameter( - 'term', - OpenApiTypes.STR, - OpenApiParameter.QUERY, - description='The name of the ingredient to search"', - required=True, - ), - OpenApiParameter( - 'language', - OpenApiTypes.STR, - OpenApiParameter.QUERY, - description='Comma separated list of language codes to search', - required=True, - ), - ], - responses={ - 200: inline_serializer( - name='IngredientSearchResponse', - fields={ - 'value': CharField(), - 'data': inline_serializer( - name='IngredientSearchItemResponse', - fields={ - 'id': IntegerField(), - 'name': CharField(), - 'category': CharField(), - 'image': CharField(), - 'image_thumbnail': CharField(), - }, - ), - }, - ) - }, -) -@api_view(['GET']) -def search(request): - """ - Searches for ingredients. - - This format is currently used by the ingredient search autocompleter - """ - term = request.GET.get('term', None) - language_codes = request.GET.get('language', ENGLISH_SHORT_NAME) - results = [] - response = {} - - if not term: - return Response(response) - - query = Ingredient.objects.all() - - # Filter the appropriate languages - languages = [load_language(l) for l in language_codes.split(',')] - if language_codes != SEARCH_ALL_LANGUAGES: - query = query.filter( - language__in=languages, - ) - - query = query.only('name') - - # Postgres uses a full-text search - if is_postgres_db(): - query = ( - query.annotate(similarity=TrigramSimilarity('name', term)) - .filter(similarity__gt=0.15) - .order_by('-similarity', 'name') - ) - else: - query = query.filter(name__icontains=term) - - for ingredient in query[:150]: - if hasattr(ingredient, 'image'): - image_obj = ingredient.image - image = image_obj.image.url - t = get_thumbnailer(image_obj.image) - thumbnail = t.get_thumbnail(aliases.get('micro_cropped')).url - else: - ingredient.get_image(request) - image = None - thumbnail = None - - ingredient_json = { - 'value': ingredient.name, - 'data': { - 'id': ingredient.id, - 'name': ingredient.name, - 'image': image, - 'image_thumbnail': thumbnail, - }, - } - results.append(ingredient_json) - response['suggestions'] = results - - return Response(response) - - class ImageViewSet(viewsets.ReadOnlyModelViewSet): """ API endpoint for ingredient images diff --git a/wger/nutrition/tests/test_ingredient.py b/wger/nutrition/tests/test_ingredient.py index 5176d1749..6a7759b87 100644 --- a/wger/nutrition/tests/test_ingredient.py +++ b/wger/nutrition/tests/test_ingredient.py @@ -239,49 +239,6 @@ def test_ingredient_detail_logged_out(self): self.ingredient_detail(editor=False) -class IngredientSearchTestCase(WgerTestCase): - """ - Tests the ingredient search functions - """ - - def search_ingredient(self, fail=True): - """ - Helper function - """ - - kwargs = {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'} - response = self.client.get(reverse('ingredient-search'), {'term': 'test'}, **kwargs) - self.assertEqual(response.status_code, 200) - result = json.loads(response.content.decode('utf8')) - self.assertEqual(len(result['suggestions']), 2) - self.assertEqual(result['suggestions'][0]['value'], 'Ingredient, test, 2, organic, raw') - self.assertEqual(result['suggestions'][0]['data']['id'], 2) - suggestion_0_name = 'Ingredient, test, 2, organic, raw' - self.assertEqual(result['suggestions'][0]['data']['name'], suggestion_0_name) - self.assertEqual(result['suggestions'][0]['data']['image'], None) - self.assertEqual(result['suggestions'][0]['data']['image_thumbnail'], None) - self.assertEqual(result['suggestions'][1]['value'], 'Test ingredient 1') - self.assertEqual(result['suggestions'][1]['data']['id'], 1) - self.assertEqual(result['suggestions'][1]['data']['name'], 'Test ingredient 1') - self.assertEqual(result['suggestions'][1]['data']['image'], None) - self.assertEqual(result['suggestions'][1]['data']['image_thumbnail'], None) - - def test_search_ingredient_anonymous(self): - """ - Test searching for an ingredient by an anonymous user - """ - - self.search_ingredient() - - def test_search_ingredient_logged_in(self): - """ - Test searching for an ingredient by a logged-in user - """ - - self.user_login('test') - self.search_ingredient() - - class IngredientValuesTestCase(WgerTestCase): """ Tests the nutritional value calculator for an ingredient diff --git a/wger/nutrition/tests/test_search_api.py b/wger/nutrition/tests/test_search_api.py index a895d4041..aa1beb5fa 100644 --- a/wger/nutrition/tests/test_search_api.py +++ b/wger/nutrition/tests/test_search_api.py @@ -22,93 +22,77 @@ class SearchIngredientApiTestCase(BaseTestCase, ApiBaseTestCase): - url = '/api/v2/ingredient/search/' - - def setUp(self): - super().setUp() - self.init_media_root() + url = '/api/v2/ingredient/' def test_basic_search_logged_out(self): """ Logged-out users are also allowed to use the search """ - response = self.client.get(self.url + '?term=test') - result1 = response.data['suggestions'][0] + response = self.client.get(self.url + '?name__search=test&language__code=en') + result1 = response.data['results'][0] self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data['suggestions']), 2) - self.assertEqual(result1['value'], 'Ingredient, test, 2, organic, raw') - self.assertEqual(result1['data']['id'], 2) + self.assertEqual(response.data['count'], 2) + self.assertEqual(result1['name'], 'Ingredient, test, 2, organic, raw') + self.assertEqual(result1['id'], 2) def test_basic_search_logged_in(self): """ Logged-in users get the same results """ self.authenticate('test') - response = self.client.get(self.url + '?term=test') - result1 = response.data['suggestions'][0] - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data['suggestions']), 2) - self.assertEqual(result1['value'], 'Ingredient, test, 2, organic, raw') - self.assertEqual(result1['data']['id'], 2) - - def test_search_language_code_en(self): - """ - Explicitly passing the en language code (same as no code) - """ - response = self.client.get(self.url + '?term=test&language=en') - result1 = response.data['suggestions'][0] + response = self.client.get(self.url + '?name__search=test&language__code=en') + result1 = response.data['results'][0] self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data['suggestions']), 2) - self.assertEqual(result1['value'], 'Ingredient, test, 2, organic, raw') - self.assertEqual(result1['data']['id'], 2) + self.assertEqual(response.data['count'], 2) + self.assertEqual(result1['name'], 'Ingredient, test, 2, organic, raw') + self.assertEqual(result1['id'], 2) def test_search_language_code_en_no_results(self): """ The "Testzutat" ingredient should not be found when searching in English """ - response = self.client.get(self.url + '?term=Testzutat&language=en') + response = self.client.get(self.url + '?name__search=Testzutat&language__code=en') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data['suggestions']), 0) + self.assertEqual(response.data['count'], 0) def test_search_language_code_de(self): """ - The "Testübung" exercise should be only found when searching in German + The "Testzutat" ingredient should be only found when searching in German """ - response = self.client.get(self.url + '?term=Testzutat&language=de') - result1 = response.data['suggestions'][0] + response = self.client.get(self.url + '?name__search=Testzutat&language__code=de') + result1 = response.data['results'][0] + self.assertEqual(response.data['count'], 1) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data['suggestions']), 1) - self.assertEqual(result1['value'], 'Testzutat 123') - self.assertEqual(result1['data']['id'], 6) + self.assertEqual(result1['name'], 'Testzutat 123') + self.assertEqual(result1['id'], 6) def test_search_several_language_codes(self): """ Passing different language codes works correctly """ - response = self.client.get(self.url + '?term=guest&language=en,de') + response = self.client.get(self.url + '?name__search=guest&language__code=en,de') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data['suggestions']), 5) + self.assertEqual(response.data['count'], 5) def test_search_unknown_language_codes(self): """ Unknown language codes are ignored """ - response = self.client.get(self.url + '?term=guest&language=en,de,kg') + response = self.client.get(self.url + '?name__search=guest&language__code=en,de,kg') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data['suggestions']), 5) + self.assertEqual(response.data['count'], 5) def test_search_all_languages(self): """ Disable all language filters """ - response = self.client.get(self.url + '?term=guest&language=*') + response = self.client.get(self.url + '?name__search=guest') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data['suggestions']), 7) + self.assertEqual(response.data['count'], 7) diff --git a/wger/urls.py b/wger/urls.py index 9e83e30a9..5872986ea 100644 --- a/wger/urls.py +++ b/wger/urls.py @@ -247,7 +247,6 @@ path('robots.txt', TextTemplateView.as_view(template_name='robots.txt'), name='robots'), # API path('api/v2/exercise/search/', exercises_api_views.search, name='exercise-search'), - path('api/v2/ingredient/search/', nutrition_api_views.search, name='ingredient-search'), path('api/v2/', include(router.urls)), # The api user login path(