From 3a45fea0832c5910acee6e0d29f230f347a50462 Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Tue, 13 Jul 2021 16:06:12 +0200 Subject: [PATCH] Fixed #21936 -- Allowed DeleteView to work with custom Forms and SuccessMessageMixin. Thanks to Mariusz Felisiak for review. Co-authored-by: Demetris Stavrou Co-authored-by: Caroline Simpson --- django/views/generic/edit.py | 22 +++++++++++-- .../ref/class-based-views/generic-editing.txt | 21 ++++++++++++ docs/releases/4.0.txt | 6 +++- tests/generic_views/forms.py | 9 ++++++ tests/generic_views/test_edit.py | 32 +++++++++++++++++++ tests/generic_views/urls.py | 1 + tests/generic_views/views.py | 10 +++++- tests/messages_tests/models.py | 5 +++ tests/messages_tests/test_mixins.py | 13 ++++++-- tests/messages_tests/urls.py | 11 ++++++- 10 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 tests/messages_tests/models.py diff --git a/django/views/generic/edit.py b/django/views/generic/edit.py index ccfef9cbcd..d0de788c46 100644 --- a/django/views/generic/edit.py +++ b/django/views/generic/edit.py @@ -1,5 +1,5 @@ from django.core.exceptions import ImproperlyConfigured -from django.forms import models as model_forms +from django.forms import Form, models as model_forms from django.http import HttpResponseRedirect from django.views.generic.base import ContextMixin, TemplateResponseMixin, View from django.views.generic.detail import ( @@ -225,12 +225,30 @@ class DeletionMixin: "No URL to redirect to. Provide a success_url.") -class BaseDeleteView(DeletionMixin, BaseDetailView): +class BaseDeleteView(DeletionMixin, FormMixin, BaseDetailView): """ Base view for deleting an object. Using this base class requires subclassing to provide a response mixin. """ + form_class = Form + + def post(self, request, *args, **kwargs): + # Set self.object before the usual form processing flow. + # Inlined because having DeletionMixin as the first base, for + # get_success_url(), makes leveraging super() with ProcessFormView + # overly complex. + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_valid(self, form): + success_url = self.get_success_url() + self.object.delete() + return HttpResponseRedirect(success_url) class DeleteView(SingleObjectTemplateResponseMixin, BaseDeleteView): diff --git a/docs/ref/class-based-views/generic-editing.txt b/docs/ref/class-based-views/generic-editing.txt index cf0b7bc13c..cb03b531cc 100644 --- a/docs/ref/class-based-views/generic-editing.txt +++ b/docs/ref/class-based-views/generic-editing.txt @@ -275,12 +275,26 @@ editing content: * :class:`django.views.generic.base.TemplateResponseMixin` * :class:`django.views.generic.edit.BaseDeleteView` * :class:`django.views.generic.edit.DeletionMixin` + * :class:`django.views.generic.edit.FormMixin` + * :class:`django.views.generic.base.ContextMixin` * :class:`django.views.generic.detail.BaseDetailView` * :class:`django.views.generic.detail.SingleObjectMixin` * :class:`django.views.generic.base.View` **Attributes** + .. attribute:: form_class + + .. versionadded:: 4.0 + + Inherited from :class:`~django.views.generic.edit.BaseDeleteView`. The + form class that will be used to confirm the request. By default + :class:`django.forms.Form`, resulting in an empty form that is always + valid. + + By providing your own ``Form`` subclass, you can add additional + requirements, such as a confirmation checkbox, for example. + .. attribute:: template_name_suffix The ``DeleteView`` page displayed to a ``GET`` request uses a @@ -305,6 +319,7 @@ editing content:
{% csrf_token %}

Are you sure you want to delete "{{ object }}"?

