Skip to content

Commit d5b2801

Browse files
authored
Merge pull request #181 from DakaraProject/feature/search
Expand searching possibilities
2 parents 29f447b + d1e06ee commit d5b2801

22 files changed

Lines changed: 975 additions & 427 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ Any important notes regarding the update.
3737
### Added
3838

3939
- Allow to fetch full lyrics of a song at URL `api/library/songs/lyrics/<id>/`.
40+
- Allow to search playlist entries, player errors, users, and song tags.
41+
- Allow to search songs, playlist entries, player errors and users by ID.
4042

4143
## 1.9.2 - 2025-03-22
4244

dakara_server/dakara_server/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
),
4949
path(
5050
"api/playlist/player/errors/",
51-
playlist_views.PlayerErrorView.as_view(),
51+
playlist_views.PlayerErrorListView.as_view(),
5252
name="playlist-player-errors",
5353
),
5454
path(

dakara_server/internal/query.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from django.db.models import Q
2+
3+
4+
def q(prefix, name, value):
5+
"""Shorthand to make a query with the Q object and a prefix.
6+
7+
Args:
8+
prefix (str or None): If truthy, prepended to `name`.
9+
name (str): Name of the field.
10+
value (str): Value for the field in the query.
11+
12+
Returns:
13+
django.db.models.Q: Query.
14+
"""
15+
if prefix:
16+
return Q(**{prefix + name: value})
17+
18+
return Q(**{name: value})
19+
20+
21+
def gather_query(query_set, query_list):
22+
"""Filter a query set by conjunction of elements of a query list.
23+
24+
Args:
25+
query_set: Initial query set.
26+
query_list (list of django.db.models.Q): List of queries. They will be
27+
chained using the conjunction (`&=`) operator.
28+
29+
Returns:
30+
New firtered query set.
31+
"""
32+
# now, gather the query objects
33+
filter_query = Q()
34+
for item in query_list:
35+
filter_query &= item
36+
37+
# gather the query objects for usual relations
38+
return query_set.filter(filter_query)
39+
40+
41+
def gather_query_remain(query_set, query_list_remain):
42+
"""Filter a query set by disjunction of elements of a query list.
43+
44+
Args:
45+
query_set: Initial query set.
46+
query_list (list of django.db.models.Q): List of queries. They will be
47+
chained using the disjunction (`|=`) operator.
48+
49+
Returns:
50+
New firtered query set.
51+
"""
52+
# now, gather the query objects
53+
filter_query = Q()
54+
for item in query_list_remain:
55+
filter_query |= item
56+
57+
# gather the query objects for usual relations
58+
return query_set.filter(filter_query)
59+
60+
61+
def gather_query_many(query_set, query_list_many):
62+
"""Chain filter a query set by elements of a query list.
63+
64+
Args:
65+
query_set: Initial query set.
66+
query_list (list of django.db.models.Q): List of queries. They will be
67+
chained to the initial query set by the `filter` method.
68+
69+
Returns:
70+
New firtered query set.
71+
"""
72+
query_set_filtered = query_set
73+
74+
# gather the query objects for custom many to many relation
75+
for item in query_list_many:
76+
query_set_filtered = query_set_filtered.filter(item)
77+
78+
return query_set_filtered
Lines changed: 43 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
11
import re
22

3-
from library.models import WorkType
4-
5-
KEYWORDS = ["artist", "work", "title"]
6-
73

84
class QueryLanguageParser:
9-
"""Parser for search query mini language used to search song."""
5+
"""Parser for search query mini language.
106
11-
def __init__(self):
12-
self.keywords_work_type = [
13-
work_type.query_name for work_type in WorkType.objects.all()
14-
]
7+
Args:
8+
keywords (list of str): List of keywords to use
9+
for parsing.
10+
"""
1511

16-
self.keywords = KEYWORDS + self.keywords_work_type
12+
def __init__(self, keywords):
13+
self.keywords = keywords
1714

1815
regex = r"""
1916
\b(?P<keyword>{keywords_regex}) # keyword
@@ -86,33 +83,24 @@ def parse(self, query):
8683
with spaces.
8784
8885
Returns:
89-
dict: Query terms arranged among the following keys:
90-
`artist`:
91-
`contains`: List of list of artists names to match
92-
partially.
93-
`exact`: List of list of artists names to match exactly.
94-
`work`:
95-
`contains`: List of works names to match partially.
96-
`exact`: List of works names to match exactly.
97-
`title:
98-
`contains`: Titles to match partially
99-
`exact`: Titles to match exactly.
86+
dict: Query terms parsed according to the
87+
provided keywords. Each item is a dict
88+
containing two lists:
89+
`contains`: List of partial matches.
90+
`exact`: List of exact matches.
91+
In addition, two extra items are present in
92+
the dict:
10093
`tag`: List of tags to match in uppercase.
101-
`work_type`: Dict with queryname as key and a dict as value
102-
with the keys `contains` and `exact`.
103-
10494
`remaining`: Unparsed text.
10595
"""
10696
# create results structure
107-
# work_type will be filled only if necessary
108-
result = {
109-
"artist": {"contains": [], "exact": []},
110-
"work": {"contains": [], "exact": []},
111-
"title": {"contains": [], "exact": []},
112-
"work_type": {},
113-
"remaining": [],
114-
"tag": [],
115-
}
97+
result = {kw: {"contains": [], "exact": []} for kw in self.keywords}
98+
result.update(
99+
{
100+
"remaining": [],
101+
"tag": [],
102+
}
103+
)
116104

117105
for match in self.language_matcher.finditer(query):
118106
group_index = match.groupdict()
@@ -126,21 +114,11 @@ def parse(self, query):
126114
.strip()
127115
)
128116

