diff --git a/misago/permissions/hooks/__init__.py b/misago/permissions/hooks/__init__.py index 3fc2bf565..aa48ff9d7 100644 --- a/misago/permissions/hooks/__init__.py +++ b/misago/permissions/hooks/__init__.py @@ -8,6 +8,7 @@ check_post_in_closed_thread_permission_hook, ) from .check_private_threads_permission import check_private_threads_permission_hook +from .check_reply_thread_permission import check_reply_thread_permission_hook from .check_see_category_permission import check_see_category_permission_hook from .check_see_private_thread_permission import ( check_see_private_thread_permission_hook, @@ -46,6 +47,7 @@ "check_post_in_closed_category_permission_hook", "check_post_in_closed_thread_permission_hook", "check_private_threads_permission_hook", + "check_reply_thread_permission_hook", "check_see_category_permission_hook", "check_see_private_thread_permission_hook", "check_see_thread_permission_hook", diff --git a/misago/permissions/hooks/check_post_in_closed_thread_permission.py b/misago/permissions/hooks/check_post_in_closed_thread_permission.py new file mode 100644 index 000000000..606e5273f --- /dev/null +++ b/misago/permissions/hooks/check_post_in_closed_thread_permission.py @@ -0,0 +1,119 @@ +from typing import TYPE_CHECKING, Protocol + +from ...plugins.hooks import FilterHook +from ...threads.models import Thread + +if TYPE_CHECKING: + from ..proxy import UserPermissionsProxy + + +class CheckPostInClosedThreadPermissionHookAction(Protocol): + """ + A standard Misago function used to check if the user has permission to + post in a closed thread. It raises Django's `PermissionDenied` with an + error message if thread is closed and they can't post in it. + + # Arguments + + ## `user_permissions: UserPermissionsProxy` + + A proxy object with the current user's permissions. + + ## `thread: Thread` + + A thread to check permissions for. + """ + + def __call__( + self, + permissions: "UserPermissionsProxy", + thread: Thread, + ) -> None: ... + + +class CheckPostInClosedThreadPermissionHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: CheckPostInClosedThreadPermissionHookAction` + + A standard Misago function used to check if the user has permission to + post in a closed thread. It raises Django's `PermissionDenied` with an + error message if thread is closed and they can't post in it. + + See the [action](#action) section for details. + + ## `user_permissions: UserPermissionsProxy` + + A proxy object with the current user's permissions. + + ## `thread: Thread` + + A thread to check permissions for. + """ + + def __call__( + self, + action: CheckPostInClosedThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + thread: Thread, + ) -> None: ... + + +class CheckPostInClosedThreadPermissionHook( + FilterHook[ + CheckPostInClosedThreadPermissionHookAction, + CheckPostInClosedThreadPermissionHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to check if the user + has permission to post in a closed thread. It raises Django's `PermissionDenied` + with an error message if thread is closed and they can't post in it. + + # Example + + The code below implements a custom filter function that permits a user to + post in the specific thread if they have a custom flag set on their account. + + ```python + from misago.permissions.hooks import check_post_in_closed_thread_permission_hook + from misago.permissions.proxy import UserPermissionsProxy + from misago.threads.models import Thread + + @check_post_in_closed_thread_permission_hook.append_filter + def check_user_can_post_in_closed_thread( + action, + permissions: UserPermissionsProxy, + thread: Thread, + ) -> None: + user = permissions.user + if user.is_authenticated: + post_in_closed_categories = ( + user.plugin_data.get("post_in_closed_threads") or [] + ) + else: + post_in_closed_categories = None + + if ( + not post_in_closed_categories + or thread.id not in post_in_closed_categories + ): + action(permissions, thread) + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: CheckPostInClosedThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + thread: Thread, + ) -> None: + return super().__call__(action, permissions, thread) + + +check_post_in_closed_thread_permission_hook = CheckPostInClosedThreadPermissionHook() diff --git a/misago/permissions/hooks/check_reply_thread_permission.py b/misago/permissions/hooks/check_reply_thread_permission.py new file mode 100644 index 000000000..7cade6d82 --- /dev/null +++ b/misago/permissions/hooks/check_reply_thread_permission.py @@ -0,0 +1,130 @@ +from typing import TYPE_CHECKING, Protocol + +from ...categories.models import Category +from ...plugins.hooks import FilterHook +from ...threads.models import Thread + +if TYPE_CHECKING: + from ..proxy import UserPermissionsProxy + + +class CheckReplyThreadPermissionHookAction(Protocol): + """ + A standard Misago function used to check if the user has permission to + reply to a thread. It raises Django's `PermissionDenied` with an + error message if they can't reply to it. + + # Arguments + + ## `user_permissions: UserPermissionsProxy` + + A proxy object with the current user's permissions. + + ## `category: Category` + + A category to check permissions for. + + ## `thread: Thread` + + A thread to check permissions for. + """ + + def __call__( + self, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + ) -> None: ... + + +class CheckReplyThreadPermissionHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: CheckReplyThreadPermissionHookAction` + + A standard Misago function used to check if the user has permission to + reply to a thread. It raises Django's `PermissionDenied` with an + error message if they can't reply to it. + + See the [action](#action) section for details. + + ## `user_permissions: UserPermissionsProxy` + + A proxy object with the current user's permissions. + + ## `category: Category` + + A category to check permissions for. + + ## `thread: Thread` + + A thread to check permissions for. + """ + + def __call__( + self, + action: CheckReplyThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + ) -> None: ... + + +class CheckReplyThreadPermissionHook( + FilterHook[ + CheckReplyThreadPermissionHookAction, + CheckReplyThreadPermissionHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to check if the user + has permission to reply to a thread. It raises Django's `PermissionDenied` + with an error message if they can't post in it. + + # Example + + The code below implements a custom filter function that prevents a user from + replying to a thread if they are thread's starter, but only in categories + with a plugin flag. + + ```python + from django.core.exceptions import PermissionDenied + from misago.categories.models import Category + from misago.permissions.hooks import check_reply_thread_permission_hook + from misago.permissions.proxy import UserPermissionsProxy + from misago.threads.models import Thread + + @check_reply_thread_permission_hook.append_filter + def check_user_can_post_in_closed_thread( + action, + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, + ) -> None: + user = permissions.user + if ( + category.plugin_data.get("block_starters") + and user.is_authenticated and user.id == thread.starter_id + ): + raise PermissionDenied("You can't reply to threads you've started.") + + action(permissions, category, thread) + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: CheckReplyThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + ) -> None: + return super().__call__(action, permissions, category, thread) + + +check_reply_thread_permission_hook = CheckReplyThreadPermissionHook() diff --git a/misago/permissions/tests/test_threads_permissions.py b/misago/permissions/tests/test_threads_permissions.py index 9f53cef3c..330432567 100644 --- a/misago/permissions/tests/test_threads_permissions.py +++ b/misago/permissions/tests/test_threads_permissions.py @@ -8,6 +8,7 @@ from ..threads import ( check_post_in_closed_category_permission, check_post_in_closed_thread_permission, + check_reply_thread_permission, check_see_thread_permission, check_start_thread_in_category_permission, ) @@ -127,60 +128,60 @@ def test_check_post_in_closed_thread_permission_fails_if_user_is_anonymous( check_post_in_closed_thread_permission(permissions, thread) -def test_check_start_thread_in_category_permission_passes_if_user_has_permission( - user, cache_versions, default_category +def test_check_reply_thread_permission_passes_if_user_has_permission( + user, cache_versions, default_category, thread ): permissions = UserPermissionsProxy(user, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_passes_if_anonymous_has_permission( - user, cache_versions, default_category +def test_check_reply_thread_permission_passes_if_anonymous_user_has_permission( + anonymous_user, cache_versions, default_category, thread ): - permissions = UserPermissionsProxy(user, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_fails_if_user_has_no_permission( - user, cache_versions, default_category +def test_check_reply_thread_permission_fails_if_user_has_no_permission( + user, cache_versions, default_category, thread ): CategoryGroupPermission.objects.filter( group=user.group, - permission=CategoryPermission.START, + permission=CategoryPermission.REPLY, ).delete() permissions = UserPermissionsProxy(user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_fails_if_anonymous_has_no_permission( - anonymous_user, guests_group, cache_versions, default_category +def test_check_reply_thread_permission_fails_if_anonymous_has_no_permission( + anonymous_user, guests_group, cache_versions, default_category, thread ): CategoryGroupPermission.objects.filter( group=guests_group, - permission=CategoryPermission.START, + permission=CategoryPermission.REPLY, ).delete() permissions = UserPermissionsProxy(anonymous_user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_passes_if_user_is_global_moderator( - moderator, cache_versions, default_category +def test_check_reply_thread_permission_in_closed_category_passes_if_user_is_global_moderator( + moderator, cache_versions, default_category, thread ): default_category.is_closed = True default_category.save() permissions = UserPermissionsProxy(moderator, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_passes_if_user_is_category_moderator( - user, cache_versions, default_category +def test_check_reply_thread_permission_in_closed_category_passes_if_user_is_category_moderator( + user, cache_versions, default_category, thread ): default_category.is_closed = True default_category.save() @@ -192,11 +193,11 @@ def test_check_start_thread_in_category_permission_passes_if_user_is_category_mo ) permissions = UserPermissionsProxy(user, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_fails_for_user_if_category_is_closed( - user, cache_versions, default_category +def test_check_reply_thread_permission_fails_for_user_if_category_is_closed( + user, cache_versions, default_category, thread ): default_category.is_closed = True default_category.save() @@ -204,11 +205,11 @@ def test_check_start_thread_in_category_permission_fails_for_user_if_category_is permissions = UserPermissionsProxy(user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_fails_for_anonymous_if_category_is_closed( - anonymous_user, cache_versions, default_category +def test_check_reply_thread_permission_fails_for_anonymous_if_category_is_closed( + anonymous_user, cache_versions, default_category, thread ): default_category.is_closed = True default_category.save() @@ -216,7 +217,57 @@ def test_check_start_thread_in_category_permission_fails_for_anonymous_if_catego permissions = UserPermissionsProxy(anonymous_user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) + + +def test_check_reply_thread_permission_in_closed_thread_passes_if_user_is_global_moderator( + moderator, cache_versions, default_category, thread +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_reply_thread_permission(permissions, default_category, thread) + + +def test_check_reply_thread_permission_in_closed_thread_passes_if_user_is_category_moderator( + user, cache_versions, default_category, thread +): + thread.is_closed = True + thread.save() + + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + permissions = UserPermissionsProxy(user, cache_versions) + check_reply_thread_permission(permissions, default_category, thread) + + +def test_check_reply_thread_permission_fails_for_user_if_thread_is_closed( + user, cache_versions, default_category, thread +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_reply_thread_permission(permissions, default_category, thread) + + +def test_check_reply_thread_permission_fails_for_anonymous_if_thread_is_closed( + anonymous_user, cache_versions, default_category, thread +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + + with pytest.raises(PermissionDenied): + check_reply_thread_permission(permissions, default_category, thread) def test_check_see_thread_permission_passes_for_user_with_permission( @@ -611,3 +662,95 @@ def test_check_see_thread_permission_fails_for_anonymous_user_without_browse_per with pytest.raises(PermissionDenied): check_see_thread_permission(permissions, default_category, thread) + + +def test_check_start_thread_in_category_permission_passes_if_user_has_permission( + user, cache_versions, default_category +): + permissions = UserPermissionsProxy(user, cache_versions) + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_passes_if_anonymous_has_permission( + anonymous_user, cache_versions, default_category +): + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_fails_if_user_has_no_permission( + user, cache_versions, default_category +): + CategoryGroupPermission.objects.filter( + group=user.group, + permission=CategoryPermission.START, + ).delete() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_fails_if_anonymous_has_no_permission( + anonymous_user, guests_group, cache_versions, default_category +): + CategoryGroupPermission.objects.filter( + group=guests_group, + permission=CategoryPermission.START, + ).delete() + + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + + with pytest.raises(PermissionDenied): + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_passes_if_user_is_global_moderator( + moderator, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_passes_if_user_is_category_moderator( + user, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + permissions = UserPermissionsProxy(user, cache_versions) + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_fails_for_user_if_category_is_closed( + user, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_fails_for_anonymous_if_category_is_closed( + anonymous_user, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + + with pytest.raises(PermissionDenied): + check_start_thread_in_category_permission(permissions, default_category) diff --git a/misago/permissions/threads/__init__.py b/misago/permissions/threads/__init__.py index 999cf6eac..c79b5fcc6 100644 --- a/misago/permissions/threads/__init__.py +++ b/misago/permissions/threads/__init__.py @@ -1,6 +1,7 @@ from .checks import ( check_post_in_closed_category_permission, check_post_in_closed_thread_permission, + check_reply_thread_permission, check_see_thread_permission, check_start_thread_in_category_permission, ) @@ -16,6 +17,7 @@ "ThreadsQuerysetFilter", "check_post_in_closed_category_permission", "check_post_in_closed_thread_permission", + "check_reply_thread_permission", "check_see_thread_permission", "check_start_thread_in_category_permission", "filter_category_threads_queryset", diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index ed599480f..0b809160b 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -9,6 +9,7 @@ from ..hooks import ( check_post_in_closed_category_permission_hook, check_post_in_closed_thread_permission_hook, + check_reply_thread_permission_hook, check_see_thread_permission_hook, check_start_thread_in_category_permission_hook, ) @@ -141,6 +142,14 @@ def _check_see_thread_permission_action( def check_reply_thread_permission( permissions: UserPermissionsProxy, category: Category, thread: Thread +): + check_reply_thread_permission_hook( + _check_reply_thread_permission_action, permissions, category, thread + ) + + +def _check_reply_thread_permission_action( + permissions: UserPermissionsProxy, category: Category, thread: Thread ): if category.id not in permissions.categories[CategoryPermission.REPLY]: raise PermissionDenied( diff --git a/misago/posting/forms/reply.py b/misago/posting/forms/reply.py new file mode 100644 index 000000000..3cca5a695 --- /dev/null +++ b/misago/posting/forms/reply.py @@ -0,0 +1,18 @@ +from django import forms + +from ..state.reply import ReplyThreadState +from .base import PostingForm +from .formset import PostingFormset + + +class ReplyThreadFormset(PostingFormset): + pass + + +class ReplyThreadForm(PostingForm): + template_name = "misago/posting/reply_thread_form.html" + + post = forms.CharField(max_length=2000, widget=forms.Textarea) + + def update_state(self, state: ReplyThreadState): + state.set_post_message(self.cleaned_data["post"]) diff --git a/misago/posting/state/reply.py b/misago/posting/state/reply.py index 8743cd893..020d878f1 100644 --- a/misago/posting/state/reply.py +++ b/misago/posting/state/reply.py @@ -6,7 +6,6 @@ from .base import PostingState - class ReplyThreadState(PostingState): def __init__(self, request: HttpRequest, thread: Thread): super().__init__(request) @@ -55,4 +54,4 @@ def save_user(self): class ReplyPrivateThreadState(ReplyThreadState): - pass \ No newline at end of file + pass diff --git a/misago/posting/views/reply.py b/misago/posting/views/reply.py new file mode 100644 index 000000000..707e8b419 --- /dev/null +++ b/misago/posting/views/reply.py @@ -0,0 +1,54 @@ +from django.http import Http404, HttpRequest, HttpResponse +from django.views import View +from django.shortcuts import redirect, render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.translation import pgettext + +from ...auth.decorators import login_required +from ...threads.models import Thread +from ..state.reply import ReplyPrivateThreadState, ReplyThreadState + + +def reply_thread_login_required(): + return login_required( + pgettext( + "reply thread page", + "Sign in to reply to threads", + ) + ) + + +class ReplyThreadView(View): + template_name: str = "misago/posting/reply_thread.html" + state_class = ReplyThreadState + + @method_decorator(reply_thread_login_required()) + def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + return super().dispatch(request, *args, **kwargs) + + def get(self, request: HttpRequest, **kwargs) -> HttpResponse: + thread = self.get_thread(request, kwargs) + formset = self.get_formset(request, thread) + + return render( + request, + self.template_name, + self.get_context_data(request, thread, formset), + ) + + def post(self, request: HttpRequest, **kwargs) -> HttpResponse: + thread = self.get_thread(request, kwargs) + formset = self.get_formset(request, thread) + + def get_thread(self, request: HttpRequest, kwargs: dict) -> Thread: + try: + thread = Thread.objects.get(id=kwargs["id"]) + except Thread.DoesNotExist: + raise Http404() + + return thread + + +class ReplyPrivateThreadView(ReplyThreadView): + pass