Repository: tournesol-app/tournesol

Issue:
refactor: create the model `Poll`
**requires** #525 

also see the issue #509 

---

**(1)**

Create the new model `Poll`.

Fields
- `name` SlugField + unique
- `entity_type`, CharField with the same choices as the one in `Entity` 
- (question) in the future do we want an `open` field to allow / prevent comparisons to be made?
- (question) in the future do we want a boolean flag `ml` telling the ML to run on this poll or not?

Create the first poll in a migration, named `videos`.

Create a FK (can be null for now) from `Comparison` to `Poll`.

Create a FK (can be null for now) from `ContributorRating` to `Poll`.

**(2)**

**n-n** relation between `Poll` and `Criteria`
- with an additional field `rank`, used to order the field on the front end ( values could be 10, 20, 30, 40, etc. to have space for re-ordering ) 

Create the new model `Criteria`.
- `name` SlugField + unique
- `optional` boolean 

Create the new model `CriteriaLocale`.
- FK to `Criteria`
- lang CharField with choices from Django settings LANGUAGES
- label

Plan to add a validation in the API, rejecting requests that contain criteria not available in the current poll.

**(3)**

Configure the admin interface, to be able to add polls and criteria.


Discussion:
> (question) in the future do we want an open field to allow / prevent comparisons to be made?

Probably yes. Let's not have this field today and create it when we are ready to implement a feature using it.

> (question) in the future do we want a boolean flag ml telling the ML to run on this poll or not?

I think that would be the same thing as the `open` field. 

> Create the first poll in a migration, named videos.

I want a more fancy name to honor the original vision of Tournesol ! 🌻 

> Create a FK (can be null for now) from Comparison to Poll.
> Create a FK (can be null for now) from ContributorRating to Poll.

Is it much easier to allow null for now, instead of setting all comparisons and ratings to the "videos" Poll ? What is your reasonning for proposing this way?

> with an additional field rank, used to order the field on the front end ( values could be 10, 20, 30, 40, etc. to have space for re-ordering )
>
> Create the new model Criteria.
>
> name SlugField + unique
> optional boolean

Do I understand that `rank` is an attribute of the `Criteria` model ?

> Configure the admin interface, to be able to add polls and criteria.

The minimum that works, because it will be a very rare operation.
> > Create the first poll in a migration, named `videos`.
> 
> I want a more fancy name to honor the original vision of Tournesol !

@lfaucon @GresilleSiffle 
Any suggestion for the name of this default poll? 

`videos` looks good to me, straight to the point and consistent with its "entity_type".
More fancy names I can think of: `main`, `default`, `origin`, `ethical`, `ytrecommendations`, `topvideos`, `robustly_beneficial_recommendations`, `yt4good`...

Note that this name will be mostly used in the API URLs, and will not necessarily be exposed to 'real' users.
I am not convince by the propositions and I don't think of a better one.

"videos" is indeed consistent and straight to the point ☝️

Resolving patch:
diff --git a/backend/tournesol/serializers.py b/backend/tournesol/serializers.py
--- a/backend/tournesol/serializers.py
+++ b/backend/tournesol/serializers.py
@@ -135,6 +135,14 @@ class Meta:
         model = ComparisonCriteriaScore
         fields = ["criteria", "score", "weight"]
 
+    def validate_criteria(self, value):
+        current_poll = self.context["poll"]
+        if value not in current_poll.criterias_list:
+            raise ValidationError(
+                f"'{value}' is not a valid criteria for poll '{current_poll.name}'"
+            )
+        return value
+
 
 class ComparisonSerializerMixin:
     def reverse_criteria_scores(self, criteria_scores):
diff --git a/backend/tournesol/views/comparison.py b/backend/tournesol/views/comparison.py
--- a/backend/tournesol/views/comparison.py
+++ b/backend/tournesol/views/comparison.py
@@ -9,7 +9,7 @@
 from rest_framework import exceptions, generics, mixins, status
 from rest_framework.response import Response
 
-from ..models import Comparison, ContributorRating
+from ..models import Comparison, ContributorRating, Poll
 from ..serializers import ComparisonSerializer, ComparisonUpdateSerializer
 
 