129-
if target in self.keywords_work_type:
130-
# create worktype if not exists
131-
if target not in result["work_type"]:
132-
result["work_type"][target] = {"contains": [], "exact": []}
133-
134-
result_target = result["work_type"][target]
135-
136-
else:
137-
result_target = result[target]
138-
139117
if value_contains and not value_exact:
140-
result_target["contains"].append(value_contains)
118+
result[target]["contains"].append(value_contains)
141119

142120
elif value_exact and not value_contains:
143-
result_target["exact"].append(value_exact)
121+
result[target]["exact"].append(value_exact)
144122

145123
else:
146124
raise ValueError("Inconsistency")
@@ -158,3 +136,23 @@ def parse(self, query):
158136
result["tag"].append(item_clean.upper())
159137

160138
return result
139+
140+
141+
def regroup(res, key, keys):
142+
"""Regroup non empty keys in a specific key.
143+
144+
Args:
145+
res (dict): Dictionary where to regroup keys.
146+
key (str): Key where to regroup `keys`.
147+
keys (list of str): Keys to regroup in `key`.
148+
Any key with no items in `exact` and
149+
`contains` will just be removed.
150+
"""
151+
res_copy = res.copy()
152+
res_copy[key] = {}
153+
for k in keys:
154+
val = res_copy.pop(k)
155+
if len(val["exact"]) or len(val["contains"]):
156+
res_copy[key][k] = val
157+
158+
return res_copy

dakara_server/internal/tests/base_test.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.contrib.auth import get_user_model
22
from django.utils import timezone
3+
from rest_framework import status
34
from rest_framework.authtoken.models import Token
45
from rest_framework.test import APITestCase
56

@@ -66,6 +67,32 @@ def create_user(
6667
user.save()
6768
return user
6869

70+
def check_query(self, query, expected, remaining=None):
71+
"""Method to check a query.
72+
73+
Returned entries should be the same as `expected`, in the same order.
74+
75+
Args:
76+
query (str): Terms of the query, to be parsed.
77+
expected_items (list): List of expected objects, that must have an
78+
`id`.
79+
80+
Returns:
81+
Response of the client, for furthur analysis.
82+
"""
83+
response = self.client.get(self.url, {"query": query})
84+
self.assertEqual(response.status_code, status.HTTP_200_OK)
85+
self.assertEqual(response.data["count"], len(expected))
86+
results = response.data["results"]
87+
self.assertEqual(len(results), len(expected))
88+
for item, expected_item in zip(results, expected, strict=False):
89+
self.assertEqual(item["id"], expected_item.id)
90+
91+
if remaining is not None:
92+
self.assertEqual(response.data["query"]["remaining"], remaining)
93+
94+
return response
95+
6996

7097
class BaseAPITestCase(APITestCase, BaseProvider):
7198
"""Base test class for Unittest."""

0 commit comments

Comments
 (0)