diff --git a/backend/tournesol/serializers/poll.py b/backend/tournesol/serializers/poll.py index 1af8accc7a..5f80c08fd4 100644 --- a/backend/tournesol/serializers/poll.py +++ b/backend/tournesol/serializers/poll.py @@ -123,3 +123,9 @@ class RecommendationsFilterSerializer(serializers.Serializer): help_text="If true and a user is authenticated, then entities compared by the" " user will be removed from the response", ) + + +class RecommendationsRandomFilterSerializer(serializers.Serializer): + random = serializers.IntegerField(default=None) + date_lte = serializers.DateTimeField(default=None) + date_gte = serializers.DateTimeField(default=None) diff --git a/backend/tournesol/urls.py b/backend/tournesol/urls.py index 60b4fdafa1..6b9caeb9b2 100644 --- a/backend/tournesol/urls.py +++ b/backend/tournesol/urls.py @@ -28,6 +28,7 @@ PollsRecommendationsView, PollsView, ) +from .views.polls_reco_random import RandomRecommendationList from .views.previews import ( DynamicWebsitePreviewComparison, DynamicWebsitePreviewDefault, @@ -196,6 +197,11 @@ PollsRecommendationsView.as_view(), name="polls_recommendations", ), + path( + "polls//recommendations/random/", + RandomRecommendationList.as_view(), + name="polls_recommendations_random", + ), path( "polls//entities/", PollsEntityView.as_view(), diff --git a/backend/tournesol/utils/constants.py b/backend/tournesol/utils/constants.py index 38de85d198..22b0bfe76f 100644 --- a/backend/tournesol/utils/constants.py +++ b/backend/tournesol/utils/constants.py @@ -18,4 +18,5 @@ COMPARISON_MAX = 10.0 # Default weight for a criteria in the recommendations +# FIXME: the default weight used by the front end is 50, not 10 CRITERIA_DEFAULT_WEIGHT = 10 diff --git a/backend/tournesol/views/polls.py b/backend/tournesol/views/polls.py index cd9d2e421c..0d01e43652 100644 --- a/backend/tournesol/views/polls.py +++ b/backend/tournesol/views/polls.py @@ -298,6 +298,13 @@ def annotate_and_prefetch_scores(self, queryset, request, poll: Poll): criteria_weight = self._build_criteria_weight_condition( request, poll, when="all_criteria_scores__criteria" ) + + # FIXME: we can significantly improve the performance of the queryset + # by filtering the criteria on their names, to remove those that are + # not present in the request. + # + # ex: + # all_criteria_scores__criteria__in=[...] queryset = queryset.filter( all_criteria_scores__poll=poll, all_criteria_scores__score_mode=score_mode, diff --git a/backend/tournesol/views/polls_reco_random.py b/backend/tournesol/views/polls_reco_random.py new file mode 100644 index 0000000000..fd6172c0b6 --- /dev/null +++ b/backend/tournesol/views/polls_reco_random.py @@ -0,0 +1,81 @@ +from django.utils.decorators import method_decorator +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiParameter, + extend_schema, + extend_schema_view, +) + +from tournesol.models import Entity +from tournesol.serializers.entity import EntityNoExtraFieldSerializer +from tournesol.serializers.poll import RecommendationsRandomFilterSerializer +from tournesol.utils.cache import cache_page_no_i18n +from tournesol.views import PollRecommendationsBaseAPIView + + +class RandomRecommendationBaseAPIView(PollRecommendationsBaseAPIView): + def get_queryset(self): + """ + Return a queryset of random recommended entities. + + This queryset is designed to be more performant than the queryset + of the regular recommendations view. For this reason, it doesn't allow + to: + - filter entities by text + - filter entities by weighted criteria score + - or anything involving a SQL JOIN on EntityCriteriaScore + """ + poll = self.poll_from_url + queryset = Entity.objects.all() + queryset, _ = self.filter_by_parameters(self.request, queryset, poll) + queryset = queryset.with_prefetched_poll_ratings(poll_name=poll.name) + queryset = queryset.filter_safe_for_poll(poll) + queryset = queryset.order_by("?") + return queryset + + +@extend_schema_view( + get=extend_schema( + parameters=[ + RecommendationsRandomFilterSerializer, + OpenApiParameter( + "metadata", + OpenApiTypes.OBJECT, + style="deepObject", + description="Filter by one or more metadata.", + examples=[ + OpenApiExample( + name="No metadata filter", + ), + OpenApiExample( + name="Videos - some examples", + value={"language": ["en", "pt"], "uploader": "Kurzgesagt – In a Nutshell"}, + ), + OpenApiExample( + name="Videos - videos of 8 minutes or less (480 sec)", + value={"duration:lte:int": "480"}, + ), + OpenApiExample( + name="Candidates - some examples", + value={ + "name": "A candidate full name", + "youtube_channel_id": "channel ID", + }, + ), + ], + ), + ], + ) +) +class RandomRecommendationList(RandomRecommendationBaseAPIView): + """ + Return a random list of recommended entities. + """ + permission_classes = [] + serializer_class = EntityNoExtraFieldSerializer + poll_parameter = "name" + + @method_decorator(cache_page_no_i18n(60 * 10)) # 10 minutes cache + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs)