Skip to content

Commit 262345f

Browse files
authored
Merge pull request #2057 from ChristianFeliks/issue-2052-exercise-search
Refactor exercise search to unified endpoint (issue #2052)
2 parents 2010fcb + a2cb8c5 commit 262345f

File tree

3 files changed

+249
-0
lines changed

3 files changed

+249
-0
lines changed

wger/exercises/api/filtersets.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# This file is part of wger Workout Manager.
2+
#
3+
# wger Workout Manager is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU Affero General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# wger Workout Manager is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU Affero General Public License
14+
# along with Workout Manager. If not, see <http://www.gnu.org/licenses/>.
15+
16+
17+
# Django
18+
from django.contrib.postgres.search import TrigramSimilarity
19+
from django.db.models import (
20+
Exists,
21+
OuterRef,
22+
Q,
23+
)
24+
25+
# Third Party
26+
from django_filters import rest_framework as filters
27+
28+
# wger
29+
from wger.exercises.models import (
30+
Exercise,
31+
Translation,
32+
)
33+
from wger.utils.db import is_postgres_db
34+
from wger.utils.language import load_language
35+
36+
37+
class ExerciseFilterSet(filters.FilterSet):
38+
"""
39+
Filters for the regular exercises endpoints to support fulltext name search
40+
and language filtering, similar to IngredientFilterSet.
41+
"""
42+
43+
name__search = filters.CharFilter(method='search_name_fulltext')
44+
language__code = filters.CharFilter(method='search_languagecode')
45+
46+
def search_name_fulltext(self, queryset, name, value):
47+
if not value:
48+
return queryset
49+
50+
languages_param = self.data.get('language__code')
51+
languages = None
52+
if languages_param:
53+
languages = [load_language(code) for code in set(languages_param.split(','))]
54+
55+
if is_postgres_db():
56+
translation_subquery = Translation.objects.filter(exercise=OuterRef('pk'))
57+
if languages:
58+
translation_subquery = translation_subquery.filter(language__in=languages)
59+
translation_subquery = translation_subquery.annotate(
60+
similarity=TrigramSimilarity('name', value)
61+
).filter(Q(similarity__gt=0.15) | Q(alias__alias__icontains=value))
62+
63+
qs = queryset.filter(Exists(translation_subquery))
64+
else:
65+
translation_subquery = Translation.objects.filter(exercise=OuterRef('pk')).filter(
66+
Q(name__icontains=value) | Q(alias__alias__icontains=value)
67+
)
68+
if languages:
69+
translation_subquery = translation_subquery.filter(language__in=languages)
70+
qs = queryset.filter(Exists(translation_subquery))
71+
72+
return qs.distinct()
73+
74+
def search_languagecode(self, queryset, name, value):
75+
if not value:
76+
return queryset
77+
languages = [load_language(code) for code in set(value.split(','))]
78+
if not languages:
79+
return queryset
80+
return queryset.filter(translations__language__in=languages).distinct()
81+
82+
class Meta:
83+
model = Exercise
84+
fields = {
85+
'id': ['exact', 'in'],
86+
'uuid': ['exact'],
87+
'category': ['exact', 'in'],
88+
'muscles': ['exact', 'in'],
89+
'muscles_secondary': ['exact', 'in'],
90+
'equipment': ['exact', 'in'],
91+
}

wger/exercises/api/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from rest_framework.viewsets import ModelViewSet
5353

5454
# wger
55+
from wger.exercises.api.filtersets import ExerciseFilterSet
5556
from wger.exercises.api.permissions import CanContributeExercises
5657
from wger.exercises.api.serializers import (
5758
DeletionLogSerializer,
@@ -330,6 +331,7 @@ class ExerciseInfoViewset(viewsets.ReadOnlyModelViewSet):
330331
queryset = Exercise.objects.all()
331332
serializer_class = ExerciseInfoSerializer
332333
ordering_fields = '__all__'
334+
filterset_class = ExerciseFilterSet
333335
filterset_fields = (
334336
'uuid',
335337
'category',
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# This file is part of wger Workout Manager.
2+
#
3+
# wger Workout Manager is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU Affero General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# wger Workout Manager is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU Affero General Public License
14+
# along with Workout Manager. If not, see <http://www.gnu.org/licenses/>.
15+
16+
# Django
17+
from django.urls import reverse
18+
19+
# Third Party
20+
from rest_framework import status
21+
22+
# wger
23+
from wger.core.tests.api_base_test import ApiBaseTestCase
24+
from wger.core.tests.base_testcase import BaseTestCase
25+
26+
27+
class ExerciseInfoFilterApiTestCase(BaseTestCase, ApiBaseTestCase):
28+
url = '/api/v2/exerciseinfo/'
29+
30+
def setUp(self):
31+
super().setUp()
32+
self.init_media_root()
33+
34+
def _results(self, response):
35+
if isinstance(response.data, dict) and 'results' in response.data:
36+
return response.data['results']
37+
return response.data
38+
39+
def _has_translation_name(self, item, expected_name: str) -> bool:
40+
for t in item.get('translations', []):
41+
if t.get('name') == expected_name:
42+
return True
43+
return False
44+
45+
def test_basic_search_logged_out(self):
46+
"""
47+
Logged-out users can search via name__search and language__code
48+
"""
49+
response = self.client.get(
50+
reverse('exerciseinfo-list'),
51+
{'name__search': 'exercise', 'language__code': 'en'},
52+
)
53+
results = self._results(response)
54+
self.assertEqual(response.status_code, status.HTTP_200_OK)
55+
self.assertEqual(len(results), 4)
56+
ids = {item['id'] for item in results}
57+
self.assertIn(1, ids)
58+
item1 = next(item for item in results if item['id'] == 1)
59+
self.assertTrue(self._has_translation_name(item1, 'An exercise'))
60+
61+
def test_basic_search_logged_in(self):
62+
"""
63+
Logged-in users get the same results
64+
"""
65+
self.authenticate('test')
66+
response = self.client.get(
67+
reverse('exerciseinfo-list'),
68+
{'name__search': 'exercise', 'language__code': 'en'},
69+
)
70+
results = self._results(response)
71+
72+
self.assertEqual(response.status_code, status.HTTP_200_OK)
73+
self.assertEqual(len(results), 4)
74+
ids = {item['id'] for item in results}
75+
self.assertIn(1, ids)
76+
item1 = next(item for item in results if item['id'] == 1)
77+
self.assertTrue(self._has_translation_name(item1, 'An exercise'))
78+
79+
def test_search_language_code_en_no_results(self):
80+
"""
81+
A DE-only exercise name should not be found when searching in English
82+
"""
83+
response = self.client.get(
84+
reverse('exerciseinfo-list'),
85+
{'name__search': 'Weitere', 'language__code': 'en'},
86+
)
87+
results = self._results(response)
88+
89+
self.assertEqual(response.status_code, status.HTTP_200_OK)
90+
self.assertEqual(len(results), 0)
91+
92+
def test_search_language_code_de(self):
93+
"""
94+
A DE-only exercise should be found when searching in German
95+
"""
96+
response = self.client.get(
97+
reverse('exerciseinfo-list'),
98+
{'name__search': 'Weitere', 'language__code': 'de'},
99+
)
100+
results = self._results(response)
101+
102+
self.assertEqual(response.status_code, status.HTTP_200_OK)
103+
self.assertEqual(len(results), 1)
104+
self.assertEqual(results[0]['id'], 4)
105+
106+
def test_search_several_language_codes(self):
107+
"""
108+
Passing different language codes works correctly
109+
"""
110+
response = self.client.get(
111+
reverse('exerciseinfo-list'),
112+
{'name__search': 'demo', 'language__code': 'en,de'},
113+
)
114+
results = self._results(response)
115+
116+
self.assertEqual(response.status_code, status.HTTP_200_OK)
117+
self.assertEqual(len(results), 4)
118+
119+
def test_search_unknown_language_codes(self):
120+
"""
121+
Unknown language codes are ignored
122+
"""
123+
response = self.client.get(
124+
reverse('exerciseinfo-list'),
125+
{'name__search': 'demo', 'language__code': 'en,de,zz'},
126+
)
127+
results = self._results(response)
128+
129+
self.assertEqual(response.status_code, status.HTTP_200_OK)
130+
self.assertEqual(len(results), 4)
131+
132+
def test_search_all_languages(self):
133+
"""
134+
Disable all language filters when language__code is omitted
135+
"""
136+
response = self.client.get(
137+
reverse('exerciseinfo-list'),
138+
{'name__search': 'demo'},
139+
)
140+
results = self._results(response)
141+
142+
self.assertEqual(response.status_code, status.HTTP_200_OK)
143+
self.assertEqual(len(results), 4)
144+
145+
def test_search_matches_alias(self):
146+
"""
147+
Alias terms should also match
148+
"""
149+
response = self.client.get(
150+
reverse('exerciseinfo-list'),
151+
{'name__search': 'different', 'language__code': 'en'},
152+
)
153+
results = self._results(response)
154+
155+
self.assertEqual(response.status_code, status.HTTP_200_OK)
156+
self.assertEqual(len(results), 1)

0 commit comments

Comments
 (0)