@@ -84,6 +84,10 @@ class ComparisonListApi(
     List all or a filtered list of comparisons made by the logged user, or
     create a new one.
     """
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+        context["poll"] = Poll.default_poll()
+        return context
 
     def get(self, request, *args, **kwargs):
         """List all comparisons made by the logged user."""
@@ -129,8 +133,6 @@ class ComparisonDetailApi(mixins.RetrieveModelMixin,
     Retrieve, update or delete a comparison between two videos made by the
     logged user.
     """
-    serializer_class = ComparisonSerializer
-
     DEFAULT_SERIALIZER = ComparisonSerializer
     UPDATE_SERIALIZER = ComparisonUpdateSerializer
 
@@ -186,6 +188,7 @@ def get_serializer_class(self):
     def get_serializer_context(self):
         context = super(ComparisonDetailApi, self).get_serializer_context()
         context["reverse"] = self.currently_reversed
+        context["poll"] = Poll.default_poll()
         return context
 
     def get(self, request, *args, **kwargs):


Test patch:
diff --git a/backend/tournesol/tests/test_api_comparison.py b/backend/tournesol/tests/test_api_comparison.py
--- a/backend/tournesol/tests/test_api_comparison.py
+++ b/backend/tournesol/tests/test_api_comparison.py
@@ -48,7 +48,7 @@ class ComparisonApiTestCase(TestCase):
         },
         "criteria_scores": [
             {
-                "criteria": "over_the_top",
+                "criteria": "pedagogy",
                 "score": 10,
                 "weight": 10
             }
@@ -62,7 +62,7 @@ def setUp(self):
 
         At least 4 videos and 2 users with 2 comparisons each are required.
         """
-        user = UserFactory(username=self._user)
+        self.user = UserFactory(username=self._user)
         UserFactory(username=self._user2)
         other = UserFactory(username=self._other)
         now = datetime.datetime.now()
@@ -77,12 +77,12 @@ def setUp(self):
         self.comparisons = [
             # "user" will have the comparisons: 01 / 02 and 01 / 04
             ComparisonFactory(
-                user=user, entity_1=self.videos[0], entity_2=self.videos[1],
+                user=self.user, entity_1=self.videos[0], entity_2=self.videos[1],
                 duration_ms=102,
                 datetime_lastedit=now,
             ),
             ComparisonFactory(
-                user=user, entity_1=self.videos[0], entity_2=self.videos[3],
+                user=self.user, entity_1=self.videos[0], entity_2=self.videos[3],
                 duration_ms=104,
                 datetime_lastedit=now + datetime.timedelta(minutes=1),
             ),
@@ -516,7 +516,7 @@ def test_authenticated_integrated_comparison_list(self):
             {
                 'criteria_scores': [
                     {
-                        "criteria": "over_the_top",
+                        "criteria": "pedagogy",
                         "score": 10,
                         "weight": 10
                     }
@@ -553,7 +553,7 @@ def test_n_ratings_from_video(self):
             },
             "criteria_scores": [
                 {
-                    "criteria": "over_the_top",
+                    "criteria": "pedagogy",
                     "score": 10,
                     "weight": 10
                 }
@@ -574,7 +574,7 @@ def test_n_ratings_from_video(self):
             },
             "criteria_scores": [
                 {
-                    "criteria": "over_the_top",
+                    "criteria": "pedagogy",
                     "score": 10,
                     "weight": 10
                 }
@@ -598,7 +598,7 @@ def test_n_ratings_from_video(self):
             },
             "criteria_scores": [
                 {
-                    "criteria": "over_the_top",
+                    "criteria": "pedagogy",
                     "score": 10,
                     "weight": 10
                 }
@@ -676,3 +676,15 @@ def test_metadata_refresh_on_comparison_creation(self, mock_get_video_metadata):
         # Video01 has been refreshed and video03 was already up-to-date.
         # No additional call to youtube API should be visible.
         self.assertEqual(len(mock_get_video_metadata.mock_calls), 2)
+
+    def test_invalid_criteria_in_comparison(self):
+        client = APIClient()
+        client.force_authenticate(self.user)
+        data = deepcopy(self.non_existing_comparison)
+        data["criteria_scores"][0]["criteria"] = "invalid"
+        response = client.post("/users/me/comparisons/", data, format="json")
+        self.assertContains(
+            response,
+            "not a valid criteria",
+            status_code=status.HTTP_400_BAD_REQUEST
+        )