Skip to content

Commit

Permalink
Add ability to use copy view for SnippetViewSet & ModelViewSet
Browse files Browse the repository at this point in the history
Closes #10921
  • Loading branch information
smark-1 authored and lb- committed Jan 24, 2024
1 parent aef6de8 commit 7f6a262
Show file tree
Hide file tree
Showing 12 changed files with 226 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions docs/extending/generic_views.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/viewsets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/releases/6.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/topics/snippets/customising.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
84 changes: 83 additions & 1 deletion wagtail/admin/tests/viewsets/test_model_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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
Expand All @@ -21,6 +21,7 @@
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
Expand Down Expand Up @@ -1303,6 +1304,11 @@ def test_simple(self):
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}'",
Expand All @@ -1325,6 +1331,82 @@ def test_simple(self):
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):
Expand Down
1 change: 1 addition & 0 deletions wagtail/admin/views/generic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
RevisionsRevertMixin,
)
from .models import ( # noqa: F401
CopyView,
CreateView,
DeleteView,
EditView,
Expand Down
27 changes: 27 additions & 0 deletions wagtail/admin/views/generic/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -326,6 +327,10 @@ def get_edit_url(self, instance):
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),))
Expand Down Expand Up @@ -422,6 +427,20 @@ def get_list_more_buttons(self, instance):
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(
Expand Down Expand Up @@ -685,6 +704,14 @@ def form_invalid(self, form):
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,
Expand Down
18 changes: 18 additions & 0 deletions wagtail/admin/viewsets/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -129,6 +135,8 @@ def get_common_view_kwargs(self, **kwargs):
**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
Expand Down Expand Up @@ -198,6 +206,9 @@ def get_inspect_view_kwargs(self, **kwargs):
**kwargs,
}

def get_copy_view_kwargs(self, **kwargs):
return self.get_add_view_kwargs(**kwargs)

@property
def index_view(self):
return self.construct_view(
Expand Down Expand Up @@ -278,6 +289,10 @@ def inspect_view(self):
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
Expand Down Expand Up @@ -622,6 +637,9 @@ def get_urlpatterns(self):
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

Expand Down
30 changes: 27 additions & 3 deletions wagtail/snippets/tests/test_snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
)
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,
Expand Down Expand Up @@ -284,10 +285,10 @@ def test_construct_snippet_listing_buttons_hook_contains_default_buttons(self):
)

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",
Expand Down Expand Up @@ -939,6 +940,29 @@ def hook_func(menu_items, request, context):
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"]
Expand Down
Loading

0 comments on commit 7f6a262

Please sign in to comment.