diff --git a/lacommunaute/forum_conversation/forms.py b/lacommunaute/forum_conversation/forms.py index 13c057a7f..6fa6f66bb 100644 --- a/lacommunaute/forum_conversation/forms.py +++ b/lacommunaute/forum_conversation/forms.py @@ -6,6 +6,8 @@ from taggit.models import Tag from lacommunaute.forum_conversation.models import Post +from lacommunaute.forum_moderation.enums import BlockedPostReason +from lacommunaute.forum_moderation.models import BlockedPost from lacommunaute.forum_moderation.utils import check_post_approbation @@ -18,6 +20,12 @@ def clean(self): ) if not post.approved: self.add_error(None, "Votre message ne respecte pas les règles de la communauté.") + + if self.user.is_authenticated: + post.poster = self.user + + if post.update_reason in [x.label for x in BlockedPostReason.reasons_tracked_for_stats()]: + BlockedPost.create_from_post(post) return cleaned_data def update_post(self, post): diff --git a/lacommunaute/forum_conversation/tests/tests_views_htmx.py b/lacommunaute/forum_conversation/tests/tests_views_htmx.py index bfb9e4b93..64e40a039 100644 --- a/lacommunaute/forum_conversation/tests/tests_views_htmx.py +++ b/lacommunaute/forum_conversation/tests/tests_views_htmx.py @@ -11,7 +11,9 @@ from lacommunaute.forum_conversation.forms import PostForm from lacommunaute.forum_conversation.models import CertifiedPost, Topic from lacommunaute.forum_conversation.views_htmx import PostListView +from lacommunaute.forum_moderation.enums import BlockedPostReason from lacommunaute.forum_moderation.factories import BlockedDomainNameFactory, BlockedEmailFactory +from lacommunaute.forum_moderation.models import BlockedPost from lacommunaute.forum_upvote.factories import UpVoteFactory from lacommunaute.users.factories import UserFactory @@ -385,6 +387,12 @@ def test_create_post_as_blocked_not_blocked_anonymous(self, *args): self.topic.refresh_from_db() self.assertEqual(self.topic.posts.count(), 2) + # the blocked post should be recorded in the database + blocked_post = BlockedPost.objects.get() + assert blocked_post.content == self.content + assert blocked_post.username == username + assert blocked_post.block_reason == BlockedPostReason.BLOCKED_USER.label + def test_create_post_with_nonfr_content(self): assign_perm("can_reply_to_topics", self.user, self.topic.forum) assign_perm("can_post_without_approval", self.user, self.topic.forum) @@ -407,6 +415,12 @@ def test_create_post_with_nonfr_content(self): self.topic.refresh_from_db() self.assertEqual(self.topic.posts.count(), 1) + # the blocked post should be recorded in the database + blocked_post = BlockedPost.objects.get() + assert blocked_post.poster == self.user + assert blocked_post.content == "популярные лучшие песни слушать онлайн" + assert blocked_post.block_reason == BlockedPostReason.ALTERNATIVE_LANGUAGE.label + def test_create_post_with_html_content(self): assign_perm("can_reply_to_topics", self.user, self.topic.forum) assign_perm("can_post_without_approval", self.user, self.topic.forum) @@ -432,6 +446,9 @@ def test_create_post_with_html_content(self): self.topic.refresh_from_db() self.assertEqual(self.topic.posts.count(), 1) + # we don't create a BlockedPost record for HTML content to avoid storing malicious code + assert BlockedPost.objects.count() == 0 + def test_create_post_with_blocked_domain_name(self): BlockedDomainNameFactory(domain="blocked.com") @@ -456,6 +473,12 @@ def test_create_post_with_blocked_domain_name(self): self.topic.refresh_from_db() self.assertEqual(self.topic.posts.count(), 1) + # the blocked post should be recorded in the database + blocked_post = BlockedPost.objects.get() + assert blocked_post.content == "la communauté" + assert blocked_post.username == "spam@blocked.com" + assert blocked_post.block_reason == BlockedPostReason.BLOCKED_DOMAIN.label + class CertifiedPostViewTest(TestCase): @classmethod diff --git a/lacommunaute/forum_moderation/admin.py b/lacommunaute/forum_moderation/admin.py index 92c64f719..b5c24af6e 100644 --- a/lacommunaute/forum_moderation/admin.py +++ b/lacommunaute/forum_moderation/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from lacommunaute.forum_moderation.models import BlockedDomainName, BlockedEmail +from lacommunaute.forum_moderation.models import BlockedDomainName, BlockedEmail, BlockedPost @admin.register(BlockedEmail) @@ -13,3 +13,9 @@ class BlockedEmailAdmin(admin.ModelAdmin): class BlockedDomainNameAdmin(admin.ModelAdmin): list_display = ("domain", "created", "reason") list_filter = ("reason",) + + +@admin.register(BlockedPost) +class BlockedPostAdmin(admin.ModelAdmin): + list_display = ("username", "created", "block_reason") + list_filter = ("block_reason",) diff --git a/lacommunaute/forum_moderation/enums.py b/lacommunaute/forum_moderation/enums.py new file mode 100644 index 000000000..72605b213 --- /dev/null +++ b/lacommunaute/forum_moderation/enums.py @@ -0,0 +1,22 @@ +from django.db import models + + +class BlockedPostReason(models.TextChoices): + HTML_TAGS = "HTML_TAGS", "HTML tags detected" + ALTERNATIVE_LANGUAGE = "ALTERNATIVE_LANGUAGE", "Alternative Language detected" + BLOCKED_DOMAIN = "BLOCKED_DOMAIN", "Blocked Domain detected" + BLOCKED_USER = "BLOCKED_USER", "Blocked Email detected" + MODERATOR_DISAPPROVAL = "MODERATOR_DISAPPROVAL", "Moderator disapproval" + + @classmethod + def reasons_tracked_for_stats(cls): + """ + We store BlockedPost objects for posts which are of interest for review + The list of "reasons for interest" are returned by this function + """ + return [ + cls.ALTERNATIVE_LANGUAGE, + cls.BLOCKED_DOMAIN, + cls.BLOCKED_USER, + cls.MODERATOR_DISAPPROVAL, + ] diff --git a/lacommunaute/forum_moderation/migrations/0003_blockedpost.py b/lacommunaute/forum_moderation/migrations/0003_blockedpost.py new file mode 100644 index 000000000..f8fc1bda7 --- /dev/null +++ b/lacommunaute/forum_moderation/migrations/0003_blockedpost.py @@ -0,0 +1,51 @@ +# Generated by Django 5.0.6 on 2024-06-07 10:09 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("forum_moderation", "0002_copy_data_from_BouncedEmail_to_BlockedEmail"), + ] + + operations = [ + migrations.CreateModel( + name="BlockedPost", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created", models.DateTimeField(auto_now_add=True, verbose_name="Creation date")), + ("updated", models.DateTimeField(auto_now=True, verbose_name="Update date")), + ( + "poster", + models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.CASCADE, + related_name="blocked_posts", + to=settings.AUTH_USER_MODEL, + verbose_name="Poster", + ), + ), + ("username", models.EmailField(max_length=254, null=True, blank=True, verbose_name="Adresse email")), + ("content", models.CharField(verbose_name="Content")), + ( + "block_reason", + models.CharField( + choices=[ + ("HTML_TAGS", "HTML tags detected"), + ("ALTERNATIVE_LANGUAGE", "Alternative Language detected"), + ("BLOCKED_DOMAIN", "Blocked Domain detected"), + ("BLOCKED_USER", "Blocked Email detected"), + ("MODERATOR_DISAPPROVAL", "Moderator disapproval"), + ], + verbose_name="Block Reason", + ), + ), + ], + options={ + "verbose_name": "Blocked Post", + "verbose_name_plural": "Blocked Posts", + }, + ), + ] diff --git a/lacommunaute/forum_moderation/models.py b/lacommunaute/forum_moderation/models.py index a9d3ced48..0250daf87 100644 --- a/lacommunaute/forum_moderation/models.py +++ b/lacommunaute/forum_moderation/models.py @@ -1,6 +1,10 @@ +from django.conf import settings from django.db import models +from django.utils.translation import gettext_lazy as _ from machina.models.abstract_models import DatedModel +from lacommunaute.forum_moderation.enums import BlockedPostReason + class BlockedEmail(DatedModel): email = models.EmailField(verbose_name="email", null=False, blank=False, unique=True) @@ -24,3 +28,43 @@ class Meta: def __str__(self): return f"{self.domain} - {self.created}" + + +class BlockedPost(DatedModel): + """ + When a user submits a Post and it is blocked by our quality control app (forum_moderation), + we save a record of the blocked Post in this table for reference purposes + + It is built of a subset of fields from django-machina's model AbstractPost + """ + + poster = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="blocked_posts", + blank=True, + null=True, + on_delete=models.CASCADE, + verbose_name=_("Poster"), + ) + username = models.EmailField(null=True, blank=True, verbose_name=("Adresse email")) + content = models.CharField(verbose_name=_("Content")) + block_reason = models.CharField(verbose_name=_("Block Reason"), choices=BlockedPostReason) + + class Meta: + verbose_name = _("Blocked Post") + verbose_name_plural = _("Blocked Posts") + + def __str__(self): + return f"Blocked Message [{ str(self.created) }]" + + @classmethod + def create_from_post(cls, post): + """ + Creates a BlockedPost object from parameterised Post (machina) + """ + return cls.objects.create( + poster=post.poster, + username=getattr(post, "username", ""), + content=str(post.content), + block_reason=post.update_reason, + ) diff --git a/lacommunaute/forum_moderation/tests/test_post_disapprove_view.py b/lacommunaute/forum_moderation/tests/test_post_disapprove_view.py index dfb36143d..00fb5afe7 100644 --- a/lacommunaute/forum_moderation/tests/test_post_disapprove_view.py +++ b/lacommunaute/forum_moderation/tests/test_post_disapprove_view.py @@ -1,8 +1,12 @@ import pytest # noqa + +from django.urls import reverse + from lacommunaute.forum_conversation.factories import TopicFactory, AnonymousPostFactory -from lacommunaute.forum_moderation.models import BlockedEmail +from lacommunaute.forum_conversation.models import Post +from lacommunaute.forum_moderation.models import BlockedEmail, BlockedPost from lacommunaute.users.factories import UserFactory -from django.urls import reverse +from lacommunaute.forum_moderation.enums import BlockedPostReason from lacommunaute.forum_moderation.factories import BlockedEmailFactory @@ -15,6 +19,13 @@ def test_post_disapprove_view(client, db): assert response.status_code == 302 assert BlockedEmail.objects.get(email=disapproved_post.username) + # the original Post should be deleted, but a BlockedPost saved + assert Post.objects.count() == 0 + blocked_post = BlockedPost.objects.get() + assert blocked_post.content == str(disapproved_post.content) + assert blocked_post.username == disapproved_post.username + assert blocked_post.block_reason == BlockedPostReason.MODERATOR_DISAPPROVAL.label + def test_post_disapprove_view_with_existing_blocked_email(client, db): disapproved_post = AnonymousPostFactory(topic=TopicFactory(approved=False)) diff --git a/lacommunaute/forum_moderation/utils.py b/lacommunaute/forum_moderation/utils.py index f409b2e0a..aa85b684c 100644 --- a/lacommunaute/forum_moderation/utils.py +++ b/lacommunaute/forum_moderation/utils.py @@ -3,6 +3,7 @@ from machina.models.fields import render_func from markdown2 import Markdown +from lacommunaute.forum_moderation.enums import BlockedPostReason from lacommunaute.forum_moderation.models import BlockedDomainName, BlockedEmail @@ -17,11 +18,14 @@ def check_post_approbation(post): conditions = [ ( post.username and BlockedDomainName.objects.filter(domain=post.username.split("@")[-1]).exists(), - "Blocked Domain detected", + BlockedPostReason.BLOCKED_DOMAIN.label, + ), + (Markdown.html_removed_text_compat in rendered, BlockedPostReason.HTML_TAGS.label), + (detect(post.content.raw) not in settings.LANGUAGE_CODE, BlockedPostReason.ALTERNATIVE_LANGUAGE.label), + ( + post.username and BlockedEmail.objects.filter(email=post.username).exists(), + BlockedPostReason.BLOCKED_USER.label, ), - (Markdown.html_removed_text_compat in rendered, "HTML tags detected"), - (detect(post.content.raw) not in settings.LANGUAGE_CODE, "Alternative Language detected"), - (post.username and BlockedEmail.objects.filter(email=post.username).exists(), "Blocked Email detected"), ] post.approved, post.update_reason = next( ((not condition, reason) for condition, reason in conditions if condition), (post.approved, post.update_reason) diff --git a/lacommunaute/forum_moderation/views.py b/lacommunaute/forum_moderation/views.py index ec8ac84a1..17d831830 100644 --- a/lacommunaute/forum_moderation/views.py +++ b/lacommunaute/forum_moderation/views.py @@ -5,7 +5,8 @@ TopicDeleteView as BaseTopicDeleteView, ) -from lacommunaute.forum_moderation.models import BlockedEmail +from lacommunaute.forum_moderation.enums import BlockedPostReason +from lacommunaute.forum_moderation.models import BlockedEmail, BlockedPost class TopicDeleteView(BaseTopicDeleteView): @@ -30,4 +31,8 @@ def post(self, request, *args, **kwargs): self.request, "l'adresse email de l'utilisateur est déjà dans la liste des emails bloqués.", ) + + post.update_reason = BlockedPostReason.MODERATOR_DISAPPROVAL.label + BlockedPost.create_from_post(post) + return self.disapprove(request, *args, **kwargs) diff --git a/locale/fr/LC_MESSAGES/django.mo b/locale/fr/LC_MESSAGES/django.mo index e95d06fc7..00fb78b10 100644 Binary files a/locale/fr/LC_MESSAGES/django.mo and b/locale/fr/LC_MESSAGES/django.mo differ diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index a6129a0e2..07b07876b 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: lacommunaute.inclusion.beta.gouv.fr\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-10-18 18:07+0200\n" -"PO-Revision-Date: 2022-10-27 17:22+0200\n" +"PO-Revision-Date: 2024-06-06 11:19+0200\n" "Last-Translator: \n" "Language-Team: French\n" "Language: fr\n" @@ -26,6 +26,30 @@ msgstr "Message certifié" msgid "Certified Posts" msgstr "Messages certifiés" +#: lacommunaute/forum_moderation/models.py +msgid "Content" +msgstr "Contenu" + +#: lacommunaute/forum_moderation/models.py +msgid "Blocked Post" +msgstr "Message bloqué" + +#: lacommunaute/forum_moderation/models.py +msgid "Blocked Posts" +msgstr "Messages bloqués" + +#: lacommunaute/forum_moderation/models.py +msgid "Block Reason" +msgstr "Motif du blocage" + +#: lacommunaute/forum_moderation/models.py +msgid "Poster" +msgstr "Auteur" + +#: lacommunaute/search/forms.py:24 +msgid "Keywords or phrase" +msgstr "Mots clés ou phrase" + msgid "This topic type has been changed successfully." msgstr "Le type du sujet a été modifié avec succès." @@ -183,6 +207,7 @@ msgstr "Copier" msgid "Search" msgstr "Rechercher" +#: lacommunaute/search/forms.py msgid "Search in" msgstr "Rechercher dans"