From df563e7d3f7046e2c2e114cfe088bb6df24b6981 Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Thu, 16 Nov 2023 10:57:29 +0100 Subject: [PATCH 01/32] [back] rework: EntityContext now replaces poll.moderation --- backend/tournesol/admin.py | 50 +++++ .../0057_entitycontext_entitycontextlocale.py | 211 ++++++++++++++++++ backend/tournesol/models/entity.py | 37 +-- backend/tournesol/models/entity_context.py | 78 +++++++ .../tournesol/models/entity_poll_rating.py | 20 +- backend/tournesol/models/poll.py | 26 +-- 6 files changed, 387 insertions(+), 35 deletions(-) create mode 100644 backend/tournesol/migrations/0057_entitycontext_entitycontextlocale.py create mode 100644 backend/tournesol/models/entity_context.py diff --git a/backend/tournesol/admin.py b/backend/tournesol/admin.py index 48a103d01f..4d591aa9ac 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,51 @@ 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.TabularInline): + model = EntityContextLocale + extra = 0 + + +@admin.register(EntityContext) +class FAQEntryAdmin(admin.ModelAdmin): + search_fields = ("name",) + list_display = ("name", "created_at", "has_answer", "unsafe", "enabled") # add poll + list_filter = (HasTextListFilter, "enabled") + ordering = ("-created_at",) + inlines = (EntityContextLocaleInline,) + + def get_queryset(self, request): + qst = super().get_queryset(request) + qst = qst.prefetch_related("texts") + return qst + + @admin.display(description="has text?", boolean=True) + def has_answer(self, obj) -> bool: + # TODO: check if the FAQ admin is properly working + if obj.texts.exists(): + return True + return False diff --git a/backend/tournesol/migrations/0057_entitycontext_entitycontextlocale.py b/backend/tournesol/migrations/0057_entitycontext_entitycontextlocale.py new file mode 100644 index 0000000000..466570fe7b --- /dev/null +++ b/backend/tournesol/migrations/0057_entitycontext_entitycontextlocale.py @@ -0,0 +1,211 @@ +# Generated by Django 4.2.7 on 2023-11-15 16:38 + +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=[("CONTRIBUTOR", "Contributor"), ("CONTRIBUTOR", "Association")], + default="ASSOCIATION", + help_text="The persons who want to share this context.", + max_length=16, + ), + ), + ( + "predicate", + models.JSONField( + blank=True, + default=dict, + help_text="A JSON object. They 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()), + ("source_url", models.URLField()), + ("source_label", models.CharField(max_length=254)), + ( + "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..281f0ebd44 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" + ).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 context_ in poll.all_entity_contexts.all(): + if not context_.enabled or not context_.unsafe: + continue + + expression = None + + for field, value in 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, diff --git a/backend/tournesol/models/entity_context.py b/backend/tournesol/models/entity_context.py new file mode 100644 index 0000000000..163b999ee0 --- /dev/null +++ b/backend/tournesol/models/entity_context.py @@ -0,0 +1,78 @@ +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): + CONTRIBUTOR = "CONTRIBUTOR" + ASSOCIATION = "ASSOCIATION" + ORIGIN_CHOICES = [ + (CONTRIBUTOR, "Contributor"), + (CONTRIBUTOR, "Association"), + ] + + name = models.CharField( + unique=True, + max_length=64, + help_text="Human readable name, used to identify the context in the admin interface.", + ) + origin = models.CharField( + max_length=16, + choices=ORIGIN_CHOICES, + default=ASSOCIATION, + help_text="The persons who want to share this context.", + ) + 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.", + ) + poll = models.ForeignKey( + Poll, + on_delete=models.CASCADE, + related_name="all_entity_contexts", + default=Poll.default_poll_pk, + ) + 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="entity_context", 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() + source_url = models.URLField() + source_label = models.CharField(max_length=254) + + 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..7458dbacd2 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.CONTRIBUTOR: + 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 78693f87eb..07e0728877 100644 --- a/backend/tournesol/models/poll.py +++ b/backend/tournesol/models/poll.py @@ -55,8 +55,7 @@ def __str__(self) -> str: @classmethod def default_poll(cls) -> "Poll": poll, _created = cls.objects.get_or_create( - name=DEFAULT_POLL_NAME, - defaults={"entity_type": VideoEntity.name} + name=DEFAULT_POLL_NAME, defaults={"entity_type": VideoEntity.name} ) return poll @@ -89,7 +88,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 +125,27 @@ 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's metadata match at least one "unsafe" context's + 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 context_ in self.all_entity_contexts.all(): + if not context_.enabled or not context_.unsafe: + continue - for predicate in self.moderation: matching = [] - for field, value in predicate.items(): + for field, value in context_.predicate.items(): try: matching.append(entity_metadata[field] == value) except KeyError: pass if matching and all(matching): - return True + return True, context_.origin - return False + return False, context_.origin From 6eacb598af9655245d40d6d22fc18bf3511b67de Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:32:42 +0100 Subject: [PATCH 02/32] [back] fix: entity_has_unsafe_context was returning an undefined var --- backend/tournesol/models/poll.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tournesol/models/poll.py b/backend/tournesol/models/poll.py index 07e0728877..0f2a1fa795 100644 --- a/backend/tournesol/models/poll.py +++ b/backend/tournesol/models/poll.py @@ -148,4 +148,4 @@ def entity_has_unsafe_context(self, entity_metadata) -> tuple: if matching and all(matching): return True, context_.origin - return False, context_.origin + return False, None From d0c1691cadadc8ce08927038e5fdc170e57ddb7b Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:38:30 +0100 Subject: [PATCH 03/32] [back] fix: the `FAQEntryAdmin.has_answer` was always returning True --- backend/backoffice/admin.py | 7 ++----- backend/tournesol/admin.py | 1 - 2 files changed, 2 insertions(+), 6 deletions(-) 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/tournesol/admin.py b/backend/tournesol/admin.py index 4d591aa9ac..33911f4377 100644 --- a/backend/tournesol/admin.py +++ b/backend/tournesol/admin.py @@ -411,7 +411,6 @@ def get_queryset(self, request): @admin.display(description="has text?", boolean=True) def has_answer(self, obj) -> bool: - # TODO: check if the FAQ admin is properly working if obj.texts.exists(): return True return False From 4e0a01338094fd6fbd7c6d39a7707af7a1500997 Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:48:05 +0100 Subject: [PATCH 04/32] [back] chore: add the admin UI for model `EntityContext` --- backend/core/locale/fr/LC_MESSAGES/django.po | 28 +++++++++---------- backend/tournesol/admin.py | 8 +++--- .../tournesol/locale/fr/LC_MESSAGES/django.po | 18 ++++++++++-- 3 files changed, 33 insertions(+), 21 deletions(-) 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 33911f4377..92d447b969 100644 --- a/backend/tournesol/admin.py +++ b/backend/tournesol/admin.py @@ -397,16 +397,16 @@ class EntityContextLocaleInline(admin.TabularInline): @admin.register(EntityContext) -class FAQEntryAdmin(admin.ModelAdmin): +class EntityContextAdmin(admin.ModelAdmin): search_fields = ("name",) - list_display = ("name", "created_at", "has_answer", "unsafe", "enabled") # add poll - list_filter = (HasTextListFilter, "enabled") + list_display = ("name", "poll", "created_at", "has_answer", "unsafe", "enabled") + list_filter = ("poll", HasTextListFilter, "unsafe", "enabled") ordering = ("-created_at",) inlines = (EntityContextLocaleInline,) def get_queryset(self, request): qst = super().get_queryset(request) - qst = qst.prefetch_related("texts") + qst = qst.prefetch_related("texts").select_related("poll") return qst @admin.display(description="has text?", boolean=True) 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." From 20557fdb458ac83dd4dd7d83daa00d2982c7aa7d Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Thu, 16 Nov 2023 16:00:50 +0100 Subject: [PATCH 05/32] [back][front] fix: EntityContext origin not being properly saved ... and displayed in the front end --- ...058_alter_entitycontext_origin_and_more.py | 32 +++++++++++++++++++ backend/tournesol/models/entity_context.py | 8 ++--- frontend/public/locales/en/translation.json | 3 +- frontend/public/locales/fr/translation.json | 3 +- .../components/entity/EntityCardScores.tsx | 5 ++- 5 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 backend/tournesol/migrations/0058_alter_entitycontext_origin_and_more.py diff --git a/backend/tournesol/migrations/0058_alter_entitycontext_origin_and_more.py b/backend/tournesol/migrations/0058_alter_entitycontext_origin_and_more.py new file mode 100644 index 0000000000..31ed5a2c63 --- /dev/null +++ b/backend/tournesol/migrations/0058_alter_entitycontext_origin_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.7 on 2023-11-16 14:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tournesol", "0057_entitycontext_entitycontextlocale"), + ] + + operations = [ + migrations.AlterField( + model_name="entitycontext", + name="origin", + field=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, + ), + ), + migrations.AlterField( + model_name="entitycontext", + name="predicate", + field=models.JSONField( + blank=True, + default=dict, + help_text="A JSON object. The keys/values should match the metadata keys/values of the targeted entities.", + ), + ), + ] diff --git a/backend/tournesol/models/entity_context.py b/backend/tournesol/models/entity_context.py index 163b999ee0..3bfd012899 100644 --- a/backend/tournesol/models/entity_context.py +++ b/backend/tournesol/models/entity_context.py @@ -6,11 +6,11 @@ class EntityContext(LocalizedFieldsMixin, models.Model): - CONTRIBUTOR = "CONTRIBUTOR" ASSOCIATION = "ASSOCIATION" + CONTRIBUTOR = "CONTRIBUTORS" ORIGIN_CHOICES = [ - (CONTRIBUTOR, "Contributor"), - (CONTRIBUTOR, "Association"), + (ASSOCIATION, "Association"), + (CONTRIBUTOR, "Contributors"), ] name = models.CharField( @@ -22,7 +22,7 @@ class EntityContext(LocalizedFieldsMixin, models.Model): max_length=16, choices=ORIGIN_CHOICES, default=ASSOCIATION, - help_text="The persons who want to share this context.", + help_text="The persons who want to share this context with the rest of the community.", ) predicate = models.JSONField( blank=True, diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 5167802eb3..7a653997b7 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", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 89763edbba..c9d8bb88cd 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", diff --git a/frontend/src/components/entity/EntityCardScores.tsx b/frontend/src/components/entity/EntityCardScores.tsx index 8d1ce62d98..2de5af7287 100644 --- a/frontend/src/components/entity/EntityCardScores.tsx +++ b/frontend/src/components/entity/EntityCardScores.tsx @@ -74,7 +74,10 @@ const EntityCardScores = ({ return t('video.unsafeNotEnoughContributor'); } if (reason === 'moderation_by_association') { - return t('video.moderationByAssociation'); + return t('video.discardedByAssociation'); + } + if (reason === 'moderation_by_contributors') { + return t('video.discardedByContributors'); } return ''; }) From 6aa2a2a341bb8d64f64006d434fe57e1217b8d6e Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Thu, 16 Nov 2023 16:04:04 +0100 Subject: [PATCH 06/32] [back] chore: display the EntityContext's origin in the admin --- backend/tournesol/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tournesol/admin.py b/backend/tournesol/admin.py index 92d447b969..967438337f 100644 --- a/backend/tournesol/admin.py +++ b/backend/tournesol/admin.py @@ -399,8 +399,8 @@ class EntityContextLocaleInline(admin.TabularInline): @admin.register(EntityContext) class EntityContextAdmin(admin.ModelAdmin): search_fields = ("name",) - list_display = ("name", "poll", "created_at", "has_answer", "unsafe", "enabled") - list_filter = ("poll", HasTextListFilter, "unsafe", "enabled") + list_display = ("name", "poll", "origin", "created_at", "has_answer", "unsafe", "enabled") + list_filter = ("poll", "origin", HasTextListFilter, "unsafe", "enabled") ordering = ("-created_at",) inlines = (EntityContextLocaleInline,) From 257d368eb927f8e9d42550f5dc4db93bbe93a02d Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Mon, 20 Nov 2023 09:59:54 +0100 Subject: [PATCH 07/32] [back] refactor: remove source_url and source_label, we... ... want to be able to add more than one URLs in the text. --- backend/tournesol/admin.py | 2 +- backend/tournesol/models/entity_context.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/tournesol/admin.py b/backend/tournesol/admin.py index 967438337f..542225b103 100644 --- a/backend/tournesol/admin.py +++ b/backend/tournesol/admin.py @@ -391,7 +391,7 @@ def queryset(self, request, queryset): return queryset -class EntityContextLocaleInline(admin.TabularInline): +class EntityContextLocaleInline(admin.StackedInline): model = EntityContextLocale extra = 0 diff --git a/backend/tournesol/models/entity_context.py b/backend/tournesol/models/entity_context.py index 3bfd012899..7141c6c1ae 100644 --- a/backend/tournesol/models/entity_context.py +++ b/backend/tournesol/models/entity_context.py @@ -68,8 +68,6 @@ class EntityContextLocale(models.Model): context = models.ForeignKey(EntityContext, on_delete=models.CASCADE, related_name="texts") language = models.CharField(max_length=10, choices=settings.LANGUAGES) text = models.TextField() - source_url = models.URLField() - source_label = models.CharField(max_length=254) class Meta: unique_together = ["context", "language"] From 986d30280df2d4c12b488895a91bbe6dcf6bb6d1 Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:22:19 +0100 Subject: [PATCH 08/32] [back] fix: add missing migration file --- ...titycontextlocale_source_label_and_more.py | 21 +++++++++++++++++++ backend/tournesol/models/entity_context.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 backend/tournesol/migrations/0059_remove_entitycontextlocale_source_label_and_more.py diff --git a/backend/tournesol/migrations/0059_remove_entitycontextlocale_source_label_and_more.py b/backend/tournesol/migrations/0059_remove_entitycontextlocale_source_label_and_more.py new file mode 100644 index 0000000000..3ae9d3638e --- /dev/null +++ b/backend/tournesol/migrations/0059_remove_entitycontextlocale_source_label_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.7 on 2023-11-20 08:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("tournesol", "0058_alter_entitycontext_origin_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="entitycontextlocale", + name="source_label", + ), + migrations.RemoveField( + model_name="entitycontextlocale", + name="source_url", + ), + ] diff --git a/backend/tournesol/models/entity_context.py b/backend/tournesol/models/entity_context.py index 7141c6c1ae..4632eae004 100644 --- a/backend/tournesol/models/entity_context.py +++ b/backend/tournesol/models/entity_context.py @@ -57,7 +57,7 @@ def __str__(self) -> str: def get_context_text_prefetch(self, lang=None) -> str: """Return the translated text of the context.""" - return self.get_localized_text_prefetch(related="entity_context", field="text", lang=lang) + return self.get_localized_text_prefetch(related="texts", field="text", lang=lang) class EntityContextLocale(models.Model): From a466afec36713018cb29681516d9e54250f3fc1d Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Mon, 20 Nov 2023 11:07:25 +0100 Subject: [PATCH 09/32] [back] feat: add the entity contexs to RecommendationSerializer serializer --- backend/tournesol/models/poll.py | 25 +++++++++++++++++++ .../tournesol/serializers/entity_context.py | 11 ++++++++ backend/tournesol/serializers/poll.py | 12 +++++++++ 3 files changed, 48 insertions(+) create mode 100644 backend/tournesol/serializers/entity_context.py diff --git a/backend/tournesol/models/poll.py b/backend/tournesol/models/poll.py index 0f2a1fa795..6130862316 100644 --- a/backend/tournesol/models/poll.py +++ b/backend/tournesol/models/poll.py @@ -149,3 +149,28 @@ def entity_has_unsafe_context(self, entity_metadata) -> tuple: return True, context_.origin return False, None + + def get_entity_contexts(self, entity_metadata) -> list: + """ + Return a list of all enabled contexts matching the given entity's + metadata. + """ + contexts = [] + + # The entity contexts are expected to be already prefetched. + for context_ in self.all_entity_contexts.all(): + if not context_.enabled: + continue + + matching = [] + + for field, value in context_.predicate.items(): + try: + matching.append(entity_metadata[field] == value) + except KeyError: + pass + + if matching and all(matching): + contexts.append(context_) + + 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..e237f22edd 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,7 @@ class RecommendationSerializer(ModelSerializer): read_only=True, allow_null=True, ) + entity_contexts = serializers.SerializerMethodField(read_only=True) recommendation_metadata = RecommendationMetadataSerializer(source="*", read_only=True) class Meta: @@ -126,10 +128,20 @@ class Meta: "unsafe", "entity", "collective_rating", + "entity_contexts", "recommendation_metadata", ] read_only_fields = fields + def get_entity_contexts(self, obj): + try: + poll = obj.single_poll_rating.poll + except AttributeError: + return [] + + entity_contexts = poll.get_entity_contexts(obj.metadata) + return EntityContextSerializer(entity_contexts, many=True).data + class RecommendationsFilterSerializer(serializers.Serializer): date_lte = serializers.DateTimeField(default=None) From c1e11686cde2dc05d94b21fa17a4303cd42ebecf Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Mon, 20 Nov 2023 11:44:20 +0100 Subject: [PATCH 10/32] [back][front] feat: make the FE aware of the new entity_contexts field --- backend/tournesol/serializers/poll.py | 5 +- frontend/scripts/openapi.yaml | 47 +++++++++++++++++++ .../src/features/videos/VideoCard.spec.tsx | 4 ++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/backend/tournesol/serializers/poll.py b/backend/tournesol/serializers/poll.py index e237f22edd..cf787e644d 100644 --- a/backend/tournesol/serializers/poll.py +++ b/backend/tournesol/serializers/poll.py @@ -111,7 +111,10 @@ class RecommendationSerializer(ModelSerializer): read_only=True, allow_null=True, ) - entity_contexts = serializers.SerializerMethodField(read_only=True) + + entity_contexts = EntityContextSerializer( + source="get_entity_contexts", read_only=True, many=True + ) recommendation_metadata = RecommendationMetadataSerializer(source="*", read_only=True) class Meta: diff --git a/frontend/scripts/openapi.yaml b/frontend/scripts/openapi.yaml index 8b0ac8f21c..bfc0e2e04c 100644 --- a/frontend/scripts/openapi.yaml +++ b/frontend/scripts/openapi.yaml @@ -2918,6 +2918,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' @@ -2929,6 +2934,7 @@ components: required: - collective_rating - entity + - entity_contexts - individual_rating - recommendation_metadata CriteriaDistributionScore: @@ -3066,6 +3072,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 @@ -3351,6 +3380,14 @@ components: description: |- * `en` - en * `fr` - fr + OriginEnum: + enum: + - ASSOCIATION + - CONTRIBUTORS + type: string + description: |- + * `ASSOCIATION` - Association + * `CONTRIBUTORS` - Contributors PaginatedBannerList: type: object properties: @@ -3785,10 +3822,14 @@ components: enum: - 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: @@ -3801,6 +3842,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' @@ -3808,6 +3854,7 @@ components: required: - collective_rating - entity + - entity_contexts - recommendation_metadata RecommendationMetadata: type: object diff --git a/frontend/src/features/videos/VideoCard.spec.tsx b/frontend/src/features/videos/VideoCard.spec.tsx index 6c3cc521cd..e1ecbd90a9 100644 --- a/frontend/src/features/videos/VideoCard.spec.tsx +++ b/frontend/src/features/videos/VideoCard.spec.tsx @@ -43,6 +43,7 @@ describe('VideoCard content', () => { reasons: [], }, }, + entity_contexts: [], recommendation_metadata: { total_score: 0.0, }, @@ -94,6 +95,7 @@ describe('VideoCard content', () => { reasons: [], }, }, + entity_contexts: [], recommendation_metadata: { total_score: 4, }, @@ -152,6 +154,7 @@ describe('VideoCard content', () => { reasons: [], }, }, + entity_contexts: [], recommendation_metadata: { total_score: 17, }, @@ -193,6 +196,7 @@ describe('VideoCard content', () => { reasons: [], }, }, + entity_contexts: [], recommendation_metadata: { total_score: 0, }, From 6209098fa05fad4f174459fd3b8ab20a1048e77d Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:59:44 +0100 Subject: [PATCH 11/32] [back] fix: RecommendationSerializer now properly return entity contexts --- backend/tournesol/serializers/poll.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/tournesol/serializers/poll.py b/backend/tournesol/serializers/poll.py index cf787e644d..8eb0df4842 100644 --- a/backend/tournesol/serializers/poll.py +++ b/backend/tournesol/serializers/poll.py @@ -112,9 +112,7 @@ class RecommendationSerializer(ModelSerializer): allow_null=True, ) - entity_contexts = EntityContextSerializer( - source="get_entity_contexts", read_only=True, many=True - ) + entity_contexts = EntityContextSerializer(read_only=True, many=True) recommendation_metadata = RecommendationMetadataSerializer(source="*", read_only=True) class Meta: @@ -136,14 +134,17 @@ class Meta: ] read_only_fields = fields - def get_entity_contexts(self, obj): + def to_representation(self, instance): + ret = super().to_representation(instance) + try: - poll = obj.single_poll_rating.poll + poll = instance.single_poll_rating.poll except AttributeError: - return [] + return ret - entity_contexts = poll.get_entity_contexts(obj.metadata) - return EntityContextSerializer(entity_contexts, many=True).data + entity_contexts = poll.get_entity_contexts(instance.metadata) + ret["entity_contexts"] = EntityContextSerializer(entity_contexts, many=True).data + return ret class RecommendationsFilterSerializer(serializers.Serializer): From 09f4126c601def3906b43133432c60dec75aec88 Mon Sep 17 00:00:00 2001 From: Gresille&Siffle <39056254+GresilleSiffle@users.noreply.github.com> Date: Wed, 22 Nov 2023 17:37:11 +0100 Subject: [PATCH 12/32] [front] feat: analysis page now displays entity contexts from asso --- frontend/public/locales/en/translation.json | 4 + frontend/public/locales/fr/translation.json | 4 + frontend/src/components/CollapseButton.tsx | 3 - .../entity_context/EntityContextBox.tsx | 108 ++++++++++++++++++ .../src/pages/videos/VideoAnalysisPage.tsx | 12 +- 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 frontend/src/features/entity_context/EntityContextBox.tsx diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 7a653997b7..5250ca2aa4 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -102,6 +102,10 @@ "comparisonSeries": { "skipTheSeries": "Skip the series" }, + "contextsFromOrigin": { + "theAssociationWouldLikeToGiveYouContext": "The Tournesol association would like to give you some context on this element.", + "theContributorsWouldLikeToGiveYouContext": "The contributors 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 c9d8bb88cd..877b1f5807 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -106,6 +106,10 @@ "comparisonSeries": { "skipTheSeries": "Passer la série" }, + "contextsFromOrigin": { + "theAssociationWouldLikeToGiveYouContext": "L'association Tournesol souhaite vous apporter du contexte sur cet élément.", + "theContributorsWouldLikeToGiveYouContext": "Les contributeur·rices souhaitent 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/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 (