Repository: tournesol-app/tournesol

Issue:
refactor: create the `Poll` API
**requires** #527

also see the issue #509

---

**backend**

Create new endpoints for the polls.

All those endpoints are public.

**(1)** `GET /polls/{pollname}` to fetch all poll's information PLUS the the list of its criteria.

**(2)** `GET /polls/{pollname}/recommendations` to fetch recommendations ordered by the " Tournesol score " DESC

**(3)** `GET /polls/{pollname}/entities` to fetch all entities, regardless of the score, without any default filter (except entities are still related to the poll scope)

Add tests.


Discussion:

Resolving patch:
diff --git a/backend/tournesol/serializers/entity.py b/backend/tournesol/serializers/entity.py
--- a/backend/tournesol/serializers/entity.py
+++ b/backend/tournesol/serializers/entity.py
@@ -1,6 +1,8 @@
+from collections import defaultdict
 from typing import Optional
 
 from django.db.models import ObjectDoesNotExist
+from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework.exceptions import NotFound, ValidationError
 from rest_framework.fields import RegexField
@@ -96,3 +98,27 @@ class Meta(VideoSerializer.Meta):
         # This serializer is always used as read-only, so the 'read_only_fields' definition
         # can be discarded safely to generate a correct OpenAPI schema.
         read_only_fields = []
+
+
+class EntityPollSerializer(serializers.Serializer):
+    name = serializers.CharField()
+    criteria_scores = EntityCriteriaScoreSerializer(many=True)
+
+
+class EntitySerializer(ModelSerializer):
+    polls = serializers.SerializerMethodField()
+
+    class Meta:
+        model = Entity
+        fields = ["uid", "type", "metadata", "polls"]
+
+    @extend_schema_field(EntityPollSerializer(many=True))
+    def get_polls(self, obj):
+        poll_to_scores = defaultdict(list)
+        for score in obj.criteria_scores.all():
+            poll_to_scores[score.poll.name].append(score)
+        items = [
+            {"name": name, "criteria_scores": scores}
+            for (name, scores) in poll_to_scores.items()
+        ]
+        return EntityPollSerializer(items, many=True).data
diff --git a/backend/tournesol/urls.py b/backend/tournesol/urls.py
--- a/backend/tournesol/urls.py
+++ b/backend/tournesol/urls.py
@@ -9,6 +9,7 @@
 
 from .views import ComparisonDetailApi, ComparisonListApi, ComparisonListFilteredApi
 from .views.email_domains import EmailDomainsList
+from .views.entities import EntitiesViewSet
 from .views.exports import ExportAllView, ExportComparisonsView, ExportPublicComparisonsView
 from .views.polls import PollsRecommendationsView, PollsView
 from .views.ratings import (
@@ -22,7 +23,8 @@
 from .views.video_rate_later import VideoRateLaterDetail, VideoRateLaterList
 
 router = routers.DefaultRouter()
-router.register(r'video', VideoViewSet)
+router.register(r'video', VideoViewSet, basename="video")
+router.register(r'entities', EntitiesViewSet)
 
 app_name = "tournesol"
 urlpatterns = [
@@ -113,5 +115,5 @@
         "polls/<str:name>/recommendations/",
         PollsRecommendationsView.as_view(),
         name="polls_recommendations"
-    )
+    ),
 ]
diff --git a/backend/tournesol/views/entities.py b/backend/tournesol/views/entities.py
new file mode 100644
--- /dev/null
+++ b/backend/tournesol/views/entities.py
@@ -0,0 +1,57 @@
+from django.db.models import Prefetch
+from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
+from rest_framework.viewsets import ReadOnlyModelViewSet
+
+from tournesol.entities import ENTITY_TYPE_CHOICES
+from tournesol.models import Entity, EntityCriteriaScore
+from tournesol.serializers.entity import EntitySerializer
+
+filter_parameters = [
+    OpenApiParameter(
+        "type",
+        description="Type of entities to return",
+        enum=map(lambda x: x[0], ENTITY_TYPE_CHOICES),
+    ),
+    OpenApiParameter(
+        "poll_name",
+        description="If defined, only scores related to this poll will be returned",
+    ),
+]
+
+
+@extend_schema_view(
+    list=extend_schema(parameters=filter_parameters),
+    retrieve=extend_schema(parameters=filter_parameters),
+)
+class EntitiesViewSet(ReadOnlyModelViewSet):
+    """
+    Fetch entities and their detailed scores in all polls
+    """
+
+    permission_classes = []
+    queryset = Entity.objects.none()
+    lookup_field = "uid"
+    serializer_class = EntitySerializer
+
+    def get_queryset(self):
+        request = self.request
+        queryset = Entity.objects.all()
+
+        entity_type = request.query_params.get("type")
+        if entity_type:
+            queryset = queryset.filter(type=entity_type)
+
+        poll_name = request.query_params.get("poll_name")
+        if poll_name:
+            criteria_scores_queryset = EntityCriteriaScore.objects.filter(
+                poll__name=poll_name
+            )
+        else:
+            criteria_scores_queryset = EntityCriteriaScore.objects.all()
+
+        return queryset.prefetch_related(
+            Prefetch(
+                "criteria_scores",
+                queryset=criteria_scores_queryset.select_related("poll"),
+            )
+        )


