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.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.http import HttpResponseRedirect
from django.views.generic.base import ContextMixin, TemplateResponseMixin, View from django.views.generic.base import ContextMixin, TemplateResponseMixin, View
from django.views.generic.detail import ( from django.views.generic.detail import (
@ -225,12 +225,30 @@ class DeletionMixin:
"No URL to redirect to. Provide a success_url.") "No URL to redirect to. Provide a success_url.")
class BaseDeleteView(DeletionMixin, BaseDetailView): class BaseDeleteView(DeletionMixin, FormMixin, BaseDetailView):
""" """
Base view for deleting an object. Base view for deleting an object.
Using this base class requires subclassing to provide a response mixin. 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): class DeleteView(SingleObjectTemplateResponseMixin, BaseDeleteView):

View File

@ -275,12 +275,26 @@ editing content:
* :class:`django.views.generic.base.TemplateResponseMixin` * :class:`django.views.generic.base.TemplateResponseMixin`
* :class:`django.views.generic.edit.BaseDeleteView` * :class:`django.views.generic.edit.BaseDeleteView`
* :class:`django.views.generic.edit.DeletionMixin` * :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.BaseDetailView`
* :class:`django.views.generic.detail.SingleObjectMixin` * :class:`django.views.generic.detail.SingleObjectMixin`
* :class:`django.views.generic.base.View` * :class:`django.views.generic.base.View`
**Attributes** **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 .. attribute:: template_name_suffix
The ``DeleteView`` page displayed to a ``GET`` request uses a The ``DeleteView`` page displayed to a ``GET`` request uses a
@ -305,6 +319,7 @@ editing content:
<form method="post">{% csrf_token %} <form method="post">{% csrf_token %}
<p>Are you sure you want to delete "{{ object }}"?</p> <p>Are you sure you want to delete "{{ object }}"?</p>
{{ form }}
<input type="submit" value="Confirm"> <input type="submit" value="Confirm">
</form> </form>
@ -319,4 +334,10 @@ editing content:
This view inherits methods and attributes from the following views: This view inherits methods and attributes from the following views:
* :class:`django.views.generic.edit.DeletionMixin` * :class:`django.views.generic.edit.DeletionMixin`
* :class:`django.views.generic.edit.FormMixin`
* :class:`django.views.generic.detail.BaseDetailView` * :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 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 Internationalization
~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~

View File

@ -15,3 +15,12 @@ class AuthorForm(forms.ModelForm):
class ContactForm(forms.Form): class ContactForm(forms.Form):
name = forms.CharField() name = forms.CharField()
message = forms.CharField(widget=forms.Textarea) 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.' msg = 'No URL to redirect to. Provide a success_url.'
with self.assertRaisesMessage(ImproperlyConfigured, msg): with self.assertRaisesMessage(ImproperlyConfigured, msg):
self.client.post('/edit/author/%d/delete/naive/' % self.author.pk) 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/', views.AuthorDelete.as_view()),
path('edit/author/<int:pk>/delete/special/', views.SpecializedAuthorDelete.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 # ArchiveIndexView
path('dates/books/', views.BookArchive.as_view()), 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.utils.decorators import method_decorator
from django.views import generic from django.views import generic
from .forms import AuthorForm, ContactForm from .forms import AuthorForm, ConfirmDeleteForm, ContactForm
from .models import Artist, Author, Book, BookSigning, Page from .models import Artist, Author, Book, BookSigning, Page
@ -179,6 +179,14 @@ class AuthorDelete(generic.DeleteView):
success_url = '/list/authors/' 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): class SpecializedAuthorDelete(generic.DeleteView):
queryset = Author.objects.all() queryset = Author.objects.all()
template_name = 'generic_views/confirm_delete.html' 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.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 django.urls import reverse
from .urls import ContactFormViewWithMsg from .models import SomeObject
from .urls import ContactFormViewWithMsg, DeleteFormViewWithMsg
@override_settings(ROOT_URLCONF='messages_tests.urls') @override_settings(ROOT_URLCONF='messages_tests.urls')
class SuccessMessageMixinTests(SimpleTestCase): class SuccessMessageMixinTests(TestCase):
def test_set_messages_success(self): def test_set_messages_success(self):
author = {'name': 'John Doe', 'slug': 'success-msg'} author = {'name': 'John Doe', 'slug': 'success-msg'}
@ -17,3 +18,9 @@ class SuccessMessageMixinTests(SimpleTestCase):
req.cookies['messages'].value.split(":")[0].encode(), req.cookies['messages'].value.split(":")[0].encode(),
).decode() ).decode()
self.assertIn(ContactFormViewWithMsg.success_message % author, value) 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.template.response import TemplateResponse
from django.urls import path, re_path, reverse from django.urls import path, re_path, reverse
from django.views.decorators.cache import never_cache 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 %} TEMPLATE = """{% if messages %}
<ul class="messages"> <ul class="messages">
@ -63,9 +65,16 @@ class ContactFormViewWithMsg(SuccessMessageMixin, FormView):
success_message = "%(name)s was created successfully" success_message = "%(name)s was created successfully"
class DeleteFormViewWithMsg(SuccessMessageMixin, DeleteView):
model = SomeObject
success_url = '/show/'
success_message = 'Object was deleted successfully'
urlpatterns = [ urlpatterns = [
re_path('^add/(debug|info|success|warning|error)/$', add, name='add_message'), re_path('^add/(debug|info|success|warning|error)/$', add, name='add_message'),
path('add/msg/', ContactFormViewWithMsg.as_view(), name='add_success_msg'), 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'), path('show/', show, name='show_message'),
re_path( re_path(
'^template_response/add/(debug|info|success|warning|error)/$', '^template_response/add/(debug|info|success|warning|error)/$',