Browse Source

Add ability to use copy view for SnippetViewSet & ModelViewSet

Closes #10921
Shlomo Markowitz 1 year ago
parent
commit
7f6a2623d1

+ 1 - 0
CHANGELOG.txt

@@ -51,6 +51,7 @@ Changelog
  * Add `DrilldownController` and `w-drilldown` component to support drilldown menus (Thibaud Colas)
  * Add support for `caption` on admin UI Table component (Aman Pandey)
  * Add API support for a redirects (contrib) endpoint (Rohit Sharma, Jaap Roes, Andreas Donig)
+ * Add the default ability for all `SnippetViewSet` & `ModelViewSet` to support being copied (Shlomo Markowitz)
  * Fix: Update system check for overwriting storage backends to recognise the `STORAGES` setting introduced in Django 4.2 (phijma-leukeleu)
  * Fix: Prevent password change form from raising a validation error when browser autocomplete fills in the "Old password" field (Chiemezuo Akujobi)
  * Fix: Ensure that the legacy dropdown options, when closed, do not get accidentally clicked by other interactions wide viewports (CheesyPhoenix, Christer Jensen)

+ 9 - 0
docs/extending/generic_views.md

@@ -35,6 +35,7 @@ class PersonViewSet(ModelViewSet):
     form_fields = ["first_name", "last_name"]
     icon = "user"
     add_to_admin_menu = True
+    copy_view_enabled = False
     inspect_view_enabled = True
 
 
@@ -94,6 +95,14 @@ You can define a `panels` or `edit_handler` attribute on the `ModelViewSet` or y
 
 If neither `panels` nor `edit_handler` is defined and the {meth}`~ModelViewSet.get_edit_handler` method is not overridden, the form will be rendered as a plain Django form. You can customise the form by setting the {attr}`~ModelViewSet.form_fields` attribute to specify the fields to be shown on the form. Alternatively, you can set the {attr}`~ModelViewSet.exclude_form_fields` attribute to specify the fields to be excluded from the form. If panels are not used, you must define `form_fields` or `exclude_form_fields`, unless {meth}`~ModelViewSet.get_form_class` is overridden.
 
+(modelviewset_copy)=
+
+### Copy view
+
+The copy view is enabled by default and will be accessible by users with the 'add' permission on the model. To disable it, set {attr}`~.ModelViewSet.copy_view_enabled` to `False`.
+
+The view's form will be generated in the same way as create or edit forms. To use a custom form, override the `copy_view_class` and modify the `form_class` property on that class.
+
 (modelviewset_inspect)=
 
 ### Inspect view

+ 3 - 0
docs/reference/viewsets.md

@@ -95,6 +95,7 @@ Viewsets are Wagtail's mechanism for defining a group of related admin views wit
    .. autoattribute:: export_filename
    .. autoattribute:: search_fields
    .. autoattribute:: search_backend_name
+   .. autoattribute:: copy_view_enabled
    .. autoattribute:: inspect_view_enabled
    .. autoattribute:: inspect_view_fields
    .. autoattribute:: inspect_view_fields_exclude
@@ -104,6 +105,7 @@ Viewsets are Wagtail's mechanism for defining a group of related admin views wit
    .. autoattribute:: delete_view_class
    .. autoattribute:: usage_view_class
    .. autoattribute:: history_view_class
+   .. autoattribute:: copy_view_class
    .. autoattribute:: inspect_view_class
    .. autoattribute:: template_prefix
    .. autoattribute:: index_template_name
@@ -183,6 +185,7 @@ Viewsets are Wagtail's mechanism for defining a group of related admin views wit
    .. autoattribute:: delete_view_class
    .. autoattribute:: usage_view_class
    .. autoattribute:: history_view_class
+   .. autoattribute:: copy_view_class
    .. autoattribute:: inspect_view_class
    .. autoattribute:: revisions_view_class
    .. autoattribute:: revisions_revert_view_class

+ 21 - 0
docs/releases/6.0.md

@@ -83,6 +83,7 @@ This feature was implemented by Nick Lee, Thibaud Colas, and Sage Abdullah.
  * Add `DrilldownController` and `w-drilldown` component to support drilldown menus (Thibaud Colas)
  * Add support for `caption` on admin UI Table component (Aman Pandey)
  * Add API support for a [redirects (contrib)](redirects_api_endpoint) endpoint (Rohit Sharma, Jaap Roes, Andreas Donig)
