diff --git a/backend/backoffice/admin.py b/backend/backoffice/admin.py index 73ea6add5e..4d26e5a104 100644 --- a/backend/backoffice/admin.py +++ b/backend/backoffice/admin.py @@ -124,12 +124,9 @@ def get_queryset(self, request): @admin.display(description="has answer?", boolean=True) def has_answer(self, obj) -> bool: - try: - obj.answers - except FAQEntry.answers.RelatedObjectDoesNotExist: # pylint: disable=no-member - return False - else: + if obj.answers.exists(): return True + return False @admin.action(description=_("Enable the selected entries.")) def enable_entries(self, request, queryset): diff --git a/backend/core/locale/fr/LC_MESSAGES/django.po b/backend/core/locale/fr/LC_MESSAGES/django.po index af02885836..783314c216 100644 --- a/backend/core/locale/fr/LC_MESSAGES/django.po +++ b/backend/core/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-07-10 14:05+0000\n" +"POT-Creation-Date: 2023-11-16 14:45+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -38,22 +38,22 @@ msgstr "Oui" msgid "No" msgstr "Non" -#: backoffice/admin.py:134 +#: backoffice/admin.py:131 msgid "Enable the selected entries." msgstr "Activer les entrées sélectionnées." -#: backoffice/admin.py:140 +#: backoffice/admin.py:137 #, python-format msgid "%d entry was successfully marked as enabled." msgid_plural "%d entries were successfully marked as enabled." msgstr[0] "%d entrée a été activée avec succès." msgstr[1] "%d entrées ont été activées avec succès." -#: backoffice/admin.py:148 +#: backoffice/admin.py:145 msgid "Disable the selected entries." msgstr "Désactiver les entrées sélectionnées." -#: backoffice/admin.py:154 +#: backoffice/admin.py:151 #, python-format msgid "%d entry was successfully marked as disabled." msgid_plural "%d entries were successfully marked as disabled." @@ -84,15 +84,15 @@ msgstr "Tournesol - confiance" msgid "Tournesol - preferences" msgstr "Tournesol - préférences" -#: core/models/user.py:33 +#: core/models/user.py:31 msgid "email address" msgstr "adresse e-mail" -#: core/models/user.py:53 +#: core/models/user.py:51 msgid "The user' preferences." msgstr "Les préférences de l'utilisateur." -#: core/models/user.py:126 +#: core/models/user.py:124 #, python-format msgid "" "A user with an email starting with '%(email)s' already exists in this domain." @@ -100,7 +100,7 @@ msgstr "" "Un utilisateur avec un e-mail commençant par '%(email)s' existe déjà dans ce " "domaine." -#: core/models/user.py:165 core/serializers/user.py:20 +#: core/models/user.py:163 core/serializers/user.py:20 msgid "A user with this email address already exists." msgstr "Un utilisateur avec cet e-mail existe déjà." @@ -113,24 +113,24 @@ msgstr "'%(name)s' est un nom d'utilisateur reservé" msgid "'@' is not allowed in username" msgstr "Un nom d'utilisateur ne peut pas contenir '@'" -#: core/serializers/user_settings.py:53 +#: core/serializers/user_settings.py:55 msgid "The main criterion cannot be in the list." msgstr "Le critère principal ne peut pas être présent." -#: core/serializers/user_settings.py:56 +#: core/serializers/user_settings.py:58 msgid "The list cannot contain duplicates." msgstr "La liste ne peut pas contenir des doublons." -#: core/serializers/user_settings.py:61 +#: core/serializers/user_settings.py:63 #, python-format msgid "Unknown criterion: %(criterion)s." msgstr "Critère inconnu: %(criterion)s." -#: core/serializers/user_settings.py:68 +#: core/serializers/user_settings.py:70 msgid "This parameter cannot be lower than 1." msgstr "Ce paramètre ne peut pas être inférieur à 1." -#: core/serializers/user_settings.py:98 +#: core/serializers/user_settings.py:108 #, python-format msgid "Unknown language code: %(lang)s." msgstr "Code de langue inconnu : %(lang)s." diff --git a/backend/tournesol/admin.py b/backend/tournesol/admin.py index 48a103d01f..62684fe373 100644 --- a/backend/tournesol/admin.py +++ b/backend/tournesol/admin.py @@ -12,6 +12,8 @@ from django.utils.translation import gettext_lazy as _ from sql_util.utils import SubqueryCount +from tournesol.models.entity_context import EntityContext, EntityContextLocale + from .entities.video import YOUTUBE_PUBLISHED_AT_FORMAT from .models import ( Comparison, @@ -365,3 +367,48 @@ def get_proof_of_vote_file(self, obj): @admin.register(Criteria) class CriteriaAdmin(admin.ModelAdmin): inlines = (CriteriaLocalesInline,) + + +class HasTextListFilter(admin.SimpleListFilter): + title = _("has text?") + parameter_name = "has_text" + + def lookups(self, request, model_admin): + return ( + (1, _("Yes")), + (0, _("No")), + ) + + def queryset(self, request, queryset): + if self.value() == "1": + return queryset.filter( + texts__isnull=False, + ) + if self.value() == "0": + return queryset.filter( + texts__isnull=True, + ) + return queryset + + +class EntityContextLocaleInline(admin.StackedInline): + model = EntityContextLocale + extra = 0 + + +@admin.register(EntityContext) +class EntityContextAdmin(admin.ModelAdmin): + search_fields = ("name",) + list_display = ("name", "poll", "origin", "created_at", "has_text", "unsafe", "enabled") + list_filter = ("poll", "origin", HasTextListFilter, "unsafe", "enabled") + ordering = ("-created_at",) + inlines = (EntityContextLocaleInline,) + + def get_queryset(self, request): + qst = super().get_queryset(request) + qst = qst.prefetch_related("texts").select_related("poll") + return qst + + @admin.display(description="has text?", boolean=True) + def has_text(self, obj) -> bool: + return obj.texts.exists() diff --git a/backend/tournesol/locale/fr/LC_MESSAGES/django.po b/backend/tournesol/locale/fr/LC_MESSAGES/django.po index a302ace89f..af86685ebe 100644 --- a/backend/tournesol/locale/fr/LC_MESSAGES/django.po +++ b/backend/tournesol/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-07-03 13:07+0000\n" +"POT-Creation-Date: 2023-11-16 14:45+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,12 +18,24 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: tournesol/admin.py:89 +#: tournesol/admin.py:91 #, python-format msgid "Successfully refreshed the metadata of %(count)s entities." msgstr "Les métadonnées de %(count)s entités ont été mises à jour." -#: tournesol/serializers/rate_later.py:31 +#: tournesol/admin.py:373 +msgid "has text?" +msgstr "contient du texte?" + +#: tournesol/admin.py:378 +msgid "Yes" +msgstr "Oui" + +#: tournesol/admin.py:379 +msgid "No" +msgstr "Non" + +#: tournesol/serializers/rate_later.py:53 msgid "The entity is already in the rate-later list of this poll." msgstr "L'entité est déjà dans la liste à comparer plus tard de ce scrutin." diff --git a/backend/tournesol/migrations/0057_entitycontext_entitycontextlocale.py b/backend/tournesol/migrations/0057_entitycontext_entitycontextlocale.py new file mode 100644 index 0000000000..b8c1ccf764 --- /dev/null +++ b/backend/tournesol/migrations/0057_entitycontext_entitycontextlocale.py @@ -0,0 +1,209 @@ +# Generated by Django 4.2.7 on 2023-11-23 14:35 + +import core.models.mixin +from django.db import migrations, models +import django.db.models.deletion +import tournesol.models.poll + + +class Migration(migrations.Migration): + + dependencies = [ + ("tournesol", "0056_poll_moderation"), + ] + + operations = [ + migrations.CreateModel( + name="EntityContext", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "name", + models.CharField( + help_text="Human readable name, used to identify the context in the admin interface.", + max_length=64, + unique=True, + ), + ), + ( + "origin", + models.CharField( + choices=[("ASSOCIATION", "Association"), ("CONTRIBUTORS", "Contributors")], + default="ASSOCIATION", + help_text="The persons who want to share this context with the rest of the community.", + max_length=16, + ), + ), + ( + "predicate", + models.JSONField( + blank=True, + default=dict, + help_text="A JSON object. The keys/values should match the metadata keys/values of the targeted entities.", + ), + ), + ( + "unsafe", + models.BooleanField( + default=False, + help_text="If True, this context will make the targeted entities unsafe.", + ), + ), + ( + "enabled", + models.BooleanField( + default=False, + help_text="If False, this context will have no effect, and won't be returned by the API.", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "poll", + models.ForeignKey( + default=tournesol.models.poll.Poll.default_poll_pk, + on_delete=django.db.models.deletion.CASCADE, + related_name="all_entity_contexts", + to="tournesol.poll", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + bases=(core.models.mixin.LocalizedFieldsMixin, models.Model), + ), + migrations.CreateModel( + name="EntityContextLocale", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "language", + models.CharField( + choices=[ + ("af", "Afrikaans"), + ("ar", "Arabic"), + ("ar-dz", "Algerian Arabic"), + ("ast", "Asturian"), + ("az", "Azerbaijani"), + ("bg", "Bulgarian"), + ("be", "Belarusian"), + ("bn", "Bengali"), + ("br", "Breton"), + ("bs", "Bosnian"), + ("ca", "Catalan"), + ("ckb", "Central Kurdish (Sorani)"), + ("cs", "Czech"), + ("cy", "Welsh"), + ("da", "Danish"), + ("de", "German"), + ("dsb", "Lower Sorbian"), + ("el", "Greek"), + ("en", "English"), + ("en-au", "Australian English"), + ("en-gb", "British English"), + ("eo", "Esperanto"), + ("es", "Spanish"), + ("es-ar", "Argentinian Spanish"), + ("es-co", "Colombian Spanish"), + ("es-mx", "Mexican Spanish"), + ("es-ni", "Nicaraguan Spanish"), + ("es-ve", "Venezuelan Spanish"), + ("et", "Estonian"), + ("eu", "Basque"), + ("fa", "Persian"), + ("fi", "Finnish"), + ("fr", "French"), + ("fy", "Frisian"), + ("ga", "Irish"), + ("gd", "Scottish Gaelic"), + ("gl", "Galician"), + ("he", "Hebrew"), + ("hi", "Hindi"), + ("hr", "Croatian"), + ("hsb", "Upper Sorbian"), + ("hu", "Hungarian"), + ("hy", "Armenian"), + ("ia", "Interlingua"), + ("id", "Indonesian"), + ("ig", "Igbo"), + ("io", "Ido"), + ("is", "Icelandic"), + ("it", "Italian"), + ("ja", "Japanese"), + ("ka", "Georgian"), + ("kab", "Kabyle"), + ("kk", "Kazakh"), + ("km", "Khmer"), + ("kn", "Kannada"), + ("ko", "Korean"), + ("ky", "Kyrgyz"), + ("lb", "Luxembourgish"), + ("lt", "Lithuanian"), + ("lv", "Latvian"), + ("mk", "Macedonian"), + ("ml", "Malayalam"), + ("mn", "Mongolian"), + ("mr", "Marathi"), + ("ms", "Malay"), + ("my", "Burmese"), + ("nb", "Norwegian Bokmål"), + ("ne", "Nepali"), + ("nl", "Dutch"), + ("nn", "Norwegian Nynorsk"), + ("os", "Ossetic"), + ("pa", "Punjabi"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("pt-br", "Brazilian Portuguese"), + ("ro", "Romanian"), + ("ru", "Russian"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("sq", "Albanian"), + ("sr", "Serbian"), + ("sr-latn", "Serbian Latin"), + ("sv", "Swedish"), + ("sw", "Swahili"), + ("ta", "Tamil"), + ("te", "Telugu"), + ("tg", "Tajik"), + ("th", "Thai"), + ("tk", "Turkmen"), + ("tr", "Turkish"), + ("tt", "Tatar"), + ("udm", "Udmurt"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("vi", "Vietnamese"), + ("zh-hans", "Simplified Chinese"), + ("zh-hant", "Traditional Chinese"), + ], + max_length=10, + ), + ), + ("text", models.TextField()), + ( + "context", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="texts", + to="tournesol.entitycontext", + ), + ), + ], + options={ + "unique_together": {("context", "language")}, + }, + ), + ] diff --git a/backend/tournesol/models/entity.py b/backend/tournesol/models/entity.py index ae3ce2ccb8..b522cc058f 100644 --- a/backend/tournesol/models/entity.py +++ b/backend/tournesol/models/entity.py @@ -79,7 +79,9 @@ def with_prefetched_poll_ratings(self, poll_name): return self.prefetch_related( Prefetch( "all_poll_ratings", - queryset=EntityPollRating.objects.select_related("poll").filter( + queryset=EntityPollRating.objects.prefetch_related( + "poll__all_entity_contexts__texts" + ).filter( poll__name=poll_name, ), to_attr="single_poll_ratings", @@ -89,23 +91,24 @@ def with_prefetched_poll_ratings(self, poll_name): def filter_safe_for_poll(self, poll): exclude_condition = None - # Do not fail if `poll.moderation` is not a list. - if isinstance(poll.moderation, list): - for predicate in poll.moderation: - expression = None - - for field, value in predicate.items(): - kwargs = {f'metadata__{field}': value} - if expression: - expression = expression & Q(**kwargs) - else: - expression = Q(**kwargs) - - if exclude_condition: - # pylint: disable-next=unsupported-binary-operation - exclude_condition = exclude_condition | expression + for entity_context in poll.all_entity_contexts.all(): + if not entity_context.enabled or not entity_context.unsafe: + continue + + expression = None + + for field, value in entity_context.predicate.items(): + kwargs = {f"metadata__{field}": value} + if expression: + expression = expression & Q(**kwargs) else: - exclude_condition = expression + expression = Q(**kwargs) + + if exclude_condition: + # pylint: disable-next=unsupported-binary-operation + exclude_condition = exclude_condition | expression + else: + exclude_condition = expression qst = self.filter( all_poll_ratings__poll=poll, @@ -494,6 +497,14 @@ def single_contributor_rating(self) -> Optional["ContributorRating"]: "queryset with `with_prefetched_contributor_ratings()" ) from exc + @property + def single_poll_entity_contexts(self): + if self.single_poll_rating is None: + return [] + + poll = self.single_poll_rating.poll + return poll.get_entity_contexts(self.metadata) + class CriteriaDistributionScore: def __init__(self, criteria, distribution, bins): diff --git a/backend/tournesol/models/entity_context.py b/backend/tournesol/models/entity_context.py new file mode 100644 index 0000000000..336f6c46a7 --- /dev/null +++ b/backend/tournesol/models/entity_context.py @@ -0,0 +1,86 @@ +from django.conf import settings +from django.db import models + +from core.models.mixin import LocalizedFieldsMixin +from tournesol.models import Poll + + +class EntityContext(LocalizedFieldsMixin, models.Model): + """ + A context related to one or more entities. + + A context represents information that a sender, such as the contributors or + the association, want to share with the rest of the community. + + When flagged as unsafe, a context becomes a reason to make the targeted + entities unsafe. + """ + + ASSOCIATION = "ASSOCIATION" + CONTRIBUTORS = "CONTRIBUTORS" + ORIGIN_CHOICES = [ + (ASSOCIATION, "Association"), + (CONTRIBUTORS, "Contributors"), + ] + + name = models.CharField( + unique=True, + max_length=64, + help_text="Human readable name, used to identify the context in the admin interface.", + ) + poll = models.ForeignKey( + Poll, + on_delete=models.CASCADE, + related_name="all_entity_contexts", + default=Poll.default_poll_pk, + ) + origin = models.CharField( + max_length=16, + choices=ORIGIN_CHOICES, + default=ASSOCIATION, + help_text="The persons who want to share this context with the rest of the community.", + ) + predicate = models.JSONField( + blank=True, + default=dict, + help_text="A JSON object. The keys/values should match the metadata " + "keys/values of the targeted entities.", + ) + unsafe = models.BooleanField( + default=False, + help_text="If True, this context will make the targeted entities unsafe.", + ) + enabled = models.BooleanField( + default=False, + help_text="If False, this context will have no effect, and won't be returned by the API.", + ) + created_at = models.DateTimeField( + blank=True, + auto_now_add=True, + ) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.name + + def get_context_text_prefetch(self, lang=None) -> str: + """Return the translated text of the context.""" + return self.get_localized_text_prefetch(related="texts", field="text", lang=lang) + + +class EntityContextLocale(models.Model): + """ + A translated text of an `EntityContext`. + """ + + context = models.ForeignKey(EntityContext, on_delete=models.CASCADE, related_name="texts") + language = models.CharField(max_length=10, choices=settings.LANGUAGES) + text = models.TextField() + + class Meta: + unique_together = ["context", "language"] + + def __str__(self) -> str: + return self.text diff --git a/backend/tournesol/models/entity_poll_rating.py b/backend/tournesol/models/entity_poll_rating.py index 2b697e76da..b2da9f7857 100755 --- a/backend/tournesol/models/entity_poll_rating.py +++ b/backend/tournesol/models/entity_poll_rating.py @@ -16,12 +16,15 @@ UNSAFE_REASON_INSUFFICIENT_SCORE = "insufficient_tournesol_score" UNSAFE_REASON_INSUFFICIENT_TRUST = "insufficient_trust" -UNSAFE_REASON_MODERATION = "moderation_by_association" + +UNSAFE_REASON_MODERATION_ASSOCIATION = "moderation_by_association" +UNSAFE_REASON_MODERATION_CONTRIBUTORS = "moderation_by_contributors" UNSAFE_REASONS = [ UNSAFE_REASON_INSUFFICIENT_TRUST, UNSAFE_REASON_INSUFFICIENT_SCORE, - UNSAFE_REASON_MODERATION + UNSAFE_REASON_MODERATION_ASSOCIATION, + UNSAFE_REASON_MODERATION_CONTRIBUTORS, ] @@ -68,7 +71,7 @@ class Meta: sum_trust_scores = models.FloatField( null=False, - default=0., + default=0.0, help_text="Sum of trust scores of the contributors who rated the entity", ) @@ -145,6 +148,9 @@ def is_recommendation_unsafe(self): @cached_property def unsafe_recommendation_reasons(self): + # pylint: disable-next=import-outside-toplevel + from tournesol.models.entity_context import EntityContext + reasons = [] if ( @@ -158,7 +164,11 @@ def unsafe_recommendation_reasons(self): ): reasons.append(UNSAFE_REASON_INSUFFICIENT_TRUST) - if self.poll.entity_in_moderation(self.entity.metadata): - reasons.append(UNSAFE_REASON_MODERATION) + unsafe, origin = self.poll.entity_has_unsafe_context(self.entity.metadata) + if unsafe: + if origin == EntityContext.CONTRIBUTORS: + reasons.append(UNSAFE_REASON_MODERATION_CONTRIBUTORS) + else: + reasons.append(UNSAFE_REASON_MODERATION_ASSOCIATION) return reasons diff --git a/backend/tournesol/models/poll.py b/backend/tournesol/models/poll.py index ce33958987..4cea0e66a7 100644 --- a/backend/tournesol/models/poll.py +++ b/backend/tournesol/models/poll.py @@ -89,7 +89,7 @@ def entity_cls(self): @property def scale_function(self): - return lambda x: MEHESTAN_MAX_SCALED_SCORE * x / np.sqrt(1 + x*x) + return lambda x: MEHESTAN_MAX_SCALED_SCORE * x / np.sqrt(1 + x * x) def user_meets_proof_requirements(self, user_id: int, keyword: str) -> bool: """ @@ -126,26 +126,51 @@ def get_user_proof(self, user_id: int, keyword: str): signer = Signer(salt=f"{keyword}:{self.name}") return signer.sign(f"{user_id:05d}") - def entity_in_moderation(self, entity_metadata) -> bool: + def entity_has_unsafe_context(self, entity_metadata) -> tuple: """ - Return True if the entity's metadata match at least one moderation - predicate, False instead. + If the entity metadata match at least one "unsafe" context predicate + of this poll, return True and the context's origin, False instead. """ - # Be tolerant with unexpected values. - if not self.moderation or not isinstance(self.moderation, list): - return False + # The entity contexts are expected to be already prefetched. + for entity_context in self.all_entity_contexts.all(): + if not entity_context.enabled or not entity_context.unsafe: + continue + + matching = [] + + for field, value in entity_context.predicate.items(): + try: + matching.append(entity_metadata[field] == value) + except KeyError: + pass + + if matching and all(matching): + return True, entity_context.origin + + return False, None + + def get_entity_contexts(self, entity_metadata) -> list: + """ + Return a list of all enabled contexts matching the given entity + metadata. + """ + contexts = [] + + # The entity contexts are expected to be already prefetched. + for entity_context in self.all_entity_contexts.all(): + if not entity_context.enabled: + continue - for predicate in self.moderation: matching = [] - for field, value in predicate.items(): + for field, value in entity_context.predicate.items(): try: matching.append(entity_metadata[field] == value) except KeyError: pass if matching and all(matching): - return True + contexts.append(entity_context) - return False + return contexts diff --git a/backend/tournesol/serializers/entity_context.py b/backend/tournesol/serializers/entity_context.py new file mode 100644 index 0000000000..41e5ed498e --- /dev/null +++ b/backend/tournesol/serializers/entity_context.py @@ -0,0 +1,11 @@ +from rest_framework import serializers + +from tournesol.models.entity_context import EntityContext + + +class EntityContextSerializer(serializers.ModelSerializer): + text = serializers.CharField(source="get_context_text_prefetch") + + class Meta: + model = EntityContext + fields = ['origin', 'unsafe', 'text', 'created_at'] diff --git a/backend/tournesol/serializers/poll.py b/backend/tournesol/serializers/poll.py index 0cfcec6dd9..f8a41cbfa2 100644 --- a/backend/tournesol/serializers/poll.py +++ b/backend/tournesol/serializers/poll.py @@ -5,6 +5,7 @@ from tournesol.models import ContributorRating, CriteriaRank, Entity, EntityPollRating, Poll from tournesol.models.entity_poll_rating import UNSAFE_REASONS from tournesol.serializers.entity import EntityCriteriaScoreSerializer, RelatedEntitySerializer +from tournesol.serializers.entity_context import EntityContextSerializer class PollCriteriaSerializer(ModelSerializer): @@ -110,6 +111,11 @@ class RecommendationSerializer(ModelSerializer): read_only=True, allow_null=True, ) + entity_contexts = EntityContextSerializer( + source="single_poll_entity_contexts", + read_only=True, + many=True + ) recommendation_metadata = RecommendationMetadataSerializer(source="*", read_only=True) class Meta: @@ -126,6 +132,7 @@ class Meta: "unsafe", "entity", "collective_rating", + "entity_contexts", "recommendation_metadata", ] read_only_fields = fields diff --git a/backend/tournesol/tests/test_api_polls.py b/backend/tournesol/tests/test_api_polls.py index 78435203b8..177141cdb0 100644 --- a/backend/tournesol/tests/test_api_polls.py +++ b/backend/tournesol/tests/test_api_polls.py @@ -1,9 +1,14 @@ +from datetime import timedelta + +from django.core.cache import cache from django.test import TestCase +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient from core.models import User from tournesol.models import Poll +from tournesol.models.entity_context import EntityContext, EntityContextLocale from tournesol.tests.factories.comparison import ComparisonFactory from tournesol.tests.factories.entity import ( EntityFactory, @@ -50,6 +55,7 @@ class PollsRecommendationsTestCase(TestCase): def setUp(self): self.client = APIClient() + self.poll = Poll.default_poll() self.video_1 = VideoFactory( metadata__publication_date="2021-01-01", @@ -122,7 +128,7 @@ def setUp(self): EntityPollRatingFactory(entity=self.video_3, sum_trust_scores=4) EntityPollRatingFactory(entity=self.video_4, sum_trust_scores=5) - def test_anonymous_can_list_recommendations(self): + def test_anon_can_list_recommendations(self): response = self.client.get("/polls/videos/recommendations/") self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -141,6 +147,206 @@ def test_anonymous_can_list_recommendations(self): self.assertEqual(results[2]["collective_rating"]["tournesol_score"], 22.0) self.assertEqual(results[0]["entity"]["type"], "video") + for result in results: + self.assertEqual(result["entity_contexts"], []) + + def test_anon_can_list_reco_with_contexts(self): + # An entity with an unsafe rating shouldn't be marked as safe by a context. + EntityContext.objects.create( + name="context_video1", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_1.metadata["video_id"]}, + unsafe=False, + enabled=True, + poll=self.poll, + ) + + # An entity with at least one unsafe context should be marked as unsafe. + EntityContext.objects.create( + name="context_video2_unsafe", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_2.metadata["video_id"]}, + unsafe=True, + enabled=True, + poll=self.poll, + ) + + EntityContext.objects.create( + name="context_video2_safe", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_2.metadata["video_id"]}, + unsafe=False, + enabled=True, + poll=self.poll, + ) + + # An entity with disabled unsafe contexts shouldn't be marked unsafe. + EntityContext.objects.create( + name="context_video3_1_unsafe_disabled", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_3.metadata["video_id"]}, + unsafe=True, + enabled=False, + poll=self.poll, + ) + + context3_2 = EntityContext.objects.create( + name="context_video3_2_unsafe_disabled", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_3.metadata["video_id"]}, + unsafe=False, + enabled=True, + poll=self.poll, + ) + + context3_2_text = EntityContextLocale.objects.create( + context=context3_2, + language="en", + text="Hello context3_2", + ) + + # An entity can have several contexts. + context4_1 = EntityContext.objects.create( + name="context_video4_1_safe", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_4.metadata["video_id"]}, + unsafe=False, + enabled=True, + created_at=timezone.now() - timedelta(days=1), + poll=self.poll, + ) + + context4_1_text = EntityContextLocale.objects.create( + context=context4_1, + language="en", + text="Hello context4_1", + ) + + context4_2 = EntityContext.objects.create( + name="context_video4_2_safe", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_4.metadata["video_id"]}, + unsafe=False, + enabled=True, + created_at=timezone.now(), + poll=self.poll, + ) + + context4_2_text = EntityContextLocale.objects.create( + context=context4_2, + language="en", + text="Hello context4_2", + ) + + response = self.client.get("/polls/videos/recommendations/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.data["results"] + self.assertEqual(len(results), 2) + + self.assertEqual(len(results[0]["entity_contexts"]), 2) + self.assertDictEqual( + results[0]["entity_contexts"][0], + { + 'origin': 'ASSOCIATION', + 'unsafe': False, + 'text': context4_2_text.text, + 'created_at': context4_2.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + } + ) + self.assertDictEqual( + results[0]["entity_contexts"][1], + { + 'origin': 'ASSOCIATION', + 'unsafe': False, + 'text': context4_1_text.text, + 'created_at': context4_1.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + } + ) + + self.assertEqual(len(results[1]["entity_contexts"]), 1) + self.assertDictEqual( + results[1]["entity_contexts"][0], + { + 'origin': 'ASSOCIATION', + 'unsafe': False, + 'text': context3_2_text.text, + 'created_at': context3_2.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + } + ) + + def test_anon_can_list_reco_with_contexts_unsafe(self): + """ + Recommendations marked as unsafe by their context should be returned + when the query parameter `unsafe` is used. + """ + response = self.client.get("/polls/videos/recommendations/") + initial_safe_results_nbr = len(response.data["results"]) + + EntityContext.objects.create( + name="context_video4_unsafe", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_4.metadata["video_id"]}, + unsafe=True, + enabled=True, + poll=self.poll, + ) + + cache.clear() + response = self.client.get("/polls/videos/recommendations/") + results = response.data["results"] + + self.assertEqual(len(results), initial_safe_results_nbr - 1) + for result in results: + self.assertNotEqual(result["uid"], self.video_4.uid) + + response = self.client.get("/polls/videos/recommendations/?unsafe=true") + vid4 = response.data["results"][0] + + self.assertEqual(vid4["uid"], self.video_4.uid) + self.assertEqual(vid4["collective_rating"]["unsafe"]["status"], True) + self.assertEqual(len(vid4["collective_rating"]["unsafe"]["reasons"]), 1) + self.assertEqual( + vid4["collective_rating"]["unsafe"]["reasons"][0], + 'moderation_by_association' + ) + + def test_anon_can_list_reco_with_contexts_poll_specific(self): + """ + Only contexts related to the poll provided in the URL should be + returned. + """ + response = self.client.get("/polls/videos/recommendations/") + initial_safe_results_nbr = len(response.data["results"]) + self.assertEqual(response.data["results"][0]["uid"], self.video_4.uid) + + other_poll = Poll.objects.create(name="other") + EntityContext.objects.create( + name="context_video4_safe", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_4.metadata["video_id"]}, + unsafe=False, + enabled=True, + poll=other_poll, + ) + + EntityContext.objects.create( + name="context_video4_unsafe", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.video_4.metadata["video_id"]}, + unsafe=True, + enabled=True, + poll=other_poll, + ) + + cache.clear() + response = self.client.get("/polls/videos/recommendations/") + results = response.data["results"] + + self.assertEqual(len(results), initial_safe_results_nbr) + self.assertEqual(response.data["results"][0]["uid"], self.video_4.uid) + self.assertEqual(response.data["results"][0]["entity_contexts"], []) + def test_ignore_score_attached_to_another_poll(self): other_poll = Poll.objects.create(name="other") video_5 = VideoFactory( diff --git a/backend/tournesol/tests/test_model_entity_poll_rating.py b/backend/tournesol/tests/test_model_entity_poll_rating.py index cfc43256c6..928f184c22 100644 --- a/backend/tournesol/tests/test_model_entity_poll_rating.py +++ b/backend/tournesol/tests/test_model_entity_poll_rating.py @@ -7,6 +7,7 @@ from core.tests.factories.user import UserFactory from tournesol.models import Comparison, EntityPollRating +from tournesol.models.entity_context import EntityContext from tournesol.tests.factories.entity import VideoFactory from tournesol.tests.factories.poll import PollFactory from tournesol.tests.factories.ratings import ( @@ -98,6 +99,92 @@ def test_update_n_ratings_n_contributors(self): self.assertEqual(updated_rating_2.n_comparisons, 1) self.assertEqual(updated_rating_2.n_contributors, 1) + def test_unsafe_recommendation_reasons(self): + entity = VideoFactory() + entity_poll_rating = EntityPollRating.objects.create( + poll=self.poll, + entity=entity, + tournesol_score=None, + sum_trust_scores=0.0, + ) + + unsafe_reasons = entity_poll_rating.unsafe_recommendation_reasons + self.assertEqual(len(unsafe_reasons), 1) + self.assertIn('insufficient_tournesol_score', unsafe_reasons) + + entity_poll_rating.tournesol_score = 0.0 + entity_poll_rating.save(update_fields=["tournesol_score"]) + # We re-assign entity_poll_rating, because + # unsafe_recommendation_reasons is a cached property tied ot its model + # instance. + entity_poll_rating = EntityPollRating.objects.get(pk=entity_poll_rating.pk) + + unsafe_reasons = entity_poll_rating.unsafe_recommendation_reasons + self.assertEqual(len(unsafe_reasons), 2) + self.assertIn('insufficient_tournesol_score', unsafe_reasons) + self.assertIn('insufficient_trust', unsafe_reasons) + + def test_unsafe_recommendation_reasons_moderation(self): + entity = VideoFactory() + entity_poll_rating = EntityPollRating.objects.create( + poll=self.poll, + entity=entity, + tournesol_score=40, + sum_trust_scores=40, + ) + + # The predicate doesn't match any entity. + self.poll.all_entity_contexts.create( + name="orphan_context", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": "_"}, + unsafe=True, + enabled=True + ) + self.assertEqual(len(entity_poll_rating.unsafe_recommendation_reasons), 0) + + self.poll.all_entity_contexts.all().delete() + entity_poll_rating = EntityPollRating.objects.get(pk=entity_poll_rating.pk) + + # The context isn't flagged as unsafe. + self.poll.all_entity_contexts.create( + name="context_safe", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": entity.metadata["video_id"]}, + unsafe=False, + enabled=True + ) + self.assertEqual(len(entity_poll_rating.unsafe_recommendation_reasons), 0) + + self.poll.all_entity_contexts.all().delete() + entity_poll_rating = EntityPollRating.objects.get(pk=entity_poll_rating.pk) + + # The context is not enabled. + self.poll.all_entity_contexts.create( + name="context_unsafe_disabled", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": entity.metadata["video_id"]}, + unsafe=True, + enabled=False + ) + self.assertEqual(len(entity_poll_rating.unsafe_recommendation_reasons), 0) + + self.poll.all_entity_contexts.all().delete() + entity_poll_rating = EntityPollRating.objects.get(pk=entity_poll_rating.pk) + + self.poll.all_entity_contexts.create( + name="context_unsafe_enabled", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": entity.metadata["video_id"]}, + unsafe=True, + enabled=True + ) + + unsafe_reasons = entity_poll_rating.unsafe_recommendation_reasons + self.assertEqual(len(unsafe_reasons), 1) + self.assertIn('moderation_by_association', unsafe_reasons) + + class EntityPollRatingBulkTrustScoreUpdate(TestCase): """ TestCase of the `EntityPollRatingTestCase` model. diff --git a/backend/tournesol/tests/test_model_poll.py b/backend/tournesol/tests/test_model_poll.py new file mode 100644 index 0000000000..9ab68f4bf9 --- /dev/null +++ b/backend/tournesol/tests/test_model_poll.py @@ -0,0 +1,169 @@ +from django.test import TestCase + +from tournesol.models.entity_context import EntityContext +from tournesol.tests.factories.entity import VideoFactory +from tournesol.tests.factories.poll import PollWithCriteriasFactory + + +class PollTestCase(TestCase): + def setUp(self): + self.poll1 = PollWithCriteriasFactory(name="poll1", entity_type="video") + self.poll2 = PollWithCriteriasFactory(name="poll2", entity_type="video") + + def test_entity_has_unsafe_context(self): + """ + The `entity_has_unsafe_context` method should return True only when: + there is at least one matching context enabled, unsafe, and attached + to the given poll. + """ + video = VideoFactory() + + # Safe context. + EntityContext.objects.create( + name="context_safe", + origin=EntityContext.ASSOCIATION, + predicate={"uploader": video.metadata["uploader"]}, + unsafe=False, + enabled=True, + poll=self.poll1, + ) + + unsafe, origin = self.poll1.entity_has_unsafe_context(video.metadata) + self.assertEqual(unsafe, False) + self.assertEqual(origin, None) + + # Disabled context. + EntityContext.objects.create( + name="context_unsafe_disabled", + origin=EntityContext.ASSOCIATION, + predicate={"uploader": video.metadata["uploader"]}, + unsafe=True, + enabled=False, + poll=self.poll1, + ) + + unsafe, origin = self.poll1.entity_has_unsafe_context(video.metadata) + self.assertEqual(unsafe, False) + self.assertEqual(origin, None) + + # Enabled and unsafe context. + EntityContext.objects.create( + name="context_unsafe_enabled", + origin=EntityContext.ASSOCIATION, + predicate={"uploader": video.metadata["uploader"]}, + unsafe=True, + enabled=True, + poll=self.poll1, + ) + + unsafe, origin = self.poll1.entity_has_unsafe_context(video.metadata) + self.assertEqual(unsafe, True) + self.assertEqual(origin, EntityContext.ASSOCIATION) + + def test_entity_has_unsafe_context_no_context(self): + video = VideoFactory() + unsafe, origin = self.poll1.entity_has_unsafe_context(video.metadata) + self.assertEqual(unsafe, False) + self.assertEqual(origin, None) + + def test_entity_has_unsafe_context_poll_specific(self): + """ + The method `entity_has_unsafe_context` should be limited to the poll + instance. + """ + video = VideoFactory() + + EntityContext.objects.create( + name="context_unsafe_enabled", + origin=EntityContext.ASSOCIATION, + predicate={"uploader": video.metadata["uploader"]}, + unsafe=True, + enabled=True, + poll=self.poll2, + ) + + unsafe, origin = self.poll1.entity_has_unsafe_context(video.metadata) + self.assertEqual(unsafe, False) + self.assertEqual(origin, None) + + def test_get_entity_contexts(self): + """ + The `get_entity_contexts` method should return a list of all enabled + matching contexts, attached to the given poll. + """ + video = VideoFactory() + + # Safe context. + entity_context = EntityContext.objects.create( + name="context_safe", + origin=EntityContext.ASSOCIATION, + predicate={"uploader": video.metadata["uploader"]}, + unsafe=False, + enabled=True, + poll=self.poll1, + ) + + contexts = self.poll1.get_entity_contexts(video.metadata) + self.assertEqual(len(contexts), 1) + self.assertEqual(contexts[0].pk, entity_context.pk) + + EntityContext.objects.all().delete() + # Unsafe context. + entity_context = EntityContext.objects.create( + name="context_unsafe", + origin=EntityContext.ASSOCIATION, + predicate={"uploader": video.metadata["uploader"]}, + unsafe=True, + enabled=True, + poll=self.poll1, + ) + + contexts = self.poll1.get_entity_contexts(video.metadata) + self.assertEqual(len(contexts), 1) + self.assertEqual(contexts[0].pk, entity_context.pk) + + EntityContext.objects.all().delete() + # Disabled contexts. + EntityContext.objects.create( + name="context_safe_disabled", + origin=EntityContext.ASSOCIATION, + predicate={"uploader": video.metadata["uploader"]}, + unsafe=False, + enabled=False, + poll=self.poll1, + ) + EntityContext.objects.create( + name="context_unsafe_disabled", + origin=EntityContext.ASSOCIATION, + predicate={"uploader": video.metadata["uploader"]}, + unsafe=True, + enabled=False, + poll=self.poll1, + ) + + contexts = self.poll1.get_entity_contexts(video.metadata) + self.assertEqual(contexts, []) + + def test_get_entity_contexts_no_context(self): + video = VideoFactory() + contexts = self.poll1.get_entity_contexts(video.metadata) + self.assertEqual(contexts, []) + + def test_get_entity_contexts_poll_specific(self): + """ + The method `get_entity_contexts` should be limited to the poll + instance. + """ + video = VideoFactory() + + EntityContext.objects.create( + name="context_safe", + origin=EntityContext.ASSOCIATION, + predicate={"uploader": video.metadata["uploader"]}, + unsafe=False, + enabled=True, + poll=self.poll2, + ) + + contexts = self.poll1.get_entity_contexts(video.metadata) + self.assertEqual(contexts, []) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 5167802eb3..6cd69f6cbf 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -21,7 +21,8 @@ "labelShowSettings": "Show settings related to this video", "insufficientScore": "The score of this video is below the recommendability threshold defined by Tournesol.", "unsafeNotEnoughContributor": "The relevance of this score is uncertain, due to too few contributors.", - "moderationByAssociation": "The Tournesol Assocition has temporarily discarded this video from the recomendations. Learn more in the FAQ.", + "discardedByAssociation": "The Tournesol Assocition has temporarily discarded this video from the recomendations. Learn more in the FAQ.", + "discardedByContributors": "The contributors have discarded this video from the recommendations.", "nbComparisonsBy_one": "{{count}} comparison by", "nbComparisonsBy_other": "{{count}} comparisons by", "nbContributors_one": "{{count}} contributor", @@ -101,6 +102,9 @@ "comparisonSeries": { "skipTheSeries": "Skip the series" }, + "contextsFromOrigin": { + "theAssociationWouldLikeToGiveYouContext": "The Tournesol association would like to give you some context on this element." + }, "entitySelector": { "newVideo": "Select a new video automatically", "letTournesolSelectAVideo": "Let Tournesol select a video", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 89763edbba..af94c252bb 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -21,7 +21,8 @@ "labelShowSettings": "Voir les paramètres de cette video", "insufficientScore": "Le score de cette vidéo est inférieur au seuil de recommendabilité défini par Tournesol.", "unsafeNotEnoughContributor": "La pertinence de ce score est incertaine, en raison d'un trop faible nombre de contributeurs.", - "moderationByAssociation": "L'Association Tournesol a temporairement écarté cette vidéo des recommandations. Consultez la FAQ pour plus de détails.", + "discardedByAssociation": "L'Association Tournesol a temporairement écarté cette vidéo des recommandations. Consultez la FAQ pour plus de détails.", + "discardedByContributors": "Les contributeur·rices ont écarté cette vidéo des recommandations.", "nbComparisonsBy_one": "{{count}} comparaison par", "nbComparisonsBy_many": "{{count}} comparaisons par", "nbComparisonsBy_other": "{{count}} comparaisons par", @@ -105,6 +106,9 @@ "comparisonSeries": { "skipTheSeries": "Passer la série" }, + "contextsFromOrigin": { + "theAssociationWouldLikeToGiveYouContext": "L'association Tournesol souhaite vous apporter du contexte sur cet élément." + }, "entitySelector": { "newVideo": "Sélectionner une nouvelle vidéo automatiquement", "letTournesolSelectAVideo": "Laisser Tournesol choisir une vidéo", diff --git a/frontend/scripts/openapi.yaml b/frontend/scripts/openapi.yaml index e9d42dfc3d..e2d3354a6e 100644 --- a/frontend/scripts/openapi.yaml +++ b/frontend/scripts/openapi.yaml @@ -2924,6 +2924,11 @@ components: - $ref: '#/components/schemas/ExtendedCollectiveRating' readOnly: true nullable: true + entity_contexts: + type: array + items: + $ref: '#/components/schemas/EntityContext' + readOnly: true recommendation_metadata: allOf: - $ref: '#/components/schemas/RecommendationMetadata' @@ -2935,6 +2940,7 @@ components: required: - collective_rating - entity + - entity_contexts - individual_rating - recommendation_metadata CriteriaDistributionScore: @@ -3072,6 +3078,29 @@ components: - tournesol_score - type - uid + EntityContext: + type: object + properties: + origin: + allOf: + - $ref: '#/components/schemas/OriginEnum' + description: |- + The persons who want to share this context with the rest of the community. + + * `ASSOCIATION` - Association + * `CONTRIBUTORS` - Contributors + unsafe: + type: boolean + description: If True, this context will make the targeted entities unsafe. + text: + type: string + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - text EntityCriteriaDistribution: type: object description: An Entity serializer that show distribution of score for a given @@ -3369,6 +3398,14 @@ components: description: |- * `en` - en * `fr` - fr + OriginEnum: + enum: + - ASSOCIATION + - CONTRIBUTORS + type: string + description: |- + * `ASSOCIATION` - Association + * `CONTRIBUTORS` - Contributors PaginatedBannerList: type: object properties: @@ -3804,11 +3841,13 @@ components: - insufficient_trust - insufficient_tournesol_score - moderation_by_association + - moderation_by_contributors type: string description: |- * `insufficient_trust` - insufficient_trust * `insufficient_tournesol_score` - insufficient_tournesol_score * `moderation_by_association` - moderation_by_association + * `moderation_by_contributors` - moderation_by_contributors Recommendation: type: object properties: @@ -3821,6 +3860,11 @@ components: - $ref: '#/components/schemas/ExtendedCollectiveRating' readOnly: true nullable: true + entity_contexts: + type: array + items: + $ref: '#/components/schemas/EntityContext' + readOnly: true recommendation_metadata: allOf: - $ref: '#/components/schemas/RecommendationMetadata' @@ -3828,6 +3872,7 @@ components: required: - collective_rating - entity + - entity_contexts - recommendation_metadata RecommendationMetadata: type: object diff --git a/frontend/src/components/CollapseButton.tsx b/frontend/src/components/CollapseButton.tsx index a51b879a7b..b28b8d300c 100644 --- a/frontend/src/components/CollapseButton.tsx +++ b/frontend/src/components/CollapseButton.tsx @@ -29,7 +29,6 @@ const CollapseButton = ({ return (