Fixed #14672 - Added admin handling for on_delete=PROTECT. Thanks to jtiai for the report.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@15249 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
Carl Meyer 2011-01-20 00:33:32 +00:00
parent e01cb07404
commit 93a4d46184
10 changed files with 115 additions and 27 deletions

View File

@ -32,7 +32,7 @@ def delete_selected(modeladmin, request, queryset):
# Populate deletable_objects, a data structure of all related objects that # Populate deletable_objects, a data structure of all related objects that
# will also be deleted. # 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) queryset, opts, request.user, modeladmin.admin_site, using)
# The user has already confirmed the deletion. # The user has already confirmed the deletion.
@ -58,6 +58,7 @@ def delete_selected(modeladmin, request, queryset):
"deletable_objects": [deletable_objects], "deletable_objects": [deletable_objects],
'queryset': queryset, 'queryset': queryset,
"perms_lacking": perms_needed, "perms_lacking": perms_needed,
"protected": protected,
"opts": opts, "opts": opts,
"root_path": modeladmin.admin_site.root_path, "root_path": modeladmin.admin_site.root_path,
"app_label": app_label, "app_label": app_label,

View File

@ -1177,7 +1177,7 @@ class ModelAdmin(BaseModelAdmin):
# Populate deleted_objects, a data structure of all related objects that # Populate deleted_objects, a data structure of all related objects that
# will also be deleted. # 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) [obj], opts, request.user, self.admin_site, using)
if request.POST: # The user has already confirmed the deletion. if request.POST: # The user has already confirmed the deletion.
@ -1199,6 +1199,7 @@ class ModelAdmin(BaseModelAdmin):
"object": obj, "object": obj,
"deleted_objects": deleted_objects, "deleted_objects": deleted_objects,
"perms_lacking": perms_needed, "perms_lacking": perms_needed,
"protected": protected,
"opts": opts, "opts": opts,
"root_path": self.admin_site.root_path, "root_path": self.admin_site.root_path,
"app_label": app_label, "app_label": app_label,

View File

@ -12,13 +12,23 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if perms_lacking %} {% if perms_lacking or protected %}
<p>{% 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 %}</p> {% if perms_lacking %}
<ul> <p>{% 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 %}</p>
{% for obj in perms_lacking %} <ul>
<li>{{ obj }}</li> {% for obj in perms_lacking %}
{% endfor %} <li>{{ obj }}</li>
</ul> {% endfor %}
</ul>
{% endif %}
{% if protected %}
<p>{% 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 %}</p>
<ul>
{% for obj in protected %}
<li>{{ obj }}</li>
{% endfor %}
</ul>
{% endif %}
{% else %} {% else %}
<p>{% 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 %}</p> <p>{% 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 %}</p>
<ul>{{ deleted_objects|unordered_list }}</ul> <ul>{{ deleted_objects|unordered_list }}</ul>

View File

@ -11,13 +11,23 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if perms_lacking %} {% if perms_lacking or protected %}
<p>{% 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 %}</p> {% if perms_lacking %}
<ul> <p>{% 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 %}</p>
{% for obj in perms_lacking %} <ul>
<li>{{ obj }}</li> {% for obj in perms_lacking %}
{% endfor %} <li>{{ obj }}</li>
</ul> {% endfor %}
</ul>
{% endif %}
{% if protected %}
<p>{% blocktrans %}Deleting the {{ object_name }} is not possible, because it would require deleting the following protected related objects:{% endblocktrans %}</p>
<ul>
{% for obj in protected %}
<li>{{ obj }}</li>
{% endfor %}
</ul>
{% endif %}
{% else %} {% else %}
<p>{% 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 %}</p> <p>{% 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 %}</p>
{% for deletable_object in deletable_objects %} {% for deletable_object in deletable_objects %}

View File

@ -104,13 +104,16 @@ def get_deleted_objects(objs, opts, user, admin_site, using):
to_delete = collector.nested(format_callback) 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): class NestedObjects(Collector):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(NestedObjects, self).__init__(*args, **kwargs) super(NestedObjects, self).__init__(*args, **kwargs)
self.edges = {} # {from_instance: [to_instances]} self.edges = {} # {from_instance: [to_instances]}
self.protected = set()
def add_edge(self, source, target): def add_edge(self, source, target):
self.edges.setdefault(source, []).append(target) self.edges.setdefault(source, []).append(target)
@ -121,7 +124,10 @@ class NestedObjects(Collector):
self.add_edge(getattr(obj, source_attr), obj) self.add_edge(getattr(obj, source_attr), obj)
else: else:
self.add_edge(None, obj) 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): def related_objects(self, related, objs):
qs = super(NestedObjects, self).related_objects(related, objs) qs = super(NestedObjects, self).related_objects(related, objs)

View File

@ -11,7 +11,7 @@ from django.db.models.fields import *
from django.db.models.fields.subclassing import SubfieldBase from django.db.models.fields.subclassing import SubfieldBase
from django.db.models.fields.files import FileField, ImageField 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.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 from django.db.models import signals
# Admin stages. # Admin stages.

View File

