From 90698b73aa1b072e6fca90b09d0b96ecfe95edfc Mon Sep 17 00:00:00 2001 From: Neraste Date: Tue, 10 Jun 2025 19:23:36 +0200 Subject: [PATCH 01/26] Move songs query to dedicated file --- dakara_server/library/query.py | 102 +++++++++++++++++++++++++++++++++ dakara_server/library/views.py | 93 ++---------------------------- 2 files changed, 106 insertions(+), 89 deletions(-) create mode 100644 dakara_server/library/query.py diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py new file mode 100644 index 0000000..55306ca --- /dev/null +++ b/dakara_server/library/query.py @@ -0,0 +1,102 @@ +from django.db.models import Q + +from library.query_language import QueryLanguageParser + + +def query_songs(query_set, query): + """Create a queryset that filters songs according to query. + + Args: + query_set (): Initial query set (containing all songs). + query (str): Query string. It can follow the + the query language, to specify which term + to search and where, or be a simple + pattern. + + Returns: + tuple: Tuple of the filtered query set, and the + parsed query. + """ + language_parser = QueryLanguageParser() + res = language_parser.parse(query) + query_list = [] + query_list_many = [] + # specific terms of the research, i.e. artists, works and titles + for artist in res["artist"]["contains"]: + query_list_many.append(Q(artists__name__icontains=artist)) + + for artist in res["artist"]["exact"]: + query_list_many.append(Q(artists__name__iexact=artist)) + + for title in res["title"]["contains"]: + query_list.append(Q(title__icontains=title)) + + for title in res["title"]["exact"]: + query_list.append(Q(title__iexact=title)) + + for work in res["work"]["contains"]: + query_list.append( + Q(works__title__icontains=work) + | Q(works__alternative_title__title__icontains=work) + ) + + for work in res["work"]["exact"]: + query_list.append( + Q(works__title__iexact=work) + | Q(works__alternative_title__title__iexact=work) + ) + + # specific terms of the research derivating from work + for query_name, search_keywords in res["work_type"].items(): + for keyword in search_keywords["contains"]: + query_list.append( + ( + Q(works__title__icontains=keyword) + | Q(works__alternative_title__title__icontains=keyword) # noqa E501 + ) + & Q(works__work_type__query_name=query_name) + ) + + for keyword in search_keywords["exact"]: + query_list.append( + ( + Q(works__title__iexact=keyword) + | Q(works__alternative_title__title__iexact=keyword) + ) + & Q(works__work_type__query_name=query_name) + ) + + # one may want to factor the duplicated query on the work type + # but it is very unlikely someone will define severals animes + # (by instance) for a song at the same time + # IMHO a factorization will make the code less clear and just + # heavier, for no practical reason + + # unspecific terms of the research + for remain in res["remaining"]: + query_list.append( + Q(title__icontains=remain) + | Q(artists__name__icontains=remain) + | Q(works__title__icontains=remain) + | Q(works__alternative_title__title__icontains=remain) + | Q(version__icontains=remain) + | Q(detail__icontains=remain) + | Q(detail_video__icontains=remain) + ) + + # tags + for tag in res["tag"]: + query_list_many.append(Q(tags__name=tag)) + + # now, gather the query objects + filter_query = Q() + for item in query_list: + filter_query &= item + + # gather the query objects for usual relations + query_set_filtered = query_set.filter(filter_query) + # gather the query objects for custom many to many relation + for item in query_list_many: + query_set_filtered = query_set_filtered.filter(item) + + return query_set_filtered, res diff --git a/dakara_server/library/views.py b/dakara_server/library/views.py index 708965c..f7d9a32 100644 --- a/dakara_server/library/views.py +++ b/dakara_server/library/views.py @@ -16,6 +16,7 @@ from internal import permissions as internal_permissions from library import models, permissions, serializers from library.query_language import QueryLanguageParser +from library.query import query_songs logger = logging.getLogger(__name__) @@ -90,95 +91,9 @@ def get_queryset(self): query = self.request.query_params.get("query", None) if query: - # the query can use a syntax, the query language, to specify which - # term to search and where - # the language manages the simple search as well the parser is - # provided from query_language.py - language_parser = QueryLanguageParser() - res = language_parser.parse(query) - query_list = [] - query_list_many = [] - # specific terms of the research, i.e. artists, works and titles - for artist in res["artist"]["contains"]: - query_list_many.append(Q(artists__name__icontains=artist)) - - for artist in res["artist"]["exact"]: - query_list_many.append(Q(artists__name__iexact=artist)) - - for title in res["title"]["contains"]: - query_list.append(Q(title__icontains=title)) - - for title in res["title"]["exact"]: - query_list.append(Q(title__iexact=title)) - - for work in res["work"]["contains"]: - query_list.append( - Q(works__title__icontains=work) - | Q(works__alternative_title__title__icontains=work) - ) - - for work in res["work"]["exact"]: - query_list.append( - Q(works__title__iexact=work) - | Q(works__alternative_title__title__iexact=work) - ) - - # specific terms of the research derivating from work - for query_name, search_keywords in res["work_type"].items(): - for keyword in search_keywords["contains"]: - query_list.append( - ( - Q(works__title__icontains=keyword) - | Q( - works__alternative_title__title__icontains=keyword - ) # noqa E501 - ) - & Q(works__work_type__query_name=query_name) - ) - - for keyword in search_keywords["exact"]: - query_list.append( - ( - Q(works__title__iexact=keyword) - | Q(works__alternative_title__title__iexact=keyword) - ) - & Q(works__work_type__query_name=query_name) - ) - - # one may want to factor the duplicated query on the work type - # but it is very unlikely someone will define severals animes - # (by instance) for a song at the same time - # IMHO a factorization will make the code less clear and just - # heavier, for no practical reason - - # unspecific terms of the research - for remain in res["remaining"]: - query_list.append( - Q(title__icontains=remain) - | Q(artists__name__icontains=remain) - | Q(works__title__icontains=remain) - | Q(works__alternative_title__title__icontains=remain) - | Q(version__icontains=remain) - | Q(detail__icontains=remain) - | Q(detail_video__icontains=remain) - ) - - # tags - for tag in res["tag"]: - query_list_many.append(Q(tags__name=tag)) - - # now, gather the query objects - filter_query = Q() - for item in query_list: - filter_query &= item - - query_set = query_set.filter(filter_query) - # gather the query objects involving custom many to many relation - for item in query_list_many: - query_set = query_set.filter(item) - - # saving the parsed query to give it back to the client - self.query_parsed = res + # query the song and save the parsed query + # to give it back to the client + query_set, self.query_parsed = query_songs(query_set, query) return query_set.distinct().order_by(Lower("title")) From 4a6ff4cdac22bff2652982e938aba5ee1b5f705f Mon Sep 17 00:00:00 2001 From: Neraste Date: Tue, 10 Jun 2025 19:38:37 +0200 Subject: [PATCH 02/26] Move artists query to dedicated file --- dakara_server/library/query.py | 28 ++++++++++++++++++++++++++++ dakara_server/library/views.py | 17 ++--------------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index 55306ca..7003fda 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -100,3 +100,31 @@ def query_songs(query_set, query): query_set_filtered = query_set_filtered.filter(item) return query_set_filtered, res + + +def query_artists(query_set, query): + """Create a queryset that filters artists according to query. + + Args: + query_set (): Initial query set (containing all songs). + query (str): Query string. It can only be a simple pattern (no query language). + + Returns: + tuple: Tuple of the filtered query set, and the + parsed query. + """ + # using query language parser to split terms and for uniformity + res = QueryLanguageParser.split_remaining(query) + query_list = [] + # only unspecific terms are used + for remain in res: + query_list.append(Q(name__icontains=remain)) + + # gather the query objects + filter_query = Q() + for item in query_list: + filter_query &= item + + query_set_filtered = query_set.filter(filter_query) + + return query_set_filtered, res diff --git a/dakara_server/library/views.py b/dakara_server/library/views.py index f7d9a32..26d3e5a 100644 --- a/dakara_server/library/views.py +++ b/dakara_server/library/views.py @@ -16,7 +16,7 @@ from internal import permissions as internal_permissions from library import models, permissions, serializers from library.query_language import QueryLanguageParser -from library.query import query_songs +from library.query import query_songs, query_artists logger = logging.getLogger(__name__) @@ -140,20 +140,7 @@ def get_queryset(self): query = self.request.query_params.get("query", None) if query: - # there is no need for query language for artists - # it is used to split terms and for uniformity - res = QueryLanguageParser.split_remaining(query) - query_list = [] - # only unspecific terms are used - for remain in res: - query_list.append(Q(name__icontains=remain)) - - # gather the query objects - filter_query = Q() - for item in query_list: - filter_query &= item - - query_set = query_set.filter(filter_query) + query_set, res = query_artists(query_set, query) # saving the parsed query to give it back to the client self.query_parsed = {"remaining": res} From 146bc42380ece5ab2c6bb17022a2ac9d61a272f7 Mon Sep 17 00:00:00 2001 From: Neraste Date: Tue, 10 Jun 2025 19:56:17 +0200 Subject: [PATCH 03/26] Move works query to dedicated file --- dakara_server/library/query.py | 34 +++++++++++++++++++++++++++++++++- dakara_server/library/views.py | 23 ++--------------------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index 7003fda..25eec16 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -106,7 +106,7 @@ def query_artists(query_set, query): """Create a queryset that filters artists according to query. Args: - query_set (): Initial query set (containing all songs). + query_set (): Initial query set (containing all artists). query (str): Query string. It can only be a simple pattern (no query language). Returns: @@ -128,3 +128,35 @@ def query_artists(query_set, query): query_set_filtered = query_set.filter(filter_query) return query_set_filtered, res + + +def query_works(query_set, query): + """Create a queryset that filters works according to query. + + Args: + query_set (): Initial query set (containing all works). + query (str): Query string. It can only be a simple pattern (no query language). + + Returns: + tuple: Tuple of the filtered query set, and the + parsed query. + """ + # using query language parser to split terms and for uniformity + res = QueryLanguageParser.split_remaining(query) + query_list = [] + # only unspecific terms are used + for remain in res: + query_list.append( + Q(title__icontains=remain) + | Q(subtitle__icontains=remain) + | Q(alternative_title__title__icontains=remain) + ) + + # gather the query objects + filter_query = Q() + for item in query_list: + filter_query &= item + + query_set_filtered = query_set.filter(filter_query) + + return query_set_filtered, res diff --git a/dakara_server/library/views.py b/dakara_server/library/views.py index 26d3e5a..fc8872d 100644 --- a/dakara_server/library/views.py +++ b/dakara_server/library/views.py @@ -1,7 +1,6 @@ import logging from django.contrib.auth import get_user_model -from django.db.models import Q from django.db.models.functions import Lower from rest_framework import status from rest_framework.generics import ( @@ -15,8 +14,7 @@ from internal import permissions as internal_permissions from library import models, permissions, serializers -from library.query_language import QueryLanguageParser -from library.query import query_songs, query_artists +from library.query import query_artists, query_songs, query_works logger = logging.getLogger(__name__) @@ -192,24 +190,7 @@ def get_queryset(self): query = self.request.query_params.get("query", None) if query: - # there is no need for query language for works it is used to split - # terms and for uniformity - res = QueryLanguageParser.split_remaining(query) - query_list = [] - # only unspecific terms are used - for remain in res: - query_list.append( - Q(title__icontains=remain) - | Q(subtitle__icontains=remain) - | Q(alternative_title__title__icontains=remain) - ) - - # gather the query objects - filter_query = Q() - for item in query_list: - filter_query &= item - - query_set = query_set.filter(filter_query) + query_set, res = query_works(query_set, query) # saving the parsed query to give it back to the client self.query_parsed = {"remaining": res} From 6d748a7fe06db42662dda331b624105a0a7436c8 Mon Sep 17 00:00:00 2001 From: Neraste Date: Tue, 10 Jun 2025 20:44:26 +0200 Subject: [PATCH 04/26] Make query language use agnostic --- dakara_server/library/query.py | 8 +- dakara_server/library/query_language.py | 87 +++++++++---------- .../library/tests/test_query_language.py | 37 +++----- 3 files changed, 58 insertions(+), 74 deletions(-) diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index 25eec16..bfc0d23 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -1,6 +1,7 @@ from django.db.models import Q -from library.query_language import QueryLanguageParser +from library.models import WorkType +from library.query_language import QueryLanguageParser, regroup def query_songs(query_set, query): @@ -17,8 +18,9 @@ def query_songs(query_set, query): tuple: Tuple of the filtered query set, and the parsed query. """ - language_parser = QueryLanguageParser() - res = language_parser.parse(query) + work_types = [wt.query_name for wt in WorkType.objects.all()] + language_parser = QueryLanguageParser(["artist", "work", "title"] + work_types) + res = regroup(language_parser.parse(query), "work_type", work_types) query_list = [] query_list_many = [] # specific terms of the research, i.e. artists, works and titles diff --git a/dakara_server/library/query_language.py b/dakara_server/library/query_language.py index 0fbb229..a33d188 100644 --- a/dakara_server/library/query_language.py +++ b/dakara_server/library/query_language.py @@ -1,19 +1,16 @@ import re -from library.models import WorkType - -KEYWORDS = ["artist", "work", "title"] - class QueryLanguageParser: - """Parser for search query mini language used to search song.""" + """Parser for search query mini language. - def __init__(self): - self.keywords_work_type = [ - work_type.query_name for work_type in WorkType.objects.all() - ] + Args: + keywords (list of str): List of keywords to use + for parsing. + """ - self.keywords = KEYWORDS + self.keywords_work_type + def __init__(self, keywords): + self.keywords = keywords regex = r""" \b(?P{keywords_regex}) # keyword @@ -88,33 +85,25 @@ def parse(self, query): with spaces. Returns: - dict: Query terms arranged among the following keys: - `artist`: - `contains`: List of list of artists names to match - partially. - `exact`: List of list of artists names to match exactly. - `work`: - `contains`: List of works names to match partially. - `exact`: List of works names to match exactly. - `title: - `contains`: Titles to match partially - `exact`: Titles to match exactly. + dict: Query terms parsed according to the + provided keywords. Each item is a dict + containing two lists: + `contains`: List of partial matches. + `exact`: List of exact matches. + In addition, two extra items are present in + the dict: `tag`: List of tags to match in uppercase. - `work_type`: Dict with queryname as key and a dict as value - with the keys `contains` and `exact`. - `remaining`: Unparsed text. """ # create results structure # work_type will be filled only if necessary - result = { - "artist": {"contains": [], "exact": []}, - "work": {"contains": [], "exact": []}, - "title": {"contains": [], "exact": []}, - "work_type": {}, - "remaining": [], - "tag": [], - } + result = {kw: {"contains": [], "exact": []} for kw in self.keywords} + result.update( + { + "remaining": [], + "tag": [], + } + ) for match in self.language_matcher.finditer(query): group_index = match.groupdict() @@ -128,21 +117,11 @@ def parse(self, query): .strip() ) - if target in self.keywords_work_type: - # create worktype if not exists - if target not in result["work_type"]: - result["work_type"][target] = {"contains": [], "exact": []} - - result_target = result["work_type"][target] - - else: - result_target = result[target] - if value_contains and not value_exact: - result_target["contains"].append(value_contains) + result[target]["contains"].append(value_contains) elif value_exact and not value_contains: - result_target["exact"].append(value_exact) + result[target]["exact"].append(value_exact) else: raise ValueError("Inconsistency") @@ -160,3 +139,23 @@ def parse(self, query): result["tag"].append(item_clean.upper()) return result + + +def regroup(res, key, keys): + """Regroup non empty keys in a specific key. + + Args: + res (dict): Dictionary where to regroup keys. + key (str): Key where to regroup `keys`. + keys (list of str): Keys to regroup in `key`. + Any key with no items in `exact` and + `contains` will just be removed. + """ + res_copy = res.copy() + res_copy[key] = {} + for k in keys: + val = res_copy.pop(k) + if len(val["exact"]) or len(val["contains"]): + res_copy[key][k] = val + + return res_copy diff --git a/dakara_server/library/tests/test_query_language.py b/dakara_server/library/tests/test_query_language.py index e351164..73f7ab1 100644 --- a/dakara_server/library/tests/test_query_language.py +++ b/dakara_server/library/tests/test_query_language.py @@ -13,7 +13,7 @@ def setUp(self): self.wt2.save() # Create parser instance - self.parser = QueryLanguageParser() + self.parser = QueryLanguageParser(("artist", "work", "title", "wt1", "wt2")) def test_parse_multiple(self): """Test complex query parse.""" @@ -32,9 +32,8 @@ def test_parse_multiple(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], ["you"]) self.assertCountEqual(res["work"]["exact"], ["exact Work"]) - self.assertCountEqual(res["work_type"].keys(), ["wt1"]) - self.assertCountEqual(res["work_type"]["wt1"]["contains"], ["workName"]) - self.assertCountEqual(res["work_type"]["wt1"]["exact"], []) + self.assertCountEqual(res["wt1"]["contains"], ["workName"]) + self.assertCountEqual(res["wt1"]["exact"], []) def test_parse_only_remaining(self): """Test simple query parse.""" @@ -50,7 +49,6 @@ def test_parse_only_remaining(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_tag(self): """Test tag query parse.""" @@ -63,7 +61,6 @@ def test_parse_tag(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_title(self): """Test title query parse.""" @@ -76,7 +73,6 @@ def test_parse_title(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_title_exact(self): """Test title exact query parse.""" @@ -89,7 +85,6 @@ def test_parse_title_exact(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_artist(self): """Test artist query parse.""" @@ -102,7 +97,6 @@ def test_parse_artist(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_artist_exact(self): """Test artist exact query parse.""" @@ -115,7 +109,6 @@ def test_parse_artist_exact(self): self.assertCountEqual(res["artist"]["exact"], ["myartist"]) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_work(self): """Test work query parse.""" @@ -128,7 +121,6 @@ def test_parse_work(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], ["mywork"]) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_work_exact(self): """Test work exact query parse.""" @@ -141,7 +133,6 @@ def test_parse_work_exact(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], ["mywork"]) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_work_type(self): """Test work type query parse.""" @@ -154,9 +145,8 @@ def test_parse_work_type(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), ["wt2"]) - self.assertCountEqual(res["work_type"]["wt2"]["contains"], ["mywork"]) - self.assertCountEqual(res["work_type"]["wt2"]["exact"], []) + self.assertCountEqual(res["wt2"]["contains"], ["mywork"]) + self.assertCountEqual(res["wt2"]["exact"], []) def test_parse_work_type_exact(self): """Test work type exact query parse.""" @@ -169,9 +159,8 @@ def test_parse_work_type_exact(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), ["wt2"]) - self.assertCountEqual(res["work_type"]["wt2"]["contains"], []) - self.assertCountEqual(res["work_type"]["wt2"]["exact"], ["mywork"]) + self.assertCountEqual(res["wt2"]["contains"], []) + self.assertCountEqual(res["wt2"]["exact"], ["mywork"]) def test_parse_contains_multi_words(self): """Test query parse with multi words criteria.""" @@ -184,7 +173,6 @@ def test_parse_contains_multi_words(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) res = self.parser.parse("""title:"words words words" remain""") self.assertCountEqual(res["remaining"], ["remain"]) @@ -195,7 +183,6 @@ def test_parse_contains_multi_words(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_remaining_multi_words(self): """Test query parse with multi words remaining.""" @@ -208,7 +195,6 @@ def test_parse_remaining_multi_words(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) res = self.parser.parse(""" word"words words words" remain""") self.assertCountEqual(res["remaining"], ["word", "words words words", "remain"]) @@ -219,7 +205,6 @@ def test_parse_remaining_multi_words(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) def test_parse_old_worktype(self): """This test attempts to reproduce a bug where old work types were kept in @@ -240,16 +225,15 @@ def test_parse_old_worktype(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), ["wt2"]) - self.assertCountEqual(res["work_type"]["wt2"]["contains"], ["mywork"]) - self.assertCountEqual(res["work_type"]["wt2"]["exact"], []) + self.assertCountEqual(res["wt2"]["contains"], ["mywork"]) + self.assertCountEqual(res["wt2"]["exact"], []) # Now remove work type 2 self.wt2.delete() # Create a new parser so that keywords are re-initialized with current # workTypes - self.parser = QueryLanguageParser() + self.parser = QueryLanguageParser(("artist", "work", "title", "wt1")) # Check parser keywords, should not include wt2 anymore self.assertCountEqual(self.parser.keywords, ["artist", "work", "title", "wt1"]) @@ -265,4 +249,3 @@ def test_parse_old_worktype(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["work_type"].keys(), []) From 1a21528c0eb0960ce868f147938ddb670c595db0 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sat, 2 Aug 2025 21:44:43 +0200 Subject: [PATCH 05/26] Add query for played playlist entries --- dakara_server/library/query.py | 169 ++++++++++++------ dakara_server/playlist/query.py | 43 +++++ dakara_server/playlist/tests/base_test.py | 4 + .../playlist/tests/test_playlist_played.py | 33 ++++ dakara_server/playlist/views.py | 22 ++- 5 files changed, 216 insertions(+), 55 deletions(-) create mode 100644 dakara_server/playlist/query.py diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index bfc0d23..ce5a24d 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -4,48 +4,99 @@ from library.query_language import QueryLanguageParser, regroup -def query_songs(query_set, query): - """Create a queryset that filters songs according to query. +def query(prefix, name, value): + """Shorthand to make a query with the Q object and a prefix. Args: - query_set (): Initial query set (containing all songs). - query (str): Query string. It can follow the - the query language, to specify which term - to search and where, or be a simple - pattern. + prefix (str or None): If truthy, prepended to `name`. + name (str): Name of the field. + value (str): Value for the field in the query. Returns: - tuple: Tuple of the filtered query set, and the - parsed query. + django.db.models.Q: Query. + """ + if prefix: + return Q(**{prefix + name: value}) + + return Q(**{name: value}) + + +def gather_query(query_set, query_list): + """Filter a query set by elements of a query list. + + Args: + query_set: Initial query set. + query_list (list of django.db.models.Q): List of queries. + + Returns: + New firtered query set. + """ + # now, gather the query objects + filter_query = Q() + for item in query_list: + filter_query &= item + + # gather the query objects for usual relations + return query_set.filter(filter_query) + + +def gather_query_many(query_set, query_list_many): + """Filter a query set by elements of a query list of many to many fields. + + Args: + query_set: Initial query set. + query_list (list of django.db.models.Q): List of queries for many to + many fields. + + Returns: + New firtered query set. + """ + query_set_filtered = query_set + + # gather the query objects for custom many to many relation + for item in query_list_many: + query_set_filtered = query_set_filtered.filter(item) + + return query_set_filtered + + +def make_songs_query_from_res(res, prefix=None): + """Make a query for songs. + + Args: + res (dict): Dictionary on research terms, parsed. + prefix (str or None): Optional prefix to add when creating the query. + + Returns: + tuple of list: List of queries, and list of queries targeting many to + many relations. """ - work_types = [wt.query_name for wt in WorkType.objects.all()] - language_parser = QueryLanguageParser(["artist", "work", "title"] + work_types) - res = regroup(language_parser.parse(query), "work_type", work_types) query_list = [] query_list_many = [] + # specific terms of the research, i.e. artists, works and titles for artist in res["artist"]["contains"]: - query_list_many.append(Q(artists__name__icontains=artist)) + query_list_many.append(query(prefix, "artists__name__icontains", artist)) for artist in res["artist"]["exact"]: - query_list_many.append(Q(artists__name__iexact=artist)) + query_list_many.append(query(prefix, "artists__name__iexact", artist)) for title in res["title"]["contains"]: - query_list.append(Q(title__icontains=title)) + query_list.append(query(prefix, "title__icontains", title)) for title in res["title"]["exact"]: - query_list.append(Q(title__iexact=title)) + query_list.append(query(prefix, "title__iexact", title)) for work in res["work"]["contains"]: query_list.append( - Q(works__title__icontains=work) - | Q(works__alternative_title__title__icontains=work) + query(prefix, "works__title__icontains", work) + | query(prefix, "works__alternative_title__title__icontains", work) ) for work in res["work"]["exact"]: query_list.append( - Q(works__title__iexact=work) - | Q(works__alternative_title__title__iexact=work) + query(prefix, "works__title__iexact", work) + | query(prefix, "works__alternative_title__title__iexact", work) ) # specific terms of the research derivating from work @@ -53,19 +104,21 @@ def query_songs(query_set, query): for keyword in search_keywords["contains"]: query_list.append( ( - Q(works__title__icontains=keyword) - | Q(works__alternative_title__title__icontains=keyword) # noqa E501 + query(prefix, "works__title__icontains", keyword) + | query( + prefix, "works__alternative_title__title__icontains", keyword + ) ) - & Q(works__work_type__query_name=query_name) + & query(prefix, "works__work_type__query_name", query_name) ) for keyword in search_keywords["exact"]: query_list.append( ( - Q(works__title__iexact=keyword) - | Q(works__alternative_title__title__iexact=keyword) + query(prefix, "works__title__iexact", keyword) + | query(prefix, "works__alternative_title__title__iexact", keyword) ) - & Q(works__work_type__query_name=query_name) + & query(prefix, "works__work_type__query_name", query_name) ) # one may want to factor the duplicated query on the work type @@ -77,29 +130,47 @@ def query_songs(query_set, query): # unspecific terms of the research for remain in res["remaining"]: query_list.append( - Q(title__icontains=remain) - | Q(artists__name__icontains=remain) - | Q(works__title__icontains=remain) - | Q(works__alternative_title__title__icontains=remain) - | Q(version__icontains=remain) - | Q(detail__icontains=remain) - | Q(detail_video__icontains=remain) + query(prefix, "title__icontains", remain) + | query(prefix, "artists__name__icontains", remain) + | query(prefix, "works__title__icontains", remain) + | query(prefix, "works__alternative_title__title__icontains", remain) + | query(prefix, "version__icontains", remain) + | query(prefix, "detail__icontains", remain) + | query(prefix, "detail_video__icontains", remain) ) # tags for tag in res["tag"]: - query_list_many.append(Q(tags__name=tag)) + query_list_many.append(query(prefix, "tags__name", tag)) - # now, gather the query objects - filter_query = Q() - for item in query_list: - filter_query &= item + return query_list, query_list_many - # gather the query objects for usual relations - query_set_filtered = query_set.filter(filter_query) - # gather the query objects for custom many to many relation - for item in query_list_many: - query_set_filtered = query_set_filtered.filter(item) + +def query_songs(query_set, query): + """Create a queryset that filters songs according to query. + + Args: + query_set (): Initial query set (containing all songs). + query (str): Query string. It can follow the + the query language, to specify which term + to search and where, or be a simple + pattern. + + Returns: + tuple: Tuple of the filtered query set, and the + parsed query. + """ + work_types = [wt.query_name for wt in WorkType.objects.all()] + language_parser = QueryLanguageParser(["artist", "work", "title"] + work_types) + res = regroup(language_parser.parse(query), "work_type", work_types) + + # query + query_list, query_list_many = make_songs_query_from_res(res) + + # gather the query objects + query_set_filtered = gather_query_many( + gather_query(query_set, query_list), query_list_many + ) return query_set_filtered, res @@ -123,11 +194,7 @@ def query_artists(query_set, query): query_list.append(Q(name__icontains=remain)) # gather the query objects - filter_query = Q() - for item in query_list: - filter_query &= item - - query_set_filtered = query_set.filter(filter_query) + query_set_filtered = gather_query(query_set, query_list) return query_set_filtered, res @@ -155,10 +222,6 @@ def query_works(query_set, query): ) # gather the query objects - filter_query = Q() - for item in query_list: - filter_query &= item - - query_set_filtered = query_set.filter(filter_query) + query_set_filtered = gather_query(query_set, query_list) return query_set_filtered, res diff --git a/dakara_server/playlist/query.py b/dakara_server/playlist/query.py new file mode 100644 index 0000000..1e38f1a --- /dev/null +++ b/dakara_server/playlist/query.py @@ -0,0 +1,43 @@ +from django.db.models import Q + +from library.models import WorkType +from library.query import gather_query, gather_query_many, make_songs_query_from_res +from library.query_language import QueryLanguageParser, regroup + + +def query_entries(query_set, query): + """Create a queryset that filters playlist entries according to a query. + + Args: + query_set (): Initial query set (containing all entries). + query (str): Query string. It can follow the + the query language, to specify which term + to search and where, or be a simple + pattern. + + Returns: + tuple: Tuple of the filtered query set, and the + parsed query. + """ + work_types = [wt.query_name for wt in WorkType.objects.all()] + language_parser = QueryLanguageParser( + ["owner", "artist", "work", "title"] + work_types + ) + res = regroup(language_parser.parse(query), "work_type", work_types) + + # query for song + query_list, query_list_many = make_songs_query_from_res(res, "song__") + + # query for owner + for owner in res["owner"]["contains"]: + query_list.append(Q(owner__username__icontains=owner)) + + for owner in res["owner"]["exact"]: + query_list.append(Q(owner__username__iexact=owner)) + + # gather the query objects + query_set_filtered = gather_query_many( + gather_query(query_set, query_list), query_list_many + ) + + return query_set_filtered, res diff --git a/dakara_server/playlist/tests/base_test.py b/dakara_server/playlist/tests/base_test.py index d52d867..c740872 100644 --- a/dakara_server/playlist/tests/base_test.py +++ b/dakara_server/playlist/tests/base_test.py @@ -43,14 +43,17 @@ def create_test_data(self): self.song2.save() # Create playlist entries + # pe1 queuing self.pe1 = PlaylistEntry(song=self.song1, owner=self.manager) self.pe1.save() + # pe2 queuing with instrumental track self.pe2 = PlaylistEntry( song=self.song2, owner=self.p_user, use_instrumental=True ) self.pe2.save() + # pe3 played self.pe3 = PlaylistEntry( song=self.song2, owner=self.manager, @@ -59,6 +62,7 @@ def create_test_data(self): ) self.pe3.save() + # pe4 played a moment ago self.pe4 = PlaylistEntry( song=self.song1, owner=self.user, diff --git a/dakara_server/playlist/tests/test_playlist_played.py b/dakara_server/playlist/tests/test_playlist_played.py index 3455e38..839e79f 100644 --- a/dakara_server/playlist/tests/test_playlist_played.py +++ b/dakara_server/playlist/tests/test_playlist_played.py @@ -31,3 +31,36 @@ def test_get_playlist_played_list_forbidden(self): # Get playlist entries list response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_get_playlist_played_list_with_query(self): + """Search playlist entries played list with simple query.""" + self.authenticate(self.user) + + self.entries_query_test("ong1", [self.pe4]) + + def test_get_playlist_played_list_with_query_song_title(self): + """Search playlist entries played list by song title.""" + self.authenticate(self.user) + + self.entries_query_test("title: song1", [self.pe4]) + + def test_get_playlist_played_list_with_query_owner(self): + """Search playlist entries played list by owner.""" + self.authenticate(self.user) + + self.entries_query_test("owner: manager", [self.pe3]) + self.entries_query_test("owner: user", [self.pe4]) + + def entries_query_test(self, query, expected_entries): + """Method to test an entry request with a given query. + + Returned entries should be the same as `expected_entries`, in the same + order. + """ + response = self.client.get(self.url, {"query": query}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], len(expected_entries)) + results = response.data["results"] + self.assertEqual(len(results), len(expected_entries)) + for entry, expected_entry in zip(results, expected_entries): + self.assertEqual(entry["id"], expected_entry.id) diff --git a/dakara_server/playlist/views.py b/dakara_server/playlist/views.py index 9e8f02e..eb8d9fe 100644 --- a/dakara_server/playlist/views.py +++ b/dakara_server/playlist/views.py @@ -19,9 +19,11 @@ from internal import permissions as internal_permissions from library import permissions as library_permissions +from library.views import QueryParsedListMixin from playlist import authentications, models, permissions, serializers from playlist.consumers import send_to_channel from playlist.date_stop import KARAOKE_JOB_NAME, clear_date_stop, scheduler +from playlist.query import query_entries from playlist.schemes import PlayerTokenScheme # noqa F401 tz = timezone.get_default_timezone() @@ -155,11 +157,27 @@ def perform_create(self, serializer): ) -class PlaylistPlayedListView(drf_generics.ListAPIView): +class PlaylistPlayedListView(QueryParsedListMixin, drf_generics.ListAPIView): """List of played entries.""" serializer_class = serializers.PlaylistEntrySerializer - queryset = models.PlaylistEntry.objects.get_played().reverse() + + def get_queryset(self): + """Search and filters the playlist entries.""" + query_set = models.PlaylistEntry.objects.get_played().reverse() + + # if 'query' is in the query string then perform search otherwise + # return all songs + if "query" not in self.request.query_params: + return query_set + + query = self.request.query_params.get("query", None) + if query: + # query the song and save the parsed query + # to give it back to the client + query_set, self.query_parsed = query_entries(query_set, query) + + return query_set.distinct() class PlayerCommandView(drf_generics.UpdateAPIView): From 46582917178d76efa42527b07050d357bab6c476 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 3 Aug 2025 16:06:35 +0200 Subject: [PATCH 06/26] Add query for queuing playlist entries --- dakara_server/playlist/tests/base_test.py | 15 +++++++++++++ .../playlist/tests/test_playlist_played.py | 22 ++++--------------- .../playlist/tests/test_playlist_queuing.py | 19 ++++++++++++++++ dakara_server/playlist/views.py | 20 +++++++++++++++-- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/dakara_server/playlist/tests/base_test.py b/dakara_server/playlist/tests/base_test.py index c740872..7f1d8d8 100644 --- a/dakara_server/playlist/tests/base_test.py +++ b/dakara_server/playlist/tests/base_test.py @@ -2,6 +2,7 @@ from django.core.cache import cache from django.utils.dateparse import parse_datetime +from rest_framework import status from internal.tests.base_test import BaseAPITestCase, BaseProvider, UserModel, tz from library.models import Song, SongTag @@ -130,6 +131,20 @@ def check_playlist_played_entry_json(self, json, expected_entry): self.check_playlist_entry_json(json, expected_entry) self.assertEqual(parse_datetime(json["date_play"]), expected_entry.date_play) + def check_playlist_entries_query(self, query, expected_entries): + """Method to check a query of playlist entries. + + Returned entries should be the same as `expected_entries`, in the same + order. + """ + response = self.client.get(self.url, {"query": query}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], len(expected_entries)) + results = response.data["results"] + self.assertEqual(len(results), len(expected_entries)) + for entry, expected_entry in zip(results, expected_entries): + self.assertEqual(entry["id"], expected_entry.id) + def get_player_token(self): """Create and give player token.""" karaoke = Karaoke.objects.get_object() diff --git a/dakara_server/playlist/tests/test_playlist_played.py b/dakara_server/playlist/tests/test_playlist_played.py index 839e79f..5380708 100644 --- a/dakara_server/playlist/tests/test_playlist_played.py +++ b/dakara_server/playlist/tests/test_playlist_played.py @@ -36,31 +36,17 @@ def test_get_playlist_played_list_with_query(self): """Search playlist entries played list with simple query.""" self.authenticate(self.user) - self.entries_query_test("ong1", [self.pe4]) + self.check_playlist_entries_query("ong1", [self.pe4]) def test_get_playlist_played_list_with_query_song_title(self): """Search playlist entries played list by song title.""" self.authenticate(self.user) - self.entries_query_test("title: song1", [self.pe4]) + self.check_playlist_entries_query("title: song1", [self.pe4]) def test_get_playlist_played_list_with_query_owner(self): """Search playlist entries played list by owner.""" self.authenticate(self.user) - self.entries_query_test("owner: manager", [self.pe3]) - self.entries_query_test("owner: user", [self.pe4]) - - def entries_query_test(self, query, expected_entries): - """Method to test an entry request with a given query. - - Returned entries should be the same as `expected_entries`, in the same - order. - """ - response = self.client.get(self.url, {"query": query}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], len(expected_entries)) - results = response.data["results"] - self.assertEqual(len(results), len(expected_entries)) - for entry, expected_entry in zip(results, expected_entries): - self.assertEqual(entry["id"], expected_entry.id) + self.check_playlist_entries_query("owner: manager", [self.pe3]) + self.check_playlist_entries_query("owner: user", [self.pe4]) diff --git a/dakara_server/playlist/tests/test_playlist_queuing.py b/dakara_server/playlist/tests/test_playlist_queuing.py index 193f56d..08f72b5 100644 --- a/dakara_server/playlist/tests/test_playlist_queuing.py +++ b/dakara_server/playlist/tests/test_playlist_queuing.py @@ -44,6 +44,25 @@ def test_get_playlist_queuing_list_forbidden(self): response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_get_playlist_queuing_list_with_query(self): + """Search playlist entries queuing list with simple query.""" + self.authenticate(self.user) + + self.check_playlist_entries_query("ong1", [self.pe1]) + + def test_get_playlist_queuing_list_with_query_song_title(self): + """Search playlist entries queuing list by song title.""" + self.authenticate(self.user) + + self.check_playlist_entries_query("title: song1", [self.pe1]) + + def test_get_playlist_queuing_list_with_query_owner(self): + """Search playlist entries queuing list by owner.""" + self.authenticate(self.user) + + self.check_playlist_entries_query("owner: manager", [self.pe1]) + self.check_playlist_entries_query("owner: user", [self.pe2]) + @patch("playlist.views.send_to_channel") def test_post_create_playlist_entry(self, mocked_send_to_channel): """Test to verify playlist entry creation.""" diff --git a/dakara_server/playlist/views.py b/dakara_server/playlist/views.py index eb8d9fe..754eead 100644 --- a/dakara_server/playlist/views.py +++ b/dakara_server/playlist/views.py @@ -62,7 +62,7 @@ def put(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) -class PlaylistQueuingListView(drf_generics.ListCreateAPIView): +class PlaylistQueuingListView(QueryParsedListMixin, drf_generics.ListCreateAPIView): """List of entries or creation of a new entry in the playlist.""" serializer_class = serializers.PlaylistEntrySerializer @@ -72,7 +72,23 @@ class PlaylistQueuingListView(drf_generics.ListCreateAPIView): (permissions.IsPlaylistManager & library_permissions.IsLibraryManager) | permissions.IsSongEnabled, ] - queryset = models.PlaylistEntry.objects.get_queuing() + + def get_queryset(self): + """Search and filters the playlist entries.""" + query_set = models.PlaylistEntry.objects.get_queuing() + + # if 'query' is in the query string then perform search otherwise + # return all songs + if "query" not in self.request.query_params: + return query_set + + query = self.request.query_params.get("query", None) + if query: + # query the song and save the parsed query + # to give it back to the client + query_set, self.query_parsed = query_entries(query_set, query) + + return query_set.distinct() def perform_create(self, serializer): # Deny creation if kara is not ongoing From 6db671fe159457067a0f3a865e064f327d44d697 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 3 Aug 2025 16:12:54 +0200 Subject: [PATCH 07/26] Move views mixins to internal --- dakara_server/internal/views_mixins.py | 41 ++++++++++++++++++++++++ dakara_server/library/views.py | 44 +------------------------- dakara_server/playlist/views.py | 2 +- 3 files changed, 43 insertions(+), 44 deletions(-) create mode 100644 dakara_server/internal/views_mixins.py diff --git a/dakara_server/internal/views_mixins.py b/dakara_server/internal/views_mixins.py new file mode 100644 index 0000000..cc63d3c --- /dev/null +++ b/dakara_server/internal/views_mixins.py @@ -0,0 +1,41 @@ +class QueryParsedListMixin: + """Mixin that adds parsed query to list response.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.query_parsed = None + + def list(self, request, *args, **kwargs): + """Add the parsed query to the serialized response.""" + response = super().list(request, *args, **kwargs) + + # pass the query words to highlight to the response + # the words have been passed to the object in the get_queryset method + # now, they have to be passed to the response + # this is why this function in overloaded + if self.query_parsed is not None: + response.data["query"] = self.query_parsed + + return response + + +class MultiSerializerMixin: + """Mixin that adapts serializer if a list of data is provided.""" + + def get_serializer(self, *args, **kwargs): + """Select accurate serializer to handle list of songs. + + Return the serializer instance that should be used for validating and + deserializing input, and for serializing output. + """ + data = kwargs.get("data") + many = kwargs.get("many") + + # check if the serializer is used to deserialize data + # and check if the data is a list + if data and isinstance(data, list) and many is None: + return super().get_serializer(*args, many=True, **kwargs) + + # otherwise + return super().get_serializer(*args, **kwargs) diff --git a/dakara_server/library/views.py b/dakara_server/library/views.py index fc8872d..ee01728 100644 --- a/dakara_server/library/views.py +++ b/dakara_server/library/views.py @@ -13,6 +13,7 @@ from rest_framework.views import APIView from internal import permissions as internal_permissions +from internal.views_mixins import MultiSerializerMixin, QueryParsedListMixin from library import models, permissions, serializers from library.query import query_artists, query_songs, query_works @@ -21,49 +22,6 @@ UserModel = get_user_model() -class QueryParsedListMixin: - """Mixin that adds parsed query to list response.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.query_parsed = None - - def list(self, request, *args, **kwargs): - """Add the parsed query to the serialized response.""" - response = super().list(request, *args, **kwargs) - - # pass the query words to highlight to the response - # the words have been passed to the object in the get_queryset method - # now, they have to be passed to the response - # this is why this function in overloaded - if self.query_parsed is not None: - response.data["query"] = self.query_parsed - - return response - - -class MultiSerializerMixin: - """Mixin that adapts serializer if a list of data is provided.""" - - def get_serializer(self, *args, **kwargs): - """Select accurate serializer to handle list of songs. - - Return the serializer instance that should be used for validating and - deserializing input, and for serializing output. - """ - data = kwargs.get("data") - many = kwargs.get("many") - - # check if the serializer is used to deserialize data - # and check if the data is a list - if data and isinstance(data, list) and many is None: - return super().get_serializer(*args, many=True, **kwargs) - - # otherwise - return super().get_serializer(*args, **kwargs) - - class SongListView(QueryParsedListMixin, MultiSerializerMixin, ListCreateAPIView): """List of songs.""" diff --git a/dakara_server/playlist/views.py b/dakara_server/playlist/views.py index 754eead..b2b87b1 100644 --- a/dakara_server/playlist/views.py +++ b/dakara_server/playlist/views.py @@ -18,8 +18,8 @@ from rest_framework.views import APIView from internal import permissions as internal_permissions +from internal.views_mixins import QueryParsedListMixin from library import permissions as library_permissions -from library.views import QueryParsedListMixin from playlist import authentications, models, permissions, serializers from playlist.consumers import send_to_channel from playlist.date_stop import KARAOKE_JOB_NAME, clear_date_stop, scheduler From d30396d85c8ebebf46897dd78b488f4b14db908b Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 3 Aug 2025 16:26:35 +0200 Subject: [PATCH 08/26] Rename PlayerErrorView into PlayerErrorListView --- dakara_server/dakara_server/urls.py | 2 +- dakara_server/playlist/tests/test_player_error.py | 2 +- dakara_server/playlist/views.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dakara_server/dakara_server/urls.py b/dakara_server/dakara_server/urls.py index 1005b01..6c59e60 100644 --- a/dakara_server/dakara_server/urls.py +++ b/dakara_server/dakara_server/urls.py @@ -48,7 +48,7 @@ ), path( "api/playlist/player/errors/", - playlist_views.PlayerErrorView.as_view(), + playlist_views.PlayerErrorListView.as_view(), name="playlist-player-errors", ), path( diff --git a/dakara_server/playlist/tests/test_player_error.py b/dakara_server/playlist/tests/test_player_error.py index cf1f36a..a78ab2f 100644 --- a/dakara_server/playlist/tests/test_player_error.py +++ b/dakara_server/playlist/tests/test_player_error.py @@ -10,7 +10,7 @@ from playlist.tests.base_test import PlaylistAPITestCase -class PlayerErrorViewTestCase(PlaylistAPITestCase): +class PlayerErrorListViewTestCase(PlaylistAPITestCase): """Test the view of the player errors.""" url = reverse("playlist-player-errors") diff --git a/dakara_server/playlist/views.py b/dakara_server/playlist/views.py index b2b87b1..d69cb72 100644 --- a/dakara_server/playlist/views.py +++ b/dakara_server/playlist/views.py @@ -490,7 +490,7 @@ def get_object(self): return player -class PlayerErrorView(drf_generics.ListCreateAPIView): +class PlayerErrorListView(QueryParsedListMixin, drf_generics.ListCreateAPIView): """View of the player errors.""" authentication_classes = [ From d10e1b5e65432d973dca691cb15ac0b308682586 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 3 Aug 2025 16:56:57 +0200 Subject: [PATCH 09/26] Add query for player errors --- dakara_server/internal/query.py | 57 ++++++++++++++ dakara_server/library/query.py | 57 +------------- dakara_server/playlist/query.py | 74 +++++++++++++++++-- .../playlist/tests/test_player_error.py | 24 ++++++ dakara_server/playlist/views.py | 20 ++++- 5 files changed, 166 insertions(+), 66 deletions(-) create mode 100644 dakara_server/internal/query.py diff --git a/dakara_server/internal/query.py b/dakara_server/internal/query.py new file mode 100644 index 0000000..5363f9e --- /dev/null +++ b/dakara_server/internal/query.py @@ -0,0 +1,57 @@ +from django.db.models import Q + + +def query(prefix, name, value): + """Shorthand to make a query with the Q object and a prefix. + + Args: + prefix (str or None): If truthy, prepended to `name`. + name (str): Name of the field. + value (str): Value for the field in the query. + + Returns: + django.db.models.Q: Query. + """ + if prefix: + return Q(**{prefix + name: value}) + + return Q(**{name: value}) + + +def gather_query(query_set, query_list): + """Filter a query set by elements of a query list. + + Args: + query_set: Initial query set. + query_list (list of django.db.models.Q): List of queries. + + Returns: + New firtered query set. + """ + # now, gather the query objects + filter_query = Q() + for item in query_list: + filter_query &= item + + # gather the query objects for usual relations + return query_set.filter(filter_query) + + +def gather_query_many(query_set, query_list_many): + """Filter a query set by elements of a query list of many to many fields. + + Args: + query_set: Initial query set. + query_list (list of django.db.models.Q): List of queries for many to + many fields. + + Returns: + New firtered query set. + """ + query_set_filtered = query_set + + # gather the query objects for custom many to many relation + for item in query_list_many: + query_set_filtered = query_set_filtered.filter(item) + + return query_set_filtered diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index ce5a24d..0650740 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -1,65 +1,10 @@ from django.db.models import Q +from internal.query import gather_query, gather_query_many, query from library.models import WorkType from library.query_language import QueryLanguageParser, regroup -def query(prefix, name, value): - """Shorthand to make a query with the Q object and a prefix. - - Args: - prefix (str or None): If truthy, prepended to `name`. - name (str): Name of the field. - value (str): Value for the field in the query. - - Returns: - django.db.models.Q: Query. - """ - if prefix: - return Q(**{prefix + name: value}) - - return Q(**{name: value}) - - -def gather_query(query_set, query_list): - """Filter a query set by elements of a query list. - - Args: - query_set: Initial query set. - query_list (list of django.db.models.Q): List of queries. - - Returns: - New firtered query set. - """ - # now, gather the query objects - filter_query = Q() - for item in query_list: - filter_query &= item - - # gather the query objects for usual relations - return query_set.filter(filter_query) - - -def gather_query_many(query_set, query_list_many): - """Filter a query set by elements of a query list of many to many fields. - - Args: - query_set: Initial query set. - query_list (list of django.db.models.Q): List of queries for many to - many fields. - - Returns: - New firtered query set. - """ - query_set_filtered = query_set - - # gather the query objects for custom many to many relation - for item in query_list_many: - query_set_filtered = query_set_filtered.filter(item) - - return query_set_filtered - - def make_songs_query_from_res(res, prefix=None): """Make a query for songs. diff --git a/dakara_server/playlist/query.py b/dakara_server/playlist/query.py index 1e38f1a..36bd37a 100644 --- a/dakara_server/playlist/query.py +++ b/dakara_server/playlist/query.py @@ -1,10 +1,37 @@ from django.db.models import Q +from internal.query import gather_query, gather_query_many, query from library.models import WorkType -from library.query import gather_query, gather_query_many, make_songs_query_from_res +from library.query import make_songs_query_from_res from library.query_language import QueryLanguageParser, regroup +def make_entries_query_from_res(res, prefix=None): + """Make a query for playlist entries. + + Args: + res (dict): Dictionary on research terms, parsed. + prefix (str or None): Optional prefix to add when creating the query. + + Returns: + tuple of list: List of queries, and list of queries targeting many to + many relations. + """ + # query for song + query_list, query_list_many = make_songs_query_from_res( + res, (prefix or "") + "song__" + ) + + # query for owner + for owner in res["owner"]["contains"]: + query_list.append(query(prefix, "owner__username__icontains", owner)) + + for owner in res["owner"]["exact"]: + query_list.append(query(prefix, "owner__username__iexact", owner)) + + return query_list, query_list_many + + def query_entries(query_set, query): """Create a queryset that filters playlist entries according to a query. @@ -25,15 +52,46 @@ def query_entries(query_set, query): ) res = regroup(language_parser.parse(query), "work_type", work_types) - # query for song - query_list, query_list_many = make_songs_query_from_res(res, "song__") + # query for entries + query_list, query_list_many = make_entries_query_from_res(res) - # query for owner - for owner in res["owner"]["contains"]: - query_list.append(Q(owner__username__icontains=owner)) + # gather the query objects + query_set_filtered = gather_query_many( + gather_query(query_set, query_list), query_list_many + ) - for owner in res["owner"]["exact"]: - query_list.append(Q(owner__username__iexact=owner)) + return query_set_filtered, res + + +def query_errors(query_set, query): + """Create a queryset that filters player errors according to a query. + + Args: + query_set (): Initial query set (containing all errors). + query (str): Query string. It can follow the + the query language, to specify which term + to search and where, or be a simple + pattern. + + Returns: + tuple: Tuple of the filtered query set, and the + parsed query. + """ + work_types = [wt.query_name for wt in WorkType.objects.all()] + language_parser = QueryLanguageParser( + ["message", "owner", "artist", "work", "title"] + work_types + ) + res = regroup(language_parser.parse(query), "work_type", work_types) + + # query for entries + query_list, query_list_many = make_entries_query_from_res(res, "playlist_entry__") + + # query for error message + for message in res["message"]["contains"]: + query_list.append(Q(error_message__icontains=message)) + + for message in res["message"]["exact"]: + query_list.append(Q(error_message__iexact=message)) # gather the query objects query_set_filtered = gather_query_many( diff --git a/dakara_server/playlist/tests/test_player_error.py b/dakara_server/playlist/tests/test_player_error.py index a78ab2f..ad5d999 100644 --- a/dakara_server/playlist/tests/test_player_error.py +++ b/dakara_server/playlist/tests/test_player_error.py @@ -94,6 +94,30 @@ def test_get_errors_forbidden(self): response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_get_errors_with_query(self): + """Search errors with simple query.""" + # set an error + with freeze_time("1970-01-01 00:01:00"): + PlayerError.objects.create( + playlist_entry=self.pe1, error_message="dummy error" + ) + + self.authenticate(self.user) + + self.check_playlist_entries_query("ong1", [self.pe1]) + + def test_get_errors_with_query_song_title(self): + """Search errors with simple query.""" + # set an error + with freeze_time("1970-01-01 00:01:00"): + PlayerError.objects.create( + playlist_entry=self.pe1, error_message="dummy error" + ) + + self.authenticate(self.user) + + self.check_playlist_entries_query("title: song1", [self.pe1]) + @patch("playlist.views.send_to_channel") def test_post_error_success(self, mocked_send_to_channel): """Test to create an error.""" diff --git a/dakara_server/playlist/views.py b/dakara_server/playlist/views.py index d69cb72..a67c7b6 100644 --- a/dakara_server/playlist/views.py +++ b/dakara_server/playlist/views.py @@ -23,7 +23,7 @@ from playlist import authentications, models, permissions, serializers from playlist.consumers import send_to_channel from playlist.date_stop import KARAOKE_JOB_NAME, clear_date_stop, scheduler -from playlist.query import query_entries +from playlist.query import query_entries, query_errors from playlist.schemes import PlayerTokenScheme # noqa F401 tz = timezone.get_default_timezone() @@ -502,7 +502,23 @@ class PlayerErrorListView(QueryParsedListMixin, drf_generics.ListCreateAPIView): IsAuthenticated & internal_permissions.IsReadOnly | permissions.IsPlayer ] serializer_class = serializers.PlayerErrorSerializer - queryset = models.PlayerError.objects.order_by("date_created").reverse() + + def get_queryset(self): + """Search and filters the playlist entries.""" + query_set = models.PlayerError.objects.all() + + # if 'query' is in the query string then perform search otherwise + # return all songs + if "query" not in self.request.query_params: + return query_set.order_by("date_created").reverse() + + query = self.request.query_params.get("query", None) + if query: + # query the song and save the parsed query + # to give it back to the client + query_set, self.query_parsed = query_errors(query_set, query) + + return query_set.distinct().order_by("date_created").reverse() def perform_create(self, serializer): """Create an error and perform other actions. From f36f56a71f374d606a63b08caf1011598db5daa2 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 3 Aug 2025 17:46:59 +0200 Subject: [PATCH 10/26] Allow free search for entries and errors --- dakara_server/internal/query.py | 10 +++++++ dakara_server/library/query.py | 16 +++++++----- dakara_server/playlist/query.py | 26 ++++++++++++++----- .../playlist/tests/test_player_error.py | 21 +++++++++++++-- .../playlist/tests/test_playlist_played.py | 7 +++-- .../playlist/tests/test_playlist_queuing.py | 3 +++ 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/dakara_server/internal/query.py b/dakara_server/internal/query.py index 5363f9e..cd8d121 100644 --- a/dakara_server/internal/query.py +++ b/dakara_server/internal/query.py @@ -37,6 +37,16 @@ def gather_query(query_set, query_list): return query_set.filter(filter_query) +def gather_query_remain(query_set, query_list_remain): + # now, gather the query objects + filter_query = Q() + for item in query_list_remain: + filter_query |= item + + # gather the query objects for usual relations + return query_set.filter(filter_query) + + def gather_query_many(query_set, query_list_many): """Filter a query set by elements of a query list of many to many fields. diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index 0650740..02000f7 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -1,6 +1,6 @@ from django.db.models import Q -from internal.query import gather_query, gather_query_many, query +from internal.query import gather_query, gather_query_many, gather_query_remain, query from library.models import WorkType from library.query_language import QueryLanguageParser, regroup @@ -13,10 +13,11 @@ def make_songs_query_from_res(res, prefix=None): prefix (str or None): Optional prefix to add when creating the query. Returns: - tuple of list: List of queries, and list of queries targeting many to - many relations. + tuple of list: List of queries, list of remaining queries, and list of + queries targeting many to many relations. """ query_list = [] + query_list_remain = [] query_list_many = [] # specific terms of the research, i.e. artists, works and titles @@ -74,7 +75,7 @@ def make_songs_query_from_res(res, prefix=None): # unspecific terms of the research for remain in res["remaining"]: - query_list.append( + query_list_remain.append( query(prefix, "title__icontains", remain) | query(prefix, "artists__name__icontains", remain) | query(prefix, "works__title__icontains", remain) @@ -88,7 +89,7 @@ def make_songs_query_from_res(res, prefix=None): for tag in res["tag"]: query_list_many.append(query(prefix, "tags__name", tag)) - return query_list, query_list_many + return query_list, query_list_remain, query_list_many def query_songs(query_set, query): @@ -110,11 +111,12 @@ def query_songs(query_set, query): res = regroup(language_parser.parse(query), "work_type", work_types) # query - query_list, query_list_many = make_songs_query_from_res(res) + query_list, query_list_remain, query_list_many = make_songs_query_from_res(res) # gather the query objects query_set_filtered = gather_query_many( - gather_query(query_set, query_list), query_list_many + gather_query_remain(gather_query(query_set, query_list), query_list_remain), + query_list_many, ) return query_set_filtered, res diff --git a/dakara_server/playlist/query.py b/dakara_server/playlist/query.py index 36bd37a..16b7708 100644 --- a/dakara_server/playlist/query.py +++ b/dakara_server/playlist/query.py @@ -1,6 +1,6 @@ from django.db.models import Q -from internal.query import gather_query, gather_query_many, query +from internal.query import gather_query, gather_query_many, gather_query_remain, query from library.models import WorkType from library.query import make_songs_query_from_res from library.query_language import QueryLanguageParser, regroup @@ -18,7 +18,7 @@ def make_entries_query_from_res(res, prefix=None): many relations. """ # query for song - query_list, query_list_many = make_songs_query_from_res( + query_list, query_list_remain, query_list_many = make_songs_query_from_res( res, (prefix or "") + "song__" ) @@ -29,7 +29,11 @@ def make_entries_query_from_res(res, prefix=None): for owner in res["owner"]["exact"]: query_list.append(query(prefix, "owner__username__iexact", owner)) - return query_list, query_list_many + # unspecific terms of the research + for remain in res["remaining"]: + query_list_remain.append(query(prefix, "owner__username__icontains", remain)) + + return query_list, query_list_remain, query_list_many def query_entries(query_set, query): @@ -53,11 +57,12 @@ def query_entries(query_set, query): res = regroup(language_parser.parse(query), "work_type", work_types) # query for entries - query_list, query_list_many = make_entries_query_from_res(res) + query_list, query_list_remain, query_list_many = make_entries_query_from_res(res) # gather the query objects query_set_filtered = gather_query_many( - gather_query(query_set, query_list), query_list_many + gather_query_remain(gather_query(query_set, query_list), query_list_remain), + query_list_many, ) return query_set_filtered, res @@ -84,7 +89,9 @@ def query_errors(query_set, query): res = regroup(language_parser.parse(query), "work_type", work_types) # query for entries - query_list, query_list_many = make_entries_query_from_res(res, "playlist_entry__") + query_list, query_list_remain, query_list_many = make_entries_query_from_res( + res, "playlist_entry__" + ) # query for error message for message in res["message"]["contains"]: @@ -93,9 +100,14 @@ def query_errors(query_set, query): for message in res["message"]["exact"]: query_list.append(Q(error_message__iexact=message)) + # unspecific terms of the research + for remain in res["remaining"]: + query_list_remain.append(Q(error_message__icontains=remain)) + # gather the query objects query_set_filtered = gather_query_many( - gather_query(query_set, query_list), query_list_many + gather_query_remain(gather_query(query_set, query_list), query_list_remain), + query_list_many, ) return query_set_filtered, res diff --git a/dakara_server/playlist/tests/test_player_error.py b/dakara_server/playlist/tests/test_player_error.py index ad5d999..341ce5a 100644 --- a/dakara_server/playlist/tests/test_player_error.py +++ b/dakara_server/playlist/tests/test_player_error.py @@ -96,19 +96,36 @@ def test_get_errors_forbidden(self): def test_get_errors_with_query(self): """Search errors with simple query.""" - # set an error with freeze_time("1970-01-01 00:01:00"): PlayerError.objects.create( playlist_entry=self.pe1, error_message="dummy error" ) + with freeze_time("1970-01-01 00:02:00"): + PlayerError.objects.create( + playlist_entry=self.pe2, error_message="leek overflow" + ) + self.authenticate(self.user) self.check_playlist_entries_query("ong1", [self.pe1]) + self.check_playlist_entries_query("overflow", [self.pe2]) + + def test_get_errors_with_query_message(self): + """Search errors with simple query.""" + with freeze_time("1970-01-01 00:01:00"): + PlayerError.objects.create( + playlist_entry=self.pe1, error_message="dummy error" + ) + + self.authenticate(self.user) + + self.check_playlist_entries_query("message:dummy", [self.pe1]) + self.check_playlist_entries_query('message:"dummy error"', [self.pe1]) + self.check_playlist_entries_query('message:""dummy error""', [self.pe1]) def test_get_errors_with_query_song_title(self): """Search errors with simple query.""" - # set an error with freeze_time("1970-01-01 00:01:00"): PlayerError.objects.create( playlist_entry=self.pe1, error_message="dummy error" diff --git a/dakara_server/playlist/tests/test_playlist_played.py b/dakara_server/playlist/tests/test_playlist_played.py index 5380708..41e2ad3 100644 --- a/dakara_server/playlist/tests/test_playlist_played.py +++ b/dakara_server/playlist/tests/test_playlist_played.py @@ -37,6 +37,7 @@ def test_get_playlist_played_list_with_query(self): self.authenticate(self.user) self.check_playlist_entries_query("ong1", [self.pe4]) + self.check_playlist_entries_query("anager", [self.pe3]) def test_get_playlist_played_list_with_query_song_title(self): """Search playlist entries played list by song title.""" @@ -48,5 +49,7 @@ def test_get_playlist_played_list_with_query_owner(self): """Search playlist entries played list by owner.""" self.authenticate(self.user) - self.check_playlist_entries_query("owner: manager", [self.pe3]) - self.check_playlist_entries_query("owner: user", [self.pe4]) + self.check_playlist_entries_query("owner:manager", [self.pe3]) + self.check_playlist_entries_query('owner:"manager"', [self.pe3]) + self.check_playlist_entries_query('owner:""testPlaylistManager""', [self.pe3]) + self.check_playlist_entries_query("owner:user", [self.pe4]) diff --git a/dakara_server/playlist/tests/test_playlist_queuing.py b/dakara_server/playlist/tests/test_playlist_queuing.py index 08f72b5..2688a2b 100644 --- a/dakara_server/playlist/tests/test_playlist_queuing.py +++ b/dakara_server/playlist/tests/test_playlist_queuing.py @@ -49,6 +49,7 @@ def test_get_playlist_queuing_list_with_query(self): self.authenticate(self.user) self.check_playlist_entries_query("ong1", [self.pe1]) + self.check_playlist_entries_query("user", [self.pe2]) def test_get_playlist_queuing_list_with_query_song_title(self): """Search playlist entries queuing list by song title.""" @@ -61,6 +62,8 @@ def test_get_playlist_queuing_list_with_query_owner(self): self.authenticate(self.user) self.check_playlist_entries_query("owner: manager", [self.pe1]) + self.check_playlist_entries_query('owner:"manager"', [self.pe1]) + self.check_playlist_entries_query('owner:""testPlaylistManager""', [self.pe1]) self.check_playlist_entries_query("owner: user", [self.pe2]) @patch("playlist.views.send_to_channel") From fb1189a98d0b19ce2f6cd8b2a542697e969db11b Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 3 Aug 2025 18:22:59 +0200 Subject: [PATCH 11/26] Add query for users --- dakara_server/internal/query.py | 2 +- dakara_server/internal/tests/base_test.py | 19 ++++++ dakara_server/library/query.py | 62 ++++++++--------- dakara_server/library/tests/test_song.py | 67 +++++++------------ dakara_server/playlist/query.py | 8 +-- dakara_server/playlist/tests/base_test.py | 15 ----- .../playlist/tests/test_player_error.py | 12 ++-- .../playlist/tests/test_playlist_played.py | 14 ++-- .../playlist/tests/test_playlist_queuing.py | 14 ++-- dakara_server/playlist/views.py | 2 +- dakara_server/users/query.py | 32 +++++++++ dakara_server/users/tests/test_views.py | 11 ++- dakara_server/users/views.py | 22 +++++- 13 files changed, 162 insertions(+), 118 deletions(-) create mode 100644 dakara_server/users/query.py diff --git a/dakara_server/internal/query.py b/dakara_server/internal/query.py index cd8d121..86e7536 100644 --- a/dakara_server/internal/query.py +++ b/dakara_server/internal/query.py @@ -1,7 +1,7 @@ from django.db.models import Q -def query(prefix, name, value): +def q(prefix, name, value): """Shorthand to make a query with the Q object and a prefix. Args: diff --git a/dakara_server/internal/tests/base_test.py b/dakara_server/internal/tests/base_test.py index 8af729c..c269c69 100644 --- a/dakara_server/internal/tests/base_test.py +++ b/dakara_server/internal/tests/base_test.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.utils import timezone +from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.test import APITestCase @@ -66,6 +67,24 @@ def create_user( user.save() return user + def check_query(self, query, expected): + """Method to check a query. + + Returned entries should be the same as `expected`, in the same order. + + Args: + query (str): Terms of the query, to be parsed. + expected_items (list): List of expected objects, that must have an + `id`. + """ + response = self.client.get(self.url, {"query": query}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], len(expected)) + results = response.data["results"] + self.assertEqual(len(results), len(expected)) + for item, expected_item in zip(results, expected): + self.assertEqual(item["id"], expected_item.id) + class BaseAPITestCase(APITestCase, BaseProvider): """Base test class for Unittest.""" diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index 02000f7..bf4d92e 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -1,6 +1,6 @@ from django.db.models import Q -from internal.query import gather_query, gather_query_many, gather_query_remain, query +from internal.query import gather_query, gather_query_many, gather_query_remain, q from library.models import WorkType from library.query_language import QueryLanguageParser, regroup @@ -22,27 +22,27 @@ def make_songs_query_from_res(res, prefix=None): # specific terms of the research, i.e. artists, works and titles for artist in res["artist"]["contains"]: - query_list_many.append(query(prefix, "artists__name__icontains", artist)) + query_list_many.append(q(prefix, "artists__name__icontains", artist)) for artist in res["artist"]["exact"]: - query_list_many.append(query(prefix, "artists__name__iexact", artist)) + query_list_many.append(q(prefix, "artists__name__iexact", artist)) for title in res["title"]["contains"]: - query_list.append(query(prefix, "title__icontains", title)) + query_list.append(q(prefix, "title__icontains", title)) for title in res["title"]["exact"]: - query_list.append(query(prefix, "title__iexact", title)) + query_list.append(q(prefix, "title__iexact", title)) for work in res["work"]["contains"]: query_list.append( - query(prefix, "works__title__icontains", work) - | query(prefix, "works__alternative_title__title__icontains", work) + q(prefix, "works__title__icontains", work) + | q(prefix, "works__alternative_title__title__icontains", work) ) for work in res["work"]["exact"]: query_list.append( - query(prefix, "works__title__iexact", work) - | query(prefix, "works__alternative_title__title__iexact", work) + q(prefix, "works__title__iexact", work) + | q(prefix, "works__alternative_title__title__iexact", work) ) # specific terms of the research derivating from work @@ -50,21 +50,19 @@ def make_songs_query_from_res(res, prefix=None): for keyword in search_keywords["contains"]: query_list.append( ( - query(prefix, "works__title__icontains", keyword) - | query( - prefix, "works__alternative_title__title__icontains", keyword - ) + q(prefix, "works__title__icontains", keyword) + | q(prefix, "works__alternative_title__title__icontains", keyword) ) - & query(prefix, "works__work_type__query_name", query_name) + & q(prefix, "works__work_type__query_name", query_name) ) for keyword in search_keywords["exact"]: query_list.append( ( - query(prefix, "works__title__iexact", keyword) - | query(prefix, "works__alternative_title__title__iexact", keyword) + q(prefix, "works__title__iexact", keyword) + | q(prefix, "works__alternative_title__title__iexact", keyword) ) - & query(prefix, "works__work_type__query_name", query_name) + & q(prefix, "works__work_type__query_name", query_name) ) # one may want to factor the duplicated query on the work type @@ -76,18 +74,18 @@ def make_songs_query_from_res(res, prefix=None): # unspecific terms of the research for remain in res["remaining"]: query_list_remain.append( - query(prefix, "title__icontains", remain) - | query(prefix, "artists__name__icontains", remain) - | query(prefix, "works__title__icontains", remain) - | query(prefix, "works__alternative_title__title__icontains", remain) - | query(prefix, "version__icontains", remain) - | query(prefix, "detail__icontains", remain) - | query(prefix, "detail_video__icontains", remain) + q(prefix, "title__icontains", remain) + | q(prefix, "artists__name__icontains", remain) + | q(prefix, "works__title__icontains", remain) + | q(prefix, "works__alternative_title__title__icontains", remain) + | q(prefix, "version__icontains", remain) + | q(prefix, "detail__icontains", remain) + | q(prefix, "detail_video__icontains", remain) ) # tags for tag in res["tag"]: - query_list_many.append(query(prefix, "tags__name", tag)) + query_list_many.append(q(prefix, "tags__name", tag)) return query_list, query_list_remain, query_list_many @@ -135,13 +133,14 @@ def query_artists(query_set, query): """ # using query language parser to split terms and for uniformity res = QueryLanguageParser.split_remaining(query) - query_list = [] + + query_list_remain = [] # only unspecific terms are used for remain in res: - query_list.append(Q(name__icontains=remain)) + query_list_remain.append(Q(name__icontains=remain)) # gather the query objects - query_set_filtered = gather_query(query_set, query_list) + query_set_filtered = gather_query_remain(query_set, query_list_remain) return query_set_filtered, res @@ -159,16 +158,17 @@ def query_works(query_set, query): """ # using query language parser to split terms and for uniformity res = QueryLanguageParser.split_remaining(query) - query_list = [] + + query_list_remain = [] # only unspecific terms are used for remain in res: - query_list.append( + query_list_remain.append( Q(title__icontains=remain) | Q(subtitle__icontains=remain) | Q(alternative_title__title__icontains=remain) ) # gather the query objects - query_set_filtered = gather_query(query_set, query_list) + query_set_filtered = gather_query_remain(query_set, query_list_remain) return query_set_filtered, res diff --git a/dakara_server/library/tests/test_song.py b/dakara_server/library/tests/test_song.py index f51fe55..fc150fc 100644 --- a/dakara_server/library/tests/test_song.py +++ b/dakara_server/library/tests/test_song.py @@ -82,15 +82,15 @@ def test_get_song_list_with_query(self): # Get songs list with query = "ong1" # Should only return song1 - self.song_query_test("ong1", [self.song1]) + self.check_query("ong1", [self.song1]) # Get songs list with query = "tist1" # Should only return song2 which has Artist1 as artist - self.song_query_test("tist1", [self.song2]) + self.check_query("tist1", [self.song2]) # Get songs list with query = "ork1" # Should only return song2 which is linked to Work1 - self.song_query_test("ork1", [self.song2]) + self.check_query("ork1", [self.song2]) def test_get_song_list_with_query_empty(self): """Test to verify song list with empty query.""" @@ -99,7 +99,7 @@ def test_get_song_list_with_query_empty(self): # Get songs list with query = "" # Should return all songs - self.song_query_test("", [self.song1, self.song2]) + self.check_query("", [self.song1, self.song2]) def test_get_song_list_with_query_detail(self): """Test to verify song list with detail query.""" @@ -108,15 +108,15 @@ def test_get_song_list_with_query_detail(self): # Get songs list with query = "Version2" # Should only return song2 - self.song_query_test("ersion2", [self.song2]) + self.check_query("ersion2", [self.song2]) # Get songs list with query = "Detail2" # Should only return song2 - self.song_query_test("etail2", [self.song2]) + self.check_query("etail2", [self.song2]) # Get songs list with query = "Detail_Video2" # Should only return song2 - self.song_query_test("etail_Video2", [self.song2]) + self.check_query("etail_Video2", [self.song2]) def test_get_song_list_with_query_tag(self): """Test to verify song list with tag query.""" @@ -125,11 +125,11 @@ def test_get_song_list_with_query_tag(self): # Get songs list with query = "#TAG1" # Should only return song2 - self.song_query_test("#TAG1", [self.song2]) + self.check_query("#TAG1", [self.song2]) # Get songs list with query = "#TAG2" # Should not return any result - self.song_query_test("#TAG2", []) + self.check_query("#TAG2", []) def test_get_song_list_with_query_artist(self): """Test to verify song list with artist query.""" @@ -138,19 +138,19 @@ def test_get_song_list_with_query_artist(self): # Get songs list with query = "artist:1" # Should only return song2 - self.song_query_test("artist:1", [self.song2]) + self.check_query("artist:1", [self.song2]) # Get songs list with query = "artist:k" # Should not return any result - self.song_query_test("artist:k", []) + self.check_query("artist:k", []) # Get songs list with query = "artist:""Artist1""" # Should only return song2 - self.song_query_test('artist:""Artist1""', [self.song2]) + self.check_query('artist:""Artist1""', [self.song2]) # Get songs list with query = "artist:""tist1""" # Should not return any result - self.song_query_test('artist:""tist1""', []) + self.check_query('artist:""tist1""', []) def test_get_song_list_with_query_work(self): """Test to verify song list with work query.""" @@ -159,15 +159,15 @@ def test_get_song_list_with_query_work(self): # Get songs list with query = "wt1:Work1" # Should only return song2 - self.song_query_test("wt1:Work1", [self.song2]) + self.check_query("wt1:Work1", [self.song2]) # Get songs list with query = "wt1:""Work1""" # Should only return song2 - self.song_query_test("""wt1:""Work1"" """, [self.song2]) + self.check_query("""wt1:""Work1"" """, [self.song2]) # Get songs list with query = "wt2:Work1" # Should not return any result since Work1 is not of type workType2 - self.song_query_test("wt2:Work1", []) + self.check_query("wt2:Work1", []) def test_get_song_list_with_query_work_alternative_title(self): """Test to verify song list with work alternative title query.""" @@ -176,27 +176,27 @@ def test_get_song_list_with_query_work_alternative_title(self): # Get songs list with query = "work:AltTitle1" # Should only return song2 - self.song_query_test("work:AltTitle1", [self.song2]) + self.check_query("work:AltTitle1", [self.song2]) # Get songs list with query = "work:""AltTitle1""" # Should only return song2 - self.song_query_test("""work:""AltTitle1"" """, [self.song2]) + self.check_query("""work:""AltTitle1"" """, [self.song2]) # Get songs list with query = "wt1:AltTitle1" # Should only return song2 - self.song_query_test("wt1:AltTitle1", [self.song2]) + self.check_query("wt1:AltTitle1", [self.song2]) # Get songs list with query = "wt1:""AltTitle1""" # Should only return song2 - self.song_query_test("""wt1:""AltTitle1"" """, [self.song2]) + self.check_query("""wt1:""AltTitle1"" """, [self.song2]) # Get songs list with query = "AltTitle1" # Should only return song2 - self.song_query_test("AltTitle1", [self.song2]) + self.check_query("AltTitle1", [self.song2]) # Get songs list with query = "wt2:AltTitle1" # Should not return any result since Work1 is not of type workType2 - self.song_query_test("wt2:AltTitle1", []) + self.check_query("wt2:AltTitle1", []) def test_get_song_list_with_query_title(self): """Test to verify song list with title query.""" @@ -205,15 +205,15 @@ def test_get_song_list_with_query_title(self): # Get songs list with query = "title:1" # Should only return song1 - self.song_query_test("title:1", [self.song1]) + self.check_query("title:1", [self.song1]) # Get songs list with query = "title:""Song1""" # Should only return song1 - self.song_query_test(""" title:""Song1"" """, [self.song1]) + self.check_query(""" title:""Song1"" """, [self.song1]) # Get songs list with query = "title:Artist" # Should not return any result - self.song_query_test("title:Artist", []) + self.check_query("title:Artist", []) def test_get_song_list_with_query_multiple(self): """Test to verify song list with title query.""" @@ -222,7 +222,7 @@ def test_get_song_list_with_query_multiple(self): # Get songs list with query = "artist:Artist1 title:1" # Should not return any song - self.song_query_test("artist:Artist1 title:1", []) + self.check_query("artist:Artist1 title:1", []) def test_get_song_list_with_query_complex(self): """Test to verify parsed query is returned.""" @@ -257,21 +257,6 @@ def test_get_song_list_with_query_complex(self): self.assertCountEqual(query["work_type"]["wt1"]["contains"], ["workName"]) self.assertCountEqual(query["work_type"]["wt1"]["exact"], []) - def song_query_test(self, query, expected_songs): - """Method to test a song request with a given query. - - Returned songs should be the same as expected_songs, - in the same order. - """ - # TODO This only works when there is only one page of songs - response = self.client.get(self.url, {"query": query}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], len(expected_songs)) - results = response.data["results"] - self.assertEqual(len(results), len(expected_songs)) - for song, expected_song in zip(results, expected_songs): - self.assertEqual(song["id"], expected_song.id) - def test_get_song_list_disabled_tag(self): """Test to verify songs with disabled for user. diff --git a/dakara_server/playlist/query.py b/dakara_server/playlist/query.py index 16b7708..0e444cd 100644 --- a/dakara_server/playlist/query.py +++ b/dakara_server/playlist/query.py @@ -1,6 +1,6 @@ from django.db.models import Q -from internal.query import gather_query, gather_query_many, gather_query_remain, query +from internal.query import gather_query, gather_query_many, gather_query_remain, q from library.models import WorkType from library.query import make_songs_query_from_res from library.query_language import QueryLanguageParser, regroup @@ -24,14 +24,14 @@ def make_entries_query_from_res(res, prefix=None): # query for owner for owner in res["owner"]["contains"]: - query_list.append(query(prefix, "owner__username__icontains", owner)) + query_list.append(q(prefix, "owner__username__icontains", owner)) for owner in res["owner"]["exact"]: - query_list.append(query(prefix, "owner__username__iexact", owner)) + query_list.append(q(prefix, "owner__username__iexact", owner)) # unspecific terms of the research for remain in res["remaining"]: - query_list_remain.append(query(prefix, "owner__username__icontains", remain)) + query_list_remain.append(q(prefix, "owner__username__icontains", remain)) return query_list, query_list_remain, query_list_many diff --git a/dakara_server/playlist/tests/base_test.py b/dakara_server/playlist/tests/base_test.py index 7f1d8d8..c740872 100644 --- a/dakara_server/playlist/tests/base_test.py +++ b/dakara_server/playlist/tests/base_test.py @@ -2,7 +2,6 @@ from django.core.cache import cache from django.utils.dateparse import parse_datetime -from rest_framework import status from internal.tests.base_test import BaseAPITestCase, BaseProvider, UserModel, tz from library.models import Song, SongTag @@ -131,20 +130,6 @@ def check_playlist_played_entry_json(self, json, expected_entry): self.check_playlist_entry_json(json, expected_entry) self.assertEqual(parse_datetime(json["date_play"]), expected_entry.date_play) - def check_playlist_entries_query(self, query, expected_entries): - """Method to check a query of playlist entries. - - Returned entries should be the same as `expected_entries`, in the same - order. - """ - response = self.client.get(self.url, {"query": query}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], len(expected_entries)) - results = response.data["results"] - self.assertEqual(len(results), len(expected_entries)) - for entry, expected_entry in zip(results, expected_entries): - self.assertEqual(entry["id"], expected_entry.id) - def get_player_token(self): """Create and give player token.""" karaoke = Karaoke.objects.get_object() diff --git a/dakara_server/playlist/tests/test_player_error.py b/dakara_server/playlist/tests/test_player_error.py index 341ce5a..d12177e 100644 --- a/dakara_server/playlist/tests/test_player_error.py +++ b/dakara_server/playlist/tests/test_player_error.py @@ -108,8 +108,8 @@ def test_get_errors_with_query(self): self.authenticate(self.user) - self.check_playlist_entries_query("ong1", [self.pe1]) - self.check_playlist_entries_query("overflow", [self.pe2]) + self.check_query("ong1", [self.pe1]) + self.check_query("overflow", [self.pe2]) def test_get_errors_with_query_message(self): """Search errors with simple query.""" @@ -120,9 +120,9 @@ def test_get_errors_with_query_message(self): self.authenticate(self.user) - self.check_playlist_entries_query("message:dummy", [self.pe1]) - self.check_playlist_entries_query('message:"dummy error"', [self.pe1]) - self.check_playlist_entries_query('message:""dummy error""', [self.pe1]) + self.check_query("message:dummy", [self.pe1]) + self.check_query('message:"dummy error"', [self.pe1]) + self.check_query('message:""dummy error""', [self.pe1]) def test_get_errors_with_query_song_title(self): """Search errors with simple query.""" @@ -133,7 +133,7 @@ def test_get_errors_with_query_song_title(self): self.authenticate(self.user) - self.check_playlist_entries_query("title: song1", [self.pe1]) + self.check_query("title: song1", [self.pe1]) @patch("playlist.views.send_to_channel") def test_post_error_success(self, mocked_send_to_channel): diff --git a/dakara_server/playlist/tests/test_playlist_played.py b/dakara_server/playlist/tests/test_playlist_played.py index 41e2ad3..31ce13f 100644 --- a/dakara_server/playlist/tests/test_playlist_played.py +++ b/dakara_server/playlist/tests/test_playlist_played.py @@ -36,20 +36,20 @@ def test_get_playlist_played_list_with_query(self): """Search playlist entries played list with simple query.""" self.authenticate(self.user) - self.check_playlist_entries_query("ong1", [self.pe4]) - self.check_playlist_entries_query("anager", [self.pe3]) + self.check_query("ong1", [self.pe4]) + self.check_query("anager", [self.pe3]) def test_get_playlist_played_list_with_query_song_title(self): """Search playlist entries played list by song title.""" self.authenticate(self.user) - self.check_playlist_entries_query("title: song1", [self.pe4]) + self.check_query("title: song1", [self.pe4]) def test_get_playlist_played_list_with_query_owner(self): """Search playlist entries played list by owner.""" self.authenticate(self.user) - self.check_playlist_entries_query("owner:manager", [self.pe3]) - self.check_playlist_entries_query('owner:"manager"', [self.pe3]) - self.check_playlist_entries_query('owner:""testPlaylistManager""', [self.pe3]) - self.check_playlist_entries_query("owner:user", [self.pe4]) + self.check_query("owner:manager", [self.pe3]) + self.check_query('owner:"manager"', [self.pe3]) + self.check_query('owner:""testPlaylistManager""', [self.pe3]) + self.check_query("owner:user", [self.pe4]) diff --git a/dakara_server/playlist/tests/test_playlist_queuing.py b/dakara_server/playlist/tests/test_playlist_queuing.py index 2688a2b..ad92397 100644 --- a/dakara_server/playlist/tests/test_playlist_queuing.py +++ b/dakara_server/playlist/tests/test_playlist_queuing.py @@ -48,23 +48,23 @@ def test_get_playlist_queuing_list_with_query(self): """Search playlist entries queuing list with simple query.""" self.authenticate(self.user) - self.check_playlist_entries_query("ong1", [self.pe1]) - self.check_playlist_entries_query("user", [self.pe2]) + self.check_query("ong1", [self.pe1]) + self.check_query("user", [self.pe2]) def test_get_playlist_queuing_list_with_query_song_title(self): """Search playlist entries queuing list by song title.""" self.authenticate(self.user) - self.check_playlist_entries_query("title: song1", [self.pe1]) + self.check_query("title: song1", [self.pe1]) def test_get_playlist_queuing_list_with_query_owner(self): """Search playlist entries queuing list by owner.""" self.authenticate(self.user) - self.check_playlist_entries_query("owner: manager", [self.pe1]) - self.check_playlist_entries_query('owner:"manager"', [self.pe1]) - self.check_playlist_entries_query('owner:""testPlaylistManager""', [self.pe1]) - self.check_playlist_entries_query("owner: user", [self.pe2]) + self.check_query("owner: manager", [self.pe1]) + self.check_query('owner:"manager"', [self.pe1]) + self.check_query('owner:""testPlaylistManager""', [self.pe1]) + self.check_query("owner: user", [self.pe2]) @patch("playlist.views.send_to_channel") def test_post_create_playlist_entry(self, mocked_send_to_channel): diff --git a/dakara_server/playlist/views.py b/dakara_server/playlist/views.py index a67c7b6..5520dda 100644 --- a/dakara_server/playlist/views.py +++ b/dakara_server/playlist/views.py @@ -504,7 +504,7 @@ class PlayerErrorListView(QueryParsedListMixin, drf_generics.ListCreateAPIView): serializer_class = serializers.PlayerErrorSerializer def get_queryset(self): - """Search and filters the playlist entries.""" + """Search and filters the player errors.""" query_set = models.PlayerError.objects.all() # if 'query' is in the query string then perform search otherwise diff --git a/dakara_server/users/query.py b/dakara_server/users/query.py new file mode 100644 index 0000000..92f6e2d --- /dev/null +++ b/dakara_server/users/query.py @@ -0,0 +1,32 @@ +from django.db.models import Q + +from internal.query import gather_query_remain +from library.query_language import QueryLanguageParser + + +def query_users(query_set, query): + """Create a queryset that filters users according to a query. + + Args: + query_set (): Initial query set (containing all users). + query (str): Query string. It can follow the + the query language, to specify which term + to search and where, or be a simple + pattern. + + Returns: + tuple: Tuple of the filtered query set, and the + parsed query. + """ + # using query language parser to split terms and for uniformity + res = QueryLanguageParser.split_remaining(query) + + query_list_remain = [] + # only unspecific terms are used + for remain in res: + query_list_remain.append(Q(username__icontains=remain)) + + # gather the query objects + query_set_filtered = gather_query_remain(query_set, query_list_remain) + + return query_set_filtered, res diff --git a/dakara_server/users/tests/test_views.py b/dakara_server/users/tests/test_views.py index 54b7551..14c3817 100644 --- a/dakara_server/users/tests/test_views.py +++ b/dakara_server/users/tests/test_views.py @@ -220,9 +220,7 @@ def setUp(self): self.user = self.create_user("TestUser") # Create a users manager - self.manager = self.create_user( - "TestUserManager", users_level=UserModel.MANAGER - ) + self.manager = self.create_user("TestManager", users_level=UserModel.MANAGER) def test_get_users_list(self): """Test to verify users list.""" @@ -241,6 +239,13 @@ def test_get_users_list_unauthorized(self): response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_get_users_list_with_query(self): + """Search users list with simple query.""" + self.authenticate(self.user) + + self.check_query("stuser", [self.user]) + self.check_query("anager", [self.manager]) + @patch("users.views.send_register_verification_email_notification") def test_create_user(self, mocked_send_email): """Test to verify user creation.""" diff --git a/dakara_server/users/views.py b/dakara_server/users/views.py index 69de86a..c78131e 100644 --- a/dakara_server/users/views.py +++ b/dakara_server/users/views.py @@ -10,7 +10,9 @@ ) from internal import permissions as internal_permissions +from internal.views_mixins import QueryParsedListMixin from users import emails, permissions, serializers +from users.query import query_users UserModel = get_user_model() @@ -29,16 +31,32 @@ def get(self, request): return Response(serializer.data) -class UserListView(generics.ListCreateAPIView): +class UserListView(QueryParsedListMixin, generics.ListCreateAPIView): """List and creation of users.""" model = UserModel - queryset = UserModel.objects.all().order_by("username") permission_classes = [ IsAuthenticated, permissions.IsUsersManager | internal_permissions.IsReadOnly, ] + def get_queryset(self): + """Search and filters the users.""" + query_set = self.model.objects.all() + + # if 'query' is in the query string then perform search otherwise + # return all songs + if "query" not in self.request.query_params: + return query_set.order_by("username") + + query = self.request.query_params.get("query", None) + if query: + # query the song and save the parsed query + # to give it back to the client + query_set, self.query_parsed = query_users(query_set, query) + + return query_set.distinct().order_by("username") + def get_serializer_class(self): # serializer depends on permission level if permissions.IsUsersManager().has_permission(self.request, self): From 746e44cda74cc591200e7f6e1a98e203bc5cee35 Mon Sep 17 00:00:00 2001 From: Neraste Date: Mon, 4 Aug 2025 19:37:45 +0200 Subject: [PATCH 12/26] Factorize code --- dakara_server/internal/views_mixins.py | 15 ++++++++++ dakara_server/library/query.py | 4 +-- dakara_server/library/views.py | 41 ++++---------------------- dakara_server/playlist/views.py | 39 ++---------------------- dakara_server/users/query.py | 2 +- dakara_server/users/views.py | 13 +------- 6 files changed, 27 insertions(+), 87 deletions(-) diff --git a/dakara_server/internal/views_mixins.py b/dakara_server/internal/views_mixins.py index cc63d3c..c9eb17b 100644 --- a/dakara_server/internal/views_mixins.py +++ b/dakara_server/internal/views_mixins.py @@ -19,6 +19,21 @@ def list(self, request, *args, **kwargs): return response + def perform_query(self, query_set, query_method): + """Perform the query in the query set.""" + # if 'query' is in the query string then perform search otherwise + # return all songs + if "query" not in self.request.query_params: + return query_set + + query = self.request.query_params.get("query", None) + if query: + # query the song and save the parsed query + # to give it back to the client + query_set, self.query_parsed = query_method(query_set, query) + + return query_set.distinct() + class MultiSerializerMixin: """Mixin that adapts serializer if a list of data is provided.""" diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index bf4d92e..5db03c4 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -142,7 +142,7 @@ def query_artists(query_set, query): # gather the query objects query_set_filtered = gather_query_remain(query_set, query_list_remain) - return query_set_filtered, res + return query_set_filtered, {"remaining": res} def query_works(query_set, query): @@ -171,4 +171,4 @@ def query_works(query_set, query): # gather the query objects query_set_filtered = gather_query_remain(query_set, query_list_remain) - return query_set_filtered, res + return query_set_filtered, {"remaining": res} diff --git a/dakara_server/library/views.py b/dakara_server/library/views.py index ee01728..b23207f 100644 --- a/dakara_server/library/views.py +++ b/dakara_server/library/views.py @@ -40,18 +40,7 @@ def get_queryset(self): if not (user.is_superuser or user.is_library_manager): query_set = query_set.exclude(tags__disabled=True) - # if 'query' is in the query string then perform search otherwise - # return all songs - if "query" not in self.request.query_params: - return query_set.order_by(Lower("title")) - - query = self.request.query_params.get("query", None) - if query: - # query the song and save the parsed query - # to give it back to the client - query_set, self.query_parsed = query_songs(query_set, query) - - return query_set.distinct().order_by(Lower("title")) + return self.perform_query(query_set, query_songs).order_by(Lower("title")) class SongView(RetrieveUpdateDestroyAPIView): @@ -89,18 +78,7 @@ def get_queryset(self): """Search and filter the artists.""" query_set = models.Artist.objects.all() - # if 'query' is in the query string then perform search return results - # of the corresponding query - if "query" not in self.request.query_params: - return query_set.order_by(Lower("name")) - - query = self.request.query_params.get("query", None) - if query: - query_set, res = query_artists(query_set, query) - # saving the parsed query to give it back to the client - self.query_parsed = {"remaining": res} - - return query_set.order_by(Lower("name")) + return self.perform_query(query_set, query_artists).order_by(Lower("name")) class ArtistPruneView(APIView): @@ -141,18 +119,9 @@ def get_queryset(self): if work_type: query_set = query_set.filter(work_type__query_name=work_type) - # if 'query' is in the query string then perform search return results - # of the corresponding query and type filter - if "query" not in self.request.query_params: - return query_set.order_by(Lower("title"), Lower("subtitle")) - - query = self.request.query_params.get("query", None) - if query: - query_set, res = query_works(query_set, query) - # saving the parsed query to give it back to the client - self.query_parsed = {"remaining": res} - - return query_set.distinct().order_by(Lower("title"), Lower("subtitle")) + return self.perform_query(query_set, query_works).order_by( + Lower("title"), Lower("subtitle") + ) class WorkView(RetrieveUpdateDestroyAPIView): diff --git a/dakara_server/playlist/views.py b/dakara_server/playlist/views.py index 5520dda..dcb49d1 100644 --- a/dakara_server/playlist/views.py +++ b/dakara_server/playlist/views.py @@ -77,18 +77,7 @@ def get_queryset(self): """Search and filters the playlist entries.""" query_set = models.PlaylistEntry.objects.get_queuing() - # if 'query' is in the query string then perform search otherwise - # return all songs - if "query" not in self.request.query_params: - return query_set - - query = self.request.query_params.get("query", None) - if query: - # query the song and save the parsed query - # to give it back to the client - query_set, self.query_parsed = query_entries(query_set, query) - - return query_set.distinct() + return self.perform_query(query_set, query_entries) def perform_create(self, serializer): # Deny creation if kara is not ongoing @@ -182,18 +171,7 @@ def get_queryset(self): """Search and filters the playlist entries.""" query_set = models.PlaylistEntry.objects.get_played().reverse() - # if 'query' is in the query string then perform search otherwise - # return all songs - if "query" not in self.request.query_params: - return query_set - - query = self.request.query_params.get("query", None) - if query: - # query the song and save the parsed query - # to give it back to the client - query_set, self.query_parsed = query_entries(query_set, query) - - return query_set.distinct() + return self.perform_query(query_set, query_entries) class PlayerCommandView(drf_generics.UpdateAPIView): @@ -507,18 +485,7 @@ def get_queryset(self): """Search and filters the player errors.""" query_set = models.PlayerError.objects.all() - # if 'query' is in the query string then perform search otherwise - # return all songs - if "query" not in self.request.query_params: - return query_set.order_by("date_created").reverse() - - query = self.request.query_params.get("query", None) - if query: - # query the song and save the parsed query - # to give it back to the client - query_set, self.query_parsed = query_errors(query_set, query) - - return query_set.distinct().order_by("date_created").reverse() + return self.perform_query(query_set, query_errors).order_by("-date_created") def perform_create(self, serializer): """Create an error and perform other actions. diff --git a/dakara_server/users/query.py b/dakara_server/users/query.py index 92f6e2d..2d36263 100644 --- a/dakara_server/users/query.py +++ b/dakara_server/users/query.py @@ -29,4 +29,4 @@ def query_users(query_set, query): # gather the query objects query_set_filtered = gather_query_remain(query_set, query_list_remain) - return query_set_filtered, res + return query_set_filtered, {"remaining": res} diff --git a/dakara_server/users/views.py b/dakara_server/users/views.py index c78131e..a4407d8 100644 --- a/dakara_server/users/views.py +++ b/dakara_server/users/views.py @@ -44,18 +44,7 @@ def get_queryset(self): """Search and filters the users.""" query_set = self.model.objects.all() - # if 'query' is in the query string then perform search otherwise - # return all songs - if "query" not in self.request.query_params: - return query_set.order_by("username") - - query = self.request.query_params.get("query", None) - if query: - # query the song and save the parsed query - # to give it back to the client - query_set, self.query_parsed = query_users(query_set, query) - - return query_set.distinct().order_by("username") + return self.perform_query(query_set, query_users).order_by("username") def get_serializer_class(self): # serializer depends on permission level From 74dcec54416d300fdc262a29bde1364d76646d5c Mon Sep 17 00:00:00 2001 From: Neraste Date: Mon, 4 Aug 2025 19:54:07 +0200 Subject: [PATCH 13/26] Test queries return a query dict in representation --- dakara_server/internal/tests/base_test.py | 5 +++++ dakara_server/playlist/tests/test_player_error.py | 9 +++++++-- dakara_server/playlist/tests/test_playlist_played.py | 9 +++++++-- dakara_server/playlist/tests/test_playlist_queuing.py | 9 +++++++-- dakara_server/users/tests/test_views.py | 9 +++++++-- 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/dakara_server/internal/tests/base_test.py b/dakara_server/internal/tests/base_test.py index c269c69..d582b1e 100644 --- a/dakara_server/internal/tests/base_test.py +++ b/dakara_server/internal/tests/base_test.py @@ -76,6 +76,9 @@ def check_query(self, query, expected): query (str): Terms of the query, to be parsed. expected_items (list): List of expected objects, that must have an `id`. + + Returns: + Response of the client, for furthur analysis. """ response = self.client.get(self.url, {"query": query}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -85,6 +88,8 @@ def check_query(self, query, expected): for item, expected_item in zip(results, expected): self.assertEqual(item["id"], expected_item.id) + return response + class BaseAPITestCase(APITestCase, BaseProvider): """Base test class for Unittest.""" diff --git a/dakara_server/playlist/tests/test_player_error.py b/dakara_server/playlist/tests/test_player_error.py index d12177e..251f97c 100644 --- a/dakara_server/playlist/tests/test_player_error.py +++ b/dakara_server/playlist/tests/test_player_error.py @@ -108,8 +108,13 @@ def test_get_errors_with_query(self): self.authenticate(self.user) - self.check_query("ong1", [self.pe1]) - self.check_query("overflow", [self.pe2]) + response0 = self.check_query("ong1", [self.pe1]) + + self.assertCountEqual(response0.data["query"]["remaining"], ["ong1"]) + + response1 = self.check_query("overflow", [self.pe2]) + + self.assertCountEqual(response1.data["query"]["remaining"], ["overflow"]) def test_get_errors_with_query_message(self): """Search errors with simple query.""" diff --git a/dakara_server/playlist/tests/test_playlist_played.py b/dakara_server/playlist/tests/test_playlist_played.py index 31ce13f..2ac6d91 100644 --- a/dakara_server/playlist/tests/test_playlist_played.py +++ b/dakara_server/playlist/tests/test_playlist_played.py @@ -36,8 +36,13 @@ def test_get_playlist_played_list_with_query(self): """Search playlist entries played list with simple query.""" self.authenticate(self.user) - self.check_query("ong1", [self.pe4]) - self.check_query("anager", [self.pe3]) + response0 = self.check_query("ong1", [self.pe4]) + + self.assertCountEqual(response0.data["query"]["remaining"], ["ong1"]) + + response1 = self.check_query("anager", [self.pe3]) + + self.assertCountEqual(response1.data["query"]["remaining"], ["anager"]) def test_get_playlist_played_list_with_query_song_title(self): """Search playlist entries played list by song title.""" diff --git a/dakara_server/playlist/tests/test_playlist_queuing.py b/dakara_server/playlist/tests/test_playlist_queuing.py index ad92397..454e054 100644 --- a/dakara_server/playlist/tests/test_playlist_queuing.py +++ b/dakara_server/playlist/tests/test_playlist_queuing.py @@ -48,8 +48,13 @@ def test_get_playlist_queuing_list_with_query(self): """Search playlist entries queuing list with simple query.""" self.authenticate(self.user) - self.check_query("ong1", [self.pe1]) - self.check_query("user", [self.pe2]) + response0 = self.check_query("ong1", [self.pe1]) + + self.assertCountEqual(response0.data["query"]["remaining"], ["ong1"]) + + response1 = self.check_query("user", [self.pe2]) + + self.assertCountEqual(response1.data["query"]["remaining"], ["user"]) def test_get_playlist_queuing_list_with_query_song_title(self): """Search playlist entries queuing list by song title.""" diff --git a/dakara_server/users/tests/test_views.py b/dakara_server/users/tests/test_views.py index 14c3817..53b4ef6 100644 --- a/dakara_server/users/tests/test_views.py +++ b/dakara_server/users/tests/test_views.py @@ -243,8 +243,13 @@ def test_get_users_list_with_query(self): """Search users list with simple query.""" self.authenticate(self.user) - self.check_query("stuser", [self.user]) - self.check_query("anager", [self.manager]) + response0 = self.check_query("stuser", [self.user]) + + self.assertCountEqual(response0.data["query"]["remaining"], ["stuser"]) + + response1 = self.check_query("anager", [self.manager]) + + self.assertCountEqual(response1.data["query"]["remaining"], ["anager"]) @patch("users.views.send_register_verification_email_notification") def test_create_user(self, mocked_send_email): From dad332f6d3d994de182af0ac1af2f237442c768f Mon Sep 17 00:00:00 2001 From: Neraste Date: Mon, 18 Aug 2025 18:53:06 +0200 Subject: [PATCH 14/26] Fix wrong comparison in tests for player errors --- .../playlist/tests/test_player_error.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/dakara_server/playlist/tests/test_player_error.py b/dakara_server/playlist/tests/test_player_error.py index 251f97c..ee15532 100644 --- a/dakara_server/playlist/tests/test_player_error.py +++ b/dakara_server/playlist/tests/test_player_error.py @@ -97,48 +97,48 @@ def test_get_errors_forbidden(self): def test_get_errors_with_query(self): """Search errors with simple query.""" with freeze_time("1970-01-01 00:01:00"): - PlayerError.objects.create( + error1 = PlayerError.objects.create( playlist_entry=self.pe1, error_message="dummy error" ) with freeze_time("1970-01-01 00:02:00"): - PlayerError.objects.create( + error2 = PlayerError.objects.create( playlist_entry=self.pe2, error_message="leek overflow" ) self.authenticate(self.user) - response0 = self.check_query("ong1", [self.pe1]) + response0 = self.check_query("ong1", [error1]) self.assertCountEqual(response0.data["query"]["remaining"], ["ong1"]) - response1 = self.check_query("overflow", [self.pe2]) + response1 = self.check_query("overflow", [error2]) self.assertCountEqual(response1.data["query"]["remaining"], ["overflow"]) def test_get_errors_with_query_message(self): """Search errors with simple query.""" with freeze_time("1970-01-01 00:01:00"): - PlayerError.objects.create( + error = PlayerError.objects.create( playlist_entry=self.pe1, error_message="dummy error" ) self.authenticate(self.user) - self.check_query("message:dummy", [self.pe1]) - self.check_query('message:"dummy error"', [self.pe1]) - self.check_query('message:""dummy error""', [self.pe1]) + self.check_query("message:dummy", [error]) + self.check_query('message:"dummy error"', [error]) + self.check_query('message:""dummy error""', [error]) def test_get_errors_with_query_song_title(self): """Search errors with simple query.""" with freeze_time("1970-01-01 00:01:00"): - PlayerError.objects.create( + error = PlayerError.objects.create( playlist_entry=self.pe1, error_message="dummy error" ) self.authenticate(self.user) - self.check_query("title: song1", [self.pe1]) + self.check_query("title: song1", [error]) @patch("playlist.views.send_to_channel") def test_post_error_success(self, mocked_send_to_channel): From f7ac951023bc6f94d9eb62d1f902bdac288b4ec1 Mon Sep 17 00:00:00 2001 From: Neraste Date: Mon, 18 Aug 2025 20:10:17 +0200 Subject: [PATCH 15/26] Allow to search by ID for playlist entries and player errors --- dakara_server/playlist/query.py | 77 ++++++++++++++----- .../playlist/tests/test_player_error.py | 11 +++ .../playlist/tests/test_playlist_played.py | 6 ++ .../playlist/tests/test_playlist_queuing.py | 6 ++ 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/dakara_server/playlist/query.py b/dakara_server/playlist/query.py index 0e444cd..fa441cf 100644 --- a/dakara_server/playlist/query.py +++ b/dakara_server/playlist/query.py @@ -10,13 +10,26 @@ def make_entries_query_from_res(res, prefix=None): """Make a query for playlist entries. Args: - res (dict): Dictionary on research terms, parsed. + res (dict): Dictionary on research terms, parsed. If `id` is in the + query terms, only filter by it. prefix (str or None): Optional prefix to add when creating the query. Returns: - tuple of list: List of queries, and list of queries targeting many to - many relations. + tuple of list: List of queries, list of remaining terms, and list of + queries targeting many to many relations. """ + # query for id + # optional and terminal + # same behavior for contains and exact + if (res_id := res.pop("id", None)) and ( + ids := res_id["contains"] + res_id["exact"] + ): + query_list = [] + for id in ids: + query_list.append(q(prefix, "id", int(id))) + + return query_list, [], [] + # query for song query_list, query_list_remain, query_list_many = make_songs_query_from_res( res, (prefix or "") + "song__" @@ -52,7 +65,7 @@ def query_entries(query_set, query): """ work_types = [wt.query_name for wt in WorkType.objects.all()] language_parser = QueryLanguageParser( - ["owner", "artist", "work", "title"] + work_types + ["id", "owner", "artist", "work", "title"] + work_types ) res = regroup(language_parser.parse(query), "work_type", work_types) @@ -68,25 +81,27 @@ def query_entries(query_set, query): return query_set_filtered, res -def query_errors(query_set, query): - """Create a queryset that filters player errors according to a query. +def make_errors_query_from_res(res): + """Make a query for player errors. Args: - query_set (): Initial query set (containing all errors). - query (str): Query string. It can follow the - the query language, to specify which term - to search and where, or be a simple - pattern. + res (dict): Dictionary on research terms, parsed. If `id` is in the + query terms, only filter by it. Returns: - tuple: Tuple of the filtered query set, and the - parsed query. + tuple of list: List of queries, list of remaining terms, and list of + queries targeting many to many relations. """ - work_types = [wt.query_name for wt in WorkType.objects.all()] - language_parser = QueryLanguageParser( - ["message", "owner", "artist", "work", "title"] + work_types - ) - res = regroup(language_parser.parse(query), "work_type", work_types) + # query for id + # terminal + # same behavior for contains and exact + res_id = res.pop("id") + if ids := res_id["contains"] + res_id["exact"]: + query_list = [] + for id in ids: + query_list.append(Q(id=int(id))) + + return query_list, [], [] # query for entries query_list, query_list_remain, query_list_many = make_entries_query_from_res( @@ -104,6 +119,32 @@ def query_errors(query_set, query): for remain in res["remaining"]: query_list_remain.append(Q(error_message__icontains=remain)) + return query_list, query_list_remain, query_list_many + + +def query_errors(query_set, query): + """Create a queryset that filters player errors according to a query. + + Args: + query_set (): Initial query set (containing all errors). + query (str): Query string. It can follow the + the query language, to specify which term + to search and where, or be a simple + pattern. + + Returns: + tuple: Tuple of the filtered query set, and the + parsed query. + """ + work_types = [wt.query_name for wt in WorkType.objects.all()] + language_parser = QueryLanguageParser( + ["id", "message", "owner", "artist", "work", "title"] + work_types + ) + res = regroup(language_parser.parse(query), "work_type", work_types) + + # query for errors + query_list, query_list_remain, query_list_many = make_errors_query_from_res(res) + # gather the query objects query_set_filtered = gather_query_many( gather_query_remain(gather_query(query_set, query_list), query_list_remain), diff --git a/dakara_server/playlist/tests/test_player_error.py b/dakara_server/playlist/tests/test_player_error.py index ee15532..efb247c 100644 --- a/dakara_server/playlist/tests/test_player_error.py +++ b/dakara_server/playlist/tests/test_player_error.py @@ -129,6 +129,17 @@ def test_get_errors_with_query_message(self): self.check_query('message:"dummy error"', [error]) self.check_query('message:""dummy error""', [error]) + def test_get_errors_with_query_id(self): + """Search error by id.""" + with freeze_time("1970-01-01 00:01:00"): + error = PlayerError.objects.create( + id=20, playlist_entry=self.pe1, error_message="dummy error" + ) + + self.authenticate(self.user) + + self.check_query("id:20", [error]) + def test_get_errors_with_query_song_title(self): """Search errors with simple query.""" with freeze_time("1970-01-01 00:01:00"): diff --git a/dakara_server/playlist/tests/test_playlist_played.py b/dakara_server/playlist/tests/test_playlist_played.py index 2ac6d91..446a757 100644 --- a/dakara_server/playlist/tests/test_playlist_played.py +++ b/dakara_server/playlist/tests/test_playlist_played.py @@ -44,6 +44,12 @@ def test_get_playlist_played_list_with_query(self): self.assertCountEqual(response1.data["query"]["remaining"], ["anager"]) + def test_get_playlist_played_list_with_query_id(self): + """Search playlist entries played list by id.""" + self.authenticate(self.user) + + self.check_query("id:4", [self.pe4]) + def test_get_playlist_played_list_with_query_song_title(self): """Search playlist entries played list by song title.""" self.authenticate(self.user) diff --git a/dakara_server/playlist/tests/test_playlist_queuing.py b/dakara_server/playlist/tests/test_playlist_queuing.py index 454e054..5631e57 100644 --- a/dakara_server/playlist/tests/test_playlist_queuing.py +++ b/dakara_server/playlist/tests/test_playlist_queuing.py @@ -56,6 +56,12 @@ def test_get_playlist_queuing_list_with_query(self): self.assertCountEqual(response1.data["query"]["remaining"], ["user"]) + def test_get_playlist_queuing_list_with_query_id(self): + """Search playlist entries queuing list by id.""" + self.authenticate(self.user) + + self.check_query("id:1", [self.pe1]) + def test_get_playlist_queuing_list_with_query_song_title(self): """Search playlist entries queuing list by song title.""" self.authenticate(self.user) From d6bb4f38681d50bbc15c1530335de7bcb1813b24 Mon Sep 17 00:00:00 2001 From: Neraste Date: Mon, 18 Aug 2025 20:53:36 +0200 Subject: [PATCH 16/26] Allow to search by ID for songs --- dakara_server/library/query.py | 19 +++++++++++++++++-- dakara_server/library/tests/test_song.py | 7 +++++++ dakara_server/playlist/query.py | 4 ++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index 5db03c4..805a78f 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -9,13 +9,26 @@ def make_songs_query_from_res(res, prefix=None): """Make a query for songs. Args: - res (dict): Dictionary on research terms, parsed. + res (dict): Dictionary on research terms, parsed. If `id` is in the + query terms, only filter by it. prefix (str or None): Optional prefix to add when creating the query. Returns: tuple of list: List of queries, list of remaining queries, and list of queries targeting many to many relations. """ + # query for id + # optional and terminal + # same behavior for contains and exact + if (res_id := res.pop("id", None)) and ( + ids := res_id["contains"] + res_id["exact"] + ): + query_list = [] + for id in ids: + query_list.append(q(prefix, "id", int(id))) + + return query_list, [], [] + query_list = [] query_list_remain = [] query_list_many = [] @@ -105,7 +118,9 @@ def query_songs(query_set, query): parsed query. """ work_types = [wt.query_name for wt in WorkType.objects.all()] - language_parser = QueryLanguageParser(["artist", "work", "title"] + work_types) + language_parser = QueryLanguageParser( + ["id", "artist", "work", "title"] + work_types + ) res = regroup(language_parser.parse(query), "work_type", work_types) # query diff --git a/dakara_server/library/tests/test_song.py b/dakara_server/library/tests/test_song.py index fc150fc..72a147c 100644 --- a/dakara_server/library/tests/test_song.py +++ b/dakara_server/library/tests/test_song.py @@ -118,6 +118,13 @@ def test_get_song_list_with_query_detail(self): # Should only return song2 self.check_query("etail_Video2", [self.song2]) + def test_get_song_list_with_query_id(self): + """Test search song by ID.""" + # Login as simple user + self.authenticate(self.user) + + self.check_query("id:1", [self.song1]) + def test_get_song_list_with_query_tag(self): """Test to verify song list with tag query.""" # Login as simple user diff --git a/dakara_server/playlist/query.py b/dakara_server/playlist/query.py index fa441cf..1ebe1b1 100644 --- a/dakara_server/playlist/query.py +++ b/dakara_server/playlist/query.py @@ -15,7 +15,7 @@ def make_entries_query_from_res(res, prefix=None): prefix (str or None): Optional prefix to add when creating the query. Returns: - tuple of list: List of queries, list of remaining terms, and list of + tuple of list: List of queries, list of remaining queries, and list of queries targeting many to many relations. """ # query for id @@ -89,7 +89,7 @@ def make_errors_query_from_res(res): query terms, only filter by it. Returns: - tuple of list: List of queries, list of remaining terms, and list of + tuple of list: List of queries, list of remaining queries, and list of queries targeting many to many relations. """ # query for id From 95697216719dddb2fcfa460fc5ac250c3eba20bd Mon Sep 17 00:00:00 2001 From: Neraste Date: Mon, 18 Aug 2025 21:24:26 +0200 Subject: [PATCH 17/26] Allow to search by ID for users. --- dakara_server/users/query.py | 48 ++++++++++++++++++++----- dakara_server/users/tests/test_views.py | 6 ++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/dakara_server/users/query.py b/dakara_server/users/query.py index 2d36263..42b6689 100644 --- a/dakara_server/users/query.py +++ b/dakara_server/users/query.py @@ -1,9 +1,40 @@ from django.db.models import Q -from internal.query import gather_query_remain +from internal.query import gather_query, gather_query_remain from library.query_language import QueryLanguageParser +def make_users_query_from_res(res): + """Make a query for users. + + Args: + res (dict): Dictionary on research terms, parsed. If `id` is in the + query terms, only filter by it. + prefix (str or None): Optional prefix to add when creating the query. + + Returns: + tuple of list: List of queries, and list of remaining queries. + """ + # query for id + # terminal + # same behavior for contains and exact + res_id = res.pop("id") + if ids := res_id["contains"] + res_id["exact"]: + query_list = [] + for id in ids: + query_list.append(Q(id=int(id))) + + return query_list, [] + + query_list_remain = [] + + # only unspecific terms are used + for remain in res["remaining"]: + query_list_remain.append(Q(username__icontains=remain)) + + return [], query_list_remain + + def query_users(query_set, query): """Create a queryset that filters users according to a query. @@ -19,14 +50,15 @@ def query_users(query_set, query): parsed query. """ # using query language parser to split terms and for uniformity - res = QueryLanguageParser.split_remaining(query) + language_parser = QueryLanguageParser(["id"]) + res = language_parser.parse(query) - query_list_remain = [] - # only unspecific terms are used - for remain in res: - query_list_remain.append(Q(username__icontains=remain)) + # query for users + query_list, query_list_remain = make_users_query_from_res(res) # gather the query objects - query_set_filtered = gather_query_remain(query_set, query_list_remain) + query_set_filtered = gather_query_remain( + gather_query(query_set, query_list), query_list_remain + ) - return query_set_filtered, {"remaining": res} + return query_set_filtered, res diff --git a/dakara_server/users/tests/test_views.py b/dakara_server/users/tests/test_views.py index 53b4ef6..537600c 100644 --- a/dakara_server/users/tests/test_views.py +++ b/dakara_server/users/tests/test_views.py @@ -251,6 +251,12 @@ def test_get_users_list_with_query(self): self.assertCountEqual(response1.data["query"]["remaining"], ["anager"]) + def test_get_users_list_with_query_id(self): + """Search users by ID.""" + self.authenticate(self.user) + + self.check_query("id:1", [self.user]) + @patch("users.views.send_register_verification_email_notification") def test_create_user(self, mocked_send_email): """Test to verify user creation.""" From 80419e2dd50ea28f6726c88abcea42e157b92014 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sat, 15 Nov 2025 17:50:52 +0100 Subject: [PATCH 18/26] Pretty up code --- dakara_server/internal/views_mixins.py | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/dakara_server/internal/views_mixins.py b/dakara_server/internal/views_mixins.py index c9eb17b..83ed919 100644 --- a/dakara_server/internal/views_mixins.py +++ b/dakara_server/internal/views_mixins.py @@ -20,19 +20,27 @@ def list(self, request, *args, **kwargs): return response def perform_query(self, query_set, query_method): - """Perform the query in the query set.""" + """Perform a query in the query set. + + Args: + query_set: Initial query set for the elements. + query_method (function): Function that performs the query. It must + returns a new, filtered, query set, and the parsed query. + + Returns: + The filtered query set if `query` is present in the query string + and not empty, the initial query set otherwise. + """ # if 'query' is in the query string then perform search otherwise - # return all songs - if "query" not in self.request.query_params: - return query_set - - query = self.request.query_params.get("query", None) - if query: - # query the song and save the parsed query - # to give it back to the client + # return the initial query set + if query := self.request.query_params.get("query", None): + # query the elements and save the parsed query to give it back to + # the client query_set, self.query_parsed = query_method(query_set, query) - return query_set.distinct() + return query_set.distinct() + + return query_set class MultiSerializerMixin: From b2afa2f1513050b90effb1aeaad380879d165a0a Mon Sep 17 00:00:00 2001 From: Neraste Date: Sat, 22 Nov 2025 20:21:42 +0100 Subject: [PATCH 19/26] Add tests for returned parsed query --- dakara_server/library/tests/test_artist.py | 10 ++++++++++ dakara_server/library/tests/test_song.py | 20 ++++++++++++++++++++ dakara_server/library/tests/test_work.py | 10 ++++++++++ 3 files changed, 40 insertions(+) diff --git a/dakara_server/library/tests/test_artist.py b/dakara_server/library/tests/test_artist.py index b159761..0f27ebf 100644 --- a/dakara_server/library/tests/test_artist.py +++ b/dakara_server/library/tests/test_artist.py @@ -53,6 +53,16 @@ def test_get_artist_list_with_query(self): # Should not return any artist self.artist_query_test("ork1", []) + def test_get_artist_list_parsed_query(self): + """Test the parsed query.""" + self.authenticate(self.user) + + response = self.client.get(self.url, {"query": "none"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + query = response.data["query"] + self.assertIn("remaining", query) + def test_get_artist_list_with_query_empty(self): """Test to verify artist list with empty query.""" # Login as simple user diff --git a/dakara_server/library/tests/test_song.py b/dakara_server/library/tests/test_song.py index 72a147c..4dfaaae 100644 --- a/dakara_server/library/tests/test_song.py +++ b/dakara_server/library/tests/test_song.py @@ -92,6 +92,26 @@ def test_get_song_list_with_query(self): # Should only return song2 which is linked to Work1 self.check_query("ork1", [self.song2]) + def test_get_song_list_parsed_query(self): + """Test the parsed query.""" + self.authenticate(self.user) + + response = self.client.get(self.url, {"query": "none"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + query = response.data["query"] + self.assertIn("artist", query) + self.assertIn("contains", query["artist"]) + self.assertIn("exact", query["artist"]) + self.assertIn("title", query) + self.assertIn("contains", query["title"]) + self.assertIn("exact", query["title"]) + self.assertIn("work", query) + self.assertIn("contains", query["work"]) + self.assertIn("exact", query["work"]) + self.assertIn("work_type", query) + self.assertIn("remaining", query) + def test_get_song_list_with_query_empty(self): """Test to verify song list with empty query.""" # Login as simple user diff --git a/dakara_server/library/tests/test_work.py b/dakara_server/library/tests/test_work.py index be5c295..f0c5527 100644 --- a/dakara_server/library/tests/test_work.py +++ b/dakara_server/library/tests/test_work.py @@ -69,6 +69,16 @@ def test_get_work_list_forbidden(self): response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_get_work_list_parsed_query(self): + """Test the parsed query.""" + self.authenticate(self.user) + + response = self.client.get(self.url, {"query": "none"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + query = response.data["query"] + self.assertIn("remaining", query) + def test_get_work_list_type_filter(self): """Test to verify work list with work type filter.""" # Login as simple user From 4ed5360534595e1bfef2880481b91aa82ae785ef Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 14 Dec 2025 18:45:04 +0100 Subject: [PATCH 20/26] Allow to search song tags --- dakara_server/library/query.py | 25 ++++++++++++ dakara_server/library/tests/test_song_tag.py | 43 +++++++++++++++++++- dakara_server/library/views.py | 11 +++-- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index 805a78f..d73a2a4 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -187,3 +187,28 @@ def query_works(query_set, query): query_set_filtered = gather_query_remain(query_set, query_list_remain) return query_set_filtered, {"remaining": res} + + +def query_song_tags(query_set, query): + """Create a queryset that filters song tags according to query. + + Args: + query_set (): Initial query set (containing all song tags). + query (str): Query string. It can only be a simple pattern (no query + language). + + Returns: + tuple: Tuple of the filtered query set, and the parsed query. + """ + # using query language parser to split terms and for uniformity + res = QueryLanguageParser.split_remaining(query) + + query_list_remain = [] + # only unspecific terms are used + for remain in res: + query_list_remain.append(Q(name__icontains=remain)) + + # gather the query objects + query_set_filtered = gather_query_remain(query_set, query_list_remain) + + return query_set_filtered, {"remaining": res} diff --git a/dakara_server/library/tests/test_song_tag.py b/dakara_server/library/tests/test_song_tag.py index be5898c..a999272 100644 --- a/dakara_server/library/tests/test_song_tag.py +++ b/dakara_server/library/tests/test_song_tag.py @@ -22,7 +22,7 @@ def setUp(self): self.create_test_data() def test_get_tag_list(self): - """Test to verify tag list.""" + """Test to verify tag list with no query.""" # Login as simple user self.authenticate(self.user) @@ -42,6 +42,29 @@ def test_get_tag_list_forbidden(self): response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_get_tag_list_with_query(self): + """Test to verify song tag list with query""" + self.authenticate(self.user) + + self.song_tag_query_test("tag1", [self.tag1]) + self.song_tag_query_test("aaaaa", []) + + def test_get_tag_list_parsed_query(self): + """Test the parsed query""" + self.authenticate(self.user) + + response = self.client.get(self.url, {"query": "none"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + query = response.data["query"] + self.assertIn("remaining", query) + + def test_get_tag_list_with_query_empty(self): + """Test to verify song tag list with empty query""" + self.authenticate(self.user) + + self.song_tag_query_test("", [self.tag1, self.tag2]) + def test_post_tag_already_exists(self): """Test to create a tag when it already exists.""" # Login as simple user @@ -51,6 +74,24 @@ def test_post_tag_already_exists(self): response = self.client.post(self.url, {"name": "TAG1"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def song_tag_query_test(self, query, expected_song_tags, remaining=None): + """Method to test a song tag request against a given query. + + Returned song tags should be the same as `expected_song_tags`, in the + same order. + """ + # TODO This only works when there is only one page of song tags + response = self.client.get(self.url, {"query": query}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], len(expected_song_tags)) + results = response.data["results"] + self.assertEqual(len(results), len(expected_song_tags)) + for song_tag, expected_song_tag in zip(results, expected_song_tags): + self.assertEqual(song_tag["id"], expected_song_tag.id) + + if remaining is not None: + self.assertEqual(response.data["query"]["remaining"], remaining) + class SongTagViewTestCase(LibraryAPITestCase): def setUp(self): diff --git a/dakara_server/library/views.py b/dakara_server/library/views.py index b23207f..bec32fc 100644 --- a/dakara_server/library/views.py +++ b/dakara_server/library/views.py @@ -15,7 +15,7 @@ from internal import permissions as internal_permissions from internal.views_mixins import MultiSerializerMixin, QueryParsedListMixin from library import models, permissions, serializers -from library.query import query_artists, query_songs, query_works +from library.query import query_artists, query_song_tags, query_songs, query_works logger = logging.getLogger(__name__) @@ -186,16 +186,21 @@ class WorkTypeView(RetrieveUpdateDestroyAPIView): serializer_class = serializers.WorkTypeSerializer -class SongTagListView(ListCreateAPIView): +class SongTagListView(QueryParsedListMixin, ListCreateAPIView): """List of song tags.""" permission_classes = [ IsAuthenticated, permissions.IsLibraryManager | internal_permissions.IsReadOnly, ] - queryset = models.SongTag.objects.all().order_by(Lower("name")) serializer_class = serializers.SongTagSerializer + def get_queryset(self): + """Search and filter the song tags.""" + query_set = models.SongTag.objects.all() + + return self.perform_query(query_set, query_song_tags).order_by(Lower("name")) + class SongTagView(RetrieveUpdateDestroyAPIView): """Update a song tag.""" From c202feca193ee9e702ccc80ff6d3d8b264bd2a70 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 14 Dec 2025 19:01:45 +0100 Subject: [PATCH 21/26] Fix test artists doc --- dakara_server/library/tests/test_artist.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dakara_server/library/tests/test_artist.py b/dakara_server/library/tests/test_artist.py index 0f27ebf..38292a4 100644 --- a/dakara_server/library/tests/test_artist.py +++ b/dakara_server/library/tests/test_artist.py @@ -81,7 +81,7 @@ def test_get_artist_list_with_query_no_keywords(self): # Should not return anything since it searched for the whole string self.artist_query_test("title:Artist1", [], ["title:Artist1"]) - def test_get_artists_list_with_query__multi_words(self): + def test_get_artists_list_with_query_multi_words(self): """Test query parse with multi words remaining.""" # Login as simple user self.authenticate(self.user) @@ -103,10 +103,10 @@ def test_get_artists_list_with_query__multi_words(self): ) def artist_query_test(self, query, expected_artists, remaining=None): - """Method to test a artist request with a given query. + """Method to test an artist request against a given query. - Returned artist should be the same as expected_artists, - in the same order. + Returned artists should be the same as `expected_artists`, in the same + order. """ # TODO This only works when there is only one page of artists response = self.client.get(self.url, {"query": query}) From a3f8358ccc4acebf87e497a21d3b75fb734416b5 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sat, 20 Dec 2025 00:00:27 +0100 Subject: [PATCH 22/26] Update docstrings --- dakara_server/internal/query.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/dakara_server/internal/query.py b/dakara_server/internal/query.py index 86e7536..1f256ac 100644 --- a/dakara_server/internal/query.py +++ b/dakara_server/internal/query.py @@ -19,11 +19,12 @@ def q(prefix, name, value): def gather_query(query_set, query_list): - """Filter a query set by elements of a query list. + """Filter a query set by conjunction of elements of a query list. Args: query_set: Initial query set. - query_list (list of django.db.models.Q): List of queries. + query_list (list of django.db.models.Q): List of queries. They will be + chained using the conjunction (`&=`) operator. Returns: New firtered query set. @@ -38,6 +39,16 @@ def gather_query(query_set, query_list): def gather_query_remain(query_set, query_list_remain): + """Filter a query set by disjunction of elements of a query list. + + Args: + query_set: Initial query set. + query_list (list of django.db.models.Q): List of queries. They will be + chained using the disjunction (`|=`) operator. + + Returns: + New firtered query set. + """ # now, gather the query objects filter_query = Q() for item in query_list_remain: @@ -48,12 +59,12 @@ def gather_query_remain(query_set, query_list_remain): def gather_query_many(query_set, query_list_many): - """Filter a query set by elements of a query list of many to many fields. + """Chain filter a query set by elements of a query list. Args: query_set: Initial query set. - query_list (list of django.db.models.Q): List of queries for many to - many fields. + query_list (list of django.db.models.Q): List of queries. They will be + chained to the initial query set by the `filter` method. Returns: New firtered query set. From 39a9dd9ac863fd74a4c30d81b26e5b9d3b033cd9 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sat, 20 Dec 2025 00:48:06 +0100 Subject: [PATCH 23/26] Update comments --- dakara_server/playlist/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dakara_server/playlist/query.py b/dakara_server/playlist/query.py index 1ebe1b1..15a5508 100644 --- a/dakara_server/playlist/query.py +++ b/dakara_server/playlist/query.py @@ -93,7 +93,7 @@ def make_errors_query_from_res(res): queries targeting many to many relations. """ # query for id - # terminal + # optional and terminal # same behavior for contains and exact res_id = res.pop("id") if ids := res_id["contains"] + res_id["exact"]: From 5f720316e1aaa4d32400e0fdb09d3f954b2a28d2 Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 21 Dec 2025 11:36:05 +0100 Subject: [PATCH 24/26] Move query language file in internals --- .../{library => internal}/query_language.py | 1 - .../tests/test_query_language.py | 53 +------------------ dakara_server/library/query.py | 2 +- dakara_server/library/tests/test_song.py | 14 +++++ dakara_server/library/tests/test_work.py | 2 +- dakara_server/playlist/query.py | 2 +- dakara_server/users/query.py | 2 +- 7 files changed, 19 insertions(+), 57 deletions(-) rename dakara_server/{library => internal}/query_language.py (98%) rename dakara_server/{library => internal}/tests/test_query_language.py (81%) diff --git a/dakara_server/library/query_language.py b/dakara_server/internal/query_language.py similarity index 98% rename from dakara_server/library/query_language.py rename to dakara_server/internal/query_language.py index a33d188..d88198e 100644 --- a/dakara_server/library/query_language.py +++ b/dakara_server/internal/query_language.py @@ -96,7 +96,6 @@ def parse(self, query): `remaining`: Unparsed text. """ # create results structure - # work_type will be filled only if necessary result = {kw: {"contains": [], "exact": []} for kw in self.keywords} result.update( { diff --git a/dakara_server/library/tests/test_query_language.py b/dakara_server/internal/tests/test_query_language.py similarity index 81% rename from dakara_server/library/tests/test_query_language.py rename to dakara_server/internal/tests/test_query_language.py index 73f7ab1..883b7e9 100644 --- a/dakara_server/library/tests/test_query_language.py +++ b/dakara_server/internal/tests/test_query_language.py @@ -1,17 +1,10 @@ from django.test import TestCase -from library.models import WorkType -from library.query_language import QueryLanguageParser +from internal.query_language import QueryLanguageParser class QueryLanguageParserTestCase(TestCase): def setUp(self): - # Create work types - self.wt1 = WorkType(name="WorkType1", query_name="wt1") - self.wt1.save() - self.wt2 = WorkType(name="WorkType2", query_name="wt2") - self.wt2.save() - # Create parser instance self.parser = QueryLanguageParser(("artist", "work", "title", "wt1", "wt2")) @@ -205,47 +198,3 @@ def test_parse_remaining_multi_words(self): self.assertCountEqual(res["artist"]["exact"], []) self.assertCountEqual(res["work"]["contains"], []) self.assertCountEqual(res["work"]["exact"], []) - - def test_parse_old_worktype(self): - """This test attempts to reproduce a bug where old work types were kept in - memory. - """ - # Pre-assertion, keywords contains wt1 and wt2 - self.assertCountEqual( - self.parser.keywords, ["artist", "work", "title", "wt1", "wt2"] - ) - - # Request with work type 2 - res = self.parser.parse("wt2:mywork") - self.assertCountEqual(res["remaining"], []) - self.assertCountEqual(res["tag"], []) - self.assertCountEqual(res["title"]["contains"], []) - self.assertCountEqual(res["title"]["exact"], []) - self.assertCountEqual(res["artist"]["contains"], []) - self.assertCountEqual(res["artist"]["exact"], []) - self.assertCountEqual(res["work"]["contains"], []) - self.assertCountEqual(res["work"]["exact"], []) - self.assertCountEqual(res["wt2"]["contains"], ["mywork"]) - self.assertCountEqual(res["wt2"]["exact"], []) - - # Now remove work type 2 - self.wt2.delete() - - # Create a new parser so that keywords are re-initialized with current - # workTypes - self.parser = QueryLanguageParser(("artist", "work", "title", "wt1")) - - # Check parser keywords, should not include wt2 anymore - self.assertCountEqual(self.parser.keywords, ["artist", "work", "title", "wt1"]) - - # Now the request with wt2 should not be parsed since wt2 is not a - # keyword anymore - res = self.parser.parse("wt2:mywork") - self.assertCountEqual(res["remaining"], ["wt2:mywork"]) - self.assertCountEqual(res["tag"], []) - self.assertCountEqual(res["title"]["contains"], []) - self.assertCountEqual(res["title"]["exact"], []) - self.assertCountEqual(res["artist"]["contains"], []) - self.assertCountEqual(res["artist"]["exact"], []) - self.assertCountEqual(res["work"]["contains"], []) - self.assertCountEqual(res["work"]["exact"], []) diff --git a/dakara_server/library/query.py b/dakara_server/library/query.py index d73a2a4..53e9193 100644 --- a/dakara_server/library/query.py +++ b/dakara_server/library/query.py @@ -1,8 +1,8 @@ from django.db.models import Q from internal.query import gather_query, gather_query_many, gather_query_remain, q +from internal.query_language import QueryLanguageParser, regroup from library.models import WorkType -from library.query_language import QueryLanguageParser, regroup def make_songs_query_from_res(res, prefix=None): diff --git a/dakara_server/library/tests/test_song.py b/dakara_server/library/tests/test_song.py index 4dfaaae..b292745 100644 --- a/dakara_server/library/tests/test_song.py +++ b/dakara_server/library/tests/test_song.py @@ -196,6 +196,20 @@ def test_get_song_list_with_query_work(self): # Should not return any result since Work1 is not of type workType2 self.check_query("wt2:Work1", []) + def test_get_song_list_with_query_old_worktype(self): + """Reproduce a bug where old work types would be kept in memory.""" + # Login as simple user + self.authenticate(self.user) + + # Pre-assertion, get a song using wt1 + self.check_query("wt1:work1", [self.song2]) + + # Now remove work type 1 + self.wt1.delete() + + # Assertion, there are no songs using wt1 + self.check_query("wt1:work1", []) + def test_get_song_list_with_query_work_alternative_title(self): """Test to verify song list with work alternative title query.""" # Login as simple user diff --git a/dakara_server/library/tests/test_work.py b/dakara_server/library/tests/test_work.py index f0c5527..113c43a 100644 --- a/dakara_server/library/tests/test_work.py +++ b/dakara_server/library/tests/test_work.py @@ -149,7 +149,7 @@ def test_get_work_list_with_query_no_keywords(self): # Should not return anything since it searched for the whole string self.work_query_test("title:work1", [], ["title:work1"]) - def test_get_works_list_with_query__multi_words(self): + def test_get_works_list_with_query_multi_words(self): """Test query parse with multi words remaining.""" # Login as simple user self.authenticate(self.user) diff --git a/dakara_server/playlist/query.py b/dakara_server/playlist/query.py index 15a5508..24ca3cf 100644 --- a/dakara_server/playlist/query.py +++ b/dakara_server/playlist/query.py @@ -1,9 +1,9 @@ from django.db.models import Q from internal.query import gather_query, gather_query_many, gather_query_remain, q +from internal.query_language import QueryLanguageParser, regroup from library.models import WorkType from library.query import make_songs_query_from_res -from library.query_language import QueryLanguageParser, regroup def make_entries_query_from_res(res, prefix=None): diff --git a/dakara_server/users/query.py b/dakara_server/users/query.py index 42b6689..14f987b 100644 --- a/dakara_server/users/query.py +++ b/dakara_server/users/query.py @@ -1,7 +1,7 @@ from django.db.models import Q from internal.query import gather_query, gather_query_remain -from library.query_language import QueryLanguageParser +from internal.query_language import QueryLanguageParser def make_users_query_from_res(res): From 4a261bdf89e8982a1b1c4f5009c4551b6e01607c Mon Sep 17 00:00:00 2001 From: Neraste Date: Sun, 21 Dec 2025 11:49:31 +0100 Subject: [PATCH 25/26] Factorize tests --- dakara_server/internal/tests/base_test.py | 5 ++- dakara_server/library/tests/test_artist.py | 30 ++++------------- dakara_server/library/tests/test_song_tag.py | 24 ++------------ dakara_server/library/tests/test_work.py | 34 +++++--------------- 4 files changed, 21 insertions(+), 72 deletions(-) diff --git a/dakara_server/internal/tests/base_test.py b/dakara_server/internal/tests/base_test.py index d582b1e..86f2532 100644 --- a/dakara_server/internal/tests/base_test.py +++ b/dakara_server/internal/tests/base_test.py @@ -67,7 +67,7 @@ def create_user( user.save() return user - def check_query(self, query, expected): + def check_query(self, query, expected, remaining=None): """Method to check a query. Returned entries should be the same as `expected`, in the same order. @@ -88,6 +88,9 @@ def check_query(self, query, expected): for item, expected_item in zip(results, expected): self.assertEqual(item["id"], expected_item.id) + if remaining is not None: + self.assertEqual(response.data["query"]["remaining"], remaining) + return response diff --git a/dakara_server/library/tests/test_artist.py b/dakara_server/library/tests/test_artist.py index 38292a4..b5cacd8 100644 --- a/dakara_server/library/tests/test_artist.py +++ b/dakara_server/library/tests/test_artist.py @@ -47,11 +47,11 @@ def test_get_artist_list_with_query(self): # Get artists list with query = "tist1" # Should only return artist1 - self.artist_query_test("tist1", [self.artist1]) + self.check_query("tist1", [self.artist1]) # Get artists list with query = "ork1" # Should not return any artist - self.artist_query_test("ork1", []) + self.check_query("ork1", []) def test_get_artist_list_parsed_query(self): """Test the parsed query.""" @@ -70,7 +70,7 @@ def test_get_artist_list_with_query_empty(self): # Get artists list with query = "" # Should return all artists - self.artist_query_test("", [self.artist1, self.artist2]) + self.check_query("", [self.artist1, self.artist2]) def test_get_artist_list_with_query_no_keywords(self): """Test to verify artist query do not parse keywords.""" @@ -79,7 +79,7 @@ def test_get_artist_list_with_query_no_keywords(self): # Get artists list with query = "title:Artist1" # Should not return anything since it searched for the whole string - self.artist_query_test("title:Artist1", [], ["title:Artist1"]) + self.check_query("title:Artist1", [], ["title:Artist1"]) def test_get_artists_list_with_query_multi_words(self): """Test query parse with multi words remaining.""" @@ -88,7 +88,7 @@ def test_get_artists_list_with_query_multi_words(self): # Get artists list with escaped space query # Should not return anything but check query - self.artist_query_test( + self.check_query( r"word words\ words\ words remain", [], ["word", "words words words", "remain"], @@ -96,30 +96,12 @@ def test_get_artists_list_with_query_multi_words(self): # Get artists list with quoted query # Should not return anything but check query - self.artist_query_test( + self.check_query( """ word"words words words" remain""", [], ["word", "words words words", "remain"], ) - def artist_query_test(self, query, expected_artists, remaining=None): - """Method to test an artist request against a given query. - - Returned artists should be the same as `expected_artists`, in the same - order. - """ - # TODO This only works when there is only one page of artists - response = self.client.get(self.url, {"query": query}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], len(expected_artists)) - results = response.data["results"] - self.assertEqual(len(results), len(expected_artists)) - for artist, expected_artist in zip(results, expected_artists): - self.assertEqual(artist["id"], expected_artist.id) - - if remaining is not None: - self.assertEqual(response.data["query"]["remaining"], remaining) - class ArtistPruneViewAPIViewTestCase(LibraryAPITestCase): url = reverse("library-artist-prune") diff --git a/dakara_server/library/tests/test_song_tag.py b/dakara_server/library/tests/test_song_tag.py index a999272..1449801 100644 --- a/dakara_server/library/tests/test_song_tag.py +++ b/dakara_server/library/tests/test_song_tag.py @@ -46,8 +46,8 @@ def test_get_tag_list_with_query(self): """Test to verify song tag list with query""" self.authenticate(self.user) - self.song_tag_query_test("tag1", [self.tag1]) - self.song_tag_query_test("aaaaa", []) + self.check_query("tag1", [self.tag1]) + self.check_query("aaaaa", []) def test_get_tag_list_parsed_query(self): """Test the parsed query""" @@ -63,7 +63,7 @@ def test_get_tag_list_with_query_empty(self): """Test to verify song tag list with empty query""" self.authenticate(self.user) - self.song_tag_query_test("", [self.tag1, self.tag2]) + self.check_query("", [self.tag1, self.tag2]) def test_post_tag_already_exists(self): """Test to create a tag when it already exists.""" @@ -74,24 +74,6 @@ def test_post_tag_already_exists(self): response = self.client.post(self.url, {"name": "TAG1"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def song_tag_query_test(self, query, expected_song_tags, remaining=None): - """Method to test a song tag request against a given query. - - Returned song tags should be the same as `expected_song_tags`, in the - same order. - """ - # TODO This only works when there is only one page of song tags - response = self.client.get(self.url, {"query": query}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], len(expected_song_tags)) - results = response.data["results"] - self.assertEqual(len(results), len(expected_song_tags)) - for song_tag, expected_song_tag in zip(results, expected_song_tags): - self.assertEqual(song_tag["id"], expected_song_tag.id) - - if remaining is not None: - self.assertEqual(response.data["query"]["remaining"], remaining) - class SongTagViewTestCase(LibraryAPITestCase): def setUp(self): diff --git a/dakara_server/library/tests/test_work.py b/dakara_server/library/tests/test_work.py index 113c43a..f64599e 100644 --- a/dakara_server/library/tests/test_work.py +++ b/dakara_server/library/tests/test_work.py @@ -24,24 +24,6 @@ def setUp(self): # create urls self.url_work1 = reverse("library-work", kwargs={"pk": self.work1.id}) - def work_query_test(self, query, expected_works, remaining=None): - """Method to test a work request with a given query and worktype. - - Returned work should be the same as expected_works, - in the same order. - """ - # TODO This only works when there is only one page of works - response = self.client.get(self.url, {"query": query}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], len(expected_works)) - results = response.data["results"] - self.assertEqual(len(results), len(expected_works)) - for work, expected_work in zip(results, expected_works): - self.assertEqual(work["id"], expected_work.id) - - if remaining is not None: - self.assertEqual(response.data["query"]["remaining"], remaining) - def test_get_work_list(self): """Test to verify work list with no query.""" # Login as simple user @@ -112,11 +94,11 @@ def test_get_work_list_with_query(self): # Get works list with query = "ork1" # Should only return work1 - self.work_query_test("ork1", [self.work1]) + self.check_query("ork1", [self.work1]) # Get works list with query = "tist1" # Should not return any work - self.work_query_test("tist1", []) + self.check_query("tist1", []) def test_get_work_list_with_query_alternative_title(self): """Test to verify work list with query alternative title.""" @@ -125,11 +107,11 @@ def test_get_work_list_with_query_alternative_title(self): # Get works list with query = "ltTitle1" # Should only return work1 - self.work_query_test("ltTitle1", [self.work1]) + self.check_query("ltTitle1", [self.work1]) # Get works list with query = "ltTitle2" # Should return work1 and work2 - self.work_query_test("ltTitle2", [self.work1, self.work2]) + self.check_query("ltTitle2", [self.work1, self.work2]) def test_get_work_list_with_query_empty(self): """Test to verify work list with empty query.""" @@ -138,7 +120,7 @@ def test_get_work_list_with_query_empty(self): # Get works list with query = "" # Should return all works - self.work_query_test("", [self.work1, self.work2, self.work3]) + self.check_query("", [self.work1, self.work2, self.work3]) def test_get_work_list_with_query_no_keywords(self): """Test to verify work query do not parse keywords.""" @@ -147,7 +129,7 @@ def test_get_work_list_with_query_no_keywords(self): # Get works list with query = "title:work1" # Should not return anything since it searched for the whole string - self.work_query_test("title:work1", [], ["title:work1"]) + self.check_query("title:work1", [], ["title:work1"]) def test_get_works_list_with_query_multi_words(self): """Test query parse with multi words remaining.""" @@ -156,7 +138,7 @@ def test_get_works_list_with_query_multi_words(self): # Get works list with escaped space query # Should not return anything but check query - self.work_query_test( + self.check_query( r"word words\ words\ words remain", [], ["word", "words words words", "remain"], @@ -164,7 +146,7 @@ def test_get_works_list_with_query_multi_words(self): # Get works list with quoted query # Should not return anything but check query - self.work_query_test( + self.check_query( """ word"words words words" remain""", [], ["word", "words words words", "remain"], From d1e06ee0be95ed50c2c8f17c91cd5ed63a99b68a Mon Sep 17 00:00:00 2001 From: Neraste Date: Mon, 6 Apr 2026 04:09:54 +0200 Subject: [PATCH 26/26] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc70e09..435df4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ Any important notes regarding the update. ### Added - Allow to fetch full lyrics of a song at URL `api/library/songs/lyrics//`. +- Allow to search playlist entries, player errors, users, and song tags. +- Allow to search songs, playlist entries, player errors and users by ID. ## 1.9.2 - 2025-03-22