mirror of https://github.com/django/django.git
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:
parent
37e8367c35
commit
3a45fea083
|
@ -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):
|
||||||
|
|
|
@ -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``.
|
||||||
|
|
|
@ -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
|
||||||
~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
|
@ -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.')
|
||||||
|
|
|
@ -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.'],
|
||||||
|
)
|
||||||
|
|
|
@ -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()),
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class SomeObject(models.Model):
|
||||||
|
name = models.CharField(max_length=255)
|
|
@ -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)
|
||||||
|
|
|
@ -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)/$',
|
||||||
|
|
Loading…
Reference in New Issue