+ * Add the default ability for all `SnippetViewSet` & `ModelViewSet` to support [being copied](modelviewset_copy), this can be disabled by `copy_view_enabled = False` (Shlomo Markowitz)
 
 
 ### Bug fixes
@@ -243,6 +244,26 @@ The `use_json_field` argument to `StreamField` is no longer required, and can be
 
 ## Upgrade considerations - changes affecting all projects
 
+### `SnippetViewSet` & `ModelViewSet` copy view enabled by default
+
+The newly introduced copy view will be enabled by default for all `ModelViewSet` and `SnippetViewSet` classes.
+
+This can be disabled by setting `copy_view_enabled = False`, for example.
+
+```python
+class PersonViewSet(SnippetViewSet):
+    model = Person
+    #...
+    copy_view_enabled = False
+
+class PersonViewSet(ModelViewSet):
+    model = Person
+    #...
+    copy_view_enabled = False
+```
+
+See [](modelviewset_copy) for additional details about this feature.
+
 ## Upgrade considerations - deprecation of old functionality
 
 ### Removed support for Django < 4.2

+ 5 - 0
docs/topics/snippets/customising.md

@@ -57,6 +57,7 @@ class MemberViewSet(SnippetViewSet):
     icon = "user"
     list_display = ["name", "shirt_size", "get_shirt_size_display", UpdatedAtColumn()]
     list_per_page = 50
+    copy_view_enabled = False
     inspect_view_enabled = True
     admin_url_namespace = "member_views"
     base_url_path = "internal/member"
@@ -92,6 +93,10 @@ You can customise the listing view to add custom columns, filters, pagination, e
 
 Additionally, you can customise the base queryset for the listing view by overriding the {meth}`~SnippetViewSet.get_queryset` method.
 
+## Copy view
+
+The copy view is enabled by default and will be accessible by users with the 'add' permission on the model. To disable it, set {attr}`~.ModelViewSet.copy_view_enabled` to `False`. Refer to [the copy view customisations for `ModelViewSet`](modelviewset_copy) for more details.
+
 ## Inspect view
 
 The inspect view is disabled by default, as it's not often useful for most models. To enable it, set {attr}`~.ModelViewSet.inspect_view_enabled` to `True`. Refer to [the inspect view customisations for `ModelViewSet`](modelviewset_inspect) for more details.

+ 83 - 1
wagtail/admin/tests/viewsets/test_model_viewset.py

@@ -6,7 +6,7 @@ from django.contrib.admin.utils import quote
 from django.contrib.auth import get_permission_codename
 from django.contrib.auth.models import Permission
 from django.contrib.contenttypes.models import ContentType
-from django.test import TestCase
+from django.test import RequestFactory, TestCase
 from django.urls import NoReverseMatch, reverse
 from django.utils.formats import date_format, localize
 from django.utils.html import escape
@@ -21,6 +21,7 @@ from wagtail.test.testapp.models import (
     SearchTestModel,
     VariousOnDeleteModel,
 )
+from wagtail.test.testapp.views import FCToyAlt1ViewSet
 from wagtail.test.utils.template_tests import AdminTemplateTestUtils
 from wagtail.test.utils.wagtail_tests import WagtailTestUtils
 from wagtail.utils.deprecation import RemovedInWagtail70Warning
@@ -1303,6 +1304,11 @@ class TestListingButtons(WagtailTestUtils, TestCase):
                 f"Edit '{self.object}'",
                 reverse("feature_complete_toy:edit", args=[quote(self.object.pk)]),
             ),
