Add ability to use copy view for SnippetViewSet & ModelViewSet

Closes #10921
pull/11182/head
Shlomo Markowitz 2023-11-21 13:50:54 -05:00 zatwierdzone przez LB (Ben Johnston)
rodzic aef6de8a2f
commit 7f6a2623d1
12 zmienionych plików z 226 dodań i 6 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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.

Wyświetl plik

@ -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):

Wyświetl plik

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

Wyświetl plik

@ -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,

Wyświetl plik

@ -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

Wyświetl plik

@ -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"]

Wyświetl plik

@ -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")

Wyświetl plik

@ -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)