diff --git a/django/contrib/admin/actions.py b/django/contrib/admin/actions.py index 669e271620..6e52e34d94 100644 --- a/django/contrib/admin/actions.py +++ b/django/contrib/admin/actions.py @@ -32,7 +32,7 @@ def delete_selected(modeladmin, request, queryset): # Populate deletable_objects, a data structure of all related objects that # will also be deleted. - deletable_objects, perms_needed = get_deleted_objects( + deletable_objects, perms_needed, protected = get_deleted_objects( queryset, opts, request.user, modeladmin.admin_site, using) # The user has already confirmed the deletion. @@ -58,6 +58,7 @@ def delete_selected(modeladmin, request, queryset): "deletable_objects": [deletable_objects], 'queryset': queryset, "perms_lacking": perms_needed, + "protected": protected, "opts": opts, "root_path": modeladmin.admin_site.root_path, "app_label": app_label, diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 864325f266..f51070ca67 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -1177,7 +1177,7 @@ class ModelAdmin(BaseModelAdmin): # Populate deleted_objects, a data structure of all related objects that # will also be deleted. - (deleted_objects, perms_needed) = get_deleted_objects( + (deleted_objects, perms_needed, protected) = get_deleted_objects( [obj], opts, request.user, self.admin_site, using) if request.POST: # The user has already confirmed the deletion. @@ -1199,6 +1199,7 @@ class ModelAdmin(BaseModelAdmin): "object": obj, "deleted_objects": deleted_objects, "perms_lacking": perms_needed, + "protected": protected, "opts": opts, "root_path": self.admin_site.root_path, "app_label": app_label, diff --git a/django/contrib/admin/templates/admin/delete_confirmation.html b/django/contrib/admin/templates/admin/delete_confirmation.html index 65e73c922d..db4ef627b7 100644 --- a/django/contrib/admin/templates/admin/delete_confirmation.html +++ b/django/contrib/admin/templates/admin/delete_confirmation.html @@ -12,13 +12,23 @@ {% endblock %} {% block content %} -{% if perms_lacking %} -
{% blocktrans with object as escaped_object %}Deleting the {{ object_name }} '{{ escaped_object }}' would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}
-{% blocktrans with object as escaped_object %}Deleting the {{ object_name }} '{{ escaped_object }}' would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}
+{% blocktrans with object as escaped_object %}Deleting the {{ object_name }} '{{ escaped_object }}' is not possible, because it would require deleting the following protected related objects:{% endblocktrans %}
+{% blocktrans with object as escaped_object %}Are you sure you want to delete the {{ object_name }} "{{ escaped_object }}"? All of the following related items will be deleted:{% endblocktrans %}
{% blocktrans %}Deleting the {{ object_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}
-{% blocktrans %}Deleting the {{ object_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}
+{% blocktrans %}Deleting the {{ object_name }} is not possible, because it would require deleting the following protected related objects:{% endblocktrans %}
+{% blocktrans %}Are you sure you want to delete the selected {{ object_name }} objects? All of the following objects and their related items will be deleted:{% endblocktrans %}
{% for deletable_object in deletable_objects %} diff --git a/django/contrib/admin/util.py b/django/contrib/admin/util.py index c6411aed35..1c39614200 100644 --- a/django/contrib/admin/util.py +++ b/django/contrib/admin/util.py @@ -104,13 +104,16 @@ def get_deleted_objects(objs, opts, user, admin_site, using): to_delete = collector.nested(format_callback) - return to_delete, perms_needed + protected = [format_callback(obj) for obj in collector.protected] + + return to_delete, perms_needed, protected class NestedObjects(Collector): def __init__(self, *args, **kwargs): super(NestedObjects, self).__init__(*args, **kwargs) self.edges = {} # {from_instance: [to_instances]} + self.protected = set() def add_edge(self, source, target): self.edges.setdefault(source, []).append(target) @@ -121,7 +124,10 @@ class NestedObjects(Collector): self.add_edge(getattr(obj, source_attr), obj) else: self.add_edge(None, obj) - return super(NestedObjects, self).collect(objs, source_attr=source_attr, **kwargs) + try: + return super(NestedObjects, self).collect(objs, source_attr=source_attr, **kwargs) + except models.ProtectedError, e: + self.protected.update(e.protected_objects) def related_objects(self, related, objs): qs = super(NestedObjects, self).related_objects(related, objs) diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index ce134ad087..9709976b19 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -11,7 +11,7 @@ from django.db.models.fields import * from django.db.models.fields.subclassing import SubfieldBase from django.db.models.fields.files import FileField, ImageField from django.db.models.fields.related import ForeignKey, OneToOneField, ManyToManyField, ManyToOneRel, ManyToManyRel, OneToOneRel -from django.db.models.deletion import CASCADE, PROTECT, SET, SET_NULL, SET_DEFAULT, DO_NOTHING +from django.db.models.deletion import CASCADE, PROTECT, SET, SET_NULL, SET_DEFAULT, DO_NOTHING, ProtectedError from django.db.models import signals # Admin stages. diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index 6cd42701d9..07f677ea65 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -7,17 +7,27 @@ from django.utils.datastructures import SortedDict from django.utils.functional import wraps +class ProtectedError(IntegrityError): + def __init__(self, msg, protected_objects): + self.protected_objects = protected_objects + super(ProtectedError, self).__init__(msg, protected_objects) + + def CASCADE(collector, field, sub_objs, using): collector.collect(sub_objs, source=field.rel.to, source_attr=field.name, nullable=field.null) if field.null and not connections[using].features.can_defer_constraint_checks: collector.add_field_update(field, None, sub_objs) + def PROTECT(collector, field, sub_objs, using): - raise IntegrityError("Cannot delete some instances of model '%s' because " + raise ProtectedError("Cannot delete some instances of model '%s' because " "they are referenced through a protected foreign key: '%s.%s'" % ( field.rel.to.__name__, sub_objs[0].__class__.__name__, field.name - )) + ), + sub_objs + ) + def SET(value): if callable(value): @@ -28,14 +38,18 @@ def SET(value): collector.add_field_update(field, value, sub_objs) return set_on_delete + SET_NULL = SET(None) + def SET_DEFAULT(collector, field, sub_objs, using): collector.add_field_update(field, field.get_default(), sub_objs) + def DO_NOTHING(collector, field, sub_objs, using): pass + def force_managed(func): @wraps(func) def decorated(self, *args, **kwargs): @@ -55,6 +69,7 @@ def force_managed(func): transaction.leave_transaction_management(using=self.using) return decorated + class Collector(object): def __init__(self, using): self.using = using diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 4b286aa232..38a7a10f2a 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -971,7 +971,8 @@ define the details of how the relation works. * :attr:`~django.db.models.CASCADE`: Cascade deletes; the default. * :attr:`~django.db.models.PROTECT`: Prevent deletion of the referenced - object by raising :exc:`django.db.IntegrityError`. + object by raising :exc:`django.db.models.ProtectedError`, a subclass of + :exc:`django.db.IntegrityError`. * :attr:`~django.db.models.SET_NULL`: Set the :class:`ForeignKey` null; this is only possible if :attr:`null` is ``True``. diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py index 0a27964ea3..e64f713b7e 100644 --- a/tests/regressiontests/admin_views/models.py +++ b/tests/regressiontests/admin_views/models.py @@ -626,6 +626,16 @@ class WorkHourAdmin(admin.ModelAdmin): list_display = ('datum', 'employee') list_filter = ('employee',) +class Question(models.Model): + question = models.CharField(max_length=20) + +class Answer(models.Model): + question = models.ForeignKey(Question, on_delete=models.PROTECT) + answer = models.CharField(max_length=20) + + def __unicode__(self): + return self.answer + admin.site.register(Article, ArticleAdmin) admin.site.register(CustomArticle, CustomArticleAdmin) admin.site.register(Section, save_as=True, inlines=[ArticleInline]) @@ -674,3 +684,5 @@ admin.site.register(ChapterXtra1, ChapterXtra1Admin) admin.site.register(Pizza, PizzaAdmin) admin.site.register(Topping) admin.site.register(Album, AlbumAdmin) +admin.site.register(Question) +admin.site.register(Answer) diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index 9b881291de..ecc2316760 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -29,11 +29,12 @@ from django.utils.translation import activate, deactivate from django.utils import unittest # local test models -from models import Article, BarAccount, CustomArticle, EmptyModel, \ - FooAccount, Gallery, ModelWithStringPrimaryKey, \ - Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \ - Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \ - Category, Post, Plot, FunkyTag, Chapter, Book, Promo, WorkHour, Employee +from models import (Article, BarAccount, CustomArticle, EmptyModel, + FooAccount, Gallery, ModelWithStringPrimaryKey, + Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, + Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, + Category, Post, Plot, FunkyTag, Chapter, Book, Promo, WorkHour, Employee, + Question, Answer) class AdminViewBasicTest(TestCase): @@ -884,6 +885,15 @@ class AdminViewDeletedObjectsTest(TestCase): self.assertContains(response, "your account doesn't have permission to delete the following types of objects") self.assertContains(response, "