@ -7,17 +7,27 @@ from django.utils.datastructures import SortedDict
from django.utils.functional import wraps 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): def CASCADE(collector, field, sub_objs, using):
collector.collect(sub_objs, source=field.rel.to, collector.collect(sub_objs, source=field.rel.to,
source_attr=field.name, nullable=field.null) source_attr=field.name, nullable=field.null)
if field.null and not connections[using].features.can_defer_constraint_checks: if field.null and not connections[using].features.can_defer_constraint_checks:
collector.add_field_update(field, None, sub_objs) collector.add_field_update(field, None, sub_objs)
def PROTECT(collector, field, sub_objs, using): 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'" % ( "they are referenced through a protected foreign key: '%s.%s'" % (
field.rel.to.__name__, sub_objs[0].__class__.__name__, field.name field.rel.to.__name__, sub_objs[0].__class__.__name__, field.name
)) ),
sub_objs
)
def SET(value): def SET(value):
if callable(value): if callable(value):
@ -28,14 +38,18 @@ def SET(value):
collector.add_field_update(field, value, sub_objs) collector.add_field_update(field, value, sub_objs)
return set_on_delete return set_on_delete
SET_NULL = SET(None) SET_NULL = SET(None)
def SET_DEFAULT(collector, field, sub_objs, using): def SET_DEFAULT(collector, field, sub_objs, using):
collector.add_field_update(field, field.get_default(), sub_objs) collector.add_field_update(field, field.get_default(), sub_objs)
def DO_NOTHING(collector, field, sub_objs, using): def DO_NOTHING(collector, field, sub_objs, using):
pass pass
def force_managed(func): def force_managed(func):
@wraps(func) @wraps(func)
def decorated(self, *args, **kwargs): def decorated(self, *args, **kwargs):
@ -55,6 +69,7 @@ def force_managed(func):
transaction.leave_transaction_management(using=self.using) transaction.leave_transaction_management(using=self.using)
return decorated return decorated
class Collector(object): class Collector(object):
def __init__(self, using): def __init__(self, using):
self.using = using self.using = using

View File

@ -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.CASCADE`: Cascade deletes; the default.
* :attr:`~django.db.models.PROTECT`: Prevent deletion of the referenced * :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; * :attr:`~django.db.models.SET_NULL`: Set the :class:`ForeignKey` null;
this is only possible if :attr:`null` is ``True``. this is only possible if :attr:`null` is ``True``.

View File

@ -626,6 +626,16 @@ class WorkHourAdmin(admin.ModelAdmin):
list_display = ('datum', 'employee') list_display = ('datum', 'employee')
list_filter = ('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(Article, ArticleAdmin)
admin.site.register(CustomArticle, CustomArticleAdmin) admin.site.register(CustomArticle, CustomArticleAdmin)
admin.site.register(Section, save_as=True, inlines=[ArticleInline]) 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(Pizza, PizzaAdmin)
admin.site.register(Topping) admin.site.register(Topping)
admin.site.register(Album, AlbumAdmin) admin.site.register(Album, AlbumAdmin)
admin.site.register(Question)
admin.site.register(Answer)

View File

@ -29,11 +29,12 @@ from django.utils.translation import activate, deactivate
from django.utils import unittest from django.utils import unittest
# local test models # local test models
from models import Article, BarAccount, CustomArticle, EmptyModel, \ from models import (Article, BarAccount, CustomArticle, EmptyModel,
FooAccount, Gallery, ModelWithStringPrimaryKey, \ FooAccount, Gallery, ModelWithStringPrimaryKey,
Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \ Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast,
Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \ Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit,
Category, Post, Plot, FunkyTag, Chapter, Book, Promo, WorkHour, Employee Category, Post, Plot, FunkyTag, Chapter, Book, Promo, WorkHour, Employee,
Question, Answer)
class AdminViewBasicTest(TestCase): 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, "your account doesn't have permission to delete the following types of objects")
self.assertContains(response, "<li>plot details</li>") self.assertContains(response, "<li>plot details</li>")
def test_protected(self):
q = Question.objects.create(question="Why?")
a1 = Answer.objects.create(question=q, answer="Because.")
a2 = Answer.objects.create(question=q, answer="Yes.")
response = self.client.get("/test_admin/admin/admin_views/question/%s/delete/" % quote(q.pk))
self.assertContains(response, "would require deleting the following protected related objects")
self.assertContains(response, '<li>Answer: <a href="/test_admin/admin/admin_views/answer/%s/">Because.</a></li>' % a1.pk)
self.assertContains(response, '<li>Answer: <a href="/test_admin/admin/admin_views/answer/%s/">Yes.</a></li>' % a2.pk)
def test_not_registered(self): def test_not_registered(self):
should_contain = """<li>Secret hideout: underground bunker""" should_contain = """<li>Secret hideout: underground bunker"""
@ -1627,6 +1637,28 @@ class AdminActionsTest(TestCase):
response = self.client.post('/test_admin/admin/admin_views/subscriber/', delete_confirmation_data) response = self.client.post('/test_admin/admin/admin_views/subscriber/', delete_confirmation_data)
self.assertEqual(Subscriber.objects.count(), 0) self.assertEqual(Subscriber.objects.count(), 0)
def test_model_admin_default_delete_action_protected(self):
"""
Tests the default delete action defined as a ModelAdmin method in the
case where some related objects are protected from deletion.
"""
q1 = Question.objects.create(question="Why?")
a1 = Answer.objects.create(question=q1, answer="Because.")
a2 = Answer.objects.create(question=q1, answer="Yes.")
q2 = Question.objects.create(question="Wherefore?")
action_data = {
ACTION_CHECKBOX_NAME: [q1.pk, q2.pk],
'action' : 'delete_selected',
'index': 0,
}
response = self.client.post("/test_admin/admin/admin_views/question/", action_data)
self.assertContains(response, "would require deleting the following protected related objects")
self.assertContains(response, '<li>Answer: <a href="/test_admin/admin/admin_views/answer/%s/">Because.</a></li>' % a1.pk)
self.assertContains(response, '<li>Answer: <a href="/test_admin/admin/admin_views/answer/%s/">Yes.</a></li>' % a2.pk)
def test_custom_function_mail_action(self): def test_custom_function_mail_action(self):
"Tests a custom action defined in a function" "Tests a custom action defined in a function"
action_data = { action_data = {