diff --git a/misago/posting/states/start.py b/misago/posting/states/start.py index 18af26eb0d..f32163e78b 100644 --- a/misago/posting/states/start.py +++ b/misago/posting/states/start.py @@ -64,12 +64,12 @@ def set_thread_title(self, title: str): @transaction.atomic() def save(self): - self.save_action() - - def save_action(self): self.thread.save() self.post.save() + self.save_action() + + def save_action(self): self.save_thread() self.save_post() diff --git a/misago/posting/tests/test_select_category_view.py b/misago/posting/tests/test_select_category_view.py new file mode 100644 index 0000000000..19ce07a089 --- /dev/null +++ b/misago/posting/tests/test_select_category_view.py @@ -0,0 +1,519 @@ +import pytest +from django.urls import reverse + +from ...categories.models import Category +from ...permissions.enums import CategoryPermission +from ...permissions.models import CategoryGroupPermission +from ...test import assert_contains, assert_not_contains +from ...testutils import grant_category_group_permissions + + +@pytest.fixture +def sibling_category(root_category, guests_group, members_group): + category = Category(name="Sibling Category", slug="sibling-category") + category.insert_at(root_category, position="last-child", save=True) + + grant_category_group_permissions( + category, + guests_group, + CategoryPermission.SEE, + CategoryPermission.BROWSE, + CategoryPermission.START, + ) + grant_category_group_permissions( + category, + members_group, + CategoryPermission.SEE, + CategoryPermission.BROWSE, + CategoryPermission.START, + ) + + return category + + +@pytest.fixture +def child_category(default_category, guests_group, members_group): + category = Category(name="Sibling Category", slug="sibling-category") + category.insert_at(default_category, position="last-child", save=True) + + grant_category_group_permissions( + category, + guests_group, + CategoryPermission.SEE, + CategoryPermission.BROWSE, + CategoryPermission.START, + ) + grant_category_group_permissions( + category, + members_group, + CategoryPermission.SEE, + CategoryPermission.BROWSE, + CategoryPermission.START, + ) + + return category + + +def test_select_category_view_displays_error_page_if_guest_cant_start_thread_in_any_category( + client, default_category +): + response = client.get(reverse("misago:start-thread")) + assert_contains(response, "You can't start new threads.", status_code=403) + + +def test_select_category_view_displays_error_page_if_user_cant_start_thread_in_any_category( + user_client, default_category +): + CategoryGroupPermission.objects.filter( + category=default_category, permission=CategoryPermission.START + ).delete() + + response = user_client.get(reverse("misago:start-thread")) + assert_contains(response, "You can't start new threads.", status_code=403) + + +def test_select_category_view_displays_error_message_in_htmx_if_guest_cant_start_thread_in_any_category( + client, default_category +): + response = client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains(response, "You can't start new threads.") + + +def test_select_category_view_displays_error_message_in_htmx_if_user_cant_start_thread_in_any_category( + user_client, default_category +): + CategoryGroupPermission.objects.filter( + category=default_category, permission=CategoryPermission.START + ).delete() + + response = user_client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains(response, "You can't start new threads.") + + +def test_select_category_view_displays_category_if_guest_can_start_thread_in_it( + client, guests_group, default_category +): + CategoryGroupPermission.objects.create( + category=default_category, + group=guests_group, + permission=CategoryPermission.START, + ) + + response = client.get(reverse("misago:start-thread")) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_displays_category_if_user_can_start_thread_in_it( + user_client, default_category +): + response = user_client.get(reverse("misago:start-thread")) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_displays_category_in_htmx_if_guest_can_start_thread_in_it( + client, guests_group, default_category +): + CategoryGroupPermission.objects.create( + category=default_category, + group=guests_group, + permission=CategoryPermission.START, + ) + + response = client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_displays_category_in_htmx_if_user_can_start_thread_in_it( + user_client, default_category +): + response = user_client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_excludes_category_if_guest_cant_start_thread_in_it( + client, guests_group, default_category, sibling_category +): + response = client.get(reverse("misago:start-thread")) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": sibling_category.id, "slug": sibling_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_excludes_category_in_htmx_if_guest_cant_start_thread_in_it( + client, guests_group, default_category, sibling_category +): + response = client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": sibling_category.id, "slug": sibling_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_excludes_category_if_user_cant_start_thread_in_it( + user_client, default_category, sibling_category +): + CategoryGroupPermission.objects.filter( + category=default_category, permission=CategoryPermission.START + ).delete() + + response = user_client.get(reverse("misago:start-thread")) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": sibling_category.id, "slug": sibling_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_excludes_category_in_htmx_if_user_cant_start_thread_in_it( + user_client, default_category, sibling_category +): + CategoryGroupPermission.objects.filter( + category=default_category, permission=CategoryPermission.START + ).delete() + + response = user_client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": sibling_category.id, "slug": sibling_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_excludes_empty_vanilla_category( + user_client, default_category, sibling_category +): + default_category.is_vanilla = True + default_category.save() + + response = user_client.get(reverse("misago:start-thread")) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": sibling_category.id, "slug": sibling_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_excludes_empty_vanilla_category_in_htmx( + user_client, default_category, sibling_category +): + default_category.is_vanilla = True + default_category.save() + + response = user_client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": sibling_category.id, "slug": sibling_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_includes_vanilla_category_with_children( + user_client, default_category, child_category +): + default_category.is_vanilla = True + default_category.save() + + response = user_client.get(reverse("misago:start-thread")) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": child_category.id, "slug": child_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_includes_vanilla_category_with_children_in_htmx( + user_client, default_category, child_category +): + default_category.is_vanilla = True + default_category.save() + + response = user_client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": child_category.id, "slug": child_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_excludes_child_category_if_user_cant_start_thread_in_it( + user_client, default_category, child_category +): + CategoryGroupPermission.objects.filter( + category=child_category, permission=CategoryPermission.START + ).delete() + + response = user_client.get(reverse("misago:start-thread")) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": child_category.id, "slug": child_category.slug}, + ), + ) + + +def test_select_category_view_excludes_child_category_in_htmx_if_user_cant_start_thread_in_it( + user_client, default_category, child_category +): + CategoryGroupPermission.objects.filter( + category=child_category, permission=CategoryPermission.START + ).delete() + + response = user_client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": child_category.id, "slug": child_category.slug}, + ), + ) + + +def test_select_category_view_includes_closed_category_if_user_can_post_in_it( + user, user_client, default_category, members_group, moderators_group +): + default_category.is_closed = True + default_category.save() + + user.set_groups(members_group, [moderators_group]) + user.save() + + response = user_client.get(reverse("misago:start-thread")) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_includes_closed_category_in_htmx_if_user_can_post_in_it( + user, user_client, default_category, members_group, moderators_group +): + default_category.is_closed = True + default_category.save() + + user.set_groups(members_group, [moderators_group]) + user.save() + + response = user_client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + + +def test_select_category_view_excludes_closed_category_if_user_cant_post_in_it( + user_client, default_category, sibling_category +): + sibling_category.is_closed = True + sibling_category.save() + + response = user_client.get(reverse("misago:start-thread")) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": sibling_category.id, "slug": sibling_category.slug}, + ), + ) + + +def test_select_category_view_excludes_closed_category_in_htmx_if_user_cant_post_in_it( + user_client, default_category, sibling_category +): + sibling_category.is_closed = True + sibling_category.save() + + response = user_client.get( + reverse("misago:start-thread"), + headers={"hx-request": "true"}, + ) + assert_contains(response, "Start new thread in") + assert_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ), + ) + assert_not_contains( + response, + reverse( + "misago:start-thread", + kwargs={"id": sibling_category.id, "slug": sibling_category.slug}, + ), + ) diff --git a/misago/posting/tests/test_start_thread_view.py b/misago/posting/tests/test_start_category_thread_view.py similarity index 88% rename from misago/posting/tests/test_start_thread_view.py rename to misago/posting/tests/test_start_category_thread_view.py index dfde5fa9fe..a8c1367b49 100644 --- a/misago/posting/tests/test_start_thread_view.py +++ b/misago/posting/tests/test_start_category_thread_view.py @@ -104,6 +104,24 @@ def test_start_thread_view_displays_form_page_to_users(user_client, default_cate assert_contains(response, "Start new thread") +def test_start_thread_view_displays_form_page_to_users_with_permission_to_post_in_closed_category( + user, user_client, default_category, members_group, moderators_group +): + default_category.is_closed = True + default_category.save() + + user.set_groups(members_group, [moderators_group]) + user.save() + + response = user_client.get( + reverse( + "misago:start-thread", + kwargs={"id": default_category.id, "slug": default_category.slug}, + ) + ) + assert_contains(response, "Start new thread") + + def test_start_thread_view_posts_new_thread(user_client, default_category): response = user_client.post( reverse( diff --git a/misago/posting/urls.py b/misago/posting/urls.py index cdd015456b..9cdea4a4d9 100644 --- a/misago/posting/urls.py +++ b/misago/posting/urls.py @@ -1,16 +1,13 @@ from django.urls import path -from .views.start import ( - StartPrivateThreadView, - StartThreadView, - StartThreadSelectCategoryView, -) +from .views.selectcategory import SelectCategoryView +from .views.start import StartPrivateThreadView, StartThreadView urlpatterns = [ path( "start-thread/", - StartThreadSelectCategoryView.as_view(), + SelectCategoryView.as_view(), name="start-thread", ), path( diff --git a/misago/posting/views/selectcategory.py b/misago/posting/views/selectcategory.py new file mode 100644 index 0000000000..ad61211d48 --- /dev/null +++ b/misago/posting/views/selectcategory.py @@ -0,0 +1,87 @@ +from django.core.exceptions import PermissionDenied +from django.http import Http404, HttpRequest, HttpResponse +from django.views import View +from django.shortcuts import render +from django.urls import reverse +from django.utils.translation import pgettext + +from ...categories.models import Category +from ...permissions.threads import check_start_thread_in_category_permission + + +class SelectCategoryView(View): + template_name = "misago/posting/select_category_page.html" + template_name_htmx = "misago/posting/select_category_modal.html" + + def get(self, request: HttpRequest) -> HttpResponse: + choices = self.get_category_choices(request) + + if request.is_htmx: + template_name = self.template_name_htmx + else: + template_name = self.template_name + + if not choices and not request.is_htmx: + raise PermissionDenied( + pgettext( + "start thread page", + "You can't start new threads.", + ) + ) + + return render(request, template_name, {"start_thread_choices": choices}) + + def get_category_choices(self, request: HttpRequest) -> list[dict]: + queryset = Category.objects.filter( + id__in=list(request.categories.categories), + ).order_by("lft") + + choices: list[dict] = [] + for category in queryset: + try: + check_start_thread_in_category_permission( + request.user_permissions, category + ) + except (Http404, PermissionDenied): + has_permission = False + else: + has_permission = True + + choice = { + "id": category.id, + "name": category.name, + "description": category.description, + "color": category.color, + "level": "", + "is_vanilla": category.is_vanilla, + "disabled": category.is_vanilla or not has_permission, + "url": reverse( + "misago:start-thread", + kwargs={"id": category.id, "slug": category.slug}, + ), + "category": category, + } + + if category.level == 1: + choice["children"] = [] + choices.append(choice) + else: + parent = choices[-1] + choice["level"] = "1" * (category.level - 1) + parent["children"].append(choice) + + # Remove branches where entire branch is disabled + clean_choices: list[dict] = [] + for category in choices: + clean_children: list[dict] = [] + for child in reversed(category["children"]): + if not child["disabled"] or ( + clean_children and clean_children[-1]["level"] > child["level"] + ): + clean_children.append(child) + + if not category["disabled"] or clean_children: + category["children"] = list(reversed(clean_children)) + clean_choices.append(category) + + return clean_choices diff --git a/misago/posting/views/start.py b/misago/posting/views/start.py index 619203a0af..c257ae430a 100644 --- a/misago/posting/views/start.py +++ b/misago/posting/views/start.py @@ -1,5 +1,3 @@ -from django.core.exceptions import PermissionDenied -from django.forms import Form from django.http import Http404, HttpRequest, HttpResponse from django.views import View from django.shortcuts import redirect, render @@ -155,81 +153,3 @@ def get_thread_url(self, request: HttpRequest, thread: Thread) -> str: "misago:private-thread", kwargs={"pk": thread.id, "slug": thread.slug}, ) - - -class StartThreadSelectCategoryView(View): - template_name = "misago/posting/select_category_page.html" - template_name_htmx = "misago/posting/select_category_modal.html" - - def get(self, request: HttpRequest) -> HttpResponse: - choices = self.get_category_choices(request) - - if request.is_htmx: - template_name = self.template_name_htmx - else: - template_name = self.template_name - - if not choices and not request.is_htmx: - raise PermissionDenied( - pgettext( - "start thread page", - "You can't start new threads.", - ) - ) - - return render(request, template_name, {"start_thread_choices": choices}) - - def get_category_choices(self, request: HttpRequest) -> list[dict]: - queryset = Category.objects.filter( - id__in=list(request.categories.categories), - ).order_by("lft") - - choices: list[dict] = [] - for category in queryset: - try: - check_start_thread_in_category_permission( - request.user_permissions, category - ) - except (Http404, PermissionDenied): - has_permission = False - else: - has_permission = True - - choice = { - "id": category.id, - "name": category.name, - "description": category.description, - "color": category.color, - "level": "", - "is_vanilla": category.is_vanilla, - "disabled": category.is_vanilla or not has_permission, - "url": reverse( - "misago:start-thread", - kwargs={"id": category.id, "slug": category.slug}, - ), - "category": category, - } - - if category.level == 1: - choice["children"] = [] - choices.append(choice) - else: - parent = choices[-1] - choice["level"] = "1" * (category.level - 1) - parent["children"].append(choice) - - # Remove branches where entire branch is disabled - clean_choices: list[dict] = [] - for category in choices: - clean_children: list[dict] = [] - for child in reversed(category["children"]): - if not child["disabled"] or ( - clean_children and clean_children[-1]["level"] > child["level"] - ): - clean_children.append(child) - - if not category["disabled"] or clean_children: - category["children"] = list(reversed(clean_children)) - clean_choices.append(category) - - return clean_choices