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 @@
+
+
+
+
+
+
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 %}