Fixed #17198 -- Ensured that a deterministic order is used across all database backends for displaying the admin change list's results. Many thanks to Luke Plant for the report and general approach, to everyone involved in the design discussions, and to Carl Meyer for the patch review. Refs #16819.
git-svn-id: http://code.djangoproject.com/svn/django/trunk@17635 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
parent
c76200a0bf
commit
d636150e53
|
@ -66,7 +66,6 @@ class ChangeList(object):
|
||||||
self.list_editable = ()
|
self.list_editable = ()
|
||||||
else:
|
else:
|
||||||
self.list_editable = list_editable
|
self.list_editable = list_editable
|
||||||
self.ordering = self.get_ordering(request)
|
|
||||||
self.query = request.GET.get(SEARCH_VAR, '')
|
self.query = request.GET.get(SEARCH_VAR, '')
|
||||||
self.query_set = self.get_query_set(request)
|
self.query_set = self.get_query_set(request)
|
||||||
self.get_results(request)
|
self.get_results(request)
|
||||||
|
@ -218,13 +217,18 @@ class ChangeList(object):
|
||||||
attr = getattr(self.model, field_name)
|
attr = getattr(self.model, field_name)
|
||||||
return getattr(attr, 'admin_order_field', None)
|
return getattr(attr, 'admin_order_field', None)
|
||||||
|
|
||||||
def get_ordering(self, request):
|
def get_ordering(self, request, queryset):
|
||||||
|
"""
|
||||||
|
Returns the list of ordering fields for the change list.
|
||||||
|
First we check the get_ordering() method in model admin, then we check
|
||||||
|
the object's default ordering. Then, any manually-specified ordering
|
||||||
|
from the query string overrides anything. Finally, a deterministic
|
||||||
|
order is guaranteed by ensuring the primary key is used as the last
|
||||||
|
ordering field.
|
||||||
|
"""
|
||||||
params = self.params
|
params = self.params
|
||||||
# For ordering, first check the if exists the "get_ordering" method
|
ordering = list(self.model_admin.get_ordering(request)
|
||||||
# in model admin, then check "ordering" parameter in the admin
|
or self._get_default_ordering())
|
||||||
# options, then check the object's default ordering. Finally, a
|
|
||||||
# manually-specified ordering from the query string overrides anything.
|
|
||||||
ordering = self.model_admin.get_ordering(request) or self._get_default_ordering()
|
|
||||||
if ORDER_VAR in params:
|
if ORDER_VAR in params:
|
||||||
# Clear ordering and used params
|
# Clear ordering and used params
|
||||||
ordering = []
|
ordering = []
|
||||||
|
@ -239,6 +243,19 @@ class ChangeList(object):
|
||||||
ordering.append(pfx + order_field)
|
ordering.append(pfx + order_field)
|
||||||
except (IndexError, ValueError):
|
except (IndexError, ValueError):
|
||||||
continue # Invalid ordering specified, skip it.
|
continue # Invalid ordering specified, skip it.
|
||||||
|
|
||||||
|
# Add the given query's ordering fields, if any.
|
||||||
|
ordering.extend(queryset.query.order_by)
|
||||||
|
|
||||||
|
# Ensure that the primary key is systematically present in the list of
|
||||||
|
# ordering fields so we can guarantee a deterministic order across all
|
||||||
|
# database backends.
|
||||||
|
pk_name = self.lookup_opts.pk.name
|
||||||
|
if not (set(ordering) & set(['pk', '-pk', pk_name, '-' + pk_name])):
|
||||||
|
# The two sets do not intersect, meaning the pk isn't present. So
|
||||||
|
# we add it.
|
||||||
|
ordering.append('pk')
|
||||||
|
|
||||||
return ordering
|
return ordering
|
||||||
|
|
||||||
def get_ordering_field_columns(self):
|
def get_ordering_field_columns(self):
|
||||||
|
@ -322,8 +339,8 @@ class ChangeList(object):
|
||||||
break
|
break
|
||||||
|
|
||||||
# Set ordering.
|
# Set ordering.
|
||||||
if self.ordering:
|
ordering = self.get_ordering(request, qs)
|
||||||
qs = qs.order_by(*self.ordering)
|
qs = qs.order_by(*ordering)
|
||||||
|
|
||||||
# Apply keyword searches.
|
# Apply keyword searches.
|
||||||
def construct_search(field_name):
|
def construct_search(field_name):
|
||||||
|
|
|
@ -57,3 +57,27 @@ class Swallow(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('speed', 'load')
|
ordering = ('speed', 'load')
|
||||||
|
|
||||||
|
|
||||||
|
class UnorderedObject(models.Model):
|
||||||
|
"""
|
||||||
|
Model without any defined `Meta.ordering`.
|
||||||
|
Refs #17198.
|
||||||
|
"""
|
||||||
|
bool = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderedObjectManager(models.Manager):
|
||||||
|
def get_query_set(self):
|
||||||
|
return super(OrderedObjectManager, self).get_query_set().order_by('-number')
|
||||||
|
|
||||||
|
class OrderedObject(models.Model):
|
||||||
|
"""
|
||||||
|
Model with Manager that defines a default order.
|
||||||
|
Refs #17198.
|
||||||
|
"""
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
bool = models.BooleanField(default=True)
|
||||||
|
number = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
objects = OrderedObjectManager()
|
|
@ -14,7 +14,8 @@ from .admin import (ChildAdmin, QuartetAdmin, BandAdmin, ChordsBandAdmin,
|
||||||
FilteredChildAdmin, CustomPaginator, site as custom_site,
|
FilteredChildAdmin, CustomPaginator, site as custom_site,
|
||||||
SwallowAdmin)
|
SwallowAdmin)
|
||||||
from .models import (Child, Parent, Genre, Band, Musician, Group, Quartet,
|
from .models import (Child, Parent, Genre, Band, Musician, Group, Quartet,
|
||||||
Membership, ChordsMusician, ChordsBand, Invitation, Swallow)
|
Membership, ChordsMusician, ChordsBand, Invitation, Swallow,
|
||||||
|
UnorderedObject, OrderedObject)
|
||||||
|
|
||||||
|
|
||||||
class ChangeListTests(TestCase):
|
class ChangeListTests(TestCase):
|
||||||
|
@ -430,3 +431,92 @@ class ChangeListTests(TestCase):
|
||||||
self.assertContains(response, unicode(swallow.load))
|
self.assertContains(response, unicode(swallow.load))
|
||||||
self.assertContains(response, unicode(swallow.speed))
|
self.assertContains(response, unicode(swallow.speed))
|
||||||
|
|
||||||
|
def test_deterministic_order_for_unordered_model(self):
|
||||||
|
"""
|
||||||
|
Ensure that the primary key is systematically used in the ordering of
|
||||||
|
the changelist's results to guarantee a deterministic order, even
|
||||||
|
when the Model doesn't have any default ordering defined.
|
||||||
|
Refs #17198.
|
||||||
|
"""
|
||||||
|
superuser = self._create_superuser('superuser')
|
||||||
|
|
||||||
|
for counter in range(1, 51):
|
||||||
|
UnorderedObject.objects.create(id=counter, bool=True)
|
||||||
|
|
||||||
|
class UnorderedObjectAdmin(admin.ModelAdmin):
|
||||||
|
list_per_page = 10
|
||||||
|
|
||||||
|
def check_results_order(reverse=False):
|
||||||
|
admin.site.register(UnorderedObject, UnorderedObjectAdmin)
|
||||||
|
model_admin = UnorderedObjectAdmin(UnorderedObject, admin.site)
|
||||||
|
counter = 51 if reverse else 0
|
||||||
|
for page in range (0, 5):
|
||||||
|
request = self._mocked_authenticated_request('/unorderedobject/?p=%s' % page, superuser)
|
||||||
|
response = model_admin.changelist_view(request)
|
||||||
|
for result in response.context_data['cl'].result_list:
|
||||||
|
counter += -1 if reverse else 1
|
||||||
|
self.assertEqual(result.id, counter)
|
||||||
|
admin.site.unregister(UnorderedObject)
|
||||||
|
|
||||||
|
# When no order is defined at all, everything is ordered by 'pk'.
|
||||||
|
check_results_order()
|
||||||
|
|
||||||
|
# When an order field is defined but multiple records have the same
|
||||||
|
# value for that field, make sure everything gets ordered by pk as well.
|
||||||
|
UnorderedObjectAdmin.ordering = ['bool']
|
||||||
|
check_results_order()
|
||||||
|
|
||||||
|
# When order fields are defined, including the pk itself, use them.
|
||||||
|
UnorderedObjectAdmin.ordering = ['bool', '-pk']
|
||||||
|
check_results_order(reverse=True)
|
||||||
|
UnorderedObjectAdmin.ordering = ['bool', 'pk']
|
||||||
|
check_results_order()
|
||||||
|
UnorderedObjectAdmin.ordering = ['-id', 'bool']
|
||||||
|
check_results_order(reverse=True)
|
||||||
|
UnorderedObjectAdmin.ordering = ['id', 'bool']
|
||||||
|
check_results_order()
|
||||||
|
|
||||||
|
def test_deterministic_order_for_model_ordered_by_its_manager(self):
|
||||||
|
"""
|
||||||
|
Ensure that the primary key is systematically used in the ordering of
|
||||||
|
the changelist's results to guarantee a deterministic order, even
|
||||||
|
when the Model has a manager that defines a default ordering.
|
||||||
|
Refs #17198.
|
||||||
|
"""
|
||||||
|
superuser = self._create_superuser('superuser')
|
||||||
|
|
||||||
|
for counter in range(1, 51):
|
||||||
|
OrderedObject.objects.create(id=counter, bool=True, number=counter)
|
||||||
|
|
||||||
|
class OrderedObjectAdmin(admin.ModelAdmin):
|
||||||
|
list_per_page = 10
|
||||||
|
|
||||||
|
def check_results_order(reverse=False):
|
||||||
|
admin.site.register(OrderedObject, OrderedObjectAdmin)
|
||||||
|
model_admin = OrderedObjectAdmin(OrderedObject, admin.site)
|
||||||
|
counter = 51 if reverse else 0
|
||||||
|
for page in range (0, 5):
|
||||||
|
request = self._mocked_authenticated_request('/orderedobject/?p=%s' % page, superuser)
|
||||||
|
response = model_admin.changelist_view(request)
|
||||||
|
for result in response.context_data['cl'].result_list:
|
||||||
|
counter += -1 if reverse else 1
|
||||||
|
self.assertEqual(result.id, counter)
|
||||||
|
admin.site.unregister(OrderedObject)
|
||||||
|
|
||||||
|
# When no order is defined at all, use the model's default ordering (i.e. '-number')
|
||||||
|
check_results_order(reverse=True)
|
||||||
|
|
||||||
|
# When an order field is defined but multiple records have the same
|
||||||
|
# value for that field, make sure everything gets ordered by pk as well.
|
||||||
|
OrderedObjectAdmin.ordering = ['bool']
|
||||||
|
check_results_order()
|
||||||
|
|
||||||
|
# When order fields are defined, including the pk itself, use them.
|
||||||
|
OrderedObjectAdmin.ordering = ['bool', '-pk']
|
||||||
|
check_results_order(reverse=True)
|
||||||
|
OrderedObjectAdmin.ordering = ['bool', 'pk']
|
||||||
|
check_results_order()
|
||||||
|
OrderedObjectAdmin.ordering = ['-id', 'bool']
|
||||||
|
check_results_order(reverse=True)
|
||||||
|
OrderedObjectAdmin.ordering = ['id', 'bool']
|
||||||
|
check_results_order()
|
|
@ -26,7 +26,8 @@ from .models import (Article, Chapter, Account, Media, Child, Parent, Picture,
|
||||||
CoverLetter, Story, OtherStory, Book, Promo, ChapterXtra1, Pizza, Topping,
|
CoverLetter, Story, OtherStory, Book, Promo, ChapterXtra1, Pizza, Topping,
|
||||||
Album, Question, Answer, ComplexSortedPerson, PrePopulatedPostLargeSlug,
|
Album, Question, Answer, ComplexSortedPerson, PrePopulatedPostLargeSlug,
|
||||||
AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod,
|
AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod,
|
||||||
AdminOrderedCallable, Report, Color2, MainPrepopulated, RelatedPrepopulated)
|
AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated,
|
||||||
|
RelatedPrepopulated)
|
||||||
|
|
||||||
|
|
||||||
def callable_year(dt_value):
|
def callable_year(dt_value):
|
||||||
|
@ -562,6 +563,11 @@ class MainPrepopulatedAdmin(admin.ModelAdmin):
|
||||||
'slug2': ['status', 'name']}
|
'slug2': ['status', 'name']}
|
||||||
|
|
||||||
|
|
||||||
|
class UnorderedObjectAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name']
|
||||||
|
list_editable = ['name']
|
||||||
|
list_per_page = 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
site = admin.AdminSite(name="admin")
|
site = admin.AdminSite(name="admin")
|
||||||
|
@ -609,6 +615,7 @@ site.register(Story, StoryAdmin)
|
||||||
site.register(OtherStory, OtherStoryAdmin)
|
site.register(OtherStory, OtherStoryAdmin)
|
||||||
site.register(Report, ReportAdmin)
|
site.register(Report, ReportAdmin)
|
||||||
site.register(MainPrepopulated, MainPrepopulatedAdmin)
|
site.register(MainPrepopulated, MainPrepopulatedAdmin)
|
||||||
|
site.register(UnorderedObject, UnorderedObjectAdmin)
|
||||||
|
|
||||||
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
|
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
|
||||||
# That way we cover all four cases:
|
# That way we cover all four cases:
|
||||||
|
|
|
@ -597,3 +597,12 @@ class RelatedPrepopulated(models.Model):
|
||||||
('option two', 'Option Two')))
|
('option two', 'Option Two')))
|
||||||
slug1 = models.SlugField(max_length=50)
|
slug1 = models.SlugField(max_length=50)
|
||||||
slug2 = models.SlugField(max_length=60)
|
slug2 = models.SlugField(max_length=60)
|
||||||
|
|
||||||
|
|
||||||
|
class UnorderedObject(models.Model):
|
||||||
|
"""
|
||||||
|
Model without any defined `Meta.ordering`.
|
||||||
|
Refs #16819.
|
||||||
|
"""
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
bool = models.BooleanField(default=True)
|
||||||
|
|
|
@ -12,6 +12,7 @@ from django.core.exceptions import SuspiciousOperation
|
||||||
from django.core.files import temp as tempfile
|
from django.core.files import temp as tempfile
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
# Register auth models with the admin.
|
# Register auth models with the admin.
|
||||||
|
from django.contrib import admin
|
||||||
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
||||||
from django.contrib.admin.models import LogEntry, DELETION
|
from django.contrib.admin.models import LogEntry, DELETION
|
||||||
from django.contrib.admin.sites import LOGIN_FORM_KEY
|
from django.contrib.admin.sites import LOGIN_FORM_KEY
|
||||||
|
@ -41,7 +42,7 @@ from .models import (Article, BarAccount, CustomArticle, EmptyModel, FooAccount,
|
||||||
FoodDelivery, RowLevelChangePermissionModel, Paper, CoverLetter, Story,
|
FoodDelivery, RowLevelChangePermissionModel, Paper, CoverLetter, Story,
|
||||||
OtherStory, ComplexSortedPerson, Parent, Child, AdminOrderedField,
|
OtherStory, ComplexSortedPerson, Parent, Child, AdminOrderedField,
|
||||||
AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable,
|
AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable,
|
||||||
Report, MainPrepopulated, RelatedPrepopulated)
|
Report, MainPrepopulated, RelatedPrepopulated, UnorderedObject)
|
||||||
|
|
||||||
|
|
||||||
ERROR_MESSAGE = "Please enter the correct username and password \
|
ERROR_MESSAGE = "Please enter the correct username and password \
|
||||||
|
@ -272,9 +273,12 @@ class AdminViewBasicTest(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def testChangeListSortingPreserveQuerySetOrdering(self):
|
def testChangeListSortingPreserveQuerySetOrdering(self):
|
||||||
# If no ordering on ModelAdmin, or query string, the underlying order of
|
"""
|
||||||
# the queryset should not be changed.
|
If no ordering is defined in `ModelAdmin.ordering` or in the query
|
||||||
|
string, then the underlying order of the queryset should not be
|
||||||
|
changed, even if it is defined in `Modeladmin.queryset()`.
|
||||||
|
Refs #11868, #7309.
|
||||||
|
"""
|
||||||
p1 = Person.objects.create(name="Amy", gender=1, alive=True, age=80)
|
p1 = Person.objects.create(name="Amy", gender=1, alive=True, age=80)
|
||||||
p2 = Person.objects.create(name="Bob", gender=1, alive=True, age=70)
|
p2 = Person.objects.create(name="Bob", gender=1, alive=True, age=70)
|
||||||
p3 = Person.objects.create(name="Chris", gender=2, alive=False, age=60)
|
p3 = Person.objects.create(name="Chris", gender=2, alive=False, age=60)
|
||||||
|
@ -1881,6 +1885,23 @@ class AdminViewListEditable(TestCase):
|
||||||
self.assertEqual(Category.objects.get(id=3).order, 1)
|
self.assertEqual(Category.objects.get(id=3).order, 1)
|
||||||
self.assertEqual(Category.objects.get(id=4).order, 0)
|
self.assertEqual(Category.objects.get(id=4).order, 0)
|
||||||
|
|
||||||
|
def test_list_editable_pagination(self):
|
||||||
|
"""
|
||||||
|
Ensure that pagination works for list_editable items.
|
||||||
|
Refs #16819.
|
||||||
|
"""
|
||||||
|
UnorderedObject.objects.create(id=1, name='Unordered object #1')
|
||||||
|
UnorderedObject.objects.create(id=2, name='Unordered object #2')
|
||||||
|
UnorderedObject.objects.create(id=3, name='Unordered object #3')
|
||||||
|
response = self.client.get('/test_admin/admin/admin_views/unorderedobject/')
|
||||||
|
self.assertContains(response, 'Unordered object #1')
|
||||||
|
self.assertContains(response, 'Unordered object #2')
|
||||||
|
self.assertNotContains(response, 'Unordered object #3')
|
||||||
|
response = self.client.get('/test_admin/admin/admin_views/unorderedobject/?p=1')
|
||||||
|
self.assertNotContains(response, 'Unordered object #1')
|
||||||
|
self.assertNotContains(response, 'Unordered object #2')
|
||||||
|
self.assertContains(response, 'Unordered object #3')
|
||||||
|
|
||||||
def test_list_editable_action_submit(self):
|
def test_list_editable_action_submit(self):
|
||||||
# List editable changes should not be executed if the action "Go" button is
|
# List editable changes should not be executed if the action "Go" button is
|
||||||
# used to submit the form.
|
# used to submit the form.
|
||||||
|
|
Loading…
Reference in New Issue