diff --git a/django/contrib/admin/util.py b/django/contrib/admin/util.py
index bc77226720..bf1ae2f132 100644
--- a/django/contrib/admin/util.py
+++ b/django/contrib/admin/util.py
@@ -7,7 +7,7 @@ from django.utils.text import capfirst
from django.utils.encoding import force_unicode, smart_unicode, smart_str
from django.utils.translation import ungettext, ugettext as _
from django.core.urlresolvers import reverse, NoReverseMatch
-
+from django.utils.datastructures import SortedDict
def quote(s):
"""
@@ -57,135 +57,179 @@ def flatten_fieldsets(fieldsets):
field_names.append(field)
return field_names
-def _nest_help(obj, depth, val):
- current = obj
- for i in range(depth):
- current = current[-1]
- current.append(val)
-
-def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
- """
- Returns the url to the admin change view for the given app_label,
- module_name and primary key.
- """
+def _format_callback(obj, user, admin_site, levels_to_root, perms_needed):
+ has_admin = obj.__class__ in admin_site._registry
+ opts = obj._meta
try:
- return reverse('%sadmin_%s_%s_change' % (admin_site.name, app_label, module_name), None, (pk,))
+ admin_url = reverse('%s:%s_%s_change'
+ % (admin_site.name,
+ opts.app_label,
+ opts.object_name.lower()),
+ None, (quote(obj._get_pk_val()),))
except NoReverseMatch:
- return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, module_name, pk)
+ admin_url = '%s%s/%s/%s/' % ('../'*levels_to_root,
+ opts.app_label,
+ opts.object_name.lower(),
+ quote(obj._get_pk_val()))
+ if has_admin:
+ p = '%s.%s' % (opts.app_label,
+ opts.get_delete_permission())
+ if not user.has_perm(p):
+ perms_needed.add(opts.verbose_name)
+ # Display a link to the admin page.
+ return mark_safe(u'%s:
%s' %
+ (escape(capfirst(opts.verbose_name)),
+ admin_url,
+ escape(obj)))
+ else:
+ # Don't display link to edit, because it either has no
+ # admin or is edited inline.
+ return u'%s: %s' % (capfirst(opts.verbose_name),
+ force_unicode(obj))
-def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site, levels_to_root=4):
+def get_deleted_objects(objs, opts, user, admin_site, levels_to_root=4):
"""
- Helper function that recursively populates deleted_objects.
+ Find all objects related to ``objs`` that should also be
+ deleted. ``objs`` should be an iterable of objects.
- `levels_to_root` defines the number of directories (../) to reach the
- admin root path. In a change_view this is 4, in a change_list view 2.
+ Returns a nested list of strings suitable for display in the
+ template with the ``unordered_list`` filter.
+
+ `levels_to_root` defines the number of directories (../) to reach
+ the admin root path. In a change_view this is 4, in a change_list
+ view 2.
This is for backwards compatibility since the options.delete_selected
method uses this function also from a change_list view.
This will not be used if we can reverse the URL.
"""
- nh = _nest_help # Bind to local variable for performance
- if current_depth > 16:
- return # Avoid recursing too deep.
- opts_seen = []
- for related in opts.get_all_related_objects():
- has_admin = related.model in admin_site._registry
- if related.opts in opts_seen:
- continue
- opts_seen.append(related.opts)
- rel_opts_name = related.get_accessor_name()
- if isinstance(related.field.rel, models.OneToOneRel):
- try:
- sub_obj = getattr(obj, rel_opts_name)
- except ObjectDoesNotExist:
- pass
- else:
- if has_admin:
- p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission())
- if not user.has_perm(p):
- perms_needed.add(related.opts.verbose_name)
- # We don't care about populating deleted_objects now.
- continue
- if not has_admin:
- # Don't display link to edit, because it either has no
- # admin or is edited inline.
- nh(deleted_objects, current_depth,
- [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
- else:
- # Display a link to the admin page.
- nh(deleted_objects, current_depth, [mark_safe(u'%s:
%s' %
- (escape(capfirst(related.opts.verbose_name)),
- get_change_view_url(related.opts.app_label,
- related.opts.object_name.lower(),
- sub_obj._get_pk_val(),
- admin_site,
- levels_to_root),
- escape(sub_obj))), []])
- get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
+ collector = NestedObjects()
+ for obj in objs:
+ # TODO using a private model API!
+ obj._collect_sub_objects(collector)
+
+ # TODO This next bit is needed only because GenericRelations are
+ # cascade-deleted way down in the internals in
+ # DeleteQuery.delete_batch_related, instead of being found by
+ # _collect_sub_objects. Refs #12593.
+ from django.contrib.contenttypes import generic
+ for f in obj._meta.many_to_many:
+ if isinstance(f, generic.GenericRelation):
+ rel_manager = f.value_from_object(obj)
+ for related in rel_manager.all():
+ # There's a wierdness here in the case that the
+ # generic-related object also has FKs pointing to it
+ # from elsewhere. DeleteQuery does not follow those
+ # FKs or delete any such objects explicitly (which is
+ # probably a bug). Some databases may cascade those
+ # deletes themselves, and some won't. So do we report
+ # those objects as to-be-deleted? No right answer; for
+ # now we opt to report only on objects that Django
+ # will explicitly delete, at risk that some further
+ # objects will be silently deleted by a
+ # referential-integrity-maintaining database.
+ collector.add(related.__class__, related.pk, related,
+ obj.__class__, obj)
+
+ perms_needed = set()
+
+ to_delete = collector.nested(_format_callback,
+ user=user,
+ admin_site=admin_site,
+ levels_to_root=levels_to_root,
+ perms_needed=perms_needed)
+
+ return to_delete, perms_needed
+
+
+class NestedObjects(object):
+ """
+ A directed acyclic graph collection that exposes the add() API
+ expected by Model._collect_sub_objects and can present its data as
+ a nested list of objects.
+
+ """
+ def __init__(self):
+ # Use object keys of the form (model, pk) because actual model
+ # objects may not be unique
+
+ # maps object key to list of child keys
+ self.children = SortedDict()
+
+ # maps object key to parent key
+ self.parents = SortedDict()
+
+ # maps object key to actual object
+ self.seen = SortedDict()
+
+ def add(self, model, pk, obj,
+ parent_model=None, parent_obj=None, nullable=False):
+ """
+ Add item ``obj`` to the graph. Returns True (and does nothing)
+ if the item has been seen already.
+
+ The ``parent_obj`` argument must already exist in the graph; if
+ not, it's ignored (but ``obj`` is still added with no
+ parent). In any case, Model._collect_sub_objects (for whom
+ this API exists) will never pass a parent that hasn't already
+ been added itself.
+
+ These restrictions in combination ensure the graph will remain
+ acyclic (but can have multiple roots).
+
+ ``model``, ``pk``, and ``parent_model`` arguments are ignored
+ in favor of the appropriate lookups on ``obj`` and
+ ``parent_obj``; unlike CollectedObjects, we can't maintain
+ independence from the knowledge that we're operating on model
+ instances, and we don't want to allow for inconsistency.
+
+ ``nullable`` arg is ignored: it doesn't affect how the tree of
+ collected objects should be nested for display.
+ """
+ model, pk = type(obj), obj._get_pk_val()
+
+ key = model, pk
+
+ if key in self.seen:
+ return True
+ self.seen.setdefault(key, obj)
+
+ if parent_obj is not None:
+ parent_model, parent_pk = (type(parent_obj),
+ parent_obj._get_pk_val())
+ parent_key = (parent_model, parent_pk)
+ if parent_key in self.seen:
+ self.children.setdefault(parent_key, list()).append(key)
+ self.parents.setdefault(key, parent_key)
+
+ def _nested(self, key, format_callback=None, **kwargs):
+ obj = self.seen[key]
+ if format_callback:
+ ret = [format_callback(obj, **kwargs)]
else:
- has_related_objs = False
- for sub_obj in getattr(obj, rel_opts_name).all():
- has_related_objs = True
- if not has_admin:
- # Don't display link to edit, because it either has no
- # admin or is edited inline.
- nh(deleted_objects, current_depth,
- [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
- else:
- # Display a link to the admin page.
- nh(deleted_objects, current_depth, [mark_safe(u'%s:
%s' %
- (escape(capfirst(related.opts.verbose_name)),
- get_change_view_url(related.opts.app_label,
- related.opts.object_name.lower(),
- sub_obj._get_pk_val(),
- admin_site,
- levels_to_root),
- escape(sub_obj))), []])
- get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
- # If there were related objects, and the user doesn't have
- # permission to delete them, add the missing perm to perms_needed.
- if has_admin and has_related_objs:
- p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission())
- if not user.has_perm(p):
- perms_needed.add(related.opts.verbose_name)
- for related in opts.get_all_related_many_to_many_objects():
- has_admin = related.model in admin_site._registry
- if related.opts in opts_seen:
- continue
- opts_seen.append(related.opts)
- rel_opts_name = related.get_accessor_name()
- has_related_objs = False
+ ret = [obj]
- # related.get_accessor_name() could return None for symmetrical relationships
- if rel_opts_name:
- rel_objs = getattr(obj, rel_opts_name, None)
- if rel_objs:
- has_related_objs = True
+ children = []
+ for child in self.children.get(key, ()):
+ children.extend(self._nested(child, format_callback, **kwargs))
+ if children:
+ ret.append(children)
+
+ return ret
+
+ def nested(self, format_callback=None, **kwargs):
+ """
+ Return the graph as a nested list.
+
+ Passes **kwargs back to the format_callback as kwargs.
+
+ """
+ roots = []
+ for key in self.seen.keys():
+ if key not in self.parents:
+ roots.extend(self._nested(key, format_callback, **kwargs))
+ return roots
- if has_related_objs:
- for sub_obj in rel_objs.all():
- if not has_admin:
- # Don't display link to edit, because it either has no
- # admin or is edited inline.
- nh(deleted_objects, current_depth, [_('One or more %(fieldname)s in %(name)s: %(obj)s') % \
- {'fieldname': force_unicode(related.field.verbose_name), 'name': force_unicode(related.opts.verbose_name), 'obj': escape(sub_obj)}, []])
- else:
- # Display a link to the admin page.
- nh(deleted_objects, current_depth, [
- mark_safe((_('One or more %(fieldname)s in %(name)s:') % {'fieldname': escape(force_unicode(related.field.verbose_name)), 'name': escape(force_unicode(related.opts.verbose_name))}) + \
- (u'
%s' % \
- (get_change_view_url(related.opts.app_label,
- related.opts.object_name.lower(),
- sub_obj._get_pk_val(),
- admin_site,
- levels_to_root),
- escape(sub_obj)))), []])
- # If there were related objects, and the user doesn't have
- # permission to change them, add the missing perm to perms_needed.
- if has_admin and has_related_objs:
- p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
- if not user.has_perm(p):
- perms_needed.add(related.opts.verbose_name)
def model_format_dict(obj):
"""
diff --git a/django/db/models/base.py b/django/db/models/base.py
index 4ed402f437..c330b6c085 100644
--- a/django/db/models/base.py
+++ b/django/db/models/base.py
@@ -549,7 +549,8 @@ class Model(object):
(model_class, {pk_val: obj, pk_val: obj, ...}), ...]
"""
pk_val = self._get_pk_val()
- if seen_objs.add(self.__class__, pk_val, self, parent, nullable):
+ if seen_objs.add(self.__class__, pk_val, self,
+ type(parent), parent, nullable):
return
for related in self._meta.get_all_related_objects():
@@ -560,7 +561,7 @@ class Model(object):
except ObjectDoesNotExist:
pass
else:
- sub_obj._collect_sub_objects(seen_objs, self.__class__, related.field.null)
+ sub_obj._collect_sub_objects(seen_objs, self, related.field.null)
else:
# To make sure we can access all elements, we can't use the
# normal manager on the related object. So we work directly
@@ -578,7 +579,7 @@ class Model(object):
continue
delete_qs = rel_descriptor.delete_manager(self).all()
for sub_obj in delete_qs:
- sub_obj._collect_sub_objects(seen_objs, self.__class__, related.field.null)
+ sub_obj._collect_sub_objects(seen_objs, self, related.field.null)
# Handle any ancestors (for the model-inheritance case). We do this by
# traversing to the most remote parent classes -- those with no parents
diff --git a/django/db/models/query_utils.py b/django/db/models/query_utils.py
index 9129d95a0b..c899a846e3 100644
--- a/django/db/models/query_utils.py
+++ b/django/db/models/query_utils.py
@@ -50,7 +50,7 @@ class CollectedObjects(object):
else:
self.blocked = {}
- def add(self, model, pk, obj, parent_model, nullable=False):
+ def add(self, model, pk, obj, parent_model, parent_obj=None, nullable=False):
"""
Adds an item to the container.
@@ -60,6 +60,8 @@ class CollectedObjects(object):
* obj - the object itself.
* parent_model - the model of the parent object that this object was
reached through.
+ * parent_obj - the parent object this object was reached
+ through (not used here, but needed in the API for use elsewhere)
* nullable - should be True if this relation is nullable.
Returns True if the item already existed in the structure and
diff --git a/tests/regressiontests/admin_util/models.py b/tests/regressiontests/admin_util/models.py
index 0c2af585cd..e81f7bbc25 100644
--- a/tests/regressiontests/admin_util/models.py
+++ b/tests/regressiontests/admin_util/models.py
@@ -17,3 +17,6 @@ class Article(models.Model):
def test_from_model_with_override(self):
return "nothing"
test_from_model_with_override.short_description = "not what you expect"
+
+class Count(models.Model):
+ num = models.PositiveSmallIntegerField()
diff --git a/tests/regressiontests/admin_util/tests.py b/tests/regressiontests/admin_util/tests.py
index 479cfdcb52..43d7057783 100644
--- a/tests/regressiontests/admin_util/tests.py
+++ b/tests/regressiontests/admin_util/tests.py
@@ -2,16 +2,69 @@ from datetime import datetime
import unittest
from django.db import models
+from django.utils.formats import localize
+from django.test import TestCase
from django.contrib import admin
from django.contrib.admin.util import display_for_field, label_for_field, lookup_field
from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
from django.contrib.sites.models import Site
-from django.utils.formats import localize
+from django.contrib.admin.util import NestedObjects
-from models import Article
+from models import Article, Count
+class NestedObjectsTests(TestCase):
+ """
+ Tests for ``NestedObject`` utility collection.
+
+ """
+ def setUp(self):
+ self.n = NestedObjects()
+ self.objs = [Count.objects.create(num=i) for i in range(5)]
+
+ def _check(self, target):
+ self.assertEquals(self.n.nested(lambda obj: obj.num), target)
+
+ def _add(self, obj, parent=None):
+ # don't bother providing the extra args that NestedObjects ignores
+ self.n.add(None, None, obj, None, parent)
+
+ def test_unrelated_roots(self):
+ self._add(self.objs[0])
+ self._add(self.objs[1])
+ self._add(self.objs[2], self.objs[1])
+
+ self._check([0, 1, [2]])
+
+ def test_siblings(self):
+ self._add(self.objs[0])
+ self._add(self.objs[1], self.objs[0])
+ self._add(self.objs[2], self.objs[0])
+
+ self._check([0, [1, 2]])
+
+ def test_duplicate_instances(self):
+ self._add(self.objs[0])
+ self._add(self.objs[1])
+ dupe = Count.objects.get(num=1)
+ self._add(dupe, self.objs[0])
+
+ self._check([0, 1])
+
+ def test_non_added_parent(self):
+ self._add(self.objs[0], self.objs[1])
+
+ self._check([0])
+
+ def test_cyclic(self):
+ self._add(self.objs[0], self.objs[2])
+ self._add(self.objs[1], self.objs[0])
+ self._add(self.objs[2], self.objs[1])
+ self._add(self.objs[0], self.objs[2])
+
+ self._check([0, [1, [2]]])
+
class UtilTests(unittest.TestCase):
def test_values_from_lookup_field(self):
diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py
index b5d8f94988..d7b465152a 100644
--- a/tests/regressiontests/admin_views/models.py
+++ b/tests/regressiontests/admin_views/models.py
@@ -10,7 +10,8 @@ from django.core.mail import EmailMessage
from django.db import models
from django import forms
from django.forms.models import BaseModelFormSet
-
+from django.contrib.contenttypes import generic
+from django.contrib.contenttypes.models import ContentType
class Section(models.Model):
"""
@@ -494,6 +495,71 @@ class GadgetAdmin(admin.ModelAdmin):
def get_changelist(self, request, **kwargs):
return CustomChangeList
+class Villain(models.Model):
+ name = models.CharField(max_length=100)
+
+ def __unicode__(self):
+ return self.name
+
+class SuperVillain(Villain):
+ pass
+
+class FunkyTag(models.Model):
+ "Because we all know there's only one real use case for GFKs."
+ name = models.CharField(max_length=25)
+ content_type = models.ForeignKey(ContentType)
+ object_id = models.PositiveIntegerField()
+ content_object = generic.GenericForeignKey('content_type', 'object_id')
+
+ def __unicode__(self):
+ return self.name
+
+class Plot(models.Model):
+ name = models.CharField(max_length=100)
+ team_leader = models.ForeignKey(Villain, related_name='lead_plots')
+ contact = models.ForeignKey(Villain, related_name='contact_plots')
+ tags = generic.GenericRelation(FunkyTag)
+
+ def __unicode__(self):
+ return self.name
+
+class PlotDetails(models.Model):
+ details = models.CharField(max_length=100)
+ plot = models.OneToOneField(Plot)
+
+ def __unicode__(self):
+ return self.details
+
+class SecretHideout(models.Model):
+ """ Secret! Not registered with the admin! """
+ location = models.CharField(max_length=100)
+ villain = models.ForeignKey(Villain)
+
+ def __unicode__(self):
+ return self.location
+
+class SuperSecretHideout(models.Model):
+ """ Secret! Not registered with the admin! """
+ location = models.CharField(max_length=100)
+ supervillain = models.ForeignKey(SuperVillain)
+
+ def __unicode__(self):
+ return self.location
+
+class CyclicOne(models.Model):
+ name = models.CharField(max_length=25)
+ two = models.ForeignKey('CyclicTwo')
+
+ def __unicode__(self):
+ return self.name
+
+class CyclicTwo(models.Model):
+ name = models.CharField(max_length=25)
+ one = models.ForeignKey(CyclicOne)
+
+ def __unicode__(self):
+ return self.name
+
admin.site.register(Article, ArticleAdmin)
admin.site.register(CustomArticle, CustomArticleAdmin)
admin.site.register(Section, save_as=True, inlines=[ArticleInline])
@@ -519,6 +585,12 @@ admin.site.register(Collector, CollectorAdmin)
admin.site.register(Category, CategoryAdmin)
admin.site.register(Post, PostAdmin)
admin.site.register(Gadget, GadgetAdmin)
+admin.site.register(Villain)
+admin.site.register(SuperVillain)
+admin.site.register(Plot)
+admin.site.register(PlotDetails)
+admin.site.register(CyclicOne)
+admin.site.register(CyclicTwo)
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
# That way we cover all four cases:
diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py
index 911549fa06..ca9b0e86fa 100644
--- a/tests/regressiontests/admin_views/tests.py
+++ b/tests/regressiontests/admin_views/tests.py
@@ -16,13 +16,14 @@ from django.utils import formats
from django.utils.cache import get_max_age
from django.utils.html import escape
from django.utils.translation import get_date_formats
+from django.utils.encoding import iri_to_uri
# local test models
from models import Article, BarAccount, CustomArticle, EmptyModel, \
ExternalSubscriber, FooAccount, Gallery, ModelWithStringPrimaryKey, \
Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \
Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \
- Category, Post
+ Category, Post, Plot, FunkyTag
class AdminViewBasicTest(TestCase):
@@ -637,6 +638,113 @@ class AdminViewPermissionsTest(TestCase):
response = self.client.get('/test_admin/admin/secure-view/')
self.assertContains(response, 'id="login-form"')
+
+class AdminViewDeletedObjectsTest(TestCase):
+ fixtures = ['admin-views-users.xml', 'deleted-objects.xml']
+
+ def setUp(self):
+ self.client.login(username='super', password='secret')
+
+ def tearDown(self):
+ self.client.logout()
+
+ def test_nesting(self):
+ """
+ Objects should be nested to display the relationships that
+ cause them to be scheduled for deletion.
+ """
+ pattern = re.compile(r"""
Plot: World Domination\s*\s*- Plot details: almost finished""")
+ response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(1))
+ self.failUnless(pattern.search(response.content))
+
+ def test_cyclic(self):
+ """
+ Cyclic relationships should still cause each object to only be
+ listed once.
+
+ """
+ one = """
- Cyclic one: I am recursive"""
+ two = """
- Cyclic two: I am recursive too"""
+ response = self.client.get('/test_admin/admin/admin_views/cyclicone/%s/delete/' % quote(1))
+
+ self.assertContains(response, one, 1)
+ self.assertContains(response, two, 1)
+
+ def test_perms_needed(self):
+ self.client.logout()
+ delete_user = User.objects.get(username='deleteuser')
+ delete_user.user_permissions.add(get_perm(Plot,
+ Plot._meta.get_delete_permission()))
+
+ self.failUnless(self.client.login(username='deleteuser',
+ password='secret'))
+
+ response = self.client.get('/test_admin/admin/admin_views/plot/%s/delete/' % quote(1))
+ self.assertContains(response, "your account doesn't have permission to delete the following types of objects")
+ self.assertContains(response, "
- plot details
")
+
+
+ def test_not_registered(self):
+ should_contain = """- Secret hideout: underground bunker"""
+ response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(1))
+ self.assertContains(response, should_contain, 1)
+
+ def test_multiple_fkeys_to_same_model(self):
+ """
+ If a deleted object has two relationships from another model,
+ both of those should be followed in looking for related
+ objects to delete.
+
+ """
+ should_contain = """
- Plot: World Domination"""
+ response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(1))
+ self.assertContains(response, should_contain)
+ response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(2))
+ self.assertContains(response, should_contain)
+
+ def test_multiple_fkeys_to_same_instance(self):
+ """
+ If a deleted object has two relationships pointing to it from
+ another object, the other object should still only be listed
+ once.
+
+ """
+ should_contain = """
- Plot: World Peace
"""
+ response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(2))
+ self.assertContains(response, should_contain, 1)
+
+ def test_inheritance(self):
+ """
+ In the case of an inherited model, if either the child or
+ parent-model instance is deleted, both instances are listed
+ for deletion, as well as any relationships they have.
+
+ """
+ should_contain = [
+ """- Villain: Bob""",
+ """
- Super villain: Bob""",
+ """
- Secret hideout: floating castle""",
+ """
- Super secret hideout: super floating castle!"""
+ ]
+ response = self.client.get('/test_admin/admin/admin_views/villain/%s/delete/' % quote(3))
+ for should in should_contain:
+ self.assertContains(response, should, 1)
+ response = self.client.get('/test_admin/admin/admin_views/supervillain/%s/delete/' % quote(3))
+ for should in should_contain:
+ self.assertContains(response, should, 1)
+
+ def test_generic_relations(self):
+ """
+ If a deleted object has GenericForeignKeys pointing to it,
+ those objects should be listed for deletion.
+
+ """
+ plot = Plot.objects.get(pk=3)
+ tag = FunkyTag.objects.create(content_object=plot, name='hott')
+ should_contain = """
- Funky tag: hott"""
+ response = self.client.get('/test_admin/admin/admin_views/plot/%s/delete/' % quote(3))
+ self.assertContains(response, should_contain)
+
class AdminViewStringPrimaryKeyTest(TestCase):
fixtures = ['admin-views-users.xml', 'string-primary-key.xml']
@@ -699,7 +807,8 @@ class AdminViewStringPrimaryKeyTest(TestCase):
def test_deleteconfirmation_link(self):
"The link from the delete confirmation page referring back to the changeform of the object should be quoted"
response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/%s/delete/' % quote(self.pk))
- should_contain = """%s""" % (quote(self.pk), escape(self.pk))
+ # this URL now comes through reverse(), thus iri_to_uri encoding
+ should_contain = """/%s/">%s""" % (iri_to_uri(quote(self.pk)), escape(self.pk))
self.assertContains(response, should_contain)
def test_url_conflicts_with_add(self):