+            (
+                "Copy",
+                f"Copy '{self.object}'",
+                reverse("feature_complete_toy:copy", args=[quote(self.object.pk)]),
+            ),
             (
                 "Inspect",
                 f"Inspect '{self.object}'",
@@ -1325,6 +1331,82 @@ class TestListingButtons(WagtailTestUtils, TestCase):
             self.assertEqual(rendered_button.attrs.get("aria-label"), aria_label)
             self.assertEqual(rendered_button.attrs.get("href"), url)
 
+    def test_copy_disabled(self):
+        response = self.client.get(reverse("fctoy_alt1:index"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, "wagtailadmin/shared/buttons.html")
+
+        soup = self.get_soup(response.content)
+        actions = soup.select_one("tbody tr td ul.actions")
+        more_dropdown = actions.select_one("li [data-controller='w-dropdown']")
+        self.assertIsNotNone(more_dropdown)
+        more_button = more_dropdown.select_one("button")
+        self.assertEqual(
+            more_button.attrs.get("aria-label").strip(),
+            f"More options for '{self.object}'",
+        )
+
+        expected_buttons = [
+            (
+                "Edit",
+                f"Edit '{self.object}'",
+                reverse("fctoy_alt1:edit", args=[quote(self.object.pk)]),
+            ),
+            (
+                "Inspect",
+                f"Inspect '{self.object}'",
+                reverse("fctoy_alt1:inspect", args=[quote(self.object.pk)]),
+            ),
+            (
+                "Delete",
+                f"Delete '{self.object}'",
+                reverse("fctoy_alt1:delete", args=[quote(self.object.pk)]),
+            ),
+        ]
+
+        rendered_buttons = more_dropdown.select("a")
+        self.assertEqual(len(rendered_buttons), len(expected_buttons))
+
+        for rendered_button, (label, aria_label, url) in zip(
+            rendered_buttons, expected_buttons
+        ):
+            self.assertEqual(rendered_button.text.strip(), label)
+            self.assertEqual(rendered_button.attrs.get("aria-label"), aria_label)
+            self.assertEqual(rendered_button.attrs.get("href"), url)
+
+
+class TestCopyView(WagtailTestUtils, TestCase):
+    def setUp(self):
+        self.user = self.login()
+        self.url = reverse("feature_complete_toy:copy", args=[quote(self.object.pk)])
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.object = FeatureCompleteToy.objects.create(name="Test Toy")
+
+    def test_without_permission(self):
+        self.user.is_superuser = False
+        self.user.save()
+        admin_permission = Permission.objects.get(
+            content_type__app_label="wagtailadmin", codename="access_admin"
+        )
+        self.user.user_permissions.add(admin_permission)
+
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 302)
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
+
+    def test_form_is_prefilled(self):
+        request = RequestFactory().get(self.url)
+        request.user = self.user
+        view = FCToyAlt1ViewSet().copy_view_class()
+        view.setup(request)
+        view.model = self.object.__class__
+        view.kwargs = {"pk": self.object.pk}
+
+        self.assertEqual(view.get_form_kwargs()["instance"], self.object)
+
 
 class TestEditHandler(WagtailTestUtils, TestCase):
     def setUp(self):

+ 1 - 0
wagtail/admin/views/generic/__init__.py

@@ -14,6 +14,7 @@ from .mixins import (  # noqa: F401
     RevisionsRevertMixin,
 )
 from .models import (  # noqa: F401
+    CopyView,
     CreateView,
     DeleteView,
     EditView,

+ 27 - 0
wagtail/admin/views/generic/models.py

@@ -107,6 +107,7 @@ class IndexView(
     results_template_name = "wagtailadmin/generic/index_results.html"
     add_url_name = None
     edit_url_name = None
+    copy_url_name = None
     inspect_url_name = None
     delete_url_name = None
     any_permission_required = ["add", "change", "delete"]
@@ -326,6 +327,10 @@ class IndexView(
         if self.edit_url_name:
             return reverse(self.edit_url_name, args=(quote(instance.pk),))
 
+    def get_copy_url(self, instance):
+        if self.copy_url_name:
+            return reverse(self.copy_url_name, args=(quote(instance.pk),))
+
     def get_inspect_url(self, instance):
         if self.inspect_url_name:
             return reverse(self.inspect_url_name, args=(quote(instance.pk),))
@@ -422,6 +427,20 @@ class IndexView(
                     priority=10,
                 )
             )
+        copy_url = self.get_copy_url(instance)
+        can_copy = self.permission_policy.user_has_permission(self.request.user, "add")
+        if copy_url and can_copy:
+            buttons.append(
+                ListingButton(
+                    _("Copy"),
+                    url=copy_url,
+                    icon_name="copy",
+                    attrs={
+                        "aria-label": _("Copy '%(title)s'") % {"title": str(instance)}
+                    },
+                    priority=20,
+                )
+            )
         inspect_url = self.get_inspect_url(instance)
         if inspect_url:
             buttons.append(
@@ -685,6 +704,14 @@ class CreateView(
         return super().form_invalid(form)
 
 
+class CopyView(CreateView):
+    def get_object(self, queryset=None):
+        return get_object_or_404(self.model, pk=self.kwargs["pk"])
+
+    def get_form_kwargs(self):
+        return {**super().get_form_kwargs(), "instance": self.get_object()}
+
+
 class EditView(
     LocaleMixin,
     PanelMixin,

+ 18 - 0
wagtail/admin/viewsets/model.py

@@ -52,6 +52,9 @@ class ModelViewSet(ViewSet):
     #: The view class to use for the usage view; must be a subclass of ``wagtail.admin.views.generic.usage.UsageView``.
     usage_view_class = usage.UsageView
 
+    #: The view class to use for the copy view; must be a subclass of ``wagtail.admin.views.generic.CopyView``.
+    copy_view_class = generic.CopyView
+
     #: The view class to use for the inspect view; must be a subclass of ``wagtail.admin.views.generic.InspectView``.
     inspect_view_class = generic.InspectView
 
@@ -88,6 +91,9 @@ class ModelViewSet(ViewSet):
     #: The fields to exclude from the inspect view.
     inspect_view_fields_exclude = []
 
+    #: Whether to enable the copy view. Defaults to ``True``.
+    copy_view_enabled = True
+
     def __init__(self, name=None, **kwargs):
         super().__init__(name=name, **kwargs)
         if not self.model:
@@ -129,6 +135,8 @@ class ModelViewSet(ViewSet):
                 **kwargs,
             }
         )
+        if self.copy_view_enabled:
+            view_kwargs["copy_url_name"] = self.get_url_name("copy")
         if self.inspect_view_enabled:
             view_kwargs["inspect_url_name"] = self.get_url_name("inspect")
         return view_kwargs
@@ -198,6 +206,9 @@ class ModelViewSet(ViewSet):
             **kwargs,
         }
 
+    def get_copy_view_kwargs(self, **kwargs):
+        return self.get_add_view_kwargs(**kwargs)
+
     @property
     def index_view(self):
         return self.construct_view(
@@ -278,6 +289,10 @@ class ModelViewSet(ViewSet):
             self.inspect_view_class, **self.get_inspect_view_kwargs()
         )
 
+    @property
+    def copy_view(self):
+        return self.construct_view(self.copy_view_class, **self.get_copy_view_kwargs())
+
     def get_templates(self, name="index", fallback=""):
         """
         Utility function that provides a list of templates to try for a given
@@ -622,6 +637,9 @@ class ModelViewSet(ViewSet):
                 path("inspect/<str:pk>/", self.inspect_view, name="inspect")
             )
 
+        if self.copy_view_enabled:
+            urlpatterns.append(path("copy/<str:pk>/", self.copy_view, name="copy"))
+
         # RemovedInWagtail70Warning: Remove legacy URL patterns
         urlpatterns += self._legacy_urlpatterns
 

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

@@ -35,6 +35,7 @@ from wagtail.snippets.action_menu import (
 )
 from wagtail.snippets.blocks import SnippetChooserBlock
 from wagtail.snippets.models import SNIPPET_MODELS, register_snippet
+from wagtail.snippets.views.snippets import CopyView
 from wagtail.snippets.widgets import (
     AdminSnippetChooser,
     SnippetChooserAdapter,
@@ -284,10 +285,10 @@ class TestSnippetListView(WagtailTestUtils, TestCase):
         )
 
         def hide_delete_button_for_lovely_advert(buttons, snippet, user):
-            # Edit, delete, dummy button
-            self.assertEqual(len(buttons), 3)
+            # Edit, delete, dummy button, copy button
+            self.assertEqual(len(buttons), 4)
             buttons[:] = [button for button in buttons if button.url != delete_url]
-            self.assertEqual(len(buttons), 2)
+            self.assertEqual(len(buttons), 3)
 
         with hooks.register_temporarily(
             "construct_snippet_listing_buttons",
@@ -939,6 +940,29 @@ class TestSnippetCreateView(WagtailTestUtils, TestCase):
         self.assertNotContains(response, "<em>'Save'</em>")
 
 
+class TestSnippetCopyView(WagtailTestUtils, TestCase):
+    def setUp(self):
+        self.snippet = StandardSnippet.objects.create(text="Test snippet")
+        self.url = reverse(
+            StandardSnippet.snippet_viewset.get_url_name("copy"),
+            args=(self.snippet.pk,),
+        )
+        self.login()
+
+    def test_simple(self):
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, "wagtailsnippets/snippets/create.html")
+
+    def test_form_prefilled(self):
+        request = RequestFactory().get(self.url)
+        view = CopyView()
+        view.model = StandardSnippet
+        view.setup(request, pk=self.snippet.pk)
+
+        self.assertEqual(view._get_initial_form_instance(), self.snippet)
+
+
 @override_settings(WAGTAIL_I18N_ENABLED=True)
 class TestLocaleSelectorOnCreate(WagtailTestUtils, TestCase):
     fixtures = ["test.json"]

+ 30 - 2
wagtail/snippets/views/snippets.py

@@ -5,7 +5,7 @@ from django.contrib.admin.utils import quote
 from django.core import checks
 from django.core.exceptions import ImproperlyConfigured, PermissionDenied
 from django.http import Http404
-from django.shortcuts import redirect
+from django.shortcuts import get_object_or_404, redirect
 from django.urls import path, re_path, reverse, reverse_lazy
 from django.utils.functional import cached_property
 from django.utils.text import capfirst
@@ -35,7 +35,10 @@ from wagtail.admin.views.generic.preview import (
 )
 from wagtail.admin.viewsets import viewsets
 from wagtail.admin.viewsets.model import ModelViewSet, ModelViewSetGroup
-from wagtail.admin.widgets.button import BaseDropdownMenuButton, ButtonWithDropdown
+from wagtail.admin.widgets.button import (
+    BaseDropdownMenuButton,
+    ButtonWithDropdown,
+)
 from wagtail.models import (
     DraftStateMixin,
     LockableMixin,
@@ -277,6 +280,18 @@ class CreateView(generic.CreateEditViewOptionalFeaturesMixin, generic.CreateView
         return context
 
 
+class CopyView(CreateView):
+    def get_object(self):
+        return get_object_or_404(self.model, pk=self.kwargs["pk"])
+
+    def _get_initial_form_instance(self):
+        instance = self.get_object()
+        # Set locale of the new instance
+        if self.locale:
+            instance.locale = self.locale
+        return instance
+
+
 class EditView(generic.CreateEditViewOptionalFeaturesMixin, generic.EditView):
     view_name = "edit"
     template_name = "wagtailsnippets/snippets/edit.html"
@@ -540,6 +555,9 @@ class SnippetViewSet(ModelViewSet):
     #: The view class to use for the create view; must be a subclass of ``wagtail.snippets.views.snippets.CreateView``.
     add_view_class = CreateView
 
+    #: The view class to use for the copy view; must be a subclass of ``wagtail.snippet.views.snippets.CopyView``.
+    copy_view_class = CopyView
+
     #: The view class to use for the edit view; must be a subclass of ``wagtail.snippets.views.snippets.EditView``.
     edit_view_class = EditView
 
@@ -690,6 +708,9 @@ class SnippetViewSet(ModelViewSet):
             **kwargs,
         )
 
+    def get_copy_view_kwargs(self, **kwargs):
+        return self.get_add_view_kwargs(**kwargs)
+
     def get_edit_view_kwargs(self, **kwargs):
         return super().get_edit_view_kwargs(
             preview_url_name=self.get_url_name("preview_on_edit"),
@@ -762,6 +783,10 @@ class SnippetViewSet(ModelViewSet):
             success_url_name=self.get_url_name("edit"),
         )
 
+    @property
+    def copy_view(self):
+        return self.construct_view(self.copy_view_class, **self.get_copy_view_kwargs())
+
     @property
     def unlock_view(self):
         return self.construct_view(
@@ -1109,6 +1134,9 @@ class SnippetViewSet(ModelViewSet):
             ),
         ]
 
+        if self.copy_view_enabled:
+            urlpatterns += [path("copy/<str:pk>/", self.copy_view, name="copy")]
+
         if self.inspect_view_enabled:
             urlpatterns += [
                 path("inspect/<str:pk>/", self.inspect_view, name="inspect")

+ 1 - 0
wagtail/test/testapp/views.py

@@ -239,6 +239,7 @@ class FCToyAlt1ViewSet(ModelViewSet):
     menu_label = "FC Toys Alt 1"
     inspect_view_enabled = True
     inspect_view_fields_exclude = ["strid", "release_date"]
+    copy_view_enabled = False
 
     def get_index_view_kwargs(self, **kwargs):
         return super().get_index_view_kwargs(is_searchable=False, **kwargs)