From 5a2634fc927e4ca5cac030efa072f9e048ad267d Mon Sep 17 00:00:00 2001 From: rafalp Date: Sun, 15 Sep 2024 13:24:49 +0200 Subject: [PATCH 01/21] Add tests for base posting state, hooks for save thread states --- misago/posting/forms/attachments.py | 26 +++++ misago/posting/hooks/__init__.py | 8 ++ .../hooks/save_start_private_thread_state.py | 104 ++++++++++++++++++ .../posting/hooks/save_start_thread_state.py | 102 +++++++++++++++++ misago/posting/state/base.py | 36 +++--- misago/posting/state/start.py | 25 +++-- misago/posting/tests/conftest.py | 15 +++ misago/posting/tests/test_posting_state.py | 90 +++++++++++++++ 8 files changed, 381 insertions(+), 25 deletions(-) create mode 100644 misago/posting/forms/attachments.py create mode 100644 misago/posting/hooks/save_start_private_thread_state.py create mode 100644 misago/posting/hooks/save_start_thread_state.py create mode 100644 misago/posting/tests/conftest.py create mode 100644 misago/posting/tests/test_posting_state.py 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/hooks/__init__.py b/misago/posting/hooks/__init__.py index e69de29bb2..7d1ac334e8 100644 --- a/misago/posting/hooks/__init__.py +++ b/misago/posting/hooks/__init__.py @@ -0,0 +1,8 @@ +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__ = [ + "save_start_private_thread_state_hook", + "save_start_thread_state_hook", +] 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..0ac23707e6 --- /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 + ) -> dict: + 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() 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..9f3594f171 --- /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 + ) -> dict: + 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() diff --git a/misago/posting/state/base.py b/misago/posting/state/base.py index 78169e7b5e..98504c9b0a 100644 --- a/misago/posting/state/base.py +++ b/misago/posting/state/base.py @@ -33,7 +33,7 @@ class PostingState: message_ast: list[dict] | None message_metadata: dict | None - models_states: dict + state: dict def __init__(self, request: HttpRequest): self.request = request @@ -44,43 +44,43 @@ 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.store_object_state(self.user) - 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/start.py b/misago/posting/state/start.py index 6605334f74..e816c4d0f5 100644 --- a/misago/posting/state/start.py +++ b/misago/posting/state/start.py @@ -9,6 +9,10 @@ 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: @@ -33,7 +37,7 @@ def __init__(self, request: HttpRequest, category: Category): self.thread = self.initialize_thread() self.post = self.initialize_post() - self.store_model_state(category) + self.store_object_state(category) def initialize_thread(self) -> Thread: return Thread( @@ -67,9 +71,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 +94,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 +113,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": {}, + } From 314c3aa26c0d01c56587f0ca6e28739376bb98b0 Mon Sep 17 00:00:00 2001 From: rafalp Date: Sun, 15 Sep 2024 14:09:05 +0200 Subject: [PATCH 02/21] Add tests for start thread states, add memory leak fix to filter hook --- misago/plugins/hooks.py | 19 ++++- .../hooks/save_start_private_thread_state.py | 2 +- .../posting/hooks/save_start_thread_state.py | 2 +- misago/posting/state/start.py | 13 ++-- .../tests/test_start_private_thread_state.py | 34 +++++++++ .../posting/tests/test_start_thread_state.py | 74 +++++++++++++++++++ 6 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 misago/posting/tests/test_start_private_thread_state.py create mode 100644 misago/posting/tests/test_start_thread_state.py 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/hooks/save_start_private_thread_state.py b/misago/posting/hooks/save_start_private_thread_state.py index 0ac23707e6..a7279a1ec7 100644 --- a/misago/posting/hooks/save_start_private_thread_state.py +++ b/misago/posting/hooks/save_start_private_thread_state.py @@ -101,4 +101,4 @@ def __call__( return super().__call__(action, request, state) -save_start_private_thread_state_hook = SaveStartPrivateThreadStateHook() +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 index 9f3594f171..87e401f592 100644 --- a/misago/posting/hooks/save_start_thread_state.py +++ b/misago/posting/hooks/save_start_thread_state.py @@ -99,4 +99,4 @@ def __call__( return super().__call__(action, request, state) -save_start_thread_state_hook = SaveStartThreadStateHook() +save_start_thread_state_hook = SaveStartThreadStateHook(cache=False) diff --git a/misago/posting/state/start.py b/misago/posting/state/start.py index e816c4d0f5..ae66eb72be 100644 --- a/misago/posting/state/start.py +++ b/misago/posting/state/start.py @@ -71,14 +71,15 @@ def save(self): self.thread.save() self.post.save() - save_start_thread_state_hook(self.save_action, self.request, self) + save_start_thread_state_hook(StartThreadState.save_action, self.request, self) - def save_action(self, request: HttpRequest, _state: "StartThreadState"): - self.save_thread() - self.save_post() + @staticmethod + def save_action(request: HttpRequest, state: "StartThreadState"): + state.save_thread() + state.save_post() - self.save_category() - self.save_user() + state.save_category() + state.save_user() def save_thread(self): self.thread.first_post = self.thread.last_post = self.post 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 From 9d2c07c88834653c391520f14968a46bb52e446c Mon Sep 17 00:00:00 2001 From: rafalp Date: Thu, 19 Sep 2024 17:47:40 +0200 Subject: [PATCH 03/21] Add start thread state hooks to posting --- misago/posting/hooks/__init__.py | 4 ++++ .../hooks/save_start_private_thread_state.py | 6 +++--- misago/posting/hooks/save_start_thread_state.py | 6 +++--- misago/posting/state/base.py | 3 +++ misago/posting/state/start.py | 12 ------------ misago/posting/views/start.py | 14 ++++++++++++++ 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/misago/posting/hooks/__init__.py b/misago/posting/hooks/__init__.py index 7d1ac334e8..e3bb0aed72 100644 --- a/misago/posting/hooks/__init__.py +++ b/misago/posting/hooks/__init__.py @@ -1,8 +1,12 @@ +from .get_start_private_thread_state import get_start_private_thread_state_hook +from .get_start_thread_state import get_start_thread_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_state_hook", + "get_start_thread_state_hook", "save_start_private_thread_state_hook", "save_start_thread_state_hook", ] diff --git a/misago/posting/hooks/save_start_private_thread_state.py b/misago/posting/hooks/save_start_private_thread_state.py index a7279a1ec7..b5b677c8d4 100644 --- a/misago/posting/hooks/save_start_private_thread_state.py +++ b/misago/posting/hooks/save_start_private_thread_state.py @@ -10,7 +10,7 @@ class SaveStartPrivateThreadStateHookAction(Protocol): """ - A standard function that Misago uses to save a new private thread to the database. + A standard function that Misago uses to save a new private thread to the database. # Arguments @@ -38,7 +38,7 @@ class SaveStartPrivateThreadStateHookFilter(Protocol): ## `action: SaveStartPrivateThreadStateHookAction` - A standard function that Misago uses to save a new private thread to the database. + A standard function that Misago uses to save a new private thread to the database. See the [action](#action) section for details. @@ -82,7 +82,7 @@ class SaveStartPrivateThreadStateHook( @save_start_private_thread_state_hook.append_filter def save_poster_ip_on_started_private_thread( action, request: HttpRequest, state: StartPrivateThreadState - ) -> dict: + ): state.thread.plugin_data["starter_ip"] = request.user_ip state.post.plugin_data["poster_id"] = request.user_ip diff --git a/misago/posting/hooks/save_start_thread_state.py b/misago/posting/hooks/save_start_thread_state.py index 87e401f592..b9fc5c7d66 100644 --- a/misago/posting/hooks/save_start_thread_state.py +++ b/misago/posting/hooks/save_start_thread_state.py @@ -10,7 +10,7 @@ class SaveStartThreadStateHookAction(Protocol): """ - A standard function that Misago uses to save a new thread to the database. + A standard function that Misago uses to save a new thread to the database. # Arguments @@ -38,7 +38,7 @@ class SaveStartThreadStateHookFilter(Protocol): ## `action: SaveStartThreadStateHookAction` - A standard function that Misago uses to save a new thread to the database. + A standard function that Misago uses to save a new thread to the database. See the [action](#action) section for details. @@ -80,7 +80,7 @@ class SaveStartThreadStateHook( @save_start_thread_state_hook.append_filter def save_poster_ip_on_started_thread( action, request: HttpRequest, state: StartThreadState - ) -> dict: + ): state.thread.plugin_data["starter_ip"] = request.user_ip state.post.plugin_data["poster_id"] = request.user_ip diff --git a/misago/posting/state/base.py b/misago/posting/state/base.py index 98504c9b0a..fe07582fee 100644 --- a/misago/posting/state/base.py +++ b/misago/posting/state/base.py @@ -34,6 +34,7 @@ class PostingState: message_metadata: dict | None state: dict + plugin_state: dict def __init__(self, request: HttpRequest): self.request = request @@ -45,6 +46,8 @@ def __init__(self, request: HttpRequest): self.message_metadata = None self.state = {} + self.plugin_state = {} + self.store_object_state(self.user) def store_object_state(self, obj: models.Model): diff --git a/misago/posting/state/start.py b/misago/posting/state/start.py index ae66eb72be..96cb9ff7a8 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,7 +5,6 @@ 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 ( @@ -20,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) diff --git a/misago/posting/views/start.py b/misago/posting/views/start.py index 3ab96ee0db..51a08b33f8 100644 --- a/misago/posting/views/start.py +++ b/misago/posting/views/start.py @@ -20,6 +20,10 @@ StartThreadForm, StartThreadFormset, ) +from ..hooks import ( + get_start_private_thread_state_hook, + get_start_thread_state_hook, +) from ..state.start import StartPrivateThreadState, StartThreadState @@ -107,6 +111,11 @@ def get_start_thread_form( return StartThreadForm(prefix=prefix) def get_state(self, request: HttpRequest, category: Category) -> StartThreadState: + return get_start_thread_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( @@ -148,6 +157,11 @@ 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_state_hook( + self.get_state_action, request, category + ) + def get_thread_url(self, request: HttpRequest, thread: Thread) -> str: return reverse( "misago:private-thread", From 375fcc0a183c288c05c6fa8e1dd659f9523363ea Mon Sep 17 00:00:00 2001 From: rafalp Date: Thu, 19 Sep 2024 18:16:18 +0200 Subject: [PATCH 04/21] Add hooks to start thread page --- misago/posting/hooks/__init__.py | 22 ++- ..._start_private_thread_page_context_data.py | 132 ++++++++++++++++++ .../get_start_private_thread_page_formset.py | 128 +++++++++++++++++ .../get_start_private_thread_page_state.py | 119 ++++++++++++++++ .../get_start_thread_page_context_data.py | 129 +++++++++++++++++ .../hooks/get_start_thread_page_formset.py | 123 ++++++++++++++++ .../hooks/get_start_thread_page_state.py | 113 +++++++++++++++ misago/posting/state/reply.py | 5 + misago/posting/views/start.py | 44 +++++- 9 files changed, 806 insertions(+), 9 deletions(-) create mode 100644 misago/posting/hooks/get_start_private_thread_page_context_data.py create mode 100644 misago/posting/hooks/get_start_private_thread_page_formset.py create mode 100644 misago/posting/hooks/get_start_private_thread_page_state.py create mode 100644 misago/posting/hooks/get_start_thread_page_context_data.py create mode 100644 misago/posting/hooks/get_start_thread_page_formset.py create mode 100644 misago/posting/hooks/get_start_thread_page_state.py create mode 100644 misago/posting/state/reply.py diff --git a/misago/posting/hooks/__init__.py b/misago/posting/hooks/__init__.py index e3bb0aed72..319ee3c929 100644 --- a/misago/posting/hooks/__init__.py +++ b/misago/posting/hooks/__init__.py @@ -1,12 +1,26 @@ -from .get_start_private_thread_state import get_start_private_thread_state_hook -from .get_start_thread_state import get_start_thread_state_hook +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_state_hook", - "get_start_thread_state_hook", + "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/state/reply.py b/misago/posting/state/reply.py new file mode 100644 index 0000000000..37ddd1816b --- /dev/null +++ b/misago/posting/state/reply.py @@ -0,0 +1,5 @@ +from .base import PostingState + + +class ReplyThreadState(PostingState): + pass diff --git a/misago/posting/views/start.py b/misago/posting/views/start.py index 51a08b33f8..bc833bb14a 100644 --- a/misago/posting/views/start.py +++ b/misago/posting/views/start.py @@ -21,8 +21,12 @@ StartThreadFormset, ) from ..hooks import ( - get_start_private_thread_state_hook, - get_start_thread_state_hook, + 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 @@ -96,6 +100,13 @@ def get_category(self, request: HttpRequest, kwargs: dict) -> 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)) @@ -111,7 +122,9 @@ def get_start_thread_form( return StartThreadForm(prefix=prefix) def get_state(self, request: HttpRequest, category: Category) -> StartThreadState: - return get_start_thread_state_hook(self.get_state_action, request, category) + return get_start_thread_page_state_hook( + self.get_state_action, request, category + ) def get_state_action( self, request: HttpRequest, category: Category @@ -120,6 +133,13 @@ def get_state_action( 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} @@ -142,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 ) @@ -158,10 +185,17 @@ 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_state_hook( + 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", From db1533868390aa29fd17e092545e359e01e39a5e Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 24 Sep 2024 17:55:41 +0200 Subject: [PATCH 05/21] Add check_post_in_closed_thread_permission thread permission --- misago/permissions/hooks/__init__.py | 4 + .../tests/test_threads_permissions.py | 58 +++++++++++ misago/permissions/threads/__init__.py | 2 + misago/permissions/threads/checks.py | 99 ++++++++++++++++++- misago/posting/state/base.py | 23 +++++ misago/posting/state/reply.py | 55 ++++++++++- misago/posting/state/start.py | 36 ++----- misago/posting/urls.py | 11 +++ 8 files changed, 256 insertions(+), 32 deletions(-) diff --git a/misago/permissions/hooks/__init__.py b/misago/permissions/hooks/__init__.py index f7a32deb94..3fc2bf5659 100644 --- a/misago/permissions/hooks/__init__.py +++ b/misago/permissions/hooks/__init__.py @@ -4,6 +4,9 @@ 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_see_category_permission import check_see_category_permission_hook from .check_see_private_thread_permission import ( @@ -41,6 +44,7 @@ "build_user_permissions_hook", "check_browse_category_permission_hook", "check_post_in_closed_category_permission_hook", + "check_post_in_closed_thread_permission_hook", "check_private_threads_permission_hook", "check_see_category_permission_hook", "check_see_private_thread_permission_hook", diff --git a/misago/permissions/tests/test_threads_permissions.py b/misago/permissions/tests/test_threads_permissions.py index 59f1777993..9f53cef3c1 100644 --- a/misago/permissions/tests/test_threads_permissions.py +++ b/misago/permissions/tests/test_threads_permissions.py @@ -7,6 +7,7 @@ from ..proxy import UserPermissionsProxy from ..threads import ( check_post_in_closed_category_permission, + check_post_in_closed_thread_permission, check_see_thread_permission, check_start_thread_in_category_permission, ) @@ -69,6 +70,63 @@ 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_post_in_closed_thread_permission_passes_if_thread_is_open( + user, cache_versions, thread +): + permissions = UserPermissionsProxy(user, cache_versions) + check_post_in_closed_thread_permission(permissions, thread) + + +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_post_in_closed_thread_permission(permissions, thread) + + +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_start_thread_in_category_permission_passes_if_user_has_permission( user, cache_versions, default_category ): diff --git a/misago/permissions/threads/__init__.py b/misago/permissions/threads/__init__.py index 4a4601e2cf..999cf6eace 100644 --- a/misago/permissions/threads/__init__.py +++ b/misago/permissions/threads/__init__.py @@ -1,5 +1,6 @@ from .checks import ( check_post_in_closed_category_permission, + check_post_in_closed_thread_permission, check_see_thread_permission, check_start_thread_in_category_permission, ) @@ -14,6 +15,7 @@ "CategoryThreadsQuerysetFilter", "ThreadsQuerysetFilter", "check_post_in_closed_category_permission", + "check_post_in_closed_thread_permission", "check_see_thread_permission", "check_start_thread_in_category_permission", "filter_category_threads_queryset", diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index 4222c44017..ed599480f8 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -3,11 +3,12 @@ from django.utils.translation import 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_post_in_closed_category_permission_hook, + check_post_in_closed_thread_permission_hook, check_see_thread_permission_hook, check_start_thread_in_category_permission_hook, ) @@ -39,6 +40,31 @@ def _check_post_in_closed_category_permission_action( ) +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_global_moderator + or thread.category_id in permissions.categories_moderator + ): + raise PermissionDenied( + pgettext( + "threads permission error", + "This thread is closed.", + ) + ) + + def check_start_thread_in_category_permission( permissions: UserPermissionsProxy, category: Category ): @@ -111,3 +137,74 @@ def _check_see_thread_permission_action( ) raise Http404() + + +def check_reply_thread_permission( + 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, + post: Post, +): + check_post_in_closed_category_permission(permissions, category) + check_post_in_closed_thread_permission(permissions, thread) + + user_id = permissions.user.id + is_poster = user_id and post.poster_id and post.poster_id == user_id + + if not is_poster and not ( + permissions.is_global_moderator + or thread.category_id in permissions.categories_moderator + ): + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit other users threads.", + ) + ) + + +def check_edit_thread_post_permission( + 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 replies in this category.", + ) + ) + + check_post_in_closed_category_permission(permissions, category) + check_post_in_closed_thread_permission(permissions, thread) + + user_id = permissions.user.id + is_poster = user_id and post.poster_id and post.poster_id == user_id + + if not is_poster and not ( + permissions.is_global_moderator + or thread.category_id in permissions.categories_moderator + ): + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit other users posts.", + ) + ) diff --git a/misago/posting/state/base.py b/misago/posting/state/base.py index fe07582fee..ced10a5bdd 100644 --- a/misago/posting/state/base.py +++ b/misago/posting/state/base.py @@ -50,6 +50,29 @@ def __init__(self, request: HttpRequest): 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_object_state(self, obj: models.Model): state_key = self.get_object_state_key(obj) self.state[state_key] = self.get_object_state(obj) diff --git a/misago/posting/state/reply.py b/misago/posting/state/reply.py index 37ddd1816b..8743cd8934 100644 --- a/misago/posting/state/reply.py +++ b/misago/posting/state/reply.py @@ -1,5 +1,58 @@ +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): - pass + 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 \ No newline at end of file diff --git a/misago/posting/state/start.py b/misago/posting/state/start.py index 96cb9ff7a8..89cd320611 100644 --- a/misago/posting/state/start.py +++ b/misago/posting/state/start.py @@ -27,29 +27,6 @@ def __init__(self, request: HttpRequest, category: Category): self.store_object_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, - ) - def set_thread_title(self, title: str): self.thread.title = title self.thread.slug = slugify(title) @@ -59,15 +36,14 @@ def save(self): self.thread.save() self.post.save() - save_start_thread_state_hook(StartThreadState.save_action, self.request, self) + save_start_thread_state_hook(self.save_action, self.request, self) - @staticmethod - def save_action(request: HttpRequest, state: "StartThreadState"): - state.save_thread() - state.save_post() + def save_action(self, request: HttpRequest, state: "StartThreadState"): + self.save_thread() + self.save_post() - state.save_category() - state.save_user() + self.save_category() + self.save_user() def save_thread(self): self.thread.first_post = self.thread.last_post = self.post 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", + ), ] From 1b31dddc37e75e405653531a6d69fcc14f2a942e Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 24 Sep 2024 19:51:19 +0200 Subject: [PATCH 06/21] Commit WIP post reply code and new permissions --- misago/permissions/hooks/__init__.py | 2 + .../check_post_in_closed_thread_permission.py | 119 +++++++++++ .../hooks/check_reply_thread_permission.py | 130 ++++++++++++ .../tests/test_threads_permissions.py | 197 +++++++++++++++--- misago/permissions/threads/__init__.py | 2 + misago/permissions/threads/checks.py | 9 + misago/posting/forms/reply.py | 18 ++ misago/posting/state/reply.py | 3 +- misago/posting/views/reply.py | 54 +++++ 9 files changed, 505 insertions(+), 29 deletions(-) create mode 100644 misago/permissions/hooks/check_post_in_closed_thread_permission.py create mode 100644 misago/permissions/hooks/check_reply_thread_permission.py create mode 100644 misago/posting/forms/reply.py create mode 100644 misago/posting/views/reply.py diff --git a/misago/permissions/hooks/__init__.py b/misago/permissions/hooks/__init__.py index 3fc2bf5659..aa48ff9d79 100644 --- a/misago/permissions/hooks/__init__.py +++ b/misago/permissions/hooks/__init__.py @@ -8,6 +8,7 @@ check_post_in_closed_thread_permission_hook, ) from .check_private_threads_permission import check_private_threads_permission_hook +from .check_reply_thread_permission import check_reply_thread_permission_hook from .check_see_category_permission import check_see_category_permission_hook from .check_see_private_thread_permission import ( check_see_private_thread_permission_hook, @@ -46,6 +47,7 @@ "check_post_in_closed_category_permission_hook", "check_post_in_closed_thread_permission_hook", "check_private_threads_permission_hook", + "check_reply_thread_permission_hook", "check_see_category_permission_hook", "check_see_private_thread_permission_hook", "check_see_thread_permission_hook", diff --git a/misago/permissions/hooks/check_post_in_closed_thread_permission.py b/misago/permissions/hooks/check_post_in_closed_thread_permission.py new file mode 100644 index 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_thread_permission.py b/misago/permissions/hooks/check_reply_thread_permission.py new file mode 100644 index 0000000000..7cade6d826 --- /dev/null +++ b/misago/permissions/hooks/check_reply_thread_permission.py @@ -0,0 +1,130 @@ +from typing import TYPE_CHECKING, Protocol + +from ...categories.models import Category +from ...plugins.hooks import FilterHook +from ...threads.models import Thread + +if TYPE_CHECKING: + from ..proxy import UserPermissionsProxy + + +class CheckReplyThreadPermissionHookAction(Protocol): + """ + A standard Misago function used to check if the user has permission to + reply to a thread. It raises Django's `PermissionDenied` with an + error message if they can't reply to it. + + # Arguments + + ## `user_permissions: UserPermissionsProxy` + + A proxy object with the current user's permissions. + + ## `category: Category` + + A category to check permissions for. + + ## `thread: Thread` + + A thread to check permissions for. + """ + + def __call__( + self, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + ) -> None: ... + + +class CheckReplyThreadPermissionHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: CheckReplyThreadPermissionHookAction` + + A standard Misago function used to check if the user has permission to + reply to a thread. It raises Django's `PermissionDenied` with an + error message if they can't reply to it. + + See the [action](#action) section for details. + + ## `user_permissions: UserPermissionsProxy` + + A proxy object with the current user's permissions. + + ## `category: Category` + + A category to check permissions for. + + ## `thread: Thread` + + A thread to check permissions for. + """ + + def __call__( + self, + action: CheckReplyThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + ) -> None: ... + + +class CheckReplyThreadPermissionHook( + FilterHook[ + CheckReplyThreadPermissionHookAction, + CheckReplyThreadPermissionHookFilter, + ] +): + """ + This hook wraps the standard function that Misago uses to check if the user + has permission to reply to a thread. It raises Django's `PermissionDenied` + with an error message if they can't post in it. + + # Example + + The code below implements a custom filter function that prevents a user from + replying to a thread if they are thread's starter, but only in categories + with a plugin flag. + + ```python + from django.core.exceptions import PermissionDenied + from misago.categories.models import Category + from misago.permissions.hooks import check_reply_thread_permission_hook + from misago.permissions.proxy import UserPermissionsProxy + from misago.threads.models import Thread + + @check_reply_thread_permission_hook.append_filter + def check_user_can_post_in_closed_thread( + action, + permissions: UserPermissionsProxy, + category: Category, + thread: Thread, + ) -> None: + user = permissions.user + if ( + category.plugin_data.get("block_starters") + and user.is_authenticated and user.id == thread.starter_id + ): + raise PermissionDenied("You can't reply to threads you've started.") + + action(permissions, category, thread) + ``` + """ + + __slots__ = FilterHook.__slots__ + + def __call__( + self, + action: CheckReplyThreadPermissionHookAction, + permissions: "UserPermissionsProxy", + category: Category, + thread: Thread, + ) -> None: + return super().__call__(action, permissions, category, thread) + + +check_reply_thread_permission_hook = CheckReplyThreadPermissionHook() diff --git a/misago/permissions/tests/test_threads_permissions.py b/misago/permissions/tests/test_threads_permissions.py index 9f53cef3c1..3304325678 100644 --- a/misago/permissions/tests/test_threads_permissions.py +++ b/misago/permissions/tests/test_threads_permissions.py @@ -8,6 +8,7 @@ from ..threads import ( check_post_in_closed_category_permission, check_post_in_closed_thread_permission, + check_reply_thread_permission, check_see_thread_permission, check_start_thread_in_category_permission, ) @@ -127,60 +128,60 @@ def test_check_post_in_closed_thread_permission_fails_if_user_is_anonymous( check_post_in_closed_thread_permission(permissions, thread) -def test_check_start_thread_in_category_permission_passes_if_user_has_permission( - user, cache_versions, default_category +def test_check_reply_thread_permission_passes_if_user_has_permission( + user, cache_versions, default_category, thread ): permissions = UserPermissionsProxy(user, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_passes_if_anonymous_has_permission( - user, cache_versions, default_category +def test_check_reply_thread_permission_passes_if_anonymous_user_has_permission( + anonymous_user, cache_versions, default_category, thread ): - permissions = UserPermissionsProxy(user, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_fails_if_user_has_no_permission( - user, cache_versions, default_category +def test_check_reply_thread_permission_fails_if_user_has_no_permission( + user, cache_versions, default_category, thread ): CategoryGroupPermission.objects.filter( group=user.group, - permission=CategoryPermission.START, + permission=CategoryPermission.REPLY, ).delete() permissions = UserPermissionsProxy(user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_fails_if_anonymous_has_no_permission( - anonymous_user, guests_group, cache_versions, default_category +def test_check_reply_thread_permission_fails_if_anonymous_has_no_permission( + anonymous_user, guests_group, cache_versions, default_category, thread ): CategoryGroupPermission.objects.filter( group=guests_group, - permission=CategoryPermission.START, + permission=CategoryPermission.REPLY, ).delete() permissions = UserPermissionsProxy(anonymous_user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_passes_if_user_is_global_moderator( - moderator, cache_versions, default_category +def test_check_reply_thread_permission_in_closed_category_passes_if_user_is_global_moderator( + moderator, cache_versions, default_category, thread ): default_category.is_closed = True default_category.save() permissions = UserPermissionsProxy(moderator, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_passes_if_user_is_category_moderator( - user, cache_versions, default_category +def test_check_reply_thread_permission_in_closed_category_passes_if_user_is_category_moderator( + user, cache_versions, default_category, thread ): default_category.is_closed = True default_category.save() @@ -192,11 +193,11 @@ def test_check_start_thread_in_category_permission_passes_if_user_is_category_mo ) permissions = UserPermissionsProxy(user, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_fails_for_user_if_category_is_closed( - user, cache_versions, default_category +def test_check_reply_thread_permission_fails_for_user_if_category_is_closed( + user, cache_versions, default_category, thread ): default_category.is_closed = True default_category.save() @@ -204,11 +205,11 @@ def test_check_start_thread_in_category_permission_fails_for_user_if_category_is permissions = UserPermissionsProxy(user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_fails_for_anonymous_if_category_is_closed( - anonymous_user, cache_versions, default_category +def test_check_reply_thread_permission_fails_for_anonymous_if_category_is_closed( + anonymous_user, cache_versions, default_category, thread ): default_category.is_closed = True default_category.save() @@ -216,7 +217,57 @@ def test_check_start_thread_in_category_permission_fails_for_anonymous_if_catego permissions = UserPermissionsProxy(anonymous_user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_reply_thread_permission(permissions, default_category, thread) + + +def test_check_reply_thread_permission_in_closed_thread_passes_if_user_is_global_moderator( + moderator, cache_versions, default_category, thread +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_reply_thread_permission(permissions, default_category, thread) + + +def test_check_reply_thread_permission_in_closed_thread_passes_if_user_is_category_moderator( + user, cache_versions, default_category, thread +): + thread.is_closed = True + thread.save() + + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + permissions = UserPermissionsProxy(user, cache_versions) + check_reply_thread_permission(permissions, default_category, thread) + + +def test_check_reply_thread_permission_fails_for_user_if_thread_is_closed( + user, cache_versions, default_category, thread +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_reply_thread_permission(permissions, default_category, thread) + + +def test_check_reply_thread_permission_fails_for_anonymous_if_thread_is_closed( + anonymous_user, cache_versions, default_category, thread +): + thread.is_closed = True + thread.save() + + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + + with pytest.raises(PermissionDenied): + check_reply_thread_permission(permissions, default_category, thread) def test_check_see_thread_permission_passes_for_user_with_permission( @@ -611,3 +662,95 @@ def test_check_see_thread_permission_fails_for_anonymous_user_without_browse_per with pytest.raises(PermissionDenied): check_see_thread_permission(permissions, default_category, thread) + + +def test_check_start_thread_in_category_permission_passes_if_user_has_permission( + user, cache_versions, default_category +): + permissions = UserPermissionsProxy(user, cache_versions) + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_passes_if_anonymous_has_permission( + anonymous_user, cache_versions, default_category +): + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_fails_if_user_has_no_permission( + user, cache_versions, default_category +): + CategoryGroupPermission.objects.filter( + group=user.group, + permission=CategoryPermission.START, + ).delete() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_fails_if_anonymous_has_no_permission( + anonymous_user, guests_group, cache_versions, default_category +): + CategoryGroupPermission.objects.filter( + group=guests_group, + permission=CategoryPermission.START, + ).delete() + + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + + with pytest.raises(PermissionDenied): + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_passes_if_user_is_global_moderator( + moderator, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(moderator, cache_versions) + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_passes_if_user_is_category_moderator( + user, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + permissions = UserPermissionsProxy(user, cache_versions) + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_fails_for_user_if_category_is_closed( + user, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(user, cache_versions) + + with pytest.raises(PermissionDenied): + check_start_thread_in_category_permission(permissions, default_category) + + +def test_check_start_thread_in_category_permission_fails_for_anonymous_if_category_is_closed( + anonymous_user, cache_versions, default_category +): + default_category.is_closed = True + default_category.save() + + permissions = UserPermissionsProxy(anonymous_user, cache_versions) + + with pytest.raises(PermissionDenied): + check_start_thread_in_category_permission(permissions, default_category) diff --git a/misago/permissions/threads/__init__.py b/misago/permissions/threads/__init__.py index 999cf6eace..c79b5fcc65 100644 --- a/misago/permissions/threads/__init__.py +++ b/misago/permissions/threads/__init__.py @@ -1,6 +1,7 @@ from .checks import ( check_post_in_closed_category_permission, check_post_in_closed_thread_permission, + check_reply_thread_permission, check_see_thread_permission, check_start_thread_in_category_permission, ) @@ -16,6 +17,7 @@ "ThreadsQuerysetFilter", "check_post_in_closed_category_permission", "check_post_in_closed_thread_permission", + "check_reply_thread_permission", "check_see_thread_permission", "check_start_thread_in_category_permission", "filter_category_threads_queryset", diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index ed599480f8..0b809160b1 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -9,6 +9,7 @@ from ..hooks import ( check_post_in_closed_category_permission_hook, check_post_in_closed_thread_permission_hook, + check_reply_thread_permission_hook, check_see_thread_permission_hook, check_start_thread_in_category_permission_hook, ) @@ -141,6 +142,14 @@ def _check_see_thread_permission_action( def check_reply_thread_permission( permissions: UserPermissionsProxy, category: Category, thread: Thread +): + check_reply_thread_permission_hook( + _check_reply_thread_permission_action, permissions, category, thread + ) + + +def _check_reply_thread_permission_action( + permissions: UserPermissionsProxy, category: Category, thread: Thread ): if category.id not in permissions.categories[CategoryPermission.REPLY]: raise PermissionDenied( diff --git a/misago/posting/forms/reply.py b/misago/posting/forms/reply.py new file mode 100644 index 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/state/reply.py b/misago/posting/state/reply.py index 8743cd8934..020d878f19 100644 --- a/misago/posting/state/reply.py +++ b/misago/posting/state/reply.py @@ -6,7 +6,6 @@ from .base import PostingState - class ReplyThreadState(PostingState): def __init__(self, request: HttpRequest, thread: Thread): super().__init__(request) @@ -55,4 +54,4 @@ def save_user(self): class ReplyPrivateThreadState(ReplyThreadState): - pass \ No newline at end of file + pass diff --git a/misago/posting/views/reply.py b/misago/posting/views/reply.py new file mode 100644 index 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 From 4941555bcc34689ccd268a5384ccc1c51756ab1d Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 24 Sep 2024 19:53:53 +0200 Subject: [PATCH 07/21] Rename start_thread_in_category to start_thread --- misago/permissions/hooks/__init__.py | 6 ++-- ...on.py => check_start_thread_permission.py} | 8 ++--- .../tests/test_threads_permissions.py | 34 +++++++++---------- misago/permissions/threads/__init__.py | 4 +-- misago/permissions/threads/checks.py | 10 +++--- misago/posting/views/selectcategory.py | 6 ++-- misago/posting/views/start.py | 4 +-- misago/threads/views/list.py | 6 ++-- 8 files changed, 36 insertions(+), 42 deletions(-) rename misago/permissions/hooks/{check_start_thread_in_category_permission.py => check_start_thread_permission.py} (92%) diff --git a/misago/permissions/hooks/__init__.py b/misago/permissions/hooks/__init__.py index aa48ff9d79..ee7844db10 100644 --- a/misago/permissions/hooks/__init__.py +++ b/misago/permissions/hooks/__init__.py @@ -17,8 +17,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 @@ -52,7 +52,7 @@ "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_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/tests/test_threads_permissions.py b/misago/permissions/tests/test_threads_permissions.py index 3304325678..31b108228b 100644 --- a/misago/permissions/tests/test_threads_permissions.py +++ b/misago/permissions/tests/test_threads_permissions.py @@ -10,7 +10,7 @@ check_post_in_closed_thread_permission, check_reply_thread_permission, check_see_thread_permission, - check_start_thread_in_category_permission, + check_start_thread_permission, ) @@ -664,21 +664,21 @@ def test_check_see_thread_permission_fails_for_anonymous_user_without_browse_per check_see_thread_permission(permissions, default_category, thread) -def test_check_start_thread_in_category_permission_passes_if_user_has_permission( +def test_check_start_thread_permission_passes_if_user_has_permission( user, cache_versions, default_category ): permissions = UserPermissionsProxy(user, cache_versions) - check_start_thread_in_category_permission(permissions, default_category) + check_start_thread_permission(permissions, default_category) -def test_check_start_thread_in_category_permission_passes_if_anonymous_has_permission( +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_in_category_permission(permissions, default_category) + check_start_thread_permission(permissions, default_category) -def test_check_start_thread_in_category_permission_fails_if_user_has_no_permission( +def test_check_start_thread_permission_fails_if_user_has_no_permission( user, cache_versions, default_category ): CategoryGroupPermission.objects.filter( @@ -689,10 +689,10 @@ def test_check_start_thread_in_category_permission_fails_if_user_has_no_permissi permissions = UserPermissionsProxy(user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_start_thread_permission(permissions, default_category) -def test_check_start_thread_in_category_permission_fails_if_anonymous_has_no_permission( +def test_check_start_thread_permission_fails_if_anonymous_has_no_permission( anonymous_user, guests_group, cache_versions, default_category ): CategoryGroupPermission.objects.filter( @@ -703,20 +703,20 @@ def test_check_start_thread_in_category_permission_fails_if_anonymous_has_no_per permissions = UserPermissionsProxy(anonymous_user, cache_versions) with pytest.raises(PermissionDenied): - check_start_thread_in_category_permission(permissions, default_category) + check_start_thread_permission(permissions, default_category) -def test_check_start_thread_in_category_permission_passes_if_user_is_global_moderator( +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_in_category_permission(permissions, default_category) + check_start_thread_permission(permissions, default_category) -def test_check_start_thread_in_category_permission_passes_if_user_is_category_moderator( +def test_check_start_thread_permission_passes_if_user_is_category_moderator( user, cache_versions, default_category ): default_category.is_closed = True @@ -729,10 +729,10 @@ 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_start_thread_permission(permissions, default_category) -def test_check_start_thread_in_category_permission_fails_for_user_if_category_is_closed( +def test_check_start_thread_permission_fails_for_user_if_category_is_closed( user, cache_versions, default_category ): default_category.is_closed = True @@ -741,10 +741,10 @@ 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_start_thread_permission(permissions, default_category) -def test_check_start_thread_in_category_permission_fails_for_anonymous_if_category_is_closed( +def test_check_start_thread_permission_fails_for_anonymous_if_category_is_closed( anonymous_user, cache_versions, default_category ): default_category.is_closed = True @@ -753,4 +753,4 @@ 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_start_thread_permission(permissions, default_category) diff --git a/misago/permissions/threads/__init__.py b/misago/permissions/threads/__init__.py index c79b5fcc65..2ee23bb735 100644 --- a/misago/permissions/threads/__init__.py +++ b/misago/permissions/threads/__init__.py @@ -3,7 +3,7 @@ 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, @@ -19,7 +19,7 @@ "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 0b809160b1..6033ee9e7b 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -11,7 +11,7 @@ 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 @@ -66,17 +66,17 @@ def _check_post_in_closed_thread_permission_action( ) -def check_start_thread_in_category_permission( +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]: 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 bc833bb14a..70568ef8f2 100644 --- a/misago/posting/views/start.py +++ b/misago/posting/views/start.py @@ -13,7 +13,7 @@ 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, @@ -94,7 +94,7 @@ 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 diff --git a/misago/threads/views/list.py b/misago/threads/views/list.py index c18d5898d3..9e95c1fc84 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 @@ -1117,9 +1117,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: From 3ac4486f931f54ee964e4b8c0eb6fc67b7e5b31b Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 24 Sep 2024 20:00:10 +0200 Subject: [PATCH 08/21] Add reply private thread permission check --- misago/permissions/hooks/__init__.py | 4 ++++ .../hooks/check_reply_thread_permission.py | 4 ++-- misago/permissions/privatethreads.py | 15 +++++++++++++++ .../tests/test_private_threads_permissions.py | 8 ++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/misago/permissions/hooks/__init__.py b/misago/permissions/hooks/__init__.py index ee7844db10..209933e079 100644 --- a/misago/permissions/hooks/__init__.py +++ b/misago/permissions/hooks/__init__.py @@ -8,6 +8,9 @@ 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 ( @@ -47,6 +50,7 @@ "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", diff --git a/misago/permissions/hooks/check_reply_thread_permission.py b/misago/permissions/hooks/check_reply_thread_permission.py index 7cade6d826..77e8fd179f 100644 --- a/misago/permissions/hooks/check_reply_thread_permission.py +++ b/misago/permissions/hooks/check_reply_thread_permission.py @@ -87,7 +87,7 @@ class CheckReplyThreadPermissionHook( # Example The code below implements a custom filter function that prevents a user from - replying to a thread if they are thread's starter, but only in categories + replying to a thread if they are a thread starter, but only in categories with a plugin flag. ```python @@ -98,7 +98,7 @@ class CheckReplyThreadPermissionHook( from misago.threads.models import Thread @check_reply_thread_permission_hook.append_filter - def check_user_can_post_in_closed_thread( + def check_user_can_reply_in_thread( action, permissions: UserPermissionsProxy, category: Category, diff --git a/misago/permissions/privatethreads.py b/misago/permissions/privatethreads.py index ea02138c0e..d6a7f8f88e 100644 --- a/misago/permissions/privatethreads.py +++ b/misago/permissions/privatethreads.py @@ -6,6 +6,7 @@ from ..threads.models import Thread, ThreadParticipant from .hooks import ( 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 +72,20 @@ 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 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/tests/test_private_threads_permissions.py b/misago/permissions/tests/test_private_threads_permissions.py index 053abacb0a..ce31256dfb 100644 --- a/misago/permissions/tests/test_private_threads_permissions.py +++ b/misago/permissions/tests/test_private_threads_permissions.py @@ -6,6 +6,7 @@ from ...threads.test import post_thread from ..privatethreads import ( check_private_threads_permission, + check_reply_private_thread_permission, check_see_private_thread_permission, check_start_private_threads_permission, filter_private_thread_posts_queryset, @@ -61,6 +62,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 ): From 79b2a201f6f156741c9a8f5cf41883803f01810d Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 24 Sep 2024 20:37:53 +0200 Subject: [PATCH 09/21] WIP add new permissions to groups --- misago/admin/groups/forms.py | 19 +++ .../templates/misago/admin/groups/edit.html | 2 + misago/permissions/copy.py | 4 + .../check_reply_private_thread_permission.py | 112 ++++++++++++++++++ misago/permissions/user.py | 24 ++++ .../users/migrations/0027_new_permissions.py | 4 + .../users/migrations/0028_default_groups.py | 12 ++ misago/users/models/group.py | 6 + 8 files changed, 183 insertions(+) create mode 100644 misago/permissions/hooks/check_reply_private_thread_permission.py diff --git a/misago/admin/groups/forms.py b/misago/admin/groups/forms.py index 1892eedb85..12a87801b4 100644 --- a/misago/admin/groups/forms.py +++ b/misago/admin/groups/forms.py @@ -117,6 +117,19 @@ class EditGroupForm(forms.ModelForm): 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", "Limit of invited users"), + help_text=pgettext_lazy( + "admin group permissions form", + "Enter zero to don't limit username changes.", + ), + min_value=1, + ) can_change_username = YesNoSwitch( label=pgettext_lazy("admin group permissions form", "Can change username"), @@ -168,6 +181,12 @@ class Meta: "is_page", "is_hidden", "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/admin/templates/misago/admin/groups/edit.html b/misago/admin/templates/misago/admin/groups/edit.html index 99645337b7..31862c0d81 100644 --- a/misago/admin/templates/misago/admin/groups/edit.html +++ b/misago/admin/templates/misago/admin/groups/edit.html @@ -75,6 +75,8 @@ {% 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/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/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/user.py b/misago/permissions/user.py index 14059c93a7..f94b397c36 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_threads, + ) + 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/users/migrations/0027_new_permissions.py b/misago/users/migrations/0027_new_permissions.py index 95f1506dae..539445c6a8 100644 --- a/misago/users/migrations/0027_new_permissions.py +++ b/misago/users/migrations/0027_new_permissions.py @@ -37,6 +37,10 @@ class Migration(migrations.Migration): ("can_use_private_threads", models.BooleanField(default=False)), ("can_start_private_threads", models.BooleanField(default=False)), ("private_thread_users_limit", models.PositiveIntegerField(default=1)), + ("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_change_username", models.BooleanField(default=False)), ("username_changes_limit", models.PositiveIntegerField(default=0)), ("username_changes_expire", models.PositiveIntegerField(default=0)), diff --git a/misago/users/migrations/0028_default_groups.py b/misago/users/migrations/0028_default_groups.py index e2b8fd20c6..02161f44ec 100644 --- a/misago/users/migrations/0028_default_groups.py +++ b/misago/users/migrations/0028_default_groups.py @@ -28,6 +28,10 @@ def create_default_groups(apps, schema_editor): can_use_private_threads=True, can_start_private_threads=True, private_thread_users_limit=20, + can_edit_own_threads=True, + own_threads_edit_time_limit=0, + can_edit_own_posts=True, + own_posts_edit_time_limit=0, can_change_username=True, can_see_user_profiles=True, ), @@ -45,6 +49,10 @@ def create_default_groups(apps, schema_editor): can_use_private_threads=True, can_start_private_threads=True, private_thread_users_limit=20, + can_edit_own_threads=True, + own_threads_edit_time_limit=0, + can_edit_own_posts=True, + own_posts_edit_time_limit=0, can_change_username=True, can_see_user_profiles=True, ), @@ -59,6 +67,10 @@ def create_default_groups(apps, schema_editor): can_use_private_threads=True, can_start_private_threads=True, private_thread_users_limit=5, + can_edit_own_threads=True, + own_threads_edit_time_limit=0, + can_edit_own_posts=True, + own_posts_edit_time_limit=0, can_change_username=True, username_changes_limit=5, username_changes_expire=4, diff --git a/misago/users/models/group.py b/misago/users/models/group.py index 4a49dc691b..9ecbe68d7e 100644 --- a/misago/users/models/group.py +++ b/misago/users/models/group.py @@ -24,6 +24,12 @@ class Group(PluginDataModel): can_start_private_threads = models.BooleanField(default=False) private_thread_users_limit = models.PositiveIntegerField(default=1) + 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_change_username = models.BooleanField(default=False) username_changes_limit = models.PositiveIntegerField(default=0) username_changes_expire = models.PositiveIntegerField(default=0) From a0abf69f322dab3d0036b4b9c844bc6c4df96114 Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 24 Sep 2024 21:43:32 +0200 Subject: [PATCH 10/21] Add edit thread/post permissions --- misago/admin/groups/forms.py | 38 ++++++++++++++++--- .../templates/misago/admin/groups/edit.html | 18 +++++++++ misago/admin/tests/test_group_edit.py | 8 ++++ .../users/migrations/0027_new_permissions.py | 6 +-- .../users/migrations/0028_default_groups.py | 18 ++++----- misago/users/models/group.py | 8 ++-- 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/misago/admin/groups/forms.py b/misago/admin/groups/forms.py index 12a87801b4..acc1656dc0 100644 --- a/misago/admin/groups/forms.py +++ b/misago/admin/groups/forms.py @@ -114,6 +114,34 @@ 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"), ) @@ -123,10 +151,10 @@ class EditGroupForm(forms.ModelForm): ), ) private_thread_users_limit = forms.IntegerField( - label=pgettext_lazy("admin group permissions form", "Limit of invited users"), + label=pgettext_lazy("admin group permissions form", "Invited users limit"), help_text=pgettext_lazy( "admin group permissions form", - "Enter zero to don't limit username changes.", + "Enter the maximum number of users that can be invited to private threads started by members of this group.", ), min_value=1, ) @@ -180,13 +208,13 @@ class Meta: "css_suffix", "is_page", "is_hidden", - "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_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 31862c0d81..cc686c0568 100644 --- a/misago/admin/templates/misago/admin/groups/edit.html +++ b/misago/admin/templates/misago/admin/groups/edit.html @@ -70,6 +70,24 @@ +
+
+ {% 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" %} 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/users/migrations/0027_new_permissions.py b/misago/users/migrations/0027_new_permissions.py index 539445c6a8..e36ce26583 100644 --- a/misago/users/migrations/0027_new_permissions.py +++ b/misago/users/migrations/0027_new_permissions.py @@ -34,13 +34,13 @@ class Migration(migrations.Migration): ("is_hidden", models.BooleanField(default=False)), ("is_default", models.BooleanField(default=False)), ("ordering", 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)), ("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)), ("can_change_username", models.BooleanField(default=False)), ("username_changes_limit", models.PositiveIntegerField(default=0)), ("username_changes_expire", models.PositiveIntegerField(default=0)), diff --git a/misago/users/migrations/0028_default_groups.py b/misago/users/migrations/0028_default_groups.py index 02161f44ec..2735c68ef1 100644 --- a/misago/users/migrations/0028_default_groups.py +++ b/misago/users/migrations/0028_default_groups.py @@ -25,13 +25,13 @@ def create_default_groups(apps, schema_editor): is_page=True, ordering=0, # Permissions - can_use_private_threads=True, - can_start_private_threads=True, - private_thread_users_limit=20, 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, can_change_username=True, can_see_user_profiles=True, ), @@ -46,13 +46,13 @@ def create_default_groups(apps, schema_editor): is_page=True, ordering=1, # Permissions - can_use_private_threads=True, - can_start_private_threads=True, - private_thread_users_limit=20, 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, can_change_username=True, can_see_user_profiles=True, ), @@ -64,13 +64,13 @@ def create_default_groups(apps, schema_editor): is_default=True, ordering=2, # Permissions - can_use_private_threads=True, - can_start_private_threads=True, - private_thread_users_limit=5, 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, can_change_username=True, username_changes_limit=5, username_changes_expire=4, diff --git a/misago/users/models/group.py b/misago/users/models/group.py index 9ecbe68d7e..c9dc495cb8 100644 --- a/misago/users/models/group.py +++ b/misago/users/models/group.py @@ -20,16 +20,16 @@ class Group(PluginDataModel): ordering = 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) - 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) + can_change_username = models.BooleanField(default=False) username_changes_limit = models.PositiveIntegerField(default=0) username_changes_expire = models.PositiveIntegerField(default=0) From ab51b9a01b08f5e4a85ca4c712d176a289d0b54b Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 24 Sep 2024 22:09:21 +0200 Subject: [PATCH 11/21] WIP edit thread permission check --- misago/permissions/threads/checks.py | 38 +++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index 6033ee9e7b..6cd8fdbff4 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -1,6 +1,7 @@ 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 Post, Thread @@ -167,18 +168,20 @@ def check_edit_thread_permission( permissions: UserPermissionsProxy, category: Category, thread: Thread, - post: Post, ): check_post_in_closed_category_permission(permissions, category) check_post_in_closed_thread_permission(permissions, thread) - user_id = permissions.user.id - is_poster = user_id and post.poster_id and post.poster_id == user_id - - if not is_poster and not ( + if ( permissions.is_global_moderator or thread.category_id in permissions.categories_moderator ): + 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", @@ -186,6 +189,29 @@ def check_edit_thread_permission( ) ) + if not permissions.can_edit_own_threads: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit this thread.", + ) + ) + + time_limit = permissions.own_threads_edit_time_limit * 60 + + if ( + permissions.own_threads_edit_time_limit + and (timezone.now() - thread.started_on).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_thread_post_permission( permissions: UserPermissionsProxy, From fff5d09667c03b78cb33e6876774db2d68508bb0 Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 24 Sep 2024 22:17:39 +0200 Subject: [PATCH 12/21] Add plugin hook to check edit thread permission --- misago/permissions/hooks/__init__.py | 2 + .../hooks/check_edit_thread_permission.py | 131 ++++++++++++++++++ misago/permissions/threads/checks.py | 11 ++ 3 files changed, 144 insertions(+) create mode 100644 misago/permissions/hooks/check_edit_thread_permission.py diff --git a/misago/permissions/hooks/__init__.py b/misago/permissions/hooks/__init__.py index 209933e079..ba80430112 100644 --- a/misago/permissions/hooks/__init__.py +++ b/misago/permissions/hooks/__init__.py @@ -1,6 +1,7 @@ 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_thread_permission import check_edit_thread_permission_hook from .check_post_in_closed_category_permission import ( check_post_in_closed_category_permission_hook, ) @@ -47,6 +48,7 @@ "build_user_category_permissions_hook", "build_user_permissions_hook", "check_browse_category_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", 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..7f3b199f1c --- /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 the 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 thread.category_id in permissions.categories_moderator + ) + ): + 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/threads/checks.py b/misago/permissions/threads/checks.py index 6cd8fdbff4..7652143dfa 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -8,6 +8,7 @@ from ..categories import check_see_category_permission from ..enums import CategoryPermission from ..hooks import ( + check_edit_thread_permission_hook, check_post_in_closed_category_permission_hook, check_post_in_closed_thread_permission_hook, check_reply_thread_permission_hook, @@ -168,6 +169,16 @@ 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, ): check_post_in_closed_category_permission(permissions, category) check_post_in_closed_thread_permission(permissions, thread) From ea4e89dbd892854e495f9a372d362b7269835a4b Mon Sep 17 00:00:00 2001 From: rafalp Date: Wed, 25 Sep 2024 18:59:34 +0200 Subject: [PATCH 13/21] Add tests for edit thread permission check --- .../tests/test_threads_permissions.py | 144 ++++++++++++++++++ misago/permissions/threads/__init__.py | 2 + misago/permissions/threads/checks.py | 5 +- 3 files changed, 147 insertions(+), 4 deletions(-) diff --git a/misago/permissions/tests/test_threads_permissions.py b/misago/permissions/tests/test_threads_permissions.py index 31b108228b..fbefc6f638 100644 --- a/misago/permissions/tests/test_threads_permissions.py +++ b/misago/permissions/tests/test_threads_permissions.py @@ -6,6 +6,7 @@ from ..models import CategoryGroupPermission, Moderator from ..proxy import UserPermissionsProxy from ..threads import ( + check_edit_thread_permission, check_post_in_closed_category_permission, check_post_in_closed_thread_permission, check_reply_thread_permission, @@ -14,6 +15,149 @@ ) +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_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_in_closed_category_permission_passes_if_user_is_global_moderator( + 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_in_closed_category_permission_passes_if_user_is_category_moderator( + 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_closed_permission_passes_if_user_is_global_moderator( + 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_closed_permission_passes_if_user_is_category_moderator( + 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_out_of_time_permission_passes_if_user_is_global_moderator( + moderator, thread, cache_versions, default_category +): + 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_out_of_time_permission_passes_if_user_is_category_moderator( + user, thread, cache_versions, default_category +): + Moderator.objects.create( + user=user, + is_global=False, + categories=[default_category.id], + ) + + 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 ): diff --git a/misago/permissions/threads/__init__.py b/misago/permissions/threads/__init__.py index 2ee23bb735..691d94f266 100644 --- a/misago/permissions/threads/__init__.py +++ b/misago/permissions/threads/__init__.py @@ -1,4 +1,5 @@ from .checks import ( + check_edit_thread_permission, check_post_in_closed_category_permission, check_post_in_closed_thread_permission, check_reply_thread_permission, @@ -15,6 +16,7 @@ __all__ = [ "CategoryThreadsQuerysetFilter", "ThreadsQuerysetFilter", + "check_edit_thread_permission", "check_post_in_closed_category_permission", "check_post_in_closed_thread_permission", "check_reply_thread_permission", diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index 7652143dfa..acd519de20 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -210,10 +210,7 @@ def _check_edit_thread_permission_action( time_limit = permissions.own_threads_edit_time_limit * 60 - if ( - permissions.own_threads_edit_time_limit - and (timezone.now() - thread.started_on).seconds > time_limit - ): + if time_limit and (timezone.now() - thread.started_on).total_seconds() > time_limit: raise PermissionDenied( npgettext( "threads permission error", From 27e5ec1f76061e4845a1493844aa519de53d97b9 Mon Sep 17 00:00:00 2001 From: rafalp Date: Wed, 25 Sep 2024 19:16:50 +0200 Subject: [PATCH 14/21] Check edit post permission --- misago/permissions/hooks/__init__.py | 2 + .../hooks/check_edit_post_permission.py | 143 ++++++++++++++++++ .../hooks/check_edit_thread_permission.py | 2 +- misago/permissions/threads/checks.py | 56 ++++++- 4 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 misago/permissions/hooks/check_edit_post_permission.py diff --git a/misago/permissions/hooks/__init__.py b/misago/permissions/hooks/__init__.py index ba80430112..aa694362db 100644 --- a/misago/permissions/hooks/__init__.py +++ b/misago/permissions/hooks/__init__.py @@ -1,6 +1,7 @@ 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_thread_permission import check_edit_thread_permission_hook from .check_post_in_closed_category_permission import ( check_post_in_closed_category_permission_hook, @@ -48,6 +49,7 @@ "build_user_category_permissions_hook", "build_user_permissions_hook", "check_browse_category_permission_hook", + "check_edit_post_permission_hook", "check_edit_thread_permission_hook", "check_post_in_closed_category_permission_hook", "check_post_in_closed_thread_permission_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..3c1d7decf9 --- /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 thread.category_id in permissions.categories_moderator + ) + ): + 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_thread_permission.py b/misago/permissions/hooks/check_edit_thread_permission.py index 7f3b199f1c..939d7ed332 100644 --- a/misago/permissions/hooks/check_edit_thread_permission.py +++ b/misago/permissions/hooks/check_edit_thread_permission.py @@ -87,7 +87,7 @@ class CheckEditThreadPermissionHook( # Example The code below implements a custom filter function that prevents a user from - editing a thread if it's title contains the special prefix. + editing a thread if it's title contains a special prefix. ```python from django.core.exceptions import PermissionDenied diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index acd519de20..92c97fcf25 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -8,6 +8,7 @@ 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, @@ -221,7 +222,18 @@ def _check_edit_thread_permission_action( ) -def check_edit_thread_post_permission( +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, @@ -238,16 +250,50 @@ def check_edit_thread_post_permission( check_post_in_closed_category_permission(permissions, category) check_post_in_closed_thread_permission(permissions, thread) - user_id = permissions.user.id - is_poster = user_id and post.poster_id and post.poster_id == user_id - - if not is_poster and not ( + if ( permissions.is_global_moderator or thread.category_id in permissions.categories_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: + if post.is_unapproved: + raise Http404() + raise PermissionDenied( pgettext( "threads permission error", "You can't edit other users posts.", ) ) + + if post.is_hidden: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit hidden posts.", + ) + ) + + if not permissions.can_edit_own_posts: + raise PermissionDenied( + pgettext( + "threads permission error", + "You can't edit this post.", + ) + ) + + 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, + ) + ) From d585671600b605286a4bd9cba38283908ea139de Mon Sep 17 00:00:00 2001 From: rafalp Date: Wed, 25 Sep 2024 21:06:36 +0200 Subject: [PATCH 15/21] Add some tests for check edit post permission --- .../tests/test_threads_permissions.py | 114 ++++++++++++++++++ misago/permissions/threads/__init__.py | 2 + misago/permissions/threads/checks.py | 17 ++- misago/permissions/user.py | 2 +- 4 files changed, 128 insertions(+), 7 deletions(-) diff --git a/misago/permissions/tests/test_threads_permissions.py b/misago/permissions/tests/test_threads_permissions.py index fbefc6f638..b8936ffe9b 100644 --- a/misago/permissions/tests/test_threads_permissions.py +++ b/misago/permissions/tests/test_threads_permissions.py @@ -6,6 +6,7 @@ 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, @@ -15,6 +16,107 @@ ) +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_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_thread_permission_passes_if_user_is_starter( user, user_thread, cache_versions, default_category ): @@ -32,6 +134,18 @@ def test_check_edit_thread_permission_passes_if_user_is_starter_in_time_limit( check_edit_thread_permission(permissions, default_category, user_thread) +def test_check_edit_thread_permission_fails_if_user_has_no_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 ): diff --git a/misago/permissions/threads/__init__.py b/misago/permissions/threads/__init__.py index 691d94f266..abd0ee23e1 100644 --- a/misago/permissions/threads/__init__.py +++ b/misago/permissions/threads/__init__.py @@ -1,4 +1,5 @@ from .checks import ( + check_edit_post_permission, check_edit_thread_permission, check_post_in_closed_category_permission, check_post_in_closed_thread_permission, @@ -16,6 +17,7 @@ __all__ = [ "CategoryThreadsQuerysetFilter", "ThreadsQuerysetFilter", + "check_edit_post_permission", "check_edit_thread_permission", "check_post_in_closed_category_permission", "check_post_in_closed_thread_permission", diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index 92c97fcf25..8567949d47 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -205,7 +205,7 @@ def _check_edit_thread_permission_action( raise PermissionDenied( pgettext( "threads permission error", - "You can't edit this thread.", + "You can't edit threads.", ) ) @@ -260,9 +260,6 @@ def _check_edit_post_permission_action( is_poster = user_id and post.poster_id and post.poster_id == user_id if not is_poster: - if post.is_unapproved: - raise Http404() - raise PermissionDenied( pgettext( "threads permission error", @@ -270,6 +267,14 @@ def _check_edit_post_permission_action( ) ) + 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( @@ -278,11 +283,11 @@ def _check_edit_post_permission_action( ) ) - if not permissions.can_edit_own_posts: + if post.is_protected: raise PermissionDenied( pgettext( "threads permission error", - "You can't edit this post.", + "You can't edit protected posts.", ) ) diff --git a/misago/permissions/user.py b/misago/permissions/user.py index f94b397c36..87c7e99292 100644 --- a/misago/permissions/user.py +++ b/misago/permissions/user.py @@ -113,7 +113,7 @@ def _build_user_permissions_action(groups: list[Group]) -> dict: if_true( permissions, "can_edit_own_posts", - group.can_edit_own_threads, + group.can_edit_own_posts, ) if_zero_or_greater( permissions, From 03f4a704a367d462678ead2209defabf21ccffb7 Mon Sep 17 00:00:00 2001 From: rafalp Date: Wed, 25 Sep 2024 21:17:59 +0200 Subject: [PATCH 16/21] Complete tests for edit thread/reply permission checks --- .../tests/test_threads_permissions.py | 154 +++++++++++++++++- 1 file changed, 148 insertions(+), 6 deletions(-) diff --git a/misago/permissions/tests/test_threads_permissions.py b/misago/permissions/tests/test_threads_permissions.py index b8936ffe9b..c6331ecb77 100644 --- a/misago/permissions/tests/test_threads_permissions.py +++ b/misago/permissions/tests/test_threads_permissions.py @@ -117,6 +117,142 @@ def test_check_edit_post_permission_fails_if_post_is_hidden( 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 ): @@ -194,7 +330,7 @@ def test_check_edit_thread_permission_fails_if_thread_is_closed( check_edit_thread_permission(permissions, default_category, user_thread) -def test_check_edit_thread_in_closed_category_permission_passes_if_user_is_global_moderator( +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 @@ -204,7 +340,7 @@ def test_check_edit_thread_in_closed_category_permission_passes_if_user_is_globa check_edit_thread_permission(permissions, default_category, user_thread) -def test_check_edit_thread_in_closed_category_permission_passes_if_user_is_category_moderator( +def test_check_edit_thread_permission_passes_for_category_moderator_if_category_is_closed( user, thread, cache_versions, default_category ): Moderator.objects.create( @@ -220,7 +356,7 @@ def test_check_edit_thread_in_closed_category_permission_passes_if_user_is_categ check_edit_thread_permission(permissions, default_category, thread) -def test_check_edit_thread_closed_permission_passes_if_user_is_global_moderator( +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 @@ -230,7 +366,7 @@ def test_check_edit_thread_closed_permission_passes_if_user_is_global_moderator( check_edit_thread_permission(permissions, default_category, user_thread) -def test_check_edit_thread_closed_permission_passes_if_user_is_category_moderator( +def test_check_edit_thread_permission_passes_for_category_moderator_if_thread_is_closed( user, thread, cache_versions, default_category ): Moderator.objects.create( @@ -246,9 +382,12 @@ def test_check_edit_thread_closed_permission_passes_if_user_is_category_moderato check_edit_thread_permission(permissions, default_category, thread) -def test_check_edit_thread_out_of_time_permission_passes_if_user_is_global_moderator( +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() @@ -256,7 +395,7 @@ def test_check_edit_thread_out_of_time_permission_passes_if_user_is_global_moder check_edit_thread_permission(permissions, default_category, thread) -def test_check_edit_thread_out_of_time_permission_passes_if_user_is_category_moderator( +def test_check_edit_thread_permission_passes_for_category_moderator_if_out_of_time( user, thread, cache_versions, default_category ): Moderator.objects.create( @@ -265,6 +404,9 @@ def test_check_edit_thread_out_of_time_permission_passes_if_user_is_category_mod 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() From b2d39a50c03a734ca95cdb9591b1f4bd0f9b0530 Mon Sep 17 00:00:00 2001 From: rafalp Date: Thu, 26 Sep 2024 17:30:30 +0200 Subject: [PATCH 17/21] Add missing start/reply permission checks for edit thread/post --- misago/permissions/privatethreads.py | 14 +++++++- .../tests/test_threads_permissions.py | 34 +++++++++++++++++-- misago/permissions/threads/checks.py | 10 +++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/misago/permissions/privatethreads.py b/misago/permissions/privatethreads.py index d6a7f8f88e..cfd8b7dd91 100644 --- a/misago/permissions/privatethreads.py +++ b/misago/permissions/privatethreads.py @@ -3,7 +3,7 @@ from django.http import Http404 from django.utils.translation import pgettext -from ..threads.models import Thread, ThreadParticipant +from ..threads.models import Post, Thread, ThreadParticipant from .hooks import ( check_private_threads_permission_hook, check_reply_private_thread_permission_hook, @@ -86,6 +86,18 @@ def _check_reply_private_thread_permission_action( pass # NOOP +def check_edit_private_thread_permission( + permissions: UserPermissionsProxy, thread: Thread +): + pass + + +def check_edit_private_thread_post_permission( + permissions: UserPermissionsProxy, thread: Thread, post: Post +): + pass + + 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/tests/test_threads_permissions.py b/misago/permissions/tests/test_threads_permissions.py index c6331ecb77..a3c3fd04b7 100644 --- a/misago/permissions/tests/test_threads_permissions.py +++ b/misago/permissions/tests/test_threads_permissions.py @@ -33,7 +33,22 @@ def test_check_edit_post_permission_passes_if_user_is_poster_in_time_limit( check_edit_post_permission(permissions, default_category, thread, user_reply) -def test_check_edit_post_permission_fails_if_user_has_no_permission( +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 @@ -270,7 +285,22 @@ def test_check_edit_thread_permission_passes_if_user_is_starter_in_time_limit( check_edit_thread_permission(permissions, default_category, user_thread) -def test_check_edit_thread_permission_fails_if_user_has_no_permission( +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 diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index 8567949d47..bf5f3f4903 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -181,6 +181,14 @@ def _check_edit_thread_permission_action( 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) @@ -243,7 +251,7 @@ def _check_edit_post_permission_action( raise PermissionDenied( pgettext( "threads permission error", - "You can't edit replies in this category.", + "You can't edit posts in this category.", ) ) From 5046b201e9e747a1a1c0337f9aa6150b2a804d00 Mon Sep 17 00:00:00 2001 From: rafalp Date: Thu, 26 Sep 2024 21:47:12 +0200 Subject: [PATCH 18/21] Add private thread edit post permission check --- misago/categories/components.py | 2 +- misago/moderation/forms.py | 4 +- misago/permissions/hooks/__init__.py | 8 + .../hooks/check_edit_post_permission.py | 2 +- .../check_edit_private_thread_permission.py | 115 +++++++++++ ...eck_edit_private_thread_post_permission.py | 130 +++++++++++++ .../hooks/check_edit_thread_permission.py | 2 +- .../hooks/filter_private_threads_queryset.py | 2 +- misago/permissions/privatethreads.py | 104 +++++++++- misago/permissions/proxy.py | 20 +- .../templates/misago/permissions_panel.html | 6 +- .../tests/test_private_threads_permissions.py | 178 ++++++++++++++++++ .../tests/test_user_permissions_proxy.py | 15 +- misago/permissions/threads/checks.py | 25 +-- misago/permissions/threads/querysets.py | 30 +-- misago/threads/models/thread.py | 8 +- misago/threads/views/list.py | 24 +-- misago/threads/views/redirect.py | 7 +- 18 files changed, 590 insertions(+), 92 deletions(-) create mode 100644 misago/permissions/hooks/check_edit_private_thread_permission.py create mode 100644 misago/permissions/hooks/check_edit_private_thread_post_permission.py 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/hooks/__init__.py b/misago/permissions/hooks/__init__.py index aa694362db..33ca83c4a6 100644 --- a/misago/permissions/hooks/__init__.py +++ b/misago/permissions/hooks/__init__.py @@ -2,6 +2,12 @@ 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, @@ -50,6 +56,8 @@ "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", diff --git a/misago/permissions/hooks/check_edit_post_permission.py b/misago/permissions/hooks/check_edit_post_permission.py index 3c1d7decf9..d45e42ca58 100644 --- a/misago/permissions/hooks/check_edit_post_permission.py +++ b/misago/permissions/hooks/check_edit_post_permission.py @@ -120,7 +120,7 @@ def check_user_can_edit_thread( "[PROTECT]" in post.original and not ( permissions.is_global_moderator - or thread.category_id in permissions.categories_moderator + or permissions.is_category_moderator(thread.category_id) ) ): raise PermissionError("Only a moderator can edit this post.") 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 index 939d7ed332..b4255851ca 100644 --- a/misago/permissions/hooks/check_edit_thread_permission.py +++ b/misago/permissions/hooks/check_edit_thread_permission.py @@ -109,7 +109,7 @@ def check_user_can_edit_thread( thread.title.startswith("[MVP]") and not ( permissions.is_global_moderator - or thread.category_id in permissions.categories_moderator + or permissions.is_category_moderator(thread.category_id) ) ): raise PermissionError("Only a moderator can edit this thread.") 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 cfd8b7dd91..50cd7d3948 100644 --- a/misago/permissions/privatethreads.py +++ b/misago/permissions/privatethreads.py @@ -1,10 +1,15 @@ +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 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, @@ -89,13 +94,106 @@ def _check_reply_private_thread_permission_action( def check_edit_private_thread_permission( permissions: UserPermissionsProxy, thread: Thread ): - pass + 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 ): - pass + 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): 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 ce31256dfb..13bd61c915 100644 --- a/misago/permissions/tests/test_private_threads_permissions.py +++ b/misago/permissions/tests/test_private_threads_permissions.py @@ -4,7 +4,10 @@ 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, @@ -15,6 +18,181 @@ 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_private_threads_permission_passes_if_user_has_permission( user, cache_versions ): diff --git a/misago/permissions/tests/test_user_permissions_proxy.py b/misago/permissions/tests/test_user_permissions_proxy.py index b4c8613634..f13bdaaea3 100644 --- a/misago/permissions/tests/test_user_permissions_proxy.py +++ b/misago/permissions/tests/test_user_permissions_proxy.py @@ -150,7 +150,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 +168,7 @@ 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_proxy_returns_false_global_moderator_for_anonymous_user( @@ -180,6 +180,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 +196,4 @@ 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 diff --git a/misago/permissions/threads/checks.py b/misago/permissions/threads/checks.py index bf5f3f4903..58d7dfee81 100644 --- a/misago/permissions/threads/checks.py +++ b/misago/permissions/threads/checks.py @@ -32,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", @@ -57,10 +54,7 @@ def check_post_in_closed_thread_permission( def _check_post_in_closed_thread_permission_action( permissions: UserPermissionsProxy, thread: Thread ): - if thread.is_closed and not ( - permissions.is_global_moderator - or thread.category_id in permissions.categories_moderator - ): + if thread.is_closed and not permissions.is_category_moderator(thread.category_id): raise PermissionDenied( pgettext( "threads permission error", @@ -104,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() @@ -192,10 +183,7 @@ def _check_edit_thread_permission_action( check_post_in_closed_category_permission(permissions, category) check_post_in_closed_thread_permission(permissions, thread) - if ( - permissions.is_global_moderator - or thread.category_id in permissions.categories_moderator - ): + if permissions.is_category_moderator(thread.category_id): return user_id = permissions.user.id @@ -258,10 +246,7 @@ def _check_edit_post_permission_action( check_post_in_closed_category_permission(permissions, category) check_post_in_closed_thread_permission(permissions, thread) - if ( - permissions.is_global_moderator - or thread.category_id in permissions.categories_moderator - ): + if permissions.is_category_moderator(thread.category_id): return user_id = permissions.user.id 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/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 9e95c1fc84..f6fbffdd81 100644 --- a/misago/threads/views/list.py +++ b/misago/threads/views/list.py @@ -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 @@ -1168,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): @@ -1352,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() From d4c150b4c852e516ad16726be07050cd74bf74ab Mon Sep 17 00:00:00 2001 From: rafalp Date: Thu, 26 Sep 2024 21:58:05 +0200 Subject: [PATCH 19/21] Edit private thread permission check --- .../tests/test_private_threads_permissions.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/misago/permissions/tests/test_private_threads_permissions.py b/misago/permissions/tests/test_private_threads_permissions.py index 13bd61c915..bb09cdeb86 100644 --- a/misago/permissions/tests/test_private_threads_permissions.py +++ b/misago/permissions/tests/test_private_threads_permissions.py @@ -193,6 +193,94 @@ def test_check_edit_private_thread_post_permission_passes_for_private_threads_mo ) +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 ): From d25e4c2689d1ec07da8c3c0c8f3cec17e646b09d Mon Sep 17 00:00:00 2001 From: rafalp Date: Thu, 26 Sep 2024 22:06:49 +0200 Subject: [PATCH 20/21] Test is_private_threads_moderator --- .../tests/test_user_permissions_proxy.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/misago/permissions/tests/test_user_permissions_proxy.py b/misago/permissions/tests/test_user_permissions_proxy.py index f13bdaaea3..504794c239 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 ): From 54bc2e8da375f9bcf7ad8064661707790245f5f7 Mon Sep 17 00:00:00 2001 From: rafalp Date: Thu, 26 Sep 2024 22:30:32 +0200 Subject: [PATCH 21/21] Add tests for is_category_moderator --- .../tests/test_user_permissions_proxy.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/misago/permissions/tests/test_user_permissions_proxy.py b/misago/permissions/tests/test_user_permissions_proxy.py index 504794c239..5e3dace451 100644 --- a/misago/permissions/tests/test_user_permissions_proxy.py +++ b/misago/permissions/tests/test_user_permissions_proxy.py @@ -226,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 ): @@ -267,6 +296,86 @@ def test_user_permissions_proxy_excludes_not_browseable_categories_from_moderate 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( django_assert_num_queries, db, anonymous_user, cache_versions ): @@ -293,3 +402,13 @@ def test_user_permissions_proxy_returns_no_moderated_categories_for_anonymous_us with django_assert_num_queries(0): 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)