+ {{ form }}
@@ -319,4 +334,10 @@ editing content: This view inherits methods and attributes from the following views: * :class:`django.views.generic.edit.DeletionMixin` + * :class:`django.views.generic.edit.FormMixin` * :class:`django.views.generic.detail.BaseDetailView` + + .. versionchanged:: 4.0 + + In older versions, ``BaseDeleteView`` does not inherit from + ``FormMixin``. diff --git a/docs/releases/4.0.txt b/docs/releases/4.0.txt index be59681fda..f0742db7bd 100644 --- a/docs/releases/4.0.txt +++ b/docs/releases/4.0.txt @@ -221,7 +221,11 @@ Forms Generic Views ~~~~~~~~~~~~~ -* ... +* :class:`~django.views.generic.edit.DeleteView` now uses + :class:`~django.views.generic.edit.FormMixin`, allowing you to provide a + :class:`~django.forms.Form` subclass, with a checkbox for example, to confirm + deletion. In addition, this allows ``DeleteView`` to function with + :class:`django.contrib.messages.views.SuccessMessageMixin`. Internationalization ~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/generic_views/forms.py b/tests/generic_views/forms.py index fd8106d1a8..7d07dbc576 100644 --- a/tests/generic_views/forms.py +++ b/tests/generic_views/forms.py @@ -15,3 +15,12 @@ class AuthorForm(forms.ModelForm): class ContactForm(forms.Form): name = forms.CharField() message = forms.CharField(widget=forms.Textarea) + + +class ConfirmDeleteForm(forms.Form): + confirm = forms.BooleanField() + + def clean(self): + cleaned_data = super().clean() + if 'confirm' not in cleaned_data: + raise forms.ValidationError('You must confirm the delete.') diff --git a/tests/generic_views/test_edit.py b/tests/generic_views/test_edit.py index ca7c457124..d69ae6463e 100644 --- a/tests/generic_views/test_edit.py +++ b/tests/generic_views/test_edit.py @@ -394,3 +394,35 @@ class DeleteViewTests(TestCase): msg = 'No URL to redirect to. Provide a success_url.' with self.assertRaisesMessage(ImproperlyConfigured, msg): self.client.post('/edit/author/%d/delete/naive/' % self.author.pk) + + def test_delete_with_form_as_post(self): + res = self.client.get('/edit/author/%d/delete/form/' % self.author.pk) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.context['object'], self.author) + self.assertEqual(res.context['author'], self.author) + self.assertTemplateUsed(res, 'generic_views/author_confirm_delete.html') + res = self.client.post( + '/edit/author/%d/delete/form/' % self.author.pk, data={'confirm': True} + ) + self.assertEqual(res.status_code, 302) + self.assertRedirects(res, '/list/authors/') + self.assertSequenceEqual(Author.objects.all(), []) + + def test_delete_with_form_as_post_with_validation_error(self): + res = self.client.get('/edit/author/%d/delete/form/' % self.author.pk) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.context['object'], self.author) + self.assertEqual(res.context['author'], self.author) + self.assertTemplateUsed(res, 'generic_views/author_confirm_delete.html') + + res = self.client.post('/edit/author/%d/delete/form/' % self.author.pk) + self.assertEqual(res.status_code, 200) + self.assertEqual(len(res.context_data['form'].errors), 2) + self.assertEqual( + res.context_data['form'].errors['__all__'], + ['You must confirm the delete.'], + ) + self.assertEqual( + res.context_data['form'].errors['confirm'], + ['This field is required.'], + ) diff --git a/tests/generic_views/urls.py b/tests/generic_views/urls.py index e5cb8380d2..940f82d5d0 100644 --- a/tests/generic_views/urls.py +++ b/tests/generic_views/urls.py @@ -101,6 +101,7 @@ urlpatterns = [ ), path('edit/author//delete/', views.AuthorDelete.as_view()), path('edit/author//delete/special/', views.SpecializedAuthorDelete.as_view()), + path('edit/author//delete/form/', views.AuthorDeleteFormView.as_view()), # ArchiveIndexView path('dates/books/', views.BookArchive.as_view()), diff --git a/tests/generic_views/views.py b/tests/generic_views/views.py index 02717333a6..8f3de97388 100644 --- a/tests/generic_views/views.py +++ b/tests/generic_views/views.py @@ -4,7 +4,7 @@ from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator from django.views import generic -from .forms import AuthorForm, ContactForm +from .forms import AuthorForm, ConfirmDeleteForm, ContactForm from .models import Artist, Author, Book, BookSigning, Page @@ -179,6 +179,14 @@ class AuthorDelete(generic.DeleteView): success_url = '/list/authors/' +class AuthorDeleteFormView(generic.DeleteView): + model = Author + form_class = ConfirmDeleteForm + + def get_success_url(self): + return reverse('authors_list') + + class SpecializedAuthorDelete(generic.DeleteView): queryset = Author.objects.all() template_name = 'generic_views/confirm_delete.html' diff --git a/tests/messages_tests/models.py b/tests/messages_tests/models.py new file mode 100644 index 0000000000..8ee894894b --- /dev/null +++ b/tests/messages_tests/models.py @@ -0,0 +1,5 @@ +from django.db import models + + +class SomeObject(models.Model): + name = models.CharField(max_length=255) diff --git a/tests/messages_tests/test_mixins.py b/tests/messages_tests/test_mixins.py index 051ed82b6b..f4d0b26d28 100644 --- a/tests/messages_tests/test_mixins.py +++ b/tests/messages_tests/test_mixins.py @@ -1,12 +1,13 @@ from django.core.signing import b64_decode -from django.test import SimpleTestCase, override_settings +from django.test import TestCase, override_settings from django.urls import reverse -from .urls import ContactFormViewWithMsg +from .models import SomeObject +from .urls import ContactFormViewWithMsg, DeleteFormViewWithMsg @override_settings(ROOT_URLCONF='messages_tests.urls') -class SuccessMessageMixinTests(SimpleTestCase): +class SuccessMessageMixinTests(TestCase): def test_set_messages_success(self): author = {'name': 'John Doe', 'slug': 'success-msg'} @@ -17,3 +18,9 @@ class SuccessMessageMixinTests(SimpleTestCase): req.cookies['messages'].value.split(":")[0].encode(), ).decode() self.assertIn(ContactFormViewWithMsg.success_message % author, value) + + def test_set_messages_success_on_delete(self): + object_to_delete = SomeObject.objects.create(name='MyObject') + delete_url = reverse('success_msg_on_delete', args=[object_to_delete.pk]) + response = self.client.post(delete_url, follow=True) + self.assertContains(response, DeleteFormViewWithMsg.success_message) diff --git a/tests/messages_tests/urls.py b/tests/messages_tests/urls.py index 433a249bb8..983cb33d72 100644 --- a/tests/messages_tests/urls.py +++ b/tests/messages_tests/urls.py @@ -6,7 +6,9 @@ from django.template import engines from django.template.response import TemplateResponse from django.urls import path, re_path, reverse from django.views.decorators.cache import never_cache -from django.views.generic.edit import FormView +from django.views.generic.edit import DeleteView, FormView + +from .models import SomeObject TEMPLATE = """{% if messages %}
    @@ -63,9 +65,16 @@ class ContactFormViewWithMsg(SuccessMessageMixin, FormView): success_message = "%(name)s was created successfully" +class DeleteFormViewWithMsg(SuccessMessageMixin, DeleteView): + model = SomeObject + success_url = '/show/' + success_message = 'Object was deleted successfully' + + urlpatterns = [ re_path('^add/(debug|info|success|warning|error)/$', add, name='add_message'), path('add/msg/', ContactFormViewWithMsg.as_view(), name='add_success_msg'), + path('delete/msg/', DeleteFormViewWithMsg.as_view(), name='success_msg_on_delete'), path('show/', show, name='show_message'), re_path( '^template_response/add/(debug|info|success|warning|error)/$',