diff --git a/misago/admin/groups/forms.py b/misago/admin/groups/forms.py index 1892eedb85..acc1656dc0 100644 --- a/misago/admin/groups/forms.py +++ b/misago/admin/groups/forms.py @@ -114,9 +114,50 @@ class EditGroupForm(forms.ModelForm): # Permissions + can_edit_own_threads = YesNoSwitch( + label=pgettext_lazy("admin group permissions form", "Can edit own threads"), + ) + own_threads_edit_time_limit = forms.IntegerField( + label=pgettext_lazy( + "admin group permissions form", "Time limit for editing own threads" + ), + help_text=pgettext_lazy( + "admin group permissions form", + "Enter the number of minutes after a user starts a thread during which they can still edit it. Enter zero to remove this time limit.", + ), + min_value=0, + ) + + can_edit_own_posts = YesNoSwitch( + label=pgettext_lazy("admin group permissions form", "Can edit own posts"), + ) + own_posts_edit_time_limit = forms.IntegerField( + label=pgettext_lazy( + "admin group permissions form", "Time limit for editing own posts" + ), + help_text=pgettext_lazy( + "admin group permissions form", + "Enter the number of minutes after a user posts a message during which they can still edit it. Enter zero to remove this time limit.", + ), + min_value=0, + ) + can_use_private_threads = YesNoSwitch( label=pgettext_lazy("admin group permissions form", "Can use private threads"), ) + can_start_private_threads = YesNoSwitch( + label=pgettext_lazy( + "admin group permissions form", "Can start new private threads" + ), + ) + private_thread_users_limit = forms.IntegerField( + label=pgettext_lazy("admin group permissions form", "Invited users limit"), + help_text=pgettext_lazy( + "admin group permissions form", + "Enter the maximum number of users that can be invited to private threads started by members of this group.", + ), + min_value=1, + ) can_change_username = YesNoSwitch( label=pgettext_lazy("admin group permissions form", "Can change username"), @@ -167,7 +208,13 @@ class Meta: "css_suffix", "is_page", "is_hidden", + "can_edit_own_threads", + "own_threads_edit_time_limit", + "can_edit_own_posts", + "own_posts_edit_time_limit", "can_use_private_threads", + "can_start_private_threads", + "private_thread_users_limit", "can_change_username", "username_changes_limit", "username_changes_expire", diff --git a/misago/admin/templates/misago/admin/groups/edit.html b/misago/admin/templates/misago/admin/groups/edit.html index 99645337b7..cc686c0568 100644 --- a/misago/admin/templates/misago/admin/groups/edit.html +++ b/misago/admin/templates/misago/admin/groups/edit.html @@ -70,11 +70,31 @@ +
+
+ {% trans "Threads" context "admin group permissions form" %} + + {% form_row form.group.can_edit_own_threads %} + {% form_row form.group.own_threads_edit_time_limit %} + +
+
+
+
+ {% trans "Posts" context "admin group permissions form" %} + + {% form_row form.group.can_edit_own_posts %} + {% form_row form.group.own_posts_edit_time_limit %} + +
+
{% trans "Private threads" context "admin group permissions form" %} {% form_row form.group.can_use_private_threads %} + {% form_row form.group.can_start_private_threads %} + {% form_row form.group.private_thread_users_limit %}
diff --git a/misago/admin/tests/test_group_edit.py b/misago/admin/tests/test_group_edit.py index 4dc4c219c2..384d48d32d 100644 --- a/misago/admin/tests/test_group_edit.py +++ b/misago/admin/tests/test_group_edit.py @@ -17,7 +17,15 @@ def get_form_data(group: Group) -> dict: "group-user_title": group.user_title or "", "group-is_page": "1" if group.is_page else "", "group-is_hidden": "1" if group.is_hidden else "", + "group-can_edit_own_threads": "1" if group.can_edit_own_threads else "", + "group-own_threads_edit_time_limit": str(group.own_threads_edit_time_limit), + "group-can_edit_own_posts": "1" if group.can_edit_own_posts else "", + "group-own_posts_edit_time_limit": str(group.own_posts_edit_time_limit), "group-can_use_private_threads": "1" if group.can_use_private_threads else "", + "group-can_start_private_threads": ( + "1" if group.can_start_private_threads else "" + ), + "group-private_thread_users_limit": str(group.private_thread_users_limit), "group-can_change_username": "1" if group.can_change_username else "", "group-username_changes_limit": str(group.username_changes_limit), "group-username_changes_expire": str(group.username_changes_expire), diff --git a/misago/categories/components.py b/misago/categories/components.py index f93c7b206c..fd38968b1e 100644 --- a/misago/categories/components.py +++ b/misago/categories/components.py @@ -139,7 +139,7 @@ def can_see_last_thread( if ( category.show_started_only - and category.id not in permissions.categories_moderator + and not permissions.is_category_moderator(category.id) and (user.is_anonymous or category.last_poster_id != user.id) ): return False diff --git a/misago/moderation/forms.py b/misago/moderation/forms.py index 6ddf025cba..6cdb3196ef 100644 --- a/misago/moderation/forms.py +++ b/misago/moderation/forms.py @@ -31,14 +31,14 @@ def get_disabled_category_choices( categories_browse = user_permissions.categories[CategoryPermission.BROWSE] categories_start = user_permissions.categories[CategoryPermission.START] - categories_moderator = user_permissions.categories_moderator + moderated_categories = user_permissions.moderated_categories for category in categories.categories_list: if ( category["is_vanilla"] or category["id"] not in categories_browse or category["id"] not in categories_start - or (category["is_closed"] and category["id"] not in categories_moderator) + or (category["is_closed"] and category["id"] not in moderated_categories) ): choices.add(category["id"]) diff --git a/misago/permissions/copy.py b/misago/permissions/copy.py index 243cb5c263..ba30baceec 100644 --- a/misago/permissions/copy.py +++ b/misago/permissions/copy.py @@ -46,6 +46,10 @@ def _copy_category_permissions_action( "can_use_private_threads", "can_start_private_threads", "private_thread_users_limit", + "can_edit_own_threads", + "own_threads_edit_time_limit", + "can_edit_own_posts", + "own_posts_edit_time_limit", "can_change_username", "username_changes_limit", "username_changes_expire", diff --git a/misago/permissions/hooks/__init__.py b/misago/permissions/hooks/__init__.py index f7a32deb94..33ca83c4a6 100644 --- a/misago/permissions/hooks/__init__.py +++ b/misago/permissions/hooks/__init__.py @@ -1,10 +1,25 @@ from .build_user_category_permissions import build_user_category_permissions_hook from .build_user_permissions import build_user_permissions_hook from .check_browse_category_permission import check_browse_category_permission_hook +from .check_edit_post_permission import check_edit_post_permission_hook +from .check_edit_private_thread_permission import ( + check_edit_private_thread_permission_hook, +) +from .check_edit_private_thread_post_permission import ( + check_edit_private_thread_post_permission_hook, +) +from .check_edit_thread_permission import check_edit_thread_permission_hook from .check_post_in_closed_category_permission import ( check_post_in_closed_category_permission_hook, ) +from .check_post_in_closed_thread_permission import ( + check_post_in_closed_thread_permission_hook, +) from .check_private_threads_permission import check_private_threads_permission_hook +from .check_reply_private_thread_permission import ( + check_reply_private_thread_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, @@ -13,8 +28,8 @@ from .check_start_private_threads_permission import ( check_start_private_threads_permission_hook, ) -from .check_start_thread_in_category_permission import ( - check_start_thread_in_category_permission_hook, +from .check_start_thread_permission import ( + check_start_thread_permission_hook, ) from .copy_category_permissions import copy_category_permissions_hook from .copy_group_permissions import copy_group_permissions_hook @@ -40,13 +55,20 @@ "build_user_category_permissions_hook", "build_user_permissions_hook", "check_browse_category_permission_hook", + "check_edit_post_permission_hook", + "check_edit_private_thread_permission_hook", + "check_edit_private_thread_post_permission_hook", + "check_edit_thread_permission_hook", "check_post_in_closed_category_permission_hook", + "check_post_in_closed_thread_permission_hook", "check_private_threads_permission_hook", + "check_reply_private_thread_permission_hook", + "check_reply_thread_permission_hook", "check_see_category_permission_hook", "check_see_private_thread_permission_hook", "check_see_thread_permission_hook", "check_start_private_threads_permission_hook", - "check_start_thread_in_category_permission_hook", + "check_start_thread_permission_hook", "copy_category_permissions_hook", "copy_group_permissions_hook", "filter_private_thread_posts_queryset_hook", diff --git a/misago/permissions/hooks/check_edit_post_permission.py b/misago/permissions/hooks/check_edit_post_permission.py new file mode 100644 index 0000000000..d45e42ca58 --- /dev/null +++ b/misago/permissions/hooks/check_edit_post_permission.py @@ -0,0 +1,143 @@ +from typing import TYPE_CHECKING, Protocol + +from ...categories.models import Category +from ...plugins.hooks import FilterHook +from ...threads.models import Post, Thread + +if TYPE_CHECKING: + from ..proxy import UserPermissionsProxy + + +class CheckEditPostPermissionHookAction(Protocol): + """ + A standard Misago function used to check if the user has permission to + edit a post. It raises Django's `PermissionDenied` with an + error message if they can't. + + # 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. + + ## `post: Post` + + A post to check permissions for. + """ + + def __call__( + self, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + post: Post, + ) -> None: ... + + +class CheckEditPostPermissionHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: CheckEditPostPermissionHookAction` + + A standard Misago function used to check if the user has permission to + edit a post. It raises Django's `PermissionDenied` with an + error message if they can't. + + 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. + + ## `post: Post` + + A post to check permissions for. + """ + + def __call__( + self, + action: CheckEditPostPermissionHookAction, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + post: Post, + ) -> None: ... + + +class CheckEditPostPermissionHook( + FilterHook[ + CheckEditPostPermissionHookAction, + CheckEditPostPermissionHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to check if the user + has permission to edit a post. It raises Django's `PermissionDenied` + with an error message if they can't. + + # Example + + The code below implements a custom filter function that prevents a user from + editing a post if it contains a special string. + + ```python + from django.core.exceptions import PermissionDenied + from misago.categories.models import Category + from misago.permissions.hooks import check_edit_post_permission_hook + from misago.permissions.proxy import UserPermissionsProxy + from misago.threads.models import Post, Thread + + @check_edit_post_permission_hook.append_filter + def check_user_can_edit_thread( + action, + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, + post: Post, + ) -> None: + action(permissions, category, post, thread) + + if ( + "[PROTECT]" in post.original + and not ( + permissions.is_global_moderator + or permissions.is_category_moderator(thread.category_id) + ) + ): + raise PermissionError("Only a moderator can edit this post.") + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: CheckEditPostPermissionHookAction, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + post: Post, + ) -> None: + return super().__call__(action, permissions, category, thread, post) + + +check_edit_post_permission_hook = CheckEditPostPermissionHook() diff --git a/misago/permissions/hooks/check_edit_private_thread_permission.py b/misago/permissions/hooks/check_edit_private_thread_permission.py new file mode 100644 index 0000000000..d8411c25d8 --- /dev/null +++ b/misago/permissions/hooks/check_edit_private_thread_permission.py @@ -0,0 +1,115 @@ +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 CheckEditPrivateThreadPermissionHookAction(Protocol): + """ + A standard Misago function used to check if the user has permission to + edit a private thread. It raises Django's `PermissionDenied` with an + error message if they can't edit 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 CheckEditPrivateThreadPermissionHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: CheckEditPrivateThreadPermissionHookAction` + + A standard Misago function used to check if the user has permission to + edit a private thread. It raises Django's `PermissionDenied` with an + error message if they can't edit 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: CheckEditPrivateThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + thread: Thread, + ) -> None: ... + + +class CheckEditPrivateThreadPermissionHook( + FilterHook[ + CheckEditPrivateThreadPermissionHookAction, + CheckEditPrivateThreadPermissionHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to check if the user + has permission to edit a private thread. It raises Django's `PermissionDenied` + with an error message if they can't edit it. + + # Example + + The code below implements a custom filter function that prevents a user from + editing a thread if it's title contains a special prefix. + + ```python + from django.core.exceptions import PermissionDenied + from misago.permissions.hooks import check_edit_private_thread_permission_hook + from misago.permissions.proxy import UserPermissionsProxy + from misago.threads.models import Thread + + @check_edit_private_thread_permission_hook.append_filter + def check_user_can_edit_thread( + action, + permissions: UserPermissionsProxy, + thread: Thread, + ) -> None: + action(permissions, thread) + + if ( + thread.title.startswith("[MVP]") + and not permissions.is_private_threads_moderator + ): + raise PermissionError("Only a moderator can edit this thread.") + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: CheckEditPrivateThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + thread: Thread, + ) -> None: + return super().__call__(action, permissions, thread) + + +check_edit_private_thread_permission_hook = CheckEditPrivateThreadPermissionHook() diff --git a/misago/permissions/hooks/check_edit_private_thread_post_permission.py b/misago/permissions/hooks/check_edit_private_thread_post_permission.py new file mode 100644 index 0000000000..9c3bd2c5c9 --- /dev/null +++ b/misago/permissions/hooks/check_edit_private_thread_post_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 Post, Thread + +if TYPE_CHECKING: + from ..proxy import UserPermissionsProxy + + +class CheckEditPrivateThreadPostPermissionHookAction(Protocol): + """ + A standard Misago function used to check if the user has permission to + edit a post in a private thread. It raises Django's `PermissionDenied` with + an error message if they can't. + + # Arguments + + ## `user_permissions: UserPermissionsProxy` + + A proxy object with the current user's permissions. + + ## `thread: Thread` + + A thread to check permissions for. + + ## `post: Post` + + A post to check permissions for. + """ + + def __call__( + self, + permissions: "UserPermissionsProxy", + thread: Thread, + post: Post, + ) -> None: ... + + +class CheckEditPrivateThreadPostPermissionHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: CheckEditPrivateThreadPostPermissionHookAction` + + A standard Misago function used to check if the user has permission to + edit a post in a private thread. It raises Django's `PermissionDenied` with + an error message if they can't. + + 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. + + ## `post: Post` + + A post to check permissions for. + """ + + def __call__( + self, + action: CheckEditPrivateThreadPostPermissionHookAction, + permissions: "UserPermissionsProxy", + thread: Thread, + post: Post, + ) -> None: ... + + +class CheckEditPrivateThreadPostPermissionHook( + FilterHook[ + CheckEditPrivateThreadPostPermissionHookAction, + CheckEditPrivateThreadPostPermissionHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to check if the user + has permission to edit a post in a private thread. It raises Django's + `PermissionDenied` with an error message if they can't. + + # Example + + The code below implements a custom filter function that prevents a user from + editing a post if it contains a special string. + + ```python + from django.core.exceptions import PermissionDenied + from misago.categories.models import Category + from misago.permissions.hooks import check_edit_private_thread_post_permission_hook + from misago.permissions.proxy import UserPermissionsProxy + from misago.threads.models import Post, Thread + + @check_edit_private_thread_post_permission_hook.append_filter + def check_user_can_edit_thread( + action, + permissions: UserPermissionsProxy, + thread: Thread, + post: Post, + ) -> None: + action(permissions, category, post, thread) + + if ( + "[PROTECT]" in post.original + and not permissions.is_private_threads_moderator + ): + raise PermissionError("Only a moderator can edit this post.") + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: CheckEditPrivateThreadPostPermissionHookAction, + permissions: "UserPermissionsProxy", + thread: Thread, + post: Post, + ) -> None: + return super().__call__(action, permissions, thread, post) + + +check_edit_private_thread_post_permission_hook = ( + CheckEditPrivateThreadPostPermissionHook() +) diff --git a/misago/permissions/hooks/check_edit_thread_permission.py b/misago/permissions/hooks/check_edit_thread_permission.py new file mode 100644 index 0000000000..b4255851ca --- /dev/null +++ b/misago/permissions/hooks/check_edit_thread_permission.py @@ -0,0 +1,131 @@ +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 CheckEditThreadPermissionHookAction(Protocol): + """ + A standard Misago function used to check if the user has permission to + edit a thread. It raises Django's `PermissionDenied` with an + error message if they can't edit 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 CheckEditThreadPermissionHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: CheckEditThreadPermissionHookAction` + + A standard Misago function used to check if the user has permission to + edit a thread. It raises Django's `PermissionDenied` with an + error message if they can't edit 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: CheckEditThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + ) -> None: ... + + +class CheckEditThreadPermissionHook( + FilterHook[ + CheckEditThreadPermissionHookAction, + CheckEditThreadPermissionHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to check if the user + has permission to edit a thread. It raises Django's `PermissionDenied` + with an error message if they can't edit it. + + # Example + + The code below implements a custom filter function that prevents a user from + editing a thread if it's title contains a special prefix. + + ```python + from django.core.exceptions import PermissionDenied + from misago.categories.models import Category + from misago.permissions.hooks import check_edit_thread_permission_hook + from misago.permissions.proxy import UserPermissionsProxy + from misago.threads.models import Thread + + @check_edit_thread_permission_hook.append_filter + def check_user_can_edit_thread( + action, + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, + ) -> None: + action(permissions, category, thread) + + if ( + thread.title.startswith("[MVP]") + and not ( + permissions.is_global_moderator + or permissions.is_category_moderator(thread.category_id) + ) + ): + raise PermissionError("Only a moderator can edit this thread.") + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: CheckEditThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + ) -> None: + return super().__call__(action, permissions, category, thread) + + +check_edit_thread_permission_hook = CheckEditThreadPermissionHook() 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 0000000000..606e5273ff --- /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_private_thread_permission.py b/misago/permissions/hooks/check_reply_private_thread_permission.py new file mode 100644 index 0000000000..a38982d6a0 --- /dev/null +++ b/misago/permissions/hooks/check_reply_private_thread_permission.py @@ -0,0 +1,112 @@ +from typing import TYPE_CHECKING, Protocol + +from ...plugins.hooks import FilterHook +from ...threads.models import Thread + +if TYPE_CHECKING: + from ..proxy import UserPermissionsProxy + + +class CheckReplyPrivateThreadPermissionHookAction(Protocol): + """ + A standard Misago function used to check if the user has permission to + reply to a private 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. + + ## `thread: Thread` + + A thread to check permissions for. + """ + + def __call__( + self, + permissions: "UserPermissionsProxy", + thread: Thread, + ) -> None: ... + + +class CheckReplyPrivateThreadPermissionHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: CheckReplyPrivateThreadPermissionHookAction` + + A standard Misago function used to check if the user has permission to + reply to a private 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. + + ## `thread: Thread` + + A thread to check permissions for. + """ + + def __call__( + self, + action: CheckReplyPrivateThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + thread: Thread, + ) -> None: ... + + +class CheckReplyPrivateThreadPermissionHook( + FilterHook[ + CheckReplyPrivateThreadPermissionHookAction, + CheckReplyPrivateThreadPermissionHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to check if the user + has permission to reply to a private 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 private thread if they are a thread starter. + + ```python + from django.core.exceptions import PermissionDenied + from misago.permissions.hooks import check_reply_private_thread_permission_hook + from misago.permissions.proxy import UserPermissionsProxy + from misago.threads.models import Thread + + @check_reply_private_thread_permission_hook.append_filter + def check_user_can_reply_in_private_thread( + action, + permissions: UserPermissionsProxy, + thread: Thread, + ) -> None: + user = permissions.user + if user.is_authenticated and user.id == thread.starter_id: + raise PermissionDenied("You can't reply to threads you've started.") + + action(permissions, thread) + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: CheckReplyPrivateThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + thread: Thread, + ) -> None: + return super().__call__(action, permissions, thread) + + +check_reply_private_thread_permission_hook = CheckReplyPrivateThreadPermissionHook() 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 0000000000..77e8fd179f --- /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 a thread 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_reply_in_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/hooks/check_start_thread_in_category_permission.py b/misago/permissions/hooks/check_start_thread_permission.py similarity index 92% rename from misago/permissions/hooks/check_start_thread_in_category_permission.py rename to misago/permissions/hooks/check_start_thread_permission.py index 32f57b3897..05892a055f 100644 --- a/misago/permissions/hooks/check_start_thread_in_category_permission.py +++ b/misago/permissions/hooks/check_start_thread_permission.py @@ -84,10 +84,10 @@ class CheckStartThreadInCategoryPermissionHook( from django.core.exceptions import PermissionDenied from django.utils import timezone from misago.categories.models import Category - from misago.permissions.hooks import check_start_thread_in_category_permission_hook + from misago.permissions.hooks import check_start_thread_permission_hook from misago.permissions.proxy import UserPermissionsProxy - @check_start_thread_in_category_permission_hook.append_filter + @check_start_thread_permission_hook.append_filter def check_user_can_start_thread( action, permissions: UserPermissionsProxy, @@ -118,6 +118,4 @@ def __call__( return super().__call__(action, permissions, category) -check_start_thread_in_category_permission_hook = ( - CheckStartThreadInCategoryPermissionHook() -) +check_start_thread_permission_hook = CheckStartThreadInCategoryPermissionHook() diff --git a/misago/permissions/hooks/filter_private_threads_queryset.py b/misago/permissions/hooks/filter_private_threads_queryset.py index b08978ec60..7820e7c46a 100644 --- a/misago/permissions/hooks/filter_private_threads_queryset.py +++ b/misago/permissions/hooks/filter_private_threads_queryset.py @@ -99,7 +99,7 @@ def exclude_old_private_threads_queryset_hook( ) -> None: queryset = action(permissions, queryset) - if permissions.private_threads_moderator: + if permissions.is_private_threads_moderator: return queryset return queryset.filter( diff --git a/misago/permissions/privatethreads.py b/misago/permissions/privatethreads.py index ea02138c0e..50cd7d3948 100644 --- a/misago/permissions/privatethreads.py +++ b/misago/permissions/privatethreads.py @@ -1,11 +1,17 @@ +from datetime import timedelta + from django.core.exceptions import PermissionDenied from django.db.models import QuerySet from django.http import Http404 -from django.utils.translation import pgettext +from django.utils import timezone +from django.utils.translation import npgettext, pgettext -from ..threads.models import Thread, ThreadParticipant +from ..threads.models import Post, Thread, ThreadParticipant from .hooks import ( + check_edit_private_thread_permission_hook, + check_edit_private_thread_post_permission_hook, check_private_threads_permission_hook, + check_reply_private_thread_permission_hook, check_see_private_thread_permission_hook, check_start_private_threads_permission_hook, filter_private_thread_posts_queryset_hook, @@ -71,6 +77,125 @@ def _check_see_private_thread_permission_action( raise Http404() +def check_reply_private_thread_permission( + permissions: UserPermissionsProxy, thread: Thread +): + check_reply_private_thread_permission_hook( + _check_see_private_thread_permission_action, permissions, thread + ) + + +def _check_reply_private_thread_permission_action( + permissions: UserPermissionsProxy, thread: Thread +): + pass # NOOP + + +def check_edit_private_thread_permission( + permissions: UserPermissionsProxy, thread: Thread +): + check_edit_private_thread_permission_hook( + _check_edit_private_thread_permission_action, permissions, thread + ) + + +def _check_edit_private_thread_permission_action( + permissions: UserPermissionsProxy, thread: Thread +): + if permissions.is_private_threads_moderator: + return + + if thread.participants_ids[0] != permissions.user.id: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit other users threads.", + ) + ) + + if not permissions.can_edit_own_threads: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit threads.", + ) + ) + + time_limit = permissions.own_threads_edit_time_limit * 60 + + if time_limit and (timezone.now() - thread.started_on).total_seconds() > time_limit: + raise PermissionDenied( + npgettext( + "threads permission error", + "You can't edit threads older than %(minutes)s minute.", + "You can't edit threads older than %(minutes)s minutes.", + permissions.own_threads_edit_time_limit, + ) + ) + + +def check_edit_private_thread_post_permission( + permissions: UserPermissionsProxy, thread: Thread, post: Post +): + check_edit_private_thread_post_permission_hook( + _check_edit_private_thread_post_permission_action, permissions, thread, post + ) + + +def _check_edit_private_thread_post_permission_action( + permissions: UserPermissionsProxy, thread: Thread, post: Post +): + if permissions.is_private_threads_moderator: + return + + user_id = permissions.user.id + is_poster = user_id and post.poster_id and post.poster_id == user_id + + if not is_poster: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit other users posts.", + ) + ) + + if not permissions.can_edit_own_posts: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit posts.", + ) + ) + + if post.is_hidden: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit hidden posts.", + ) + ) + + if post.is_protected: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit protected posts.", + ) + ) + + time_limit = permissions.own_posts_edit_time_limit * 60 + + if time_limit and (timezone.now() - post.posted_on).total_seconds() > time_limit: + raise PermissionDenied( + npgettext( + "threads permission error", + "You can't edit posts older than %(minutes)s minute.", + "You can't edit posts older than %(minutes)s minutes.", + permissions.own_posts_edit_time_limit, + ) + ) + + def filter_private_threads_queryset(permissions: UserPermissionsProxy, queryset): return filter_private_threads_queryset_hook( _filter_private_threads_queryset_action, permissions, queryset diff --git a/misago/permissions/proxy.py b/misago/permissions/proxy.py index 7f27245555..8dce3c8095 100644 --- a/misago/permissions/proxy.py +++ b/misago/permissions/proxy.py @@ -60,8 +60,18 @@ def is_global_moderator(self) -> bool: return self.moderator.is_global + @property + def is_private_threads_moderator(self) -> bool: + if self.user.is_anonymous: + return False + + if self.is_global_moderator: + return True + + return self.moderator.private_threads + @cached_property - def categories_moderator(self) -> set[int]: + def moderated_categories(self) -> set[int]: if self.user.is_anonymous: return set() @@ -77,12 +87,8 @@ def categories_moderator(self) -> set[int]: return browsed_categories.intersection(self.moderator.categories_ids) - @property - def private_threads_moderator(self) -> bool: - if self.user.is_anonymous: - return False - + def is_category_moderator(self, category_id: int) -> bool: if self.is_global_moderator: return True - return self.moderator.private_threads + return category_id in self.moderated_categories diff --git a/misago/permissions/templates/misago/permissions_panel.html b/misago/permissions/templates/misago/permissions_panel.html index e0b2ae70a1..5cb64859a1 100644 --- a/misago/permissions/templates/misago/permissions_panel.html +++ b/misago/permissions/templates/misago/permissions_panel.html @@ -89,12 +89,12 @@

categories - {{ misago_permissions.categories_moderator }} + {{ misago_permissions.moderated_categories }} {% with misago_permissions.moderator as moderator %} - private_threads - {{ moderator.private_threads }} + is_private_threads_moderator + {{ misago_permissions.is_private_threads_moderator }} {% endwith %} diff --git a/misago/permissions/tests/test_private_threads_permissions.py b/misago/permissions/tests/test_private_threads_permissions.py index 053abacb0a..bb09cdeb86 100644 --- a/misago/permissions/tests/test_private_threads_permissions.py +++ b/misago/permissions/tests/test_private_threads_permissions.py @@ -4,8 +4,12 @@ from ...threads.models import ThreadParticipant from ...threads.test import post_thread +from ..models import Moderator from ..privatethreads import ( + check_edit_private_thread_post_permission, + check_edit_private_thread_permission, check_private_threads_permission, + check_reply_private_thread_permission, check_see_private_thread_permission, check_start_private_threads_permission, filter_private_thread_posts_queryset, @@ -14,6 +18,269 @@ from ..proxy import UserPermissionsProxy +def test_check_edit_private_thread_post_permission_passes_if_user_is_poster( + user, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=user) + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_post_permission_passes_if_user_is_poster_in_time_limit( + user, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=user) + + user.group.own_posts_edit_time_limit = 5 + user.group.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_post_permission_fails_if_user_has_no_edit_permission( + user, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=user) + + user.group.can_edit_own_posts = False + user.group.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_post_permission_fails_if_user_is_not_poster( + user, private_thread, private_thread_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=user) + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_reply + ) + + +def test_check_edit_private_thread_post_permission_fails_if_user_is_poster_out_of_time_limit( + user, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=user) + + user.group.own_posts_edit_time_limit = 1 + user.group.save() + + private_thread_user_reply.posted_on = private_thread_user_reply.posted_on.replace( + year=2015 + ) + private_thread_user_reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_post_permission_fails_if_post_is_protected( + user, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=user) + + private_thread_user_reply.is_protected = True + private_thread_user_reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_post_permission_fails_if_post_is_hidden( + user, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=user) + + private_thread_user_reply.is_hidden = True + private_thread_user_reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_post_permission_passes_for_global_moderator_if_post_is_protected( + moderator, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=moderator) + + private_thread_user_reply.is_protected = True + private_thread_user_reply.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_post_permission_passes_for_private_threads_moderator_if_post_is_protected( + user, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=user) + + Moderator.objects.create( + user=user, + is_global=False, + private_threads=True, + ) + + private_thread_user_reply.is_protected = True + private_thread_user_reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_post_permission_passes_for_global_moderator_if_post_is_hidden( + moderator, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=moderator) + + private_thread_user_reply.is_hidden = True + private_thread_user_reply.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_post_permission_passes_for_private_threads_moderator_if_post_is_hidden( + user, private_thread, private_thread_user_reply, cache_versions +): + ThreadParticipant.objects.create(thread=private_thread, user=user) + + Moderator.objects.create( + user=user, + is_global=False, + private_threads=True, + ) + + private_thread_user_reply.is_hidden = True + private_thread_user_reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_private_thread_post_permission( + permissions, private_thread, private_thread_user_reply + ) + + +def test_check_edit_private_thread_permission_passes_if_user_is_starter( + user, user_private_thread, cache_versions +): + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_private_thread_permission(permissions, user_private_thread) + + +def test_check_edit_private_thread_permission_passes_if_user_is_poster_in_time_limit( + user, user_private_thread, cache_versions +): + user.group.own_posts_edit_time_limit = 5 + user.group.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_private_thread_permission(permissions, user_private_thread) + + +def test_check_edit_private_thread_permission_fails_if_user_has_no_edit_permission( + user, user_private_thread, cache_versions +): + user.group.can_edit_own_threads = False + user.group.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_private_thread_permission(permissions, user_private_thread) + + +def test_check_edit_private_thread_permission_fails_if_user_is_not_thread_owner( + user, other_user_private_thread, cache_versions +): + user.group.can_edit_own_threads = False + user.group.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_private_thread_permission(permissions, other_user_private_thread) + + +def test_check_edit_private_thread_permission_fails_if_user_is_out_of_time_limit( + user, user_private_thread, cache_versions +): + user.group.own_threads_edit_time_limit = 1 + user.group.save() + + user_private_thread.started_on = user_private_thread.started_on.replace(year=2015) + user_private_thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_private_thread_permission(permissions, user_private_thread) + + +def test_check_edit_private_thread_permission_passes_for_global_moderator_if_out_of_time( + moderator, user_private_thread, cache_versions +): + moderator.group.own_threads_edit_time_limit = 1 + moderator.group.save() + + user_private_thread.started_on = user_private_thread.started_on.replace(year=2015) + user_private_thread.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_private_thread_permission(permissions, user_private_thread) + + +def test_check_edit_private_thread_permission_passes_for_private_threads_moderator_if_out_of_time( + user, user_private_thread, cache_versions +): + Moderator.objects.create( + user=user, + is_global=False, + private_threads=True, + ) + + user.group.own_threads_edit_time_limit = 1 + user.group.save() + + user_private_thread.started_on = user_private_thread.started_on.replace(year=2015) + user_private_thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_private_thread_permission(permissions, user_private_thread) + + def test_check_private_threads_permission_passes_if_user_has_permission( user, cache_versions ): @@ -61,6 +328,13 @@ def test_check_start_private_threads_permission_fails_if_user_has_no_permission( check_start_private_threads_permission(permissions) +def test_check_reply_private_thread_permission_passes(user, cache_versions, thread): + thread.participants.add(user) + + permissions = UserPermissionsProxy(user, cache_versions) + check_reply_private_thread_permission(permissions, thread) + + def test_check_see_private_thread_permission_passes_if_user_has_permission( user, cache_versions, thread ): diff --git a/misago/permissions/tests/test_threads_permissions.py b/misago/permissions/tests/test_threads_permissions.py index 59f1777993..a3c3fd04b7 100644 --- a/misago/permissions/tests/test_threads_permissions.py +++ b/misago/permissions/tests/test_threads_permissions.py @@ -6,12 +6,444 @@ from ..models import CategoryGroupPermission, Moderator from ..proxy import UserPermissionsProxy from ..threads import ( + check_edit_post_permission, + check_edit_thread_permission, 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, + check_start_thread_permission, ) +def test_check_edit_post_permission_passes_if_user_is_poster( + user, thread, user_reply, cache_versions, default_category +): + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_passes_if_user_is_poster_in_time_limit( + user, thread, user_reply, cache_versions, default_category +): + user.group.own_posts_edit_time_limit = 5 + user.group.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_fails_if_user_has_no_reply_permission( + user, thread, user_reply, cache_versions, default_category +): + CategoryGroupPermission.objects.filter( + group=user.group, + category=default_category, + permission=CategoryPermission.REPLY, + ).delete() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_fails_if_user_has_no_edit_permission( + user, thread, user_reply, cache_versions, default_category +): + user.group.can_edit_own_posts = False + user.group.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_fails_if_user_is_not_poster( + user, thread, reply, cache_versions, default_category +): + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_post_permission(permissions, default_category, thread, reply) + + +def test_check_edit_post_permission_fails_if_user_is_poster_out_of_time_limit( + user, thread, user_reply, cache_versions, default_category +): + user.group.own_posts_edit_time_limit = 1 + user.group.save() + + user_reply.posted_on = user_reply.posted_on.replace(year=2015) + user_reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_fails_if_category_is_closed( + user, thread, user_reply, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_fails_if_thread_is_closed( + user, thread, user_reply, cache_versions, default_category +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_fails_if_post_is_protected( + user, thread, user_reply, cache_versions, default_category +): + user_reply.is_protected = True + user_reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_fails_if_post_is_hidden( + user, thread, user_reply, cache_versions, default_category +): + user_reply.is_hidden = True + user_reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_passes_for_global_moderator_if_category_is_closed( + moderator, thread, user_reply, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_passes_for_category_moderator_if_category_is_closed( + user, thread, reply, cache_versions, default_category +): + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_post_permission(permissions, default_category, thread, reply) + + +def test_check_edit_post_permission_passes_for_global_moderator_if_thread_is_closed( + moderator, thread, user_reply, cache_versions, default_category +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_passes_for_category_moderator_if_thread_is_closed( + user, thread, reply, cache_versions, default_category +): + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_post_permission(permissions, default_category, thread, reply) + + +def test_check_edit_post_permission_passes_for_global_moderator_if_post_is_protected( + moderator, thread, user_reply, cache_versions, default_category +): + user_reply.is_protected = True + user_reply.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_passes_for_category_moderator_if_post_is_protected( + user, thread, reply, cache_versions, default_category +): + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + reply.is_protected = True + reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_post_permission(permissions, default_category, thread, reply) + + +def test_check_edit_post_permission_passes_for_global_moderator_if_post_is_hidden( + moderator, thread, user_reply, cache_versions, default_category +): + user_reply.is_hidden = True + user_reply.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_passes_for_category_moderator_if_post_is_hidden( + user, thread, reply, cache_versions, default_category +): + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + reply.is_hidden = True + reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_post_permission(permissions, default_category, thread, reply) + + +def test_check_edit_post_permission_passes_for_global_moderator_if_out_of_time( + moderator, thread, user_reply, cache_versions, default_category +): + moderator.group.own_posts_edit_time_limit = 1 + moderator.group.save() + + user_reply.posted_on = user_reply.posted_on.replace(year=2015) + user_reply.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_post_permission(permissions, default_category, thread, user_reply) + + +def test_check_edit_post_permission_passes_for_category_moderator_if_out_of_time( + user, thread, reply, cache_versions, default_category +): + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + user.group.own_posts_edit_time_limit = 1 + user.group.save() + + reply.posted_on = reply.posted_on.replace(year=2015) + reply.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_post_permission(permissions, default_category, thread, reply) + + +def test_check_edit_thread_permission_passes_if_user_is_starter( + user, user_thread, cache_versions, default_category +): + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_thread_permission(permissions, default_category, user_thread) + + +def test_check_edit_thread_permission_passes_if_user_is_starter_in_time_limit( + user, user_thread, cache_versions, default_category +): + user.group.own_threads_edit_time_limit = 5 + user.group.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_thread_permission(permissions, default_category, user_thread) + + +def test_check_edit_thread_permission_fails_if_user_has_no_start_permission( + user, user_thread, cache_versions, default_category +): + CategoryGroupPermission.objects.filter( + group=user.group, + category=default_category, + permission=CategoryPermission.START, + ).delete() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_thread_permission(permissions, default_category, user_thread) + + +def test_check_edit_thread_permission_fails_if_user_has_no_edit_permission( + user, user_thread, cache_versions, default_category +): + user.group.can_edit_own_threads = False + user.group.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_thread_permission(permissions, default_category, user_thread) + + +def test_check_edit_thread_permission_fails_if_user_is_not_starter( + user, thread, cache_versions, default_category +): + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_thread_permission(permissions, default_category, thread) + + +def test_check_edit_thread_permission_fails_if_user_is_starter_out_of_time_limit( + user, user_thread, cache_versions, default_category +): + user.group.own_threads_edit_time_limit = 1 + user.group.save() + + user_thread.started_on = user_thread.started_on.replace(year=2015) + user_thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_thread_permission(permissions, default_category, user_thread) + + +def test_check_edit_thread_permission_fails_if_category_is_closed( + user, user_thread, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_thread_permission(permissions, default_category, user_thread) + + +def test_check_edit_thread_permission_fails_if_thread_is_closed( + user, user_thread, cache_versions, default_category +): + user_thread.is_closed = True + user_thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_edit_thread_permission(permissions, default_category, user_thread) + + +def test_check_edit_thread_permission_passes_for_global_moderator_if_category_is_closed( + moderator, user_thread, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_thread_permission(permissions, default_category, user_thread) + + +def test_check_edit_thread_permission_passes_for_category_moderator_if_category_is_closed( + user, thread, cache_versions, default_category +): + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_thread_permission(permissions, default_category, thread) + + +def test_check_edit_thread_permission_passes_for_global_moderator_if_thread_is_closed( + moderator, user_thread, cache_versions, default_category +): + user_thread.is_closed = True + user_thread.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_thread_permission(permissions, default_category, user_thread) + + +def test_check_edit_thread_permission_passes_for_category_moderator_if_thread_is_closed( + user, thread, cache_versions, default_category +): + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_thread_permission(permissions, default_category, thread) + + +def test_check_edit_thread_permission_passes_for_global_moderator_if_out_of_time( + moderator, thread, cache_versions, default_category +): + moderator.group.own_threads_edit_time_limit = 1 + moderator.group.save() + + thread.started_on = thread.started_on.replace(year=2015) + thread.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_edit_thread_permission(permissions, default_category, thread) + + +def test_check_edit_thread_permission_passes_for_category_moderator_if_out_of_time( + user, thread, cache_versions, default_category +): + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + user.group.own_threads_edit_time_limit = 1 + user.group.save() + + thread.started_on = thread.started_on.replace(year=2015) + thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + check_edit_thread_permission(permissions, default_category, thread) + + def test_check_post_in_closed_category_permission_passes_if_category_is_open( user, cache_versions, default_category ): @@ -69,60 +501,117 @@ def test_check_post_in_closed_category_permission_fails_if_user_is_anonymous( check_post_in_closed_category_permission(permissions, default_category) -def test_check_start_thread_in_category_permission_passes_if_user_has_permission( - user, cache_versions, default_category +def test_check_post_in_closed_thread_permission_passes_if_thread_is_open( + user, cache_versions, thread ): permissions = UserPermissionsProxy(user, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + check_post_in_closed_thread_permission(permissions, thread) -def test_check_start_thread_in_category_permission_passes_if_anonymous_has_permission( - user, cache_versions, default_category +def test_check_post_in_closed_thread_permission_passes_if_user_is_global_moderator( + moderator, cache_versions, thread ): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_post_in_closed_thread_permission(permissions, thread) + + +def test_check_post_in_closed_thread_permission_passes_if_user_is_category_moderator( + user, cache_versions, thread +): + thread.is_closed = True + thread.save() + + Moderator.objects.create( + user=user, + is_global=False, + categories=[thread.category_id], + ) + permissions = UserPermissionsProxy(user, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + check_post_in_closed_thread_permission(permissions, thread) -def test_check_start_thread_in_category_permission_fails_if_user_has_no_permission( - user, cache_versions, default_category +def test_check_post_in_closed_thread_permission_fails_if_user_is_not_moderator( + user, cache_versions, thread +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_post_in_closed_thread_permission(permissions, thread) + + +def test_check_post_in_closed_thread_permission_fails_if_user_is_anonymous( + anonymous_user, cache_versions, thread +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + + with pytest.raises(PermissionDenied): + check_post_in_closed_thread_permission(permissions, thread) + + +def test_check_reply_thread_permission_passes_if_user_has_permission( + user, cache_versions, default_category, thread +): + permissions = UserPermissionsProxy(user, cache_versions) + check_reply_thread_permission(permissions, default_category, thread) + + +def test_check_reply_thread_permission_passes_if_anonymous_user_has_permission( + anonymous_user, cache_versions, default_category, thread +): + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + check_reply_thread_permission(permissions, default_category, thread) + + +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() @@ -134,11 +623,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() @@ -146,11 +635,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() @@ -158,7 +647,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( @@ -553,3 +1092,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_permission_passes_if_user_has_permission( + user, cache_versions, default_category +): + permissions = UserPermissionsProxy(user, cache_versions) + check_start_thread_permission(permissions, default_category) + + +def test_check_start_thread_permission_passes_if_anonymous_has_permission( + anonymous_user, cache_versions, default_category +): + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + check_start_thread_permission(permissions, default_category) + + +def test_check_start_thread_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_permission(permissions, default_category) + + +def test_check_start_thread_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_permission(permissions, default_category) + + +def test_check_start_thread_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_permission(permissions, default_category) + + +def test_check_start_thread_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_permission(permissions, default_category) + + +def test_check_start_thread_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_permission(permissions, default_category) + + +def test_check_start_thread_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_permission(permissions, default_category) diff --git a/misago/permissions/tests/test_user_permissions_proxy.py b/misago/permissions/tests/test_user_permissions_proxy.py index b4c8613634..5e3dace451 100644 --- a/misago/permissions/tests/test_user_permissions_proxy.py +++ b/misago/permissions/tests/test_user_permissions_proxy.py @@ -120,6 +120,102 @@ def test_user_permissions_proxy_returns_false_for_member_without_global_moderato assert not proxy.is_global_moderator +def test_user_permissions_proxy_returns_admins_member_private_threads_moderator_permission( + django_assert_num_queries, user, admins_group, cache_versions +): + user.set_groups(admins_group) + user.save() + + with django_assert_num_queries(0): + proxy = UserPermissionsProxy(user, cache_versions) + assert proxy.is_private_threads_moderator + + +def test_user_permissions_proxy_returns_moderators_member_private_threads_moderator_permission( + django_assert_num_queries, user, moderators_group, cache_versions +): + user.set_groups(moderators_group) + user.save() + + with django_assert_num_queries(0): + proxy = UserPermissionsProxy(user, cache_versions) + assert proxy.is_private_threads_moderator + + +def test_user_permissions_proxy_returns_secondary_admins_member_private_threads_moderator_permission( + django_assert_num_queries, user, members_group, admins_group, cache_versions +): + user.set_groups(members_group, [admins_group]) + user.save() + + with django_assert_num_queries(0): + proxy = UserPermissionsProxy(user, cache_versions) + assert proxy.is_private_threads_moderator + + +def test_user_permissions_proxy_returns_secondary_moderators_member_private_threads_moderator_permission( + django_assert_num_queries, user, members_group, moderators_group, cache_versions +): + user.set_groups(members_group, [moderators_group]) + user.save() + + with django_assert_num_queries(0): + proxy = UserPermissionsProxy(user, cache_versions) + assert proxy.is_private_threads_moderator + + +def test_user_permissions_proxy_returns_custom_moderators_member_private_threads_moderator_permission( + django_assert_num_queries, user, custom_group, cache_versions +): + Moderator.objects.create(is_global=False, private_threads=True, group=custom_group) + + user.set_groups(custom_group) + user.save() + + with django_assert_num_queries(1): + proxy = UserPermissionsProxy(user, cache_versions) + assert proxy.is_private_threads_moderator + + +def test_user_permissions_proxy_returns_custom_moderators_secondary_member_private_threads_moderator_permission( + django_assert_num_queries, user, members_group, custom_group, cache_versions +): + Moderator.objects.create(is_global=False, private_threads=True, group=custom_group) + + user.set_groups(members_group, [custom_group]) + user.save() + + with django_assert_num_queries(1): + proxy = UserPermissionsProxy(user, cache_versions) + assert proxy.is_private_threads_moderator + + +def test_user_permissions_proxy_returns_true_for_member_private_threads_moderator_permission( + django_assert_num_queries, user, cache_versions +): + Moderator.objects.create(is_global=False, private_threads=True, user=user) + + with django_assert_num_queries(1): + proxy = UserPermissionsProxy(user, cache_versions) + assert proxy.is_private_threads_moderator + + +def test_user_permissions_proxy_returns_false_for_member_without_private_threads_moderator_permission( + django_assert_num_queries, user, cache_versions +): + with django_assert_num_queries(1): + proxy = UserPermissionsProxy(user, cache_versions) + assert not proxy.is_private_threads_moderator + + +def test_user_permissions_proxy_returns_false_for_member_without_private_threads_moderator_permission( + django_assert_num_queries, user, cache_versions +): + with django_assert_num_queries(1): + proxy = UserPermissionsProxy(user, cache_versions) + assert not proxy.is_private_threads_moderator + + def test_user_permissions_proxy_returns_false_for_category_moderator( django_assert_num_queries, user, cache_versions, other_category ): @@ -130,6 +226,35 @@ def test_user_permissions_proxy_returns_false_for_category_moderator( assert not proxy.is_global_moderator +def test_user_permissions_proxy_returns_list_of_moderated_categories_ids_for_global_moderator( + django_assert_num_queries, + user, + moderators_group, + cache_versions, + default_category, + other_category, +): + CategoryGroupPermission.objects.create( + category=other_category, + group=moderators_group, + permission=CategoryPermission.SEE, + ) + CategoryGroupPermission.objects.create( + category=other_category, + group=moderators_group, + permission=CategoryPermission.BROWSE, + ) + + user.set_groups(moderators_group) + user.save() + + proxy = UserPermissionsProxy(user, cache_versions) + proxy.permissions + + with django_assert_num_queries(0): + assert proxy.moderated_categories == {default_category.id, other_category.id} + + def test_user_permissions_proxy_returns_list_of_moderated_categories_ids_for_local_moderator( django_assert_num_queries, user, cache_versions, other_category ): @@ -150,7 +275,7 @@ def test_user_permissions_proxy_returns_list_of_moderated_categories_ids_for_loc proxy.permissions with django_assert_num_queries(1): - assert proxy.categories_moderator == {other_category.id} + assert proxy.moderated_categories == {other_category.id} def test_user_permissions_proxy_excludes_not_browseable_categories_from_moderated_categories( @@ -168,7 +293,87 @@ def test_user_permissions_proxy_excludes_not_browseable_categories_from_moderate proxy.permissions with django_assert_num_queries(1): - assert proxy.categories_moderator == set() + assert proxy.moderated_categories == set() + + +def test_user_permissions_is_category_moderator_returns_true_for_global_moderator( + user, + moderators_group, + cache_versions, + default_category, + other_category, +): + CategoryGroupPermission.objects.create( + category=other_category, + group=moderators_group, + permission=CategoryPermission.SEE, + ) + CategoryGroupPermission.objects.create( + category=other_category, + group=moderators_group, + permission=CategoryPermission.BROWSE, + ) + + user.set_groups(moderators_group) + user.save() + + proxy = UserPermissionsProxy(user, cache_versions) + proxy.permissions + + assert proxy.is_category_moderator(default_category.id) + assert proxy.is_category_moderator(other_category.id) + + +def test_user_permissions_is_category_moderator_returns_true_for_category_moderator( + user, + cache_versions, + default_category, + other_category, +): + CategoryGroupPermission.objects.create( + category=other_category, + group=user.group, + permission=CategoryPermission.SEE, + ) + CategoryGroupPermission.objects.create( + category=other_category, + group=user.group, + permission=CategoryPermission.BROWSE, + ) + + proxy = UserPermissionsProxy(user, cache_versions) + proxy.permissions + + Moderator.objects.create(is_global=False, user=user, categories=[other_category.id]) + + assert not proxy.is_category_moderator(default_category.id) + assert proxy.is_category_moderator(other_category.id) + + +def test_user_permissions_is_category_moderator_returns_true_for_category_moderator( + user, + cache_versions, + default_category, + other_category, +): + CategoryGroupPermission.objects.create( + category=other_category, + group=user.group, + permission=CategoryPermission.SEE, + ) + CategoryGroupPermission.objects.create( + category=other_category, + group=user.group, + permission=CategoryPermission.BROWSE, + ) + + proxy = UserPermissionsProxy(user, cache_versions) + proxy.permissions + + Moderator.objects.create(is_global=False, user=user, categories=[other_category.id]) + + assert not proxy.is_category_moderator(default_category.id) + assert proxy.is_category_moderator(other_category.id) def test_user_permissions_proxy_returns_false_global_moderator_for_anonymous_user( @@ -180,6 +385,15 @@ def test_user_permissions_proxy_returns_false_global_moderator_for_anonymous_use assert not proxy.is_global_moderator +def test_user_permissions_proxy_returns_false_private_threads_moderator_for_anonymous_user( + django_assert_num_queries, db, anonymous_user, cache_versions +): + proxy = UserPermissionsProxy(anonymous_user, cache_versions) + + with django_assert_num_queries(0): + assert not proxy.is_private_threads_moderator + + def test_user_permissions_proxy_returns_no_moderated_categories_for_anonymous_user( django_assert_num_queries, db, anonymous_user, cache_versions ): @@ -187,4 +401,14 @@ def test_user_permissions_proxy_returns_no_moderated_categories_for_anonymous_us proxy.permissions with django_assert_num_queries(0): - assert not proxy.categories_moderator + assert not proxy.moderated_categories + + +def test_user_permissions_proxy_is_category_moderator_returns_false_for_anonymous_user( + django_assert_num_queries, db, anonymous_user, cache_versions, default_category +): + proxy = UserPermissionsProxy(anonymous_user, cache_versions) + proxy.permissions + + with django_assert_num_queries(0): + assert not proxy.is_category_moderator(default_category.id) diff --git a/misago/permissions/threads/__init__.py b/misago/permissions/threads/__init__.py index 4a4601e2cf..abd0ee23e1 100644 --- a/misago/permissions/threads/__init__.py +++ b/misago/permissions/threads/__init__.py @@ -1,7 +1,11 @@ from .checks import ( + check_edit_post_permission, + check_edit_thread_permission, 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, + check_start_thread_permission, ) from .querysets import ( CategoryThreadsQuerysetFilter, @@ -13,9 +17,13 @@ __all__ = [ "CategoryThreadsQuerysetFilter", "ThreadsQuerysetFilter", + "check_edit_post_permission", + "check_edit_thread_permission", "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", + "check_start_thread_permission", "filter_category_threads_queryset", "filter_thread_posts_queryset", ] diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index 4222c44017..58d7dfee81 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -1,15 +1,20 @@ from django.core.exceptions import PermissionDenied from django.http import Http404 -from django.utils.translation import pgettext +from django.utils import timezone +from django.utils.translation import npgettext, pgettext from ...categories.models import Category -from ...threads.models import Thread +from ...threads.models import Post, Thread from ..categories import check_see_category_permission from ..enums import CategoryPermission from ..hooks import ( + check_edit_post_permission_hook, + check_edit_thread_permission_hook, 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, + check_start_thread_permission_hook, ) from ..proxy import UserPermissionsProxy @@ -27,10 +32,7 @@ def check_post_in_closed_category_permission( def _check_post_in_closed_category_permission_action( permissions: UserPermissionsProxy, category: Category ): - if category.is_closed and not ( - permissions.is_global_moderator - or category.id in permissions.categories_moderator - ): + if category.is_closed and not permissions.is_category_moderator(category.id): raise PermissionDenied( pgettext( "threads permission error", @@ -39,17 +41,39 @@ def _check_post_in_closed_category_permission_action( ) -def check_start_thread_in_category_permission( +def check_post_in_closed_thread_permission( + permissions: UserPermissionsProxy, thread: Thread +): + check_post_in_closed_thread_permission_hook( + _check_post_in_closed_thread_permission_action, + permissions, + thread, + ) + + +def _check_post_in_closed_thread_permission_action( + permissions: UserPermissionsProxy, thread: Thread +): + if thread.is_closed and not permissions.is_category_moderator(thread.category_id): + raise PermissionDenied( + pgettext( + "threads permission error", + "This thread is closed.", + ) + ) + + +def check_start_thread_permission( permissions: UserPermissionsProxy, category: Category ): - check_start_thread_in_category_permission_hook( - _check_start_thread_in_category_permission_action, + check_start_thread_permission_hook( + _check_start_thread_permission_action, permissions, category, ) -def _check_start_thread_in_category_permission_action( +def _check_start_thread_permission_action( permissions: UserPermissionsProxy, category: Category ): if category.id not in permissions.categories[CategoryPermission.START]: @@ -74,10 +98,7 @@ def check_see_thread_permission( def _check_see_thread_permission_action( permissions: UserPermissionsProxy, category: Category, thread: Thread ): - if not ( - permissions.is_global_moderator - or category.id in permissions.categories_moderator - ): + if not permissions.is_category_moderator(category.id): if thread.is_hidden: raise Http404() @@ -111,3 +132,166 @@ def _check_see_thread_permission_action( ) raise Http404() + + +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( + pgettext( + "threads permission error", + "You can't reply to threads in this category.", + ) + ) + + check_post_in_closed_category_permission(permissions, category) + check_post_in_closed_thread_permission(permissions, thread) + + +def check_edit_thread_permission( + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, +): + check_edit_thread_permission_hook( + _check_edit_thread_permission_action, permissions, category, thread + ) + + +def _check_edit_thread_permission_action( + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, +): + if category.id not in permissions.categories[CategoryPermission.START]: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit threads in this category.", + ) + ) + + check_post_in_closed_category_permission(permissions, category) + check_post_in_closed_thread_permission(permissions, thread) + + if permissions.is_category_moderator(thread.category_id): + return + + user_id = permissions.user.id + is_starter = user_id and thread.starter_id and thread.starter_id == user_id + + if not is_starter: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit other users threads.", + ) + ) + + if not permissions.can_edit_own_threads: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit threads.", + ) + ) + + time_limit = permissions.own_threads_edit_time_limit * 60 + + if time_limit and (timezone.now() - thread.started_on).total_seconds() > time_limit: + raise PermissionDenied( + npgettext( + "threads permission error", + "You can't edit threads older than %(minutes)s minute.", + "You can't edit threads older than %(minutes)s minutes.", + permissions.own_threads_edit_time_limit, + ) + ) + + +def check_edit_post_permission( + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, + post: Post, +): + check_edit_post_permission_hook( + _check_edit_post_permission_action, permissions, category, thread, post + ) + + +def _check_edit_post_permission_action( + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, + post: Post, +): + if category.id not in permissions.categories[CategoryPermission.REPLY]: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit posts in this category.", + ) + ) + + check_post_in_closed_category_permission(permissions, category) + check_post_in_closed_thread_permission(permissions, thread) + + if permissions.is_category_moderator(thread.category_id): + return + + user_id = permissions.user.id + is_poster = user_id and post.poster_id and post.poster_id == user_id + + if not is_poster: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit other users posts.", + ) + ) + + if not permissions.can_edit_own_posts: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit posts.", + ) + ) + + if post.is_hidden: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit hidden posts.", + ) + ) + + if post.is_protected: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit protected posts.", + ) + ) + + time_limit = permissions.own_posts_edit_time_limit * 60 + + if time_limit and (timezone.now() - post.posted_on).total_seconds() > time_limit: + raise PermissionDenied( + npgettext( + "threads permission error", + "You can't edit posts older than %(minutes)s minute.", + "You can't edit posts older than %(minutes)s minutes.", + permissions.own_posts_edit_time_limit, + ) + ) diff --git a/misago/permissions/threads/querysets.py b/misago/permissions/threads/querysets.py index 012e5b8270..ce5b8de11f 100644 --- a/misago/permissions/threads/querysets.py +++ b/misago/permissions/threads/querysets.py @@ -201,10 +201,7 @@ def get_category_threads_query( def _get_category_threads_query_action( permissions: UserPermissionsProxy, category: dict ) -> str | list[str] | None: - if ( - permissions.is_global_moderator - or category["id"] in permissions.categories_moderator - ): + if permissions.is_category_moderator(category["id"]): return CategoryThreadsQuery.ALL if category["show_started_only"]: @@ -233,10 +230,7 @@ def get_threads_category_query( def _get_threads_category_query_action( permissions: UserPermissionsProxy, category: dict ) -> str | list[str] | None: - if ( - permissions.is_global_moderator - or category["id"] in permissions.categories_moderator - ): + if permissions.is_category_moderator(category["id"]): return CategoryThreadsQuery.ALL_NOT_PINNED_GLOBALLY if category["show_started_only"]: @@ -265,10 +259,7 @@ def get_threads_pinned_category_query( def _get_threads_pinned_category_query_action( permissions: UserPermissionsProxy, category: dict ) -> str | list[str] | None: - if ( - permissions.is_global_moderator - or category["id"] in permissions.categories_moderator - ): + if permissions.is_category_moderator(category["id"]): return CategoryThreadsQuery.ALL_PINNED_GLOBALLY if permissions.user.is_authenticated: @@ -294,10 +285,7 @@ def _get_category_threads_category_query_action( if context == CategoryQueryContext.OTHER: return None # We don't display non-category items on category pages - if ( - permissions.is_global_moderator - or category["id"] in permissions.categories_moderator - ): + if permissions.is_category_moderator(category["id"]): if context == CategoryQueryContext.CURRENT: return CategoryThreadsQuery.ALL_NOT_PINNED @@ -344,10 +332,7 @@ def get_category_threads_pinned_category_query( def _get_category_threads_pinned_category_query_action( permissions: UserPermissionsProxy, category: dict, context: str ) -> str | list[str] | None: - if ( - permissions.is_global_moderator - or category["id"] in permissions.categories_moderator - ): + if permissions.is_category_moderator(category["id"]): if context == CategoryQueryContext.CURRENT: return CategoryThreadsQuery.ALL_PINNED @@ -579,10 +564,7 @@ def _filter_thread_posts_queryset_action( thread: Thread, queryset: QuerySet, ) -> QuerySet: - if ( - permissions.is_global_moderator - or thread.category_id in permissions.categories_moderator - ): + if permissions.is_category_moderator(thread.category_id): return queryset if permissions.user.is_authenticated: diff --git a/misago/permissions/user.py b/misago/permissions/user.py index 14059c93a7..87c7e99292 100644 --- a/misago/permissions/user.py +++ b/misago/permissions/user.py @@ -72,6 +72,10 @@ def _build_user_permissions_action(groups: list[Group]) -> dict: "can_use_private_threads": False, "can_start_private_threads": False, "private_thread_users_limit": 1, + "can_edit_own_threads": False, + "own_threads_edit_time_limit": 0, + "can_edit_own_posts": False, + "own_posts_edit_time_limit": 0, "can_change_username": False, "username_changes_limit": 0, "username_changes_expire": 0, @@ -96,6 +100,26 @@ def _build_user_permissions_action(groups: list[Group]) -> dict: "private_thread_users_limit", group.private_thread_users_limit, ) + if_true( + permissions, + "can_edit_own_threads", + group.can_edit_own_threads, + ) + if_zero_or_greater( + permissions, + "own_threads_edit_time_limit", + group.own_threads_edit_time_limit, + ) + if_true( + permissions, + "can_edit_own_posts", + group.can_edit_own_posts, + ) + if_zero_or_greater( + permissions, + "own_posts_edit_time_limit", + group.own_posts_edit_time_limit, + ) if_true( permissions, "can_change_username", diff --git a/misago/plugins/hooks.py b/misago/plugins/hooks.py index ccf7147653..be07ab94d7 100644 --- a/misago/plugins/hooks.py +++ b/misago/plugins/hooks.py @@ -41,15 +41,21 @@ def __call__(self, *args, **kwargs) -> List[Any]: class FilterHook(Generic[Action, Filter]): - __slots__ = ("_filters_first", "_filters_last", "_cache") + __slots__ = ("cache", "_filters_first", "_filters_last", "_use_filters", "_cache") + + cache: bool _filters_first: List[Filter] _filters_last: List[Filter] + _use_filters: bool _cache: Action | None - def __init__(self): + def __init__(self, cache: bool = True): + self.cache = cache + self._filters_first = [] self._filters_last = [] + self._use_filters = False self._cache = None def __bool__(self) -> bool: @@ -57,10 +63,12 @@ def __bool__(self) -> bool: def append_filter(self, filter_: Filter): self._filters_last.append(filter_) + self._use_filters = True self.invalidate_cache() def prepend_filter(self, filter_: Filter): self._filters_first.insert(0, filter_) + self._use_filters = True self.invalidate_cache() def invalidate_cache(self): @@ -77,6 +85,13 @@ def reduced_filter(*args, **kwargs): return reduce(reduce_filter, filters, action) def __call__(self, action: Action, *args, **kwargs): + if not self._use_filters: + return action(*args, **kwargs) + + if not self.cache: + reduced_action = self.get_reduced_action(action) + return reduced_action(*args, **kwargs) + if self._cache is None: self._cache = self.get_reduced_action(action) diff --git a/misago/posting/forms/attachments.py b/misago/posting/forms/attachments.py new file mode 100644 index 0000000000..ec80e796ef --- /dev/null +++ b/misago/posting/forms/attachments.py @@ -0,0 +1,26 @@ +from django import forms +from django.utils.translation import npgettext, pgettext + +from .base import PostingForm, PostingState + + +class MultipleFileInput(forms.ClearableFileInput): + allow_multiple_selected = True + + +class MultipleFileField(forms.FileField): + def __init__(self, *args, **kwargs): + kwargs.setdefault("widget", MultipleFileInput()) + super().__init__(*args, **kwargs) + + def clean(self, data, initial=None): + single_file_clean = super().clean + if isinstance(data, (list, tuple)): + result = [single_file_clean(d, initial) for d in data] + else: + result = [single_file_clean(data, initial)] + return result + + +class AttachmentsForm(PostingForm): + upload = MultipleFileField(required=False) diff --git a/misago/posting/forms/reply.py b/misago/posting/forms/reply.py new file mode 100644 index 0000000000..3cca5a695f --- /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/hooks/__init__.py b/misago/posting/hooks/__init__.py index e69de29bb2..319ee3c929 100644 --- a/misago/posting/hooks/__init__.py +++ b/misago/posting/hooks/__init__.py @@ -0,0 +1,26 @@ +from .get_start_private_thread_page_context_data import ( + get_start_private_thread_page_context_data_hook, +) +from .get_start_private_thread_page_formset import ( + get_start_private_thread_page_formset_hook, +) +from .get_start_private_thread_page_state import ( + get_start_private_thread_page_state_hook, +) +from .get_start_thread_page_context_data import get_start_thread_page_context_data_hook +from .get_start_thread_page_formset import get_start_thread_page_formset_hook +from .get_start_thread_page_state import get_start_thread_page_state_hook +from .save_start_private_thread_state import save_start_private_thread_state_hook +from .save_start_thread_state import save_start_thread_state_hook + + +__all__ = [ + "get_start_private_thread_page_context_data_hook", + "get_start_private_thread_page_formset_hook", + "get_start_private_thread_page_state_hook", + "get_start_thread_page_context_data_hook", + "get_start_thread_page_formset_hook", + "get_start_thread_page_state_hook", + "save_start_private_thread_state_hook", + "save_start_thread_state_hook", +] diff --git a/misago/posting/hooks/get_start_private_thread_page_context_data.py b/misago/posting/hooks/get_start_private_thread_page_context_data.py new file mode 100644 index 0000000000..d1c9fa2dcb --- /dev/null +++ b/misago/posting/hooks/get_start_private_thread_page_context_data.py @@ -0,0 +1,132 @@ +from typing import TYPE_CHECKING, Protocol + +from django.http import HttpRequest + +from ...categories.models import Category +from ...plugins.hooks import FilterHook + +if TYPE_CHECKING: + from ..forms.start import StartThreadFormset + + +class GetStartPrivateThreadPageContextDataHookAction(Protocol): + """ + A standard Misago function used to get the template context data + for the start private thread page. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + ## `formset: StartThreadFormset` + + The `StartThreadFormset` instance. + + # Return value + + A Python `dict` with context data to use to `render` the start private thread page. + """ + + def __call__( + self, + request: HttpRequest, + category: Category, + formset: "StartThreadFormset", + ) -> dict: ... + + +class GetStartPrivateThreadPageContextDataHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: GetStartPrivateThreadPageContextDataHookAction` + + A standard Misago function used to get the template context data + for the start private thread page. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + ## `formset: StartThreadFormset` + + The `StartThreadFormset` instance. + + # Return value + + A Python `dict` with context data to use to `render` the start private thread page. + """ + + def __call__( + self, + action: GetStartPrivateThreadPageContextDataHookAction, + request: HttpRequest, + category: Category, + formset: "StartThreadFormset", + ) -> dict: ... + + +class GetStartPrivateThreadPageContextDataHook( + FilterHook[ + GetStartPrivateThreadPageContextDataHookAction, + GetStartPrivateThreadPageContextDataHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to get the template + context data for the start private thread page. + + # Example + + The code below implements a custom filter function that adds extra values to + the template context data: + + ```python + from django.http import HttpRequest + from misago.categories.models import Category + from misago.posting.forms.start import StartThreadFormset + from misago.posting.hooks import get_start_private_thread_page_context_data_hook + + + @get_start_private_thread_page_context_data_hook.append_filter + def set_show_first_post_warning_in_context( + action, + request: HttpRequest, + category: Category, + formset: StartThreadFormset, + ) -> dict: + context = action(request, category, formset) + context["show_first_post_warning"] = not requser.user.posts + return context + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: GetStartPrivateThreadPageContextDataHookAction, + request: HttpRequest, + category: Category, + formset: "StartThreadFormset", + ) -> dict: + return super().__call__(action, request, category, formset) + + +get_start_private_thread_page_context_data_hook = ( + GetStartPrivateThreadPageContextDataHook(cache=False) +) diff --git a/misago/posting/hooks/get_start_private_thread_page_formset.py b/misago/posting/hooks/get_start_private_thread_page_formset.py new file mode 100644 index 0000000000..0c76e6c364 --- /dev/null +++ b/misago/posting/hooks/get_start_private_thread_page_formset.py @@ -0,0 +1,128 @@ +from typing import TYPE_CHECKING, Protocol + +from django.http import HttpRequest + +from ...categories.models import Category +from ...plugins.hooks import FilterHook + +if TYPE_CHECKING: + from ..forms.start import StartThreadFormset + + +class GetStartPrivateThreadPageFormsetHookAction(Protocol): + """ + A standard function that Misago uses to create a new + `StartThreadFormset` instance for the start a new private thread page. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + # Return value + + A `StartThreadFormset` instance with forms to display + on the start a new private thread page. + """ + + def __call__( + self, + request: HttpRequest, + category: Category, + ) -> "StartThreadFormset": ... + + +class GetStartPrivateThreadPageFormsetHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: GetStartPrivateThreadPageFormsetHookAction` + + A standard function that Misago uses to create a new + `StartThreadFormset` instance for the start a new private thread page. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + # Return value + + A `StartThreadFormset` instance with forms to display + on the start a new private thread page. + """ + + def __call__( + self, + action: GetStartPrivateThreadPageFormsetHookAction, + request: HttpRequest, + category: Category, + ) -> "StartThreadFormset": ... + + +class GetStartPrivateThreadPageFormsetHook( + FilterHook[ + GetStartPrivateThreadPageFormsetHookAction, + GetStartPrivateThreadPageFormsetHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to create a new + `StartThreadFormset` instance for the start a new private thread page. + + # Example + + The code below implements a custom filter function that adds custom form to + the start a new private thread page: + + ```python + from django.http import HttpRequest + from misago.categories.models import Category + from misago.posting.hooks import get_start_private_thread_page_formset_hook + from misago.posting.forms.start import StartThreadFormset + + from .forms import SelectUserForm + + + @get_start_private_thread_page_formset_hook.append_filter + def add_select_user_form( + action, request: HttpRequest, category: Category + ) -> StartThreadFormset: + formset = action(request, category) + + if request.method == "POST": + form = SelectUserForm(request.POST, prefix="select-user") + else: + form = SelectUserForm(prefix="select-user") + + formset.add_form(form) + return formset + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: GetStartPrivateThreadPageFormsetHookAction, + request: HttpRequest, + category: Category, + ) -> "StartThreadFormset": + return super().__call__(action, request, category) + + +get_start_private_thread_page_formset_hook = GetStartPrivateThreadPageFormsetHook( + cache=False +) diff --git a/misago/posting/hooks/get_start_private_thread_page_state.py b/misago/posting/hooks/get_start_private_thread_page_state.py new file mode 100644 index 0000000000..1d89a23a48 --- /dev/null +++ b/misago/posting/hooks/get_start_private_thread_page_state.py @@ -0,0 +1,119 @@ +from typing import TYPE_CHECKING, Protocol + +from django.http import HttpRequest + +from ...categories.models import Category +from ...plugins.hooks import FilterHook + +if TYPE_CHECKING: + from ..state.start import StartPrivateThreadState + + +class GetStartPrivateThreadPageStateHookAction(Protocol): + """ + A standard function that Misago uses to create a new + `StartPrivateThreadState` instance for the start a new private thread page. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + # Return value + + A `StartPrivateThreadState` instance to use to save new private thread + to the database. + """ + + def __call__( + self, + request: HttpRequest, + category: Category, + ) -> "StartPrivateThreadState": ... + + +class GetStartPrivateThreadPageStateHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: GetStartPrivateThreadPageStateHookAction` + + A standard function that Misago uses to create a new + `StartPrivateThreadState` instance for the start a new private thread page. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + # Return value + + A `StartPrivateThreadState` instance to use to save new private thread + to the database. + """ + + def __call__( + self, + action: GetStartPrivateThreadPageStateHookAction, + request: HttpRequest, + category: Category, + ) -> "StartPrivateThreadState": ... + + +class GetStartPrivateThreadPageStateHook( + FilterHook[ + GetStartPrivateThreadPageStateHookAction, + GetStartPrivateThreadPageStateHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to create a new + `StartPrivateThreadState` instance for the start a new private thread page. + # Example + + The code below implements a custom filter function that stores the user's IP + in the state. + + ```python + from django.http import HttpRequest + from misago.categories.models import Category + from misago.posting.hooks import get_start_private_thread_page_state_hook + from misago.posting.state.start import StartPrivateThreadState + + + @get_start_private_thread_page_state_hook.append_filter + def set_poster_ip_on_start_private_thread_page_state( + action, request: HttpRequest, category: Category + ) -> StartPrivateThreadState: + state = action(request, category) + state.plugin_state["user_id"] = request.user_ip + return state + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: GetStartPrivateThreadPageStateHookAction, + request: HttpRequest, + category: Category, + ) -> "StartPrivateThreadState": + return super().__call__(action, request, category) + + +get_start_private_thread_page_state_hook = GetStartPrivateThreadPageStateHook( + cache=False +) diff --git a/misago/posting/hooks/get_start_thread_page_context_data.py b/misago/posting/hooks/get_start_thread_page_context_data.py new file mode 100644 index 0000000000..8196a16239 --- /dev/null +++ b/misago/posting/hooks/get_start_thread_page_context_data.py @@ -0,0 +1,129 @@ +from typing import TYPE_CHECKING, Protocol + +from django.http import HttpRequest + +from ...categories.models import Category +from ...plugins.hooks import FilterHook + +if TYPE_CHECKING: + from ..forms.start import StartThreadFormset + + +class GetStartThreadPageContextDataHookAction(Protocol): + """ + A standard Misago function used to get the template context data + for the start thread page. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + ## `formset: StartThreadFormset` + + The `StartThreadFormset` instance. + + # Return value + + A Python `dict` with context data to use to `render` the start thread page. + """ + + def __call__( + self, + request: HttpRequest, + category: Category, + formset: "StartThreadFormset", + ) -> dict: ... + + +class GetStartThreadPageContextDataHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: GetStartThreadPageContextDataHookAction` + + A standard Misago function used to get the template context data + for the start thread page. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + ## `formset: StartThreadFormset` + + The `StartThreadFormset` instance. + + # Return value + + A Python `dict` with context data to use to `render` the start thread page. + """ + + def __call__( + self, + action: GetStartThreadPageContextDataHookAction, + request: HttpRequest, + category: Category, + formset: "StartThreadFormset", + ) -> dict: ... + + +class GetStartThreadPageContextDataHook( + FilterHook[ + GetStartThreadPageContextDataHookAction, GetStartThreadPageContextDataHookFilter + ] +): + """ + This hook wraps the standard function that Misago uses to get the template + context data for the start thread page. + + # Example + + The code below implements a custom filter function that adds extra values to + the template context data: + + ```python + from django.http import HttpRequest + from misago.categories.models import Category + from misago.posting.forms.start import StartThreadFormset + from misago.posting.hooks import get_start_thread_page_context_data_hook + + + @get_start_thread_page_context_data_hook.append_filter + def set_show_first_post_warning_in_context( + action, + request: HttpRequest, + category: Category, + formset: StartThreadFormset, + ) -> dict: + context = action(request, category, formset) + context["show_first_post_warning"] = not requser.user.posts + return context + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: GetStartThreadPageContextDataHookAction, + request: HttpRequest, + category: Category, + formset: "StartThreadFormset", + ) -> dict: + return super().__call__(action, request, category, formset) + + +get_start_thread_page_context_data_hook = GetStartThreadPageContextDataHook(cache=False) diff --git a/misago/posting/hooks/get_start_thread_page_formset.py b/misago/posting/hooks/get_start_thread_page_formset.py new file mode 100644 index 0000000000..ee3740c20c --- /dev/null +++ b/misago/posting/hooks/get_start_thread_page_formset.py @@ -0,0 +1,123 @@ +from typing import TYPE_CHECKING, Protocol + +from django.http import HttpRequest + +from ...categories.models import Category +from ...plugins.hooks import FilterHook + +if TYPE_CHECKING: + from ..forms.start import StartThreadFormset + + +class GetStartThreadPageFormsetHookAction(Protocol): + """ + A standard function that Misago uses to create a new + `StartThreadFormset` instance for the start a new thread page. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + # Return value + + A `StartThreadFormset` instance with forms to display + on the start a new thread page. + """ + + def __call__( + self, + request: HttpRequest, + category: Category, + ) -> "StartThreadFormset": ... + + +class GetStartThreadPageFormsetHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: GetStartThreadPageFormsetHookAction` + + A standard function that Misago uses to create a new + `StartThreadFormset` instance for the start a new thread page. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + # Return value + + A `StartThreadFormset` instance with forms to display + on the start a new thread page. + """ + + def __call__( + self, + action: GetStartThreadPageFormsetHookAction, + request: HttpRequest, + category: Category, + ) -> "StartThreadFormset": ... + + +class GetStartThreadPageFormsetHook( + FilterHook[GetStartThreadPageFormsetHookAction, GetStartThreadPageFormsetHookFilter] +): + """ + This hook wraps the standard function that Misago uses to create a new + `StartThreadFormset` instance for the start a new thread page. + + # Example + + The code below implements a custom filter function that adds custom form to + the start a new thread page: + + ```python + from django.http import HttpRequest + from misago.categories.models import Category + from misago.posting.hooks import get_start_thread_page_formset_hook + from misago.posting.forms.start import StartThreadFormset + + from .forms import SelectUserForm + + + @get_start_thread_page_formset_hook.append_filter + def add_select_user_form( + action, request: HttpRequest, category: Category + ) -> StartThreadFormset: + formset = action(request, category) + + if request.method == "POST": + form = SelectUserForm(request.POST, prefix="select-user") + else: + form = SelectUserForm(prefix="select-user") + + formset.add_form(form) + return formset + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: GetStartThreadPageFormsetHookAction, + request: HttpRequest, + category: Category, + ) -> "StartThreadFormset": + return super().__call__(action, request, category) + + +get_start_thread_page_formset_hook = GetStartThreadPageFormsetHook(cache=False) diff --git a/misago/posting/hooks/get_start_thread_page_state.py b/misago/posting/hooks/get_start_thread_page_state.py new file mode 100644 index 0000000000..98bb4744b4 --- /dev/null +++ b/misago/posting/hooks/get_start_thread_page_state.py @@ -0,0 +1,113 @@ +from typing import TYPE_CHECKING, Protocol + +from django.http import HttpRequest + +from ...categories.models import Category +from ...plugins.hooks import FilterHook + +if TYPE_CHECKING: + from ..state.start import StartThreadState + + +class GetStartThreadPageStateHookAction(Protocol): + """ + A standard function that Misago uses to create a new + `StartThreadState` instance for the start a new thread page. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + # Return value + + A `StartThreadState` instance to use to save new thread to the database. + """ + + def __call__( + self, + request: HttpRequest, + category: Category, + ) -> "StartThreadState": ... + + +class GetStartThreadPageStateHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: GetStartThreadPageStateHookAction` + + A standard function that Misago uses to create a new + `StartThreadState` instance for the start a new thread page. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `category: Category` + + The `Category` instance. + + # Return value + + A `StartThreadState` instance to use to save new thread to the database. + """ + + def __call__( + self, + action: GetStartThreadPageStateHookAction, + request: HttpRequest, + category: Category, + ) -> "StartThreadState": ... + + +class GetStartThreadPageStateHook( + FilterHook[GetStartThreadPageStateHookAction, GetStartThreadPageStateHookFilter] +): + """ + This hook wraps the standard function that Misago uses to create a new + `StartThreadState` instance for the start a new thread page. + + # Example + + The code below implements a custom filter function that stores the user's IP + in the state. + + ```python + from django.http import HttpRequest + from misago.categories.models import Category + from misago.posting.hooks import get_start_thread_page_state_hook + from misago.posting.state.start import StartThreadState + + + @get_start_thread_page_state_hook.append_filter + def set_poster_ip_on_start_thread_state( + action, request: HttpRequest, category: Category + ) -> StartThreadState: + state = action(request, category) + state.plugin_state["user_id"] = request.user_ip + return state + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: GetStartThreadPageStateHookAction, + request: HttpRequest, + category: Category, + ) -> "StartThreadState": + return super().__call__(action, request, category) + + +get_start_thread_page_state_hook = GetStartThreadPageStateHook(cache=False) diff --git a/misago/posting/hooks/save_start_private_thread_state.py b/misago/posting/hooks/save_start_private_thread_state.py new file mode 100644 index 0000000000..b5b677c8d4 --- /dev/null +++ b/misago/posting/hooks/save_start_private_thread_state.py @@ -0,0 +1,104 @@ +from typing import TYPE_CHECKING, Protocol + +from django.http import HttpRequest + +from ...plugins.hooks import FilterHook + +if TYPE_CHECKING: + from ..state.start import StartPrivateThreadState + + +class SaveStartPrivateThreadStateHookAction(Protocol): + """ + A standard function that Misago uses to save a new private thread to the database. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `state: StartPrivateThreadState` + + The `StartPrivateThreadState` object that stores all data to save to the database. + """ + + def __call__( + self, + request: HttpRequest, + state: "StartPrivateThreadState", + ): ... + + +class SaveStartPrivateThreadStateHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: SaveStartPrivateThreadStateHookAction` + + A standard function that Misago uses to save a new private thread to the database. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `state: StartPrivateThreadState` + + The `StartPrivateThreadState` object that stores all data to save to the database. + """ + + def __call__( + self, + action: SaveStartPrivateThreadStateHookAction, + request: HttpRequest, + state: "StartPrivateThreadState", + ): ... + + +class SaveStartPrivateThreadStateHook( + FilterHook[ + SaveStartPrivateThreadStateHookAction, SaveStartPrivateThreadStateHookFilter + ] +): + """ + This hook wraps the standard function that Misago uses to save a new private + thread to the database. + + # Example + + The code below implements a custom filter function that stores the user's IP + on the saved thread and post. + + ```python + from django.http import HttpRequest + from misago.posting.hooks import save_start_private_thread_state_hook + from misago.posting.state.start import StartPrivateThreadState + + + @save_start_private_thread_state_hook.append_filter + def save_poster_ip_on_started_private_thread( + action, request: HttpRequest, state: StartPrivateThreadState + ): + state.thread.plugin_data["starter_ip"] = request.user_ip + state.post.plugin_data["poster_id"] = request.user_ip + + action(request, state) + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: SaveStartPrivateThreadStateHookAction, + request: HttpRequest, + state: "StartPrivateThreadState", + ): + return super().__call__(action, request, state) + + +save_start_private_thread_state_hook = SaveStartPrivateThreadStateHook(cache=False) diff --git a/misago/posting/hooks/save_start_thread_state.py b/misago/posting/hooks/save_start_thread_state.py new file mode 100644 index 0000000000..b9fc5c7d66 --- /dev/null +++ b/misago/posting/hooks/save_start_thread_state.py @@ -0,0 +1,102 @@ +from typing import TYPE_CHECKING, Protocol + +from django.http import HttpRequest + +from ...plugins.hooks import FilterHook + +if TYPE_CHECKING: + from ..state.start import StartThreadState + + +class SaveStartThreadStateHookAction(Protocol): + """ + A standard function that Misago uses to save a new thread to the database. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `state: StartThreadState` + + The `StartThreadState` object that stores all data to save to the database. + """ + + def __call__( + self, + request: HttpRequest, + state: "StartThreadState", + ): ... + + +class SaveStartThreadStateHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: SaveStartThreadStateHookAction` + + A standard function that Misago uses to save a new thread to the database. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `state: StartThreadState` + + The `StartThreadState` object that stores all data to save to the database. + """ + + def __call__( + self, + action: SaveStartThreadStateHookAction, + request: HttpRequest, + state: "StartThreadState", + ): ... + + +class SaveStartThreadStateHook( + FilterHook[SaveStartThreadStateHookAction, SaveStartThreadStateHookFilter] +): + """ + This hook wraps the standard function that Misago uses to save a new thread + to the database. + + # Example + + The code below implements a custom filter function that stores the user's IP + on the saved thread and post. + + ```python + from django.http import HttpRequest + from misago.posting.hooks import save_start_thread_state_hook + from misago.posting.state.start import StartThreadState + + + @save_start_thread_state_hook.append_filter + def save_poster_ip_on_started_thread( + action, request: HttpRequest, state: StartThreadState + ): + state.thread.plugin_data["starter_ip"] = request.user_ip + state.post.plugin_data["poster_id"] = request.user_ip + + action(request, state) + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: SaveStartThreadStateHookAction, + request: HttpRequest, + state: "StartThreadState", + ): + return super().__call__(action, request, state) + + +save_start_thread_state_hook = SaveStartThreadStateHook(cache=False) diff --git a/misago/posting/state/base.py b/misago/posting/state/base.py index 78169e7b5e..ced10a5bdd 100644 --- a/misago/posting/state/base.py +++ b/misago/posting/state/base.py @@ -33,7 +33,8 @@ class PostingState: message_ast: list[dict] | None message_metadata: dict | None - models_states: dict + state: dict + plugin_state: dict def __init__(self, request: HttpRequest): self.request = request @@ -44,43 +45,68 @@ def __init__(self, request: HttpRequest): self.message_ast = None self.message_metadata = None - self.models_states = {} - self.store_model_state(self.user) + self.state = {} + self.plugin_state = {} + + self.store_object_state(self.user) + + def initialize_thread(self) -> Thread: + return Thread( + category=self.category, + started_on=self.timestamp, + last_post_on=self.timestamp, + starter=self.user, + starter_name=self.user.username, + starter_slug=self.user.slug, + last_poster=self.user, + last_poster_name=self.user.username, + last_poster_slug=self.user.slug, + ) + + def initialize_post(self) -> Post: + return Post( + category=self.category, + thread=self.thread, + poster=self.user, + poster_name=self.user.username, + posted_on=self.timestamp, + updated_on=self.timestamp, + ) - def store_model_state(self, model: models.Model): - state_key = self.get_model_state_key(model) - self.models_states[state_key] = self.get_model_state(model) + def store_object_state(self, obj: models.Model): + state_key = self.get_object_state_key(obj) + self.state[state_key] = self.get_object_state(obj) - def get_model_state_key(self, model: models.Model) -> str: - return f"{model.__class__.__name__}:{model.pk}" + def get_object_state_key(self, obj: models.Model) -> str: + return f"{obj.__class__.__name__}:{obj.pk}" - def get_model_state(self, model: models.Model) -> dict[str, Any]: + def get_object_state(self, obj: models.Model) -> dict[str, Any]: state = {} - for field in model._meta.get_fields(): + for field in obj._meta.get_fields(): if not isinstance( field, (models.ManyToManyRel, models.ManyToOneRel, models.ManyToManyField), ): - state[field.name] = deepcopy(getattr(model, field.attname)) + state[field.name] = deepcopy(getattr(obj, field.attname)) return state - def get_model_changed_fields(self, model: models.Model) -> set[str]: - state_key = self.get_model_state_key(model) - old_state = self.models_states[state_key] + def get_object_changed_fields(self, obj: models.Model) -> set[str]: + state_key = self.get_object_state_key(obj) + old_state = self.state[state_key] changed_fields: set[str] = set() - for field, value in self.get_model_state(model).items(): + for field, value in self.get_object_state(obj).items(): if old_state[field] != value: changed_fields.add(field) return changed_fields - def save_model_changes(self, model: models.Model) -> set[str]: - update_fields = self.get_model_changed_fields(model) + def update_object(self, obj: models.Model) -> set[str]: + update_fields = self.get_object_changed_fields(obj) if update_fields: - model.save(update_fields=update_fields) + obj.save(update_fields=update_fields) return update_fields def initialize_parser_context(self) -> ParserContext: diff --git a/misago/posting/state/reply.py b/misago/posting/state/reply.py new file mode 100644 index 0000000000..020d878f19 --- /dev/null +++ b/misago/posting/state/reply.py @@ -0,0 +1,57 @@ +from django.db import models, transaction +from django.http import HttpRequest + +from ...threads.checksums import update_post_checksum +from ...threads.models import Thread +from .base import PostingState + + +class ReplyThreadState(PostingState): + def __init__(self, request: HttpRequest, thread: Thread): + super().__init__(request) + + self.category = thread.category + self.thread = thread + self.post = self.initialize_post() + + self.store_object_state(thread.category) + self.store_object_state(thread) + + @transaction.atomic() + def save(self): + self.save_action(self.request, self) + + def save_action(self, request: HttpRequest, state: "ReplyThreadState"): + self.save_post() + self.save_thread() + + self.save_category() + self.save_user() + + def save_thread(self): + self.thread.replies = models.F("posts") + 1 + self.thread.set_last_post(self.post) + + self.update_object(self.thread) + + def save_post(self): + self.post.save() + + update_post_checksum(self.post) + self.post.update_search_vector() + self.post.save() + + def save_category(self): + self.category.posts = models.F("posts") + 1 + self.category.set_last_thread(self.thread) + + self.update_object(self.category) + + def save_user(self): + self.user.posts = models.F("posts") + 1 + + self.update_object(self.user) + + +class ReplyPrivateThreadState(ReplyThreadState): + pass diff --git a/misago/posting/state/start.py b/misago/posting/state/start.py index 6605334f74..89cd320611 100644 --- a/misago/posting/state/start.py +++ b/misago/posting/state/start.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import TYPE_CHECKING from django.db import models, transaction @@ -6,9 +5,12 @@ from ...categories.models import Category from ...core.utils import slugify -from ...parser.context import ParserContext from ...threads.checksums import update_post_checksum from ...threads.models import Post, Thread, ThreadParticipant +from ..hooks import ( + save_start_private_thread_state_hook, + save_start_thread_state_hook, +) from .base import PostingState if TYPE_CHECKING: @@ -16,16 +18,6 @@ class StartThreadState(PostingState): - request: HttpRequest - timestamp: datetime - category: Category - thread: Thread - post: Post - user: "User" - parser_context: ParserContext | None - message_ast: list[dict] | None - message_metadata: dict | None - def __init__(self, request: HttpRequest, category: Category): super().__init__(request) @@ -33,30 +25,7 @@ def __init__(self, request: HttpRequest, category: Category): self.thread = self.initialize_thread() self.post = self.initialize_post() - self.store_model_state(category) - - def initialize_thread(self) -> Thread: - return Thread( - category=self.category, - started_on=self.timestamp, - last_post_on=self.timestamp, - starter=self.user, - starter_name=self.user.username, - starter_slug=self.user.slug, - last_poster=self.user, - last_poster_name=self.user.username, - last_poster_slug=self.user.slug, - ) - - def initialize_post(self) -> Post: - return Post( - category=self.category, - thread=self.thread, - poster=self.user, - poster_name=self.user.username, - posted_on=self.timestamp, - updated_on=self.timestamp, - ) + self.store_object_state(category) def set_thread_title(self, title: str): self.thread.title = title @@ -67,9 +36,9 @@ def save(self): self.thread.save() self.post.save() - self.save_action() + save_start_thread_state_hook(self.save_action, self.request, self) - def save_action(self): + def save_action(self, request: HttpRequest, state: "StartThreadState"): self.save_thread() self.save_post() @@ -90,13 +59,13 @@ def save_category(self): self.category.posts = models.F("posts") + 1 self.category.set_last_thread(self.thread) - self.save_model_changes(self.category) + self.update_object(self.category) def save_user(self): self.user.threads = models.F("threads") + 1 self.user.posts = models.F("posts") + 1 - self.save_model_changes(self.user) + self.update_object(self.user) class StartPrivateThreadState(StartThreadState): @@ -109,8 +78,15 @@ def __init__(self, request: HttpRequest, category: Category): def set_invite_users(self, users: list["User"]): self.invite_users = users - def save_action(self): - super().save_action() + @transaction.atomic() + def save(self): + self.thread.save() + self.post.save() + + save_start_private_thread_state_hook(self.save_action, self.request, self) + + def save_action(self, request: HttpRequest, state: "StartPrivateThreadState"): + super().save_action(request, state) self.save_users() diff --git a/misago/posting/tests/conftest.py b/misago/posting/tests/conftest.py new file mode 100644 index 0000000000..cd8342d24f --- /dev/null +++ b/misago/posting/tests/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from ...permissions.proxy import UserPermissionsProxy + + +@pytest.fixture +def user_request(rf, cache_versions, dynamic_settings, user): + request = rf.post("/post/") + + request.cache_versions = cache_versions + request.settings = dynamic_settings + request.user = user + request.user_permissions = UserPermissionsProxy(user, cache_versions) + + return request diff --git a/misago/posting/tests/test_posting_state.py b/misago/posting/tests/test_posting_state.py new file mode 100644 index 0000000000..c785d0c15d --- /dev/null +++ b/misago/posting/tests/test_posting_state.py @@ -0,0 +1,90 @@ +from unittest.mock import patch + +from ..state.base import PostingState + + +def test_posting_state_stores_request(user_request): + state = PostingState(user_request) + assert state.request == user_request + + +def test_posting_state_stores_request_user(user_request): + state = PostingState(user_request) + assert state.user == user_request.user + + +def test_posting_state_stores_current_timestamp(user_request): + state = PostingState(user_request) + assert state.timestamp + + +def test_posting_state_stores_original_user_state(user_request): + state = PostingState(user_request) + assert state.get_object_state(user_request.user) + + +def test_posting_state_stores_original_obj_state(user_request, default_category): + state = PostingState(user_request) + state.store_object_state(default_category) + assert state.get_object_state(default_category) + + +def test_posting_state_returns_list_of_changed_obj_attributes( + user_request, default_category +): + state = PostingState(user_request) + state.store_object_state(default_category) + + default_category.name = "Updated" + default_category.threads = 2000 + + assert state.get_object_changed_fields(default_category) == {"name", "threads"} + + +def test_posting_state_returns_list_of_changed_obj_foreign_keys( + user_request, default_category +): + state = PostingState(user_request) + state.store_object_state(default_category) + + default_category.last_poster = user_request.user + + assert state.get_object_changed_fields(default_category) == {"last_poster"} + + +def test_posting_state_updates_only_changed_obj_attributes( + user_request, default_category +): + state = PostingState(user_request) + state.store_object_state(default_category) + + default_category.name = "Updated" + default_category.last_poster = user_request.user + + with patch.object(default_category, "save") as mock_save: + state.update_object(default_category) + mock_save.assert_called_once_with(update_fields={"name", "last_poster"}) + + +def test_posting_state_set_post_message_updates_post_contents(user_request, post): + state = PostingState(user_request) + state.post = post + state.set_post_message("Hello world") + + assert post.original == "Hello world" + assert post.parsed == "

Hello world

" + + assert state.message_ast == [ + { + "type": "paragraph", + "children": [ + {"type": "text", "text": "Hello world"}, + ], + }, + ] + assert state.message_metadata == { + "outbound-links": set(), + "posts": {"ids": set(), "objs": {}}, + "usernames": set(), + "users": {}, + } diff --git a/misago/posting/tests/test_start_private_thread_state.py b/misago/posting/tests/test_start_private_thread_state.py new file mode 100644 index 0000000000..e170ece673 --- /dev/null +++ b/misago/posting/tests/test_start_private_thread_state.py @@ -0,0 +1,34 @@ +from ...threads.models import ThreadParticipant +from ..state.start import StartPrivateThreadState + + +def test_start_private_thread_state_save_sets_request_user_as_thread_owner( + user_request, private_threads_category, user, other_user +): + state = StartPrivateThreadState(user_request, private_threads_category) + state.set_thread_title("Test thread") + state.set_post_message("Hello world") + state.set_invite_users([other_user]) + state.save() + + ThreadParticipant.objects.get( + thread=state.thread, + user=user, + is_owner=True, + ) + + +def test_start_private_thread_state_save_invites_users_to_saved_thread( + user_request, private_threads_category, other_user +): + state = StartPrivateThreadState(user_request, private_threads_category) + state.set_thread_title("Test thread") + state.set_post_message("Hello world") + state.set_invite_users([other_user]) + state.save() + + ThreadParticipant.objects.get( + thread=state.thread, + user=other_user, + is_owner=False, + ) diff --git a/misago/posting/tests/test_start_thread_state.py b/misago/posting/tests/test_start_thread_state.py new file mode 100644 index 0000000000..e80923e002 --- /dev/null +++ b/misago/posting/tests/test_start_thread_state.py @@ -0,0 +1,74 @@ +from ..state.start import StartThreadState + + +def test_start_thread_state_initializes_thread_and_post(user_request, default_category): + state = StartThreadState(user_request, default_category) + + assert state.thread + assert state.thread.starter == user_request.user + assert state.thread.starter_name == user_request.user.username + assert state.thread.starter_slug == user_request.user.slug + assert state.thread.last_poster == user_request.user + assert state.thread.last_poster_name == user_request.user.username + assert state.thread.last_poster_slug == user_request.user.slug + assert state.thread.category == default_category + assert state.thread.started_on == state.timestamp + assert state.thread.last_post_on == state.timestamp + + assert state.post + assert state.post.poster == user_request.user + assert state.post.poster_name == user_request.user.username + assert state.post.category == default_category + assert state.post.posted_on == state.timestamp + + +def test_start_thread_state_stores_category_state(user_request, default_category): + state = StartThreadState(user_request, default_category) + assert state.get_object_state(default_category) + + +def test_start_thread_state_set_thread_title_updates_thread_title_and_slug( + user_request, default_category +): + state = StartThreadState(user_request, default_category) + state.set_thread_title("Test thread") + assert state.thread.title == "Test thread" + assert state.thread.slug == "test-thread" + + +def test_start_thread_state_save_saves_thread_and_post(user_request, default_category): + state = StartThreadState(user_request, default_category) + state.set_thread_title("Test thread") + state.set_post_message("Hello world") + state.save() + + assert state.thread.id + assert state.post.id + assert state.post.thread == state.thread + + +def test_start_thread_state_updates_category_stats(user_request, default_category): + state = StartThreadState(user_request, default_category) + state.set_thread_title("Test thread") + state.set_post_message("Hello world") + state.save() + + default_category.refresh_from_db() + assert default_category.threads == 1 + assert default_category.posts == 1 + assert default_category.last_thread == state.thread + assert default_category.last_post_on == state.thread.last_post_on + assert default_category.last_poster == state.thread.last_poster + assert default_category.last_poster_name == state.thread.last_poster_name + assert default_category.last_poster_slug == state.thread.last_poster_slug + + +def test_start_thread_state_updates_user_stats(user_request, default_category, user): + state = StartThreadState(user_request, default_category) + state.set_thread_title("Test thread") + state.set_post_message("Hello world") + state.save() + + user.refresh_from_db() + assert user.threads == 1 + assert user.posts == 1 diff --git a/misago/posting/urls.py b/misago/posting/urls.py index 9cdea4a4d9..e88f04b774 100644 --- a/misago/posting/urls.py +++ b/misago/posting/urls.py @@ -1,5 +1,6 @@ from django.urls import path +from .views.reply import ReplyPrivateThreadView, ReplyThreadView from .views.selectcategory import SelectCategoryView from .views.start import StartPrivateThreadView, StartThreadView @@ -20,4 +21,14 @@ StartPrivateThreadView.as_view(), name="start-private-thread", ), + path( + "t///reply/", + ReplyThreadView.as_view(), + name="thread-reply", + ), + path( + "p///reply/", + ReplyPrivateThreadView.as_view(), + name="private-thread-reply", + ), ] diff --git a/misago/posting/views/reply.py b/misago/posting/views/reply.py new file mode 100644 index 0000000000..707e8b419d --- /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 diff --git a/misago/posting/views/selectcategory.py b/misago/posting/views/selectcategory.py index 3e4c7a3b1c..40639a530a 100644 --- a/misago/posting/views/selectcategory.py +++ b/misago/posting/views/selectcategory.py @@ -6,7 +6,7 @@ from django.utils.translation import pgettext from ...categories.models import Category -from ...permissions.threads import check_start_thread_in_category_permission +from ...permissions.threads import check_start_thread_permission class SelectCategoryView(View): @@ -39,9 +39,7 @@ def get_category_choices(self, request: HttpRequest) -> list[dict]: choices: list[dict] = [] for category in queryset: try: - check_start_thread_in_category_permission( - request.user_permissions, category - ) + check_start_thread_permission(request.user_permissions, category) except (Http404, PermissionDenied): has_permission = False else: diff --git a/misago/posting/views/start.py b/misago/posting/views/start.py index 3ab96ee0db..70568ef8f2 100644 --- a/misago/posting/views/start.py +++ b/misago/posting/views/start.py @@ -13,13 +13,21 @@ check_private_threads_permission, check_start_private_threads_permission, ) -from ...permissions.threads import check_start_thread_in_category_permission +from ...permissions.threads import check_start_thread_permission from ...threads.models import Thread from ..forms.start import ( StartPrivateThreadForm, StartThreadForm, StartThreadFormset, ) +from ..hooks import ( + get_start_private_thread_page_context_data_hook, + get_start_private_thread_page_formset_hook, + get_start_private_thread_page_state_hook, + get_start_thread_page_context_data_hook, + get_start_thread_page_formset_hook, + get_start_thread_page_state_hook, +) from ..state.start import StartPrivateThreadState, StartThreadState @@ -86,12 +94,19 @@ def get_category(self, request: HttpRequest, kwargs: dict) -> Category: check_browse_category_permission( request.user_permissions, category, can_delay=True ) - check_start_thread_in_category_permission(request.user_permissions, category) + check_start_thread_permission(request.user_permissions, category) return category def get_formset( self, request: HttpRequest, category: Category + ) -> StartThreadFormset: + return get_start_thread_page_formset_hook( + self.get_formset_action, request, category + ) + + def get_formset_action( + self, request: HttpRequest, category: Category ) -> StartThreadFormset: formset = StartThreadFormset() formset.add_form(self.get_start_thread_form(request, category)) @@ -107,10 +122,24 @@ def get_start_thread_form( return StartThreadForm(prefix=prefix) def get_state(self, request: HttpRequest, category: Category) -> StartThreadState: + return get_start_thread_page_state_hook( + self.get_state_action, request, category + ) + + def get_state_action( + self, request: HttpRequest, category: Category + ) -> StartThreadState: return self.state_class(request, category) def get_context_data( self, request: HttpRequest, category: Category, formset: StartThreadFormset + ) -> dict: + return get_start_thread_page_context_data_hook( + self.get_context_data_action, request, category, formset + ) + + def get_context_data_action( + self, request: HttpRequest, category: Category, formset: StartThreadFormset ) -> dict: return {"category": category, "formset": formset} @@ -133,7 +162,14 @@ def get_category(self, request: HttpRequest, kwargs: dict) -> Category: def get_formset( self, request: HttpRequest, category: Category ) -> StartThreadFormset: - formset = super().get_formset(request, category) + return get_start_private_thread_page_formset_hook( + self.get_formset_action, request, category + ) + + def get_formset_action( + self, request: HttpRequest, category: Category + ) -> StartThreadFormset: + formset = super().get_formset_action(request, category) formset.add_form( self.get_start_private_thread_form(request, category), append=False ) @@ -148,6 +184,18 @@ def get_start_private_thread_form( return StartPrivateThreadForm(prefix=prefix, request=request) + def get_state(self, request: HttpRequest, category: Category) -> StartThreadState: + return get_start_private_thread_page_state_hook( + self.get_state_action, request, category + ) + + def get_context_data( + self, request: HttpRequest, category: Category, formset: StartThreadFormset + ) -> dict: + return get_start_private_thread_page_context_data_hook( + self.get_context_data_action, request, category, formset + ) + def get_thread_url(self, request: HttpRequest, thread: Thread) -> str: return reverse( "misago:private-thread", diff --git a/misago/threads/models/thread.py b/misago/threads/models/thread.py index 568b652f1c..3541a4707d 100644 --- a/misago/threads/models/thread.py +++ b/misago/threads/models/thread.py @@ -234,9 +234,11 @@ def participants_ids(self) -> list[int]: Cached property. Thread owner is guaranteed to be first item of the list. """ - return self.participants.through.objects.order_by( - "-is_owner", "id" - ).values_list("user_id", flat=True) + return list( + self.participants.through.objects.order_by("-is_owner", "id").values_list( + "user_id", flat=True + ) + ) @property def thread_type(self): diff --git a/misago/threads/views/list.py b/misago/threads/views/list.py index c18d5898d3..f6fbffdd81 100644 --- a/misago/threads/views/list.py +++ b/misago/threads/views/list.py @@ -50,7 +50,7 @@ from ...permissions.threads import ( CategoryThreadsQuerysetFilter, ThreadsQuerysetFilter, - check_start_thread_in_category_permission, + check_start_thread_permission, ) from ...readtracker.privatethreads import unread_private_threads_exist from ...readtracker.threads import is_category_read @@ -363,10 +363,7 @@ def get_thread_pages_count(self, request: HttpRequest, thread: Thread) -> int: return ceil(posts / request.settings.posts_per_page) def allow_thread_moderation(self, request: HttpRequest, thread: Thread) -> bool: - return ( - request.user_permissions.is_global_moderator - or thread.category_id in request.user_permissions.categories_moderator - ) + return request.user_permissions.is_category_moderator(thread.category_id) def get_metatags(self, request: HttpRequest, context: dict) -> dict: return get_default_metatags(request) @@ -606,10 +603,7 @@ def get_threads_filters_action(self, request: HttpRequest) -> list[ThreadsFilter MyThreadsFilter(request), ] - if ( - request.user_permissions.is_global_moderator - or request.user_permissions.categories_moderator - ): + if request.user_permissions.moderated_categories: filters.append(UnapprovedThreadsFilter(request)) return filters @@ -676,10 +670,7 @@ def get_moderation_actions_action( self, request: HttpRequest ) -> list[Type[ThreadsBulkModerationAction]]: actions: list = [] - if not ( - request.user_permissions.is_global_moderator - or request.user_permissions.categories_moderator - ): + if not request.user_permissions.moderated_categories: return actions actions += [ @@ -1056,10 +1047,7 @@ def get_threads_filters_action( MyThreadsFilter(request), ] - if ( - request.user_permissions.is_global_moderator - or category.id in request.user_permissions.categories_moderator - ): + if request.user_permissions.is_category_moderator(category.id): filters.append(UnapprovedThreadsFilter(request)) return filters @@ -1117,9 +1105,7 @@ def get_start_thread_url( self, request: HttpRequest, category: Category ) -> str | None: try: - check_start_thread_in_category_permission( - request.user_permissions, category - ) + check_start_thread_permission(request.user_permissions, category) except: return None else: @@ -1170,7 +1156,7 @@ def show_moderation_actions_in_category( ) return bool( - request.user_permissions.categories_moderator.intersection(categories_ids) + request.user_permissions.moderated_categories.intersection(categories_ids) ) def raise_404_for_vanilla_category(self, category: Category, context: dict): @@ -1354,7 +1340,7 @@ def get_threads_action( mark_read = bool(threads_list) - moderator = request.user_permissions.private_threads_moderator + moderator = request.user_permissions.is_private_threads_moderator items: list[dict] = [] for thread in threads_list: diff --git a/misago/threads/views/redirect.py b/misago/threads/views/redirect.py index 9838219e8b..8a42f193a1 100644 --- a/misago/threads/views/redirect.py +++ b/misago/threads/views/redirect.py @@ -106,10 +106,7 @@ class ThreadUnapprovedPostRedirectView(UnapprovedPostRedirectView, ThreadView): def get_post( self, request: HttpRequest, thread: Thread, queryset: QuerySet, kwargs: dict ) -> Post | None: - if not ( - request.user_permissions.is_global_moderator - or thread.category in request.user_permissions.categories_moderator - ): + if not request.user_permissions.is_category_moderator(thread.category_id): self.raise_permission_denied_error() return queryset.filter(is_unapproved=True).first() @@ -121,7 +118,7 @@ class PrivateThreadUnapprovedPostRedirectView( def get_post( self, request: HttpRequest, thread: Thread, queryset: QuerySet, kwargs: dict ) -> Post | None: - if not request.user_permissions.private_threads_moderator: + if not request.user_permissions.is_private_threads_moderator: self.raise_permission_denied_error() return queryset.filter(is_unapproved=True).first() diff --git a/misago/users/migrations/0027_new_permissions.py b/misago/users/migrations/0027_new_permissions.py index 95f1506dae..e36ce26583 100644 --- a/misago/users/migrations/0027_new_permissions.py +++ b/misago/users/migrations/0027_new_permissions.py @@ -34,6 +34,10 @@ class Migration(migrations.Migration): ("is_hidden", models.BooleanField(default=False)), ("is_default", models.BooleanField(default=False)), ("ordering", models.PositiveIntegerField(default=0)), + ("can_edit_own_threads", models.BooleanField(default=False)), + ("own_threads_edit_time_limit", models.PositiveIntegerField(default=0)), + ("can_edit_own_posts", models.BooleanField(default=False)), + ("own_posts_edit_time_limit", models.PositiveIntegerField(default=0)), ("can_use_private_threads", models.BooleanField(default=False)), ("can_start_private_threads", models.BooleanField(default=False)), ("private_thread_users_limit", models.PositiveIntegerField(default=1)), diff --git a/misago/users/migrations/0028_default_groups.py b/misago/users/migrations/0028_default_groups.py index e2b8fd20c6..2735c68ef1 100644 --- a/misago/users/migrations/0028_default_groups.py +++ b/misago/users/migrations/0028_default_groups.py @@ -25,6 +25,10 @@ def create_default_groups(apps, schema_editor): is_page=True, ordering=0, # Permissions + can_edit_own_threads=True, + own_threads_edit_time_limit=0, + can_edit_own_posts=True, + own_posts_edit_time_limit=0, can_use_private_threads=True, can_start_private_threads=True, private_thread_users_limit=20, @@ -42,6 +46,10 @@ def create_default_groups(apps, schema_editor): is_page=True, ordering=1, # Permissions + can_edit_own_threads=True, + own_threads_edit_time_limit=0, + can_edit_own_posts=True, + own_posts_edit_time_limit=0, can_use_private_threads=True, can_start_private_threads=True, private_thread_users_limit=20, @@ -56,6 +64,10 @@ def create_default_groups(apps, schema_editor): is_default=True, ordering=2, # Permissions + can_edit_own_threads=True, + own_threads_edit_time_limit=0, + can_edit_own_posts=True, + own_posts_edit_time_limit=0, can_use_private_threads=True, can_start_private_threads=True, private_thread_users_limit=5, diff --git a/misago/users/models/group.py b/misago/users/models/group.py index 4a49dc691b..c9dc495cb8 100644 --- a/misago/users/models/group.py +++ b/misago/users/models/group.py @@ -20,6 +20,12 @@ class Group(PluginDataModel): ordering = models.PositiveIntegerField(default=0) + can_edit_own_threads = models.BooleanField(default=False) + own_threads_edit_time_limit = models.PositiveIntegerField(default=0) + + can_edit_own_posts = models.BooleanField(default=False) + own_posts_edit_time_limit = models.PositiveIntegerField(default=0) + can_use_private_threads = models.BooleanField(default=False) can_start_private_threads = models.BooleanField(default=False) private_thread_users_limit = models.PositiveIntegerField(default=1)