From bfc3380ffabf70797fdb1433a05b103d2dcd6daa Mon Sep 17 00:00:00 2001 From: vincent porte Date: Wed, 26 Feb 2025 11:10:35 +0100 Subject: [PATCH 1/6] add can_moderate_post shortcut --- lacommunaute/forum_conversation/shortcuts.py | 4 ++++ .../forum_conversation/tests/tests_shortcuts.py | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lacommunaute/forum_conversation/shortcuts.py b/lacommunaute/forum_conversation/shortcuts.py index c85bf9e55..c9a394da9 100644 --- a/lacommunaute/forum_conversation/shortcuts.py +++ b/lacommunaute/forum_conversation/shortcuts.py @@ -38,3 +38,7 @@ def get_posts_of_a_topic_except_first_one(topic: Topic, user: User) -> QuerySet[ def can_certify_post(user): return user.is_authenticated and user.is_staff + + +def can_moderate_post(user): + return user.is_authenticated and user.is_staff diff --git a/lacommunaute/forum_conversation/tests/tests_shortcuts.py b/lacommunaute/forum_conversation/tests/tests_shortcuts.py index 79d015978..facf71469 100644 --- a/lacommunaute/forum_conversation/tests/tests_shortcuts.py +++ b/lacommunaute/forum_conversation/tests/tests_shortcuts.py @@ -1,9 +1,14 @@ +import pytest from django.contrib.auth.models import AnonymousUser from django.test import TestCase from lacommunaute.forum.factories import ForumFactory from lacommunaute.forum_conversation.factories import PostFactory, TopicFactory -from lacommunaute.forum_conversation.shortcuts import can_certify_post, get_posts_of_a_topic_except_first_one +from lacommunaute.forum_conversation.shortcuts import ( + can_certify_post, + can_moderate_post, + get_posts_of_a_topic_except_first_one, +) from lacommunaute.forum_upvote.factories import UpVoteFactory from lacommunaute.users.factories import UserFactory @@ -85,3 +90,10 @@ def test_user_is_staff(self): def test_user_is_not_staff(self): self.assertFalse(can_certify_post(self.user)) + +@pytest.mark.parametrize( + "user,has_right", + [(lambda: AnonymousUser(), False), (lambda: UserFactory(), False), (lambda: UserFactory(is_staff=True), True)], +) +def test_can_moderate_post(db, user, has_right): + assert can_moderate_post(user()) == has_right From a28f7619ad3978a8c82125fd663045e8093dd971 Mon Sep 17 00:00:00 2001 From: vincent porte Date: Wed, 26 Feb 2025 11:12:16 +0100 Subject: [PATCH 2/6] refactor can_certify_topic shortcut test --- .../tests/tests_shortcuts.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/lacommunaute/forum_conversation/tests/tests_shortcuts.py b/lacommunaute/forum_conversation/tests/tests_shortcuts.py index facf71469..b32b8da06 100644 --- a/lacommunaute/forum_conversation/tests/tests_shortcuts.py +++ b/lacommunaute/forum_conversation/tests/tests_shortcuts.py @@ -2,7 +2,6 @@ from django.contrib.auth.models import AnonymousUser from django.test import TestCase -from lacommunaute.forum.factories import ForumFactory from lacommunaute.forum_conversation.factories import PostFactory, TopicFactory from lacommunaute.forum_conversation.shortcuts import ( can_certify_post, @@ -75,21 +74,13 @@ def test_topic_has_been_upvoted_by_the_user(self): self.assertTrue(post.has_upvoted) -class CanCertifyPostShortcutTest(TestCase): - @classmethod - def setUpTestData(cls): - cls.user = UserFactory.create() - cls.forum = ForumFactory.create() - - def test_user_is_not_authenticated(self): - self.assertFalse(can_certify_post(AnonymousUser())) - - def test_user_is_staff(self): - self.user.is_staff = True - self.assertTrue(can_certify_post(self.user)) +@pytest.mark.parametrize( + "user,has_right", + [(lambda: AnonymousUser(), False), (lambda: UserFactory(), False), (lambda: UserFactory(is_staff=True), True)], +) +def test_can_certify_post(db, user, has_right): + assert can_certify_post(user()) == has_right - def test_user_is_not_staff(self): - self.assertFalse(can_certify_post(self.user)) @pytest.mark.parametrize( "user,has_right", From 7b692a6442972eca9a7eb4038e08427ccc60af9c Mon Sep 17 00:00:00 2001 From: vincent porte Date: Wed, 26 Feb 2025 11:34:23 +0100 Subject: [PATCH 3/6] move fixtures in forum_conversation views tests --- .../forum_conversation/tests/tests_views.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lacommunaute/forum_conversation/tests/tests_views.py b/lacommunaute/forum_conversation/tests/tests_views.py index 3e60ddde6..70c163571 100644 --- a/lacommunaute/forum_conversation/tests/tests_views.py +++ b/lacommunaute/forum_conversation/tests/tests_views.py @@ -41,6 +41,18 @@ assign_perm = get_class("forum_permission.shortcuts", "assign_perm") +@pytest.fixture(name="topics_url") +def fixture_topics_url(): + return reverse("forum_conversation_extension:topics") + + +@pytest.fixture(name="public_forum_with_topic") +def fixture_public_forum_with_topic(db): + forum = ForumFactory(with_public_perms=True) + TopicFactory(with_post=True, forum=forum, with_tags=["tag"]) + return forum + + def check_email_last_seen(email): return EmailLastSeen.objects.filter( email=email, last_seen_kind__in=[EmailLastSeenKind.POST, EmailLastSeenKind.LOGGED] @@ -822,18 +834,6 @@ def test_breadcrumbs_on_topic_view(client, db, snapshot): assert str(content) == snapshot(name="discussion_area_topic") -@pytest.fixture(name="topics_url") -def fixture_topics_url(): - return reverse("forum_conversation_extension:topics") - - -@pytest.fixture(name="public_forum_with_topic") -def fixture_public_forum_with_topic(db): - forum = ForumFactory(with_public_perms=True) - TopicFactory(with_post=True, forum=forum, with_tags=["tag"]) - return forum - - class TestTopicListView: def test_context(self, client, topics_url, public_forum_with_topic): response = client.get(topics_url) From 5a7cfd980f8a8a88396b497bf9a1d7efa4ed6c41 Mon Sep 17 00:00:00 2001 From: vincent porte Date: Wed, 26 Feb 2025 16:22:58 +0100 Subject: [PATCH 4/6] add PostModerateView to unapprove a post by staff user --- .../forum_conversation/tests/tests_views.py | 134 ++++++++++++++++++ lacommunaute/forum_conversation/urls.py | 3 +- lacommunaute/forum_conversation/views.py | 62 +++++++- 3 files changed, 195 insertions(+), 4 deletions(-) diff --git a/lacommunaute/forum_conversation/tests/tests_views.py b/lacommunaute/forum_conversation/tests/tests_views.py index 70c163571..7160adb0b 100644 --- a/lacommunaute/forum_conversation/tests/tests_views.py +++ b/lacommunaute/forum_conversation/tests/tests_views.py @@ -672,6 +672,140 @@ def test_redirection(self): self.assertTrue(view.success_message, msgs._queued_messages[0].message) +class TestPostModerateView: + def get_post_moderate_url(self, topic): + return reverse( + "forum_conversation_extension:post_moderate", + kwargs={ + "forum_slug": topic.forum.slug, + "forum_pk": topic.forum.pk, + "slug": topic.slug, + "pk": topic.pk, + }, + ) + + @pytest.mark.parametrize( + "method,status_code", [("post", 302), ("get", 405), ("put", 405), ("delete", 405), ("patch", 405)] + ) + def test_allowed_method(self, client, db, method, status_code, public_forum_with_topic): + topic = public_forum_with_topic.topics.first() + client.force_login(UserFactory(is_staff=True)) + response = getattr(client, method)(self.get_post_moderate_url(topic), data={"post_pk": topic.last_post.pk}) + assert response.status_code == status_code + + @pytest.mark.parametrize( + "user,topic,status_code", + [ + ( + lambda: UserFactory(is_staff=True), + lambda: TopicFactory(with_post=True, forum=ForumFactory(with_public_perms=True)), + 302, + ), + (lambda: UserFactory(is_staff=True), lambda: TopicFactory(with_post=True), 403), + ( + lambda: UserFactory(), + lambda: TopicFactory(with_post=True, forum=ForumFactory(with_public_perms=True)), + 403, + ), + (None, lambda: TopicFactory(with_post=True, forum=ForumFactory(with_public_perms=True)), 403), + ], + ) + def test_user_permission(self, client, db, user, topic, status_code): + topic = topic() + if user: + client.force_login(user()) + response = client.post(self.get_post_moderate_url(topic), data={"post_pk": topic.last_post.pk}) + assert response.status_code == status_code + + @pytest.mark.parametrize( + "topic,expected_url", + [ + ( + lambda: TopicFactory(with_post=True, forum=ForumFactory(with_public_perms=True)), + lambda topic: reverse("forum_conversation_extension:topics"), + ), + ( + lambda: TopicFactory( + with_post=True, forum=ForumFactory(with_public_perms=True, parent=CategoryForumFactory()) + ), + lambda topic: reverse( + "forum_extension:forum", kwargs={"slug": topic.forum.slug, "pk": topic.forum.pk} + ), + ), + ( + lambda: TopicFactory(with_post=True, answered=True, forum=ForumFactory(with_public_perms=True)), + lambda topic: reverse( + "forum_conversation:topic", + kwargs={ + "forum_slug": topic.forum.slug, + "forum_pk": topic.forum.pk, + "slug": topic.slug, + "pk": topic.pk, + }, + ), + ), + ], + ) + def test_redirection(self, client, db, topic, expected_url): + topic = topic() + expected_url = expected_url(topic=topic) + client.force_login(UserFactory(is_staff=True)) + response = client.post(self.get_post_moderate_url(topic), data={"post_pk": topic.last_post.pk}) + assert response.url == expected_url + + @pytest.mark.parametrize( + "post_exists,kwargs,status_code", + [ + ( + True, + lambda topic: { + "forum_slug": topic.forum.slug, + "forum_pk": topic.forum.pk, + "slug": topic.slug, + "pk": topic.pk, + }, + 302, + ), + ( + False, + { + "forum_slug": faker.slug(), + "forum_pk": 99999, + "slug": faker.slug(), + "pk": 99999, + }, + 404, + ), + ], + ) + def test_post_existence(self, client, db, post_exists, kwargs, status_code, public_forum_with_topic): + if post_exists: + topic = public_forum_with_topic.topics.first() + kwargs = kwargs(topic=topic) + last_post_pk = topic.last_post.pk + else: + kwargs = kwargs + last_post_pk = 9999 + client.force_login(UserFactory(is_staff=True)) + url = reverse("forum_conversation_extension:post_moderate", kwargs=kwargs) + response = client.post(url, data={"post_pk": last_post_pk}) + assert response.status_code == status_code + + def test_pk_is_missing_in_payload(self, client, db, public_forum_with_topic): + topic = public_forum_with_topic.topics.first() + client.force_login(UserFactory(is_staff=True)) + response = client.post(self.get_post_moderate_url(topic), data={}) + assert response.status_code == 404 + + def test_post_is_unapproved(self, client, db, public_forum_with_topic): + topic = public_forum_with_topic.topics.first() + client.force_login(UserFactory(is_staff=True)) + response = client.post(self.get_post_moderate_url(topic), data={"post_pk": topic.last_post.pk}) + assert response.status_code == 302 + topic.last_post.refresh_from_db() + assert not topic.last_post.approved + + class TopicViewTest(TestCase): @classmethod def setUpTestData(cls): diff --git a/lacommunaute/forum_conversation/urls.py b/lacommunaute/forum_conversation/urls.py index 6a393cb1e..8094b9d76 100644 --- a/lacommunaute/forum_conversation/urls.py +++ b/lacommunaute/forum_conversation/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path -from lacommunaute.forum_conversation.views import TopicListView +from lacommunaute.forum_conversation.views import PostModerateView, TopicListView from lacommunaute.forum_conversation.views_htmx import ( CertifiedPostView, PostFeedCreateView, @@ -18,6 +18,7 @@ path("topic/-/showmore/certified", TopicCertifiedPostView.as_view(), name="showmore_certified"), path("topic/-/comment", PostFeedCreateView.as_view(), name="post_create"), path("topic/-/certify", CertifiedPostView.as_view(), name="certify"), + path("topic/-/moderate", PostModerateView.as_view(), name="post_moderate"), ] diff --git a/lacommunaute/forum_conversation/views.py b/lacommunaute/forum_conversation/views.py index def06aa9b..8ccfa3565 100644 --- a/lacommunaute/forum_conversation/views.py +++ b/lacommunaute/forum_conversation/views.py @@ -2,15 +2,22 @@ from django.conf import settings from django.contrib import messages +from django.core.exceptions import PermissionDenied +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 from django.urls import reverse -from django.views.generic import ListView +from django.views.generic import ListView, View from machina.apps.forum_conversation import views from machina.core.loading import get_class from lacommunaute.forum.models import Forum from lacommunaute.forum_conversation.forms import PostForm, TopicForm -from lacommunaute.forum_conversation.models import Topic -from lacommunaute.forum_conversation.shortcuts import can_certify_post, get_posts_of_a_topic_except_first_one +from lacommunaute.forum_conversation.models import Post, Topic +from lacommunaute.forum_conversation.shortcuts import ( + can_certify_post, + can_moderate_post, + get_posts_of_a_topic_except_first_one, +) from lacommunaute.forum_conversation.view_mixins import FilteredTopicsListViewMixin from lacommunaute.notification.models import Notification @@ -78,6 +85,55 @@ def get_success_url(self): ) +class PostModerateView(View): + def dispatch(self, request, *args, **kwargs): + if request.method != "POST": + return self.http_method_not_allowed(request) + + user = request.user + obj = self.get_object().topic.forum + if not (self.request.forum_permission_handler.can_read_forum(obj, user) and can_moderate_post(user)): + raise PermissionDenied + + return super().dispatch(request, *args, **kwargs) + + def get_object(self): + if not hasattr(self, "object"): + self.object = get_object_or_404( + Post, + pk=self.request.POST.get("post_pk", None), + ) + return self.object + + def post(self, request, *args, **kwargs): + post = self.get_object() + post.approved = False + post.save() + return HttpResponseRedirect(self.get_redirection_url()) + + def get_redirection_url(self): + messages.success(self.request, "Le message a été modéré avec succès.") + if self.object.is_topic_head: + if self.object.topic.forum.is_in_documentation_area: + return reverse( + "forum_extension:forum", + kwargs={ + "slug": self.object.topic.forum.slug, + "pk": self.object.topic.forum.pk, + }, + ) + return reverse("forum_conversation_extension:topics") + return reverse( + "forum_conversation:topic", + kwargs={ + "forum_slug": self.object.topic.forum.slug, + "forum_pk": self.object.topic.forum.pk, + "slug": self.object.topic.slug, + "pk": self.object.topic.pk, + }, + ) + + class TopicView(views.TopicView): def get_topic(self): topic = super().get_topic() From fc8b15736f690a2c96ff17e05463fc595dae7477 Mon Sep 17 00:00:00 2001 From: vincent porte Date: Wed, 26 Feb 2025 16:24:08 +0100 Subject: [PATCH 5/6] show topic subject in moderation queue if post is not topic.head --- .../templates/forum_moderation/moderation_queue/list.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lacommunaute/templates/forum_moderation/moderation_queue/list.html b/lacommunaute/templates/forum_moderation/moderation_queue/list.html index 016423690..a6ac93491 100644 --- a/lacommunaute/templates/forum_moderation/moderation_queue/list.html +++ b/lacommunaute/templates/forum_moderation/moderation_queue/list.html @@ -44,7 +44,13 @@

{% trans "Moderation queue" %}

- {{ post.subject }} + + {% if post.subject %} + {{ post.subject }} + {% else %} + {{ post.topic.subject }} + {% endif %} +
{% if post.poster %} From d565de03cc22943dae9c68308b7d6419b2babb80 Mon Sep 17 00:00:00 2001 From: vincent porte Date: Wed, 26 Feb 2025 16:44:12 +0100 Subject: [PATCH 6/6] show button to PostModerateView in post_update and topic_form templates --- .../forum_conversation/partials/topic_form.html | 10 ++++++++++ .../templates/forum_conversation/post_update.html | 10 ++++++++++ lacommunaute/utils/templatetags/permission_tags.py | 11 +++++++++++ 3 files changed, 31 insertions(+) create mode 100644 lacommunaute/utils/templatetags/permission_tags.py diff --git a/lacommunaute/templates/forum_conversation/partials/topic_form.html b/lacommunaute/templates/forum_conversation/partials/topic_form.html index ac40d25f0..004b5b051 100644 --- a/lacommunaute/templates/forum_conversation/partials/topic_form.html +++ b/lacommunaute/templates/forum_conversation/partials/topic_form.html @@ -1,6 +1,7 @@ {% load i18n %} {% load widget_tweaks %} {% load forum_permission_tags %} +{% load permission_tags %}
{% csrf_token %} {% for error in post_form.non_field_errors %} @@ -8,6 +9,7 @@ {{ error }}
{% endfor %} + {% include "partials/form_field.html" with field=post_form.subject %} {% include "partials/form_field.html" with field=post_form.content %} {% if post_form.username %} @@ -98,6 +100,14 @@ {% trans "Delete" %}
{% endif %} + {% user_can_moderate_post request.user as user_can_moderate_post %} + {% if user_can_moderate_post %} +
+ +
+ {% endif %} {% endif %} diff --git a/lacommunaute/templates/forum_conversation/post_update.html b/lacommunaute/templates/forum_conversation/post_update.html index f9512eb07..c7d057560 100644 --- a/lacommunaute/templates/forum_conversation/post_update.html +++ b/lacommunaute/templates/forum_conversation/post_update.html @@ -2,6 +2,7 @@ {% load i18n %} {% load forum_conversation_tags %} {% load forum_permission_tags %} +{% load permission_tags %} {% block sub_title %} {% trans "Edit post" %} {% endblock sub_title %} @@ -20,6 +21,7 @@

{% trans "Edit post" %}

{% csrf_token %} + {% include "forum_conversation/partials/post_form.html" %}
@@ -31,6 +33,14 @@

{% trans "Edit post" %}

{% trans "Delete" %}
{% endif %} + {% user_can_moderate_post request.user as user_can_moderate_post %} + {% if user_can_moderate_post %} +
+ +
+ {% endif %}
diff --git a/lacommunaute/utils/templatetags/permission_tags.py b/lacommunaute/utils/templatetags/permission_tags.py new file mode 100644 index 000000000..29e2e950b --- /dev/null +++ b/lacommunaute/utils/templatetags/permission_tags.py @@ -0,0 +1,11 @@ +from django.template import Library + +from lacommunaute.forum_conversation.shortcuts import can_moderate_post + + +register = Library() + + +@register.simple_tag(takes_context=True) +def user_can_moderate_post(context, user): + return can_moderate_post(user)