Browse Source

Fixed #21936 -- Allowed DeleteView to work with custom Forms and SuccessMessageMixin.

Thanks to Mariusz Felisiak for review.

Co-authored-by: Demetris Stavrou <demestav@gmail.com>
Co-authored-by: Caroline Simpson <github@hoojiboo.com>
Carlton Gibson 3 years ago
parent
commit
3a45fea083

+ 20 - 2
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):

+ 21 - 0
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:
 
         <form method="post">{% csrf_token %}
             <p>Are you sure you want to delete "{{ object }}"?</p>
+            {{ form }}
             <input type="submit" value="Confirm">
         </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``.

+ 5 - 1
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
 ~~~~~~~~~~~~~~~~~~~~

+ 9 - 0
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.')

+ 32 - 0
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.'],
+        )

+ 1 - 0
tests/generic_views/urls.py

@@ -101,6 +101,7 @@ urlpatterns = [
     ),
     path('edit/author/<int:pk>/delete/', views.AuthorDelete.as_view()),
     path('edit/author/<int:pk>/delete/special/', views.SpecializedAuthorDelete.as_view()),
+    path('edit/author/<int:pk>/delete/form/', views.AuthorDeleteFormView.as_view()),
 
     # ArchiveIndexView
     path('dates/books/', views.BookArchive.as_view()),

+ 9 - 1
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'

+ 5 - 0
tests/messages_tests/models.py

@@ -0,0 +1,5 @@
+from django.db import models
+
+
+class SomeObject(models.Model):
+    name = models.CharField(max_length=255)

+ 10 - 3
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)

+ 10 - 1
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 %}
 <ul class="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/<int:pk>', DeleteFormViewWithMsg.as_view(), name='success_msg_on_delete'),
     path('show/', show, name='show_message'),
     re_path(
         '^template_response/add/(debug|info|success|warning|error)/$',