Test patch:
diff --git a/backend/tournesol/tests/test_api_entities.py b/backend/tournesol/tests/test_api_entities.py
new file mode 100644
--- /dev/null
+++ b/backend/tournesol/tests/test_api_entities.py
@@ -0,0 +1,71 @@
+from unittest.mock import ANY
+
+from django.test import TestCase
+from rest_framework import status
+from rest_framework.test import APIClient
+
+from tournesol.models import Poll
+
+from .factories.video import VideoCriteriaScoreFactory, VideoFactory
+
+
+class EntitiesApi(TestCase):
+    def setUp(self):
+        self.client = APIClient()
+        poll2 = Poll.objects.create(name="poll2")
+        self.video = VideoFactory()
+
+        # Scores for default poll "videos"
+        VideoCriteriaScoreFactory(entity=self.video)
+        VideoCriteriaScoreFactory.create_batch(2)
+
+        # Scores for "poll2"
+        VideoCriteriaScoreFactory(entity=self.video, poll=poll2)
+        VideoCriteriaScoreFactory.create_batch(1, poll=poll2)
+
+    def test_anonymous_can_list(self):
+        response = self.client.get("/entities/")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data["results"]), 4)
+        self.assertDictEqual(
+            response.data["results"][0],
+            {
+                "uid": self.video.uid,
+                "metadata": ANY,
+                "type": "video",
+                "polls": [
+                    {
+                        "name": "videos",
+                        "criteria_scores": [
+                            {"criteria": "better_habits", "score": ANY}
+                        ],
+                    },
+                    {
+                        "name": "poll2",
+                        "criteria_scores": [
+                            {"criteria": "better_habits", "score": ANY}
+                        ],
+                    },
+                ],
+            },
+        )
+
+    def test_anonymous_can_list_filter_by_type(self):
+        response = self.client.get("/entities/?type=other")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data["results"]), 0)
+
+        response = self.client.get("/entities/?type=video")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data["results"]), 4)
+
+    def test_anonymous_can_list_filter_polls(self):
+        response = self.client.get("/entities/?poll_name=videos")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data["results"]), 4)
+        self.assertEqual(len(response.data["results"][0]["polls"]), 1)
+
+    def test_anonymous_can_get_single_entity(self):
+        response = self.client.get(f"/entities/{self.video.uid}/")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data["uid"], self.video.uid)
diff --git a/backend/tournesol/tests/test_api_video.py b/backend/tournesol/tests/test_api_video.py
--- a/backend/tournesol/tests/test_api_video.py
+++ b/backend/tournesol/tests/test_api_video.py
@@ -59,10 +59,7 @@ def test_anonymous_can_list(self):
         client = APIClient()
 
         # test a request without query parameters
-        response = client.get(
-            reverse("tournesol:entity-list"),
-            format="json",
-        )
+        response = client.get("/video/", format="json")
         self.assertEqual(response.status_code, status.HTTP_200_OK)
 
         returned_video_ids = [video["video_id"] for video in response.data["results"]]
@@ -82,7 +79,7 @@ def test_anonymous_can_list_with_limit(self):
         # test a request with the limit query parameter
         limit = 2
         response = client.get(
-            reverse("tournesol:entity-list"),
+            "/video/",
             {"limit": limit},
             format="json",
         )
@@ -97,7 +94,7 @@ def test_anonymous_can_list_with_limit(self):
         # test that a huge limit doesn't break anything
         limit = 10000
         response = client.get(
-            reverse("tournesol:entity-list"),
+            "/video/",
             {"limit": limit},
             format="json",
         )
@@ -118,7 +115,7 @@ def test_anonymous_can_list_with_offset(self):
 
         offset = 2
         response = client.get(
-            reverse("tournesol:entity-list"),
+            "/video/",
             {"offset": offset},
             format="json",
         )
@@ -134,7 +131,7 @@ def test_anonymous_can_list_with_offset(self):
         # test that a huge offset doesn't break anything
         offset = 10000
         response = client.get(
-            reverse("tournesol:entity-list"),
+            "/video/",
             {"offset": offset},
             format="json",
         )
@@ -166,7 +163,7 @@ def test_anonymous_can_list_with_pagination(self):
 
         for param in parameters:
             response = client.get(
-                reverse("tournesol:entity-list"),
+                "/video/",
                 {"limit": param["limit"], "offset": param["offset"]},
                 format="json",
             )