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>
This commit is contained in:
Carlton Gibson 2021-07-13 16:06:12 +02:00
parent 37e8367c35
commit 3a45fea083
10 changed files with 122 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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.'],
)

View File

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

View File

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

View File

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

View File

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

View File

@ -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)/$',