Переглянути джерело

Add unpublish view and action menu item for snippets with DraftStateMixin

Sage Abdullah 2 роки тому
батько
коміт
6d3ea0cb3e

+ 26 - 2
wagtail/snippets/action_menu.py

@@ -75,6 +75,27 @@ class PublishMenuItem(ActionMenuItem):
         return context["request"].user.has_perm(publish_permission)
 
 
+class UnpublishMenuItem(ActionMenuItem):
+    label = _("Unpublish")
+    name = "action-unpublish"
+    icon_name = "download-alt"
+    classname = "action-secondary"
+
+    def is_shown(self, context):
+        if context["view"] == "edit" and context["instance"].live:
+            publish_permission = get_permission_name("publish", context["model"])
+            return context["request"].user.has_perm(publish_permission)
+        return False
+
+    def get_url(self, context):
+        app_label = context["model"]._meta.app_label
+        model_name = context["model"]._meta.model_name
+        return reverse(
+            f"wagtailsnippets_{app_label}_{model_name}:unpublish",
+            args=[quote(context["instance"].pk)],
+        )
+
+
 class DeleteMenuItem(ActionMenuItem):
     name = "action-delete"
     label = _("Delete")
@@ -112,10 +133,13 @@ def get_base_snippet_action_menu_items(model):
     """
     menu_items = [
         SaveMenuItem(order=0),
-        DeleteMenuItem(order=20),
+        DeleteMenuItem(order=10),
     ]
     if issubclass(model, DraftStateMixin):
-        menu_items += [PublishMenuItem(order=10)]
+        menu_items += [
+            UnpublishMenuItem(order=20),
+            PublishMenuItem(order=30),
+        ]
 
     for hook in hooks.get_hooks("register_snippet_action_menu_item"):
         action_menu_item = hook(model)

+ 181 - 3
wagtail/snippets/tests/test_snippets.py

@@ -1,5 +1,6 @@
 import datetime
 import json
+from unittest import mock
 
 from django.contrib.admin.utils import quote
 from django.contrib.auth import get_user_model
@@ -24,6 +25,7 @@ from wagtail.admin.forms import WagtailAdminModelForm
 from wagtail.admin.panels import FieldPanel, ObjectList, Panel, get_edit_handler
 from wagtail.blocks.field_block import FieldBlockAdapter
 from wagtail.models import Locale, ModelLogEntry, Page, Revision
+from wagtail.signals import unpublished
 from wagtail.snippets.action_menu import (
     ActionMenuItem,
     get_base_snippet_action_menu_items,
@@ -912,6 +914,11 @@ class TestCreateDraftStateSnippet(TestCase, WagtailTestUtils):
             '<div class="form-side__panel" data-side-panel="status">',
         )
 
+        # Should not show the Unpublish action menu item
+        unpublish_url = "/admin/snippets/tests/draftstatemodel/unpublish/"
+        self.assertNotContains(response, unpublish_url)
+        self.assertNotContains(response, "Unpublish")
+
     def test_save_draft(self):
         response = self.post(post_data={"text": "Draft-enabled Foo"})
         snippet = DraftStateModel.objects.get(text="Draft-enabled Foo")
@@ -1343,6 +1350,17 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
             '<button type="submit" name="action-publish" value="action-publish" class="button action-save button-longrunning" data-clicked-text="Publishing…">',
         )
 
+        # Should not show the Unpublish action menu item
+        unpublish_url = reverse(
+            "wagtailsnippets_tests_draftstatemodel:unpublish",
+            args=(quote(self.test_snippet.pk),),
+        )
+        self.assertNotContains(
+            response,
+            f'<a class="button action-secondary" href="{unpublish_url}">',
+        )
+        self.assertNotContains(response, "Unpublish")
+
     def test_save_draft(self):
         response = self.post(post_data={"text": "Draft-enabled Bar"})
         self.test_snippet.refresh_from_db()
@@ -1574,6 +1592,17 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
             html=True,
         )
 
+        # Should not show the Unpublish action menu item
+        unpublish_url = reverse(
+            "wagtailsnippets_tests_draftstatemodel:unpublish",
+            args=(quote(self.test_snippet.pk),),
+        )
+        self.assertNotContains(
+            response,
+            f'<a class="button action-secondary" href="{unpublish_url}">',
+        )
+        self.assertNotContains(response, "Unpublish")
+
     def test_get_after_publish(self):
         self.post(
             post_data={
@@ -1599,6 +1628,17 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
             html=True,
         )
 
+        # Should show the Unpublish action menu item
+        unpublish_url = reverse(
+            "wagtailsnippets_tests_draftstatemodel:unpublish",
+            args=(quote(self.test_snippet.pk),),
+        )
+        self.assertContains(
+            response,
+            f'<a class="button action-secondary" href="{unpublish_url}">',
+        )
+        self.assertContains(response, "Unpublish")
+
     def test_get_after_publish_and_save_draft(self):
         self.post(
             post_data={
@@ -1626,6 +1666,17 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
             html=True,
         )
 
+        # Should show the Unpublish action menu item
+        unpublish_url = reverse(
+            "wagtailsnippets_tests_draftstatemodel:unpublish",
+            args=(quote(self.test_snippet.pk),),
+        )
+        self.assertContains(
+            response,
+            f'<a class="button action-secondary" href="{unpublish_url}">',
+        )
+        self.assertContains(response, "Unpublish")
+
         # Should use the latest draft content for the title
         self.assertContains(
             response,
@@ -1641,6 +1692,122 @@ class TestEditDraftStateSnippet(BaseTestSnippetEditView):
         )
 
 
+class TestSnippetUnpublish(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.user = self.login()
+        self.snippet = DraftStateModel.objects.create(text="to be unpublished")
+        self.unpublish_url = reverse(
+            "wagtailsnippets_tests_draftstatemodel:unpublish",
+            args=(quote(self.snippet.pk),),
+        )
+
+    def test_unpublish_view(self):
+        """
+        This tests that the unpublish view responds with an unpublish confirm page
+        """
+        # Get unpublish page
+        response = self.client.get(self.unpublish_url)
+
+        # Check that the user received an unpublish confirm page
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, "wagtailadmin/shared/confirm_unpublish.html")
+
+    def test_unpublish_view_invalid_pk(self):
+        """
+        This tests that the unpublish view returns an error if the object pk is invalid
+        """
+        # Get unpublish page
+        response = self.client.get(
+            reverse(
+                "wagtailsnippets_tests_draftstatemodel:unpublish", args=(quote(12345),)
+            )
+        )
+
+        # Check that the user received a 404 response
+        self.assertEqual(response.status_code, 404)
+
+    def test_unpublish_view_bad_permissions(self):
+        """
+        This tests that the unpublish view doesn't allow users without unpublish permissions
+        """
+        # Remove privileges from user
+        self.user.is_superuser = False
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
+        )
+        self.user.save()
+
+        # Get unpublish page
+        response = self.client.get(self.unpublish_url)
+
+        # Check that the user received a 302 redirected response
+        self.assertEqual(response.status_code, 302)
+
+    def test_unpublish_view_post(self):
+        """
+        This posts to the unpublish view and checks that the object was unpublished
+        """
+        # Connect a mock signal handler to unpublished signal
+        mock_handler = mock.MagicMock()
+        unpublished.connect(mock_handler)
+
+        # Post to the unpublish view
+        response = self.client.post(self.unpublish_url)
+
+        # Should be redirected to the listing page
+        self.assertRedirects(
+            response, reverse("wagtailsnippets_tests_draftstatemodel:list")
+        )
+
+        # Check that the object was unpublished
+        self.assertFalse(DraftStateModel.objects.get(pk=self.snippet.pk).live)
+
+        # Check that the unpublished signal was fired
+        self.assertEqual(mock_handler.call_count, 1)
+        mock_call = mock_handler.mock_calls[0][2]
+
+        self.assertEqual(mock_call["sender"], DraftStateModel)
+        self.assertEqual(mock_call["instance"], self.snippet)
+        self.assertIsInstance(mock_call["instance"], DraftStateModel)
+
+    def test_after_unpublish_hook(self):
+        def hook_func(request, snippet):
+            self.assertIsInstance(request, HttpRequest)
+            self.assertEqual(snippet.pk, self.snippet.pk)
+
+            return HttpResponse("Overridden!")
+
+        with self.register_hook("after_unpublish", hook_func):
+            post_data = {}
+            response = self.client.post(self.unpublish_url, post_data)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.content, b"Overridden!")
+
+        self.snippet.refresh_from_db()
+        self.assertEqual(self.snippet.status_string, "draft")
+
+    def test_before_unpublish(self):
+        def hook_func(request, snippet):
+            self.assertIsInstance(request, HttpRequest)
+            self.assertEqual(snippet.pk, self.snippet.pk)
+
+            return HttpResponse("Overridden!")
+
+        with self.register_hook("before_unpublish", hook_func):
+            post_data = {}
+            response = self.client.post(self.unpublish_url, post_data)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.content, b"Overridden!")
+
+        # The hook response is served before unpublish is called.
+        self.snippet.refresh_from_db()
+        self.assertEqual(self.snippet.status_string, "live")
+
+
 class TestSnippetDelete(TestCase, WagtailTestUtils):
     fixtures = ["test.json"]
 
@@ -1709,7 +1876,7 @@ class TestSnippetDelete(TestCase, WagtailTestUtils):
             )
         )
 
-        # Should be redirected to explorer page
+        # Should be redirected to the listing page
         self.assertRedirects(response, reverse("wagtailsnippets_tests_advert:list"))
 
         # Check that the page is gone
@@ -1820,7 +1987,7 @@ class TestSnippetDeleteMultipleWithOne(TestCase, WagtailTestUtils):
         url += "?id=%s" % (self.snippet.id)
         response = self.client.post(url)
 
-        # Should be redirected to explorer page
+        # Should be redirected to the listing page
         self.assertRedirects(response, reverse("wagtailsnippets_tests_advert:list"))
 
         # Check that the page is gone
@@ -1855,7 +2022,7 @@ class TestSnippetDeleteMultipleWithThree(TestCase, WagtailTestUtils):
         )
         response = self.client.post(url)
 
-        # Should be redirected to explorer page
+        # Should be redirected to the listing page
         self.assertRedirects(response, reverse("wagtailsnippets_tests_advert:list"))
 
         # Check that the page is gone
@@ -2213,6 +2380,17 @@ class TestSnippetRevisions(TestCase, WagtailTestUtils):
             '<button type="submit" name="action-publish" value="action-publish" class="button action-save button-longrunning warning" data-clicked-text="Publishing…">',
         )
 
+        # Should not show the Unpublish action menu item
+        unpublish_url = reverse(
+            "wagtailsnippets_tests_draftstatemodel:unpublish",
+            args=(quote(self.snippet.pk),),
+        )
+        self.assertNotContains(
+            response,
+            f'<a class="button action-secondary" href="{unpublish_url}">',
+        )
+        self.assertNotContains(response, "Unpublish")
+
     def test_replace_revision(self):
         get_response = self.get()
         text_from_revision = get_response.context["form"].initial["text"]

+ 21 - 1
wagtail/snippets/views/snippets.py

@@ -27,7 +27,7 @@ from wagtail.admin.ui.tables import (
 )
 from wagtail.admin.views.generic import CreateView, DeleteView, EditView, IndexView
 from wagtail.admin.views.generic.mixins import RevisionsRevertMixin
-from wagtail.admin.views.generic.models import RevisionsCompareView
+from wagtail.admin.views.generic.models import RevisionsCompareView, UnpublishView
 from wagtail.admin.views.generic.permissions import PermissionCheckedMixin
 from wagtail.admin.views.generic.preview import (
     PreviewOnCreate,
@@ -665,6 +665,10 @@ class RevisionsCompare(PermissionCheckedMixin, RevisionsCompareView):
         )
 
 
+class Unpublish(PermissionCheckedMixin, UnpublishView):
+    permission_required = "publish"
+
+
 class SnippetViewSet(ViewSet):
     index_view_class = List
     add_view_class = Create
@@ -674,6 +678,7 @@ class SnippetViewSet(ViewSet):
     history_view_class = History
     revisions_view_class = RevisionsView
     revisions_compare_view_class = RevisionsCompare
+    unpublish_view_class = Unpublish
     preview_on_add_view_class = PreviewOnCreate
     preview_on_edit_view_class = PreviewOnEdit
 
@@ -796,6 +801,16 @@ class SnippetViewSet(ViewSet):
             history_url_name=self.get_url_name("history"),
         )
 
+    @property
+    def unpublish_view(self):
+        return self.unpublish_view_class.as_view(
+            model=self.model,
+            permission_policy=self.permission_policy,
+            index_url_name=self.get_url_name("list"),
+            edit_url_name=self.get_url_name("edit"),
+            unpublish_url_name=self.get_url_name("unpublish"),
+        )
+
     @property
     def preview_on_add_view(self):
         return self.preview_on_add_view_class.as_view(model=self.model)
@@ -873,6 +888,11 @@ class SnippetViewSet(ViewSet):
                 ),
             ]
 
+        if issubclass(self.model, DraftStateMixin):
+            urlpatterns += [
+                path("unpublish/<str:pk>/", self.unpublish_view, name="unpublish"),
+            ]
+
         legacy_redirects = [
             # legacy URLs that could potentially collide if the pk matches one of the reserved names above
             # ('add', 'edit' etc) - redirect to the unambiguous version