Skip to content

Commit 0f78285

Browse files
feat: implement exercise search with alias-aware, language-scoped matching. Wire ExerciseFilterSet into ExerciseInfoViewset and add tests similar to ingredient search while keeping the legacy search endpoint for compatibility
1 parent 8b4b2fa commit 0f78285

File tree

3 files changed

+222
-0
lines changed

3 files changed

+222
-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: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
# Third Party
17+
from rest_framework import status
18+
19+
# wger
20+
from wger.core.tests.api_base_test import ApiBaseTestCase
21+
from wger.core.tests.base_testcase import BaseTestCase
22+
23+
24+
class ExerciseInfoFilterApiTestCase(BaseTestCase, ApiBaseTestCase):
25+
url = '/api/v2/exerciseinfo/'
26+
27+
def setUp(self):
28+
super().setUp()
29+
self.init_media_root()
30+
31+
def _results(self, response):
32+
if isinstance(response.data, dict) and 'results' in response.data:
33+
return response.data['results']
34+
return response.data
35+
36+
def _has_translation_name(self, item, expected_name: str) -> bool:
37+
for t in item.get('translations', []):
38+
if t.get('name') == expected_name:
39+
return True
40+
return False
41+
42+
def test_basic_search_logged_out(self):
43+
"""
44+
Logged-out users can search via name__search and language__code
45+
"""
46+
response = self.client.get(self.url + '?name__search=exercise&language__code=en')
47+
results = self._results(response)
48+
self.assertEqual(response.status_code, status.HTTP_200_OK)
49+
self.assertEqual(len(results), 4)
50+
ids = {item['id'] for item in results}
51+
self.assertIn(1, ids)
52+
item1 = next(item for item in results if item['id'] == 1)
53+
self.assertTrue(self._has_translation_name(item1, 'An exercise'))
54+
55+
def test_basic_search_logged_in(self):
56+
"""
57+
Logged-in users get the same results
58+
"""
59+
self.authenticate('test')
60+
response = self.client.get(self.url + '?name__search=exercise&language__code=en')
61+
results = self._results(response)
62+
63+
self.assertEqual(response.status_code, status.HTTP_200_OK)
64+
self.assertEqual(len(results), 4)
65+
ids = {item['id'] for item in results}
66+
self.assertIn(1, ids)
67+
item1 = next(item for item in results if item['id'] == 1)
68+
self.assertTrue(self._has_translation_name(item1, 'An exercise'))
69+
70+
def test_search_language_code_en_no_results(self):
71+
"""
72+
A DE-only exercise name should not be found when searching in English
73+
"""
74+
response = self.client.get(self.url + '?name__search=Weitere&language__code=en')
75+
results = self._results(response)
76+
77+
self.assertEqual(response.status_code, status.HTTP_200_OK)
78+
self.assertEqual(len(results), 0)
79+
80+
def test_search_language_code_de(self):
81+
"""
82+
A DE-only exercise should be found when searching in German
83+
"""
84+
response = self.client.get(self.url + '?name__search=Weitere&language__code=de')
85+
results = self._results(response)
86+
87+
self.assertEqual(response.status_code, status.HTTP_200_OK)
88+
self.assertEqual(len(results), 1)
89+
self.assertEqual(results[0]['id'], 4)
90+
91+
def test_search_several_language_codes(self):
92+
"""
93+
Passing different language codes works correctly
94+
"""
95+
response = self.client.get(self.url + '?name__search=demo&language__code=en,de')
96+
results = self._results(response)
97+
98+
self.assertEqual(response.status_code, status.HTTP_200_OK)
99+
self.assertEqual(len(results), 4)
100+
101+
def test_search_unknown_language_codes(self):
102+
"""
103+
Unknown language codes are ignored
104+
"""
105+
response = self.client.get(self.url + '?name__search=demo&language__code=en,de,zz')
106+
results = self._results(response)
107+
108+
self.assertEqual(response.status_code, status.HTTP_200_OK)
109+
self.assertEqual(len(results), 4)
110+
111+
def test_search_all_languages(self):
112+
"""
113+
Disable all language filters when language__code is omitted
114+
"""
115+
response = self.client.get(self.url + '?name__search=demo')
116+
results = self._results(response)
117+
118+
self.assertEqual(response.status_code, status.HTTP_200_OK)
119+
self.assertEqual(len(results), 4)
120+
121+
def test_search_matches_alias(self):
122+
"""
123+
Alias terms should also match
124+
"""
125+
response = self.client.get(self.url + '?name__search=different&language__code=en')
126+
results = self._results(response)
127+
128+
self.assertEqual(response.status_code, status.HTTP_200_OK)
129+
self.assertEqual(len(results), 1)

0 commit comments

Comments
 (0)