Skip to content

Commit 478aaef

Browse files
committed
Implement history_order_by_date and get_version_ordering() hook
1 parent abe3535 commit 478aaef

3 files changed

Lines changed: 297 additions & 7 deletions

File tree

docs/admin.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ A subclass of ``django.contrib.ModelAdmin`` providing rollback and recovery.
9090
If ``True``, revisions will be displayed with the most recent revision first.
9191

9292

93+
``history_order_by_date = False``
94+
95+
If ``True``, revisions will be ordered by ``date_created`` instead of the numeric version ID.
96+
97+
9398
.. _VersionAdmin_register:
9499

95100
``reversion_register(model, **options)``
@@ -107,3 +112,21 @@ A subclass of ``django.contrib.ModelAdmin`` providing rollback and recovery.
107112

108113
``options``
109114
Registration options, see :ref:`reversion.register() <register>`.
115+
116+
.. _VersionAdmin_get_version_ordering:
117+
118+
``get_version_ordering(model, **options)``
119+
120+
Hook for custom or dynamic ordering of the version queryset, with similar semantics to the built-in ``get_ordering`` method in Django's ``ModelAdmin``. Implementations are expected to return a tuple specifying the field names (relative to the ``Version`` model) for ordering.
121+
122+
The values of ``history_latest_first`` and ``history_order_by_date`` are ignored when overriding this method unless explicitly used in the override logic.
123+
124+
.. code:: python
125+
126+
def get_version_ordering(self, request):
127+
if request.user.is_superuser():
128+
return ("-revision__date_created", "pk")
129+
return ("-pk",)
130+
131+
``request``
132+
The current request.

reversion/admin.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,16 @@ class VersionAdmin(admin.ModelAdmin):
4040

4141
history_latest_first = False
4242

43+
history_order_by_date = False
44+
4345
def reversion_register(self, model, **kwargs):
4446
"""Registers the model with reversion."""
4547
register(model, **kwargs)
4648

49+
def get_version_ordering(self, request):
50+
"""Hook for specifying custom field ordering for the version queryset."""
51+
pass
52+
4753
@contextmanager
4854
def create_revision(self, request):
4955
with create_revision():
@@ -60,11 +66,20 @@ def _reversion_get_template_list(self, template_name):
6066
"reversion/%s" % template_name,
6167
)
6268

63-
def _reversion_order_version_queryset(self, queryset):
69+
def _reversion_order_version_queryset(self, request, queryset):
6470
"""Applies the correct ordering to the given version queryset."""
65-
if not self.history_latest_first:
66-
queryset = queryset.order_by("pk")
67-
return queryset
71+
ordering = self.get_version_ordering(request) or ()
72+
# Default ordering logic should remain here to prevent accidental side effects
73+
# from calling super().get_version_ordering() in subclass implementations
74+
if not ordering:
75+
order_fields = ["pk"]
76+
if self.history_order_by_date:
77+
order_fields.insert(0, "revision__date_created")
78+
# history_latest_first causes order to be reversed on all fields
79+
if self.history_latest_first:
80+
order_fields = [f"-{field}" for field in order_fields]
81+
ordering = tuple(order_fields)
82+
return queryset.order_by(*ordering)
6883

6984
# Messages.
7085

@@ -258,6 +273,7 @@ def recoverlist_view(self, request, extra_context=None):
258273
model = self.model
259274
opts = model._meta
260275
deleted = self._reversion_order_version_queryset(
276+
request,
261277
Version.objects.get_deleted(self.model).select_related("revision")
262278
)
263279
# Set the app name.
@@ -298,10 +314,10 @@ def history_view(self, request, object_id, extra_context=None):
298314
),
299315
}
300316
for version
301-
in self._reversion_order_version_queryset(Version.objects.get_for_object_reference(
317+
in self._reversion_order_version_queryset(request, Version.objects.get_for_object_reference(
302318
self.model,
303319
unquote(object_id), # Underscores in primary key get quoted to "_5F"
304-
).select_related("revision__user"))
320+
).select_related("revision", "revision__user"))
305321
]
306322
# Compile the context.
307323
context = {"action_list": action_list}

tests/test_app/tests/test_admin.py

Lines changed: 252 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import re
2+
from datetime import datetime, timedelta
3+
24
from django.contrib import admin
35
from django.contrib.contenttypes.admin import GenericTabularInline
46
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed
57
from django.shortcuts import resolve_url
68
import reversion
79
from reversion.admin import VersionAdmin
8-
from reversion.models import Version
10+
from reversion.models import Version, Revision
911
from test_app.models import TestModel, TestModelParent, TestModelInline, TestModelGenericInline, TestModelEscapePK
1012
from test_app.tests.base import TestBase, LoginMixin
1113

@@ -248,6 +250,255 @@ def testHistorylistView(self):
248250
Version.objects.get_for_model(TestModelParent).get().pk,
249251
))
250252

253+
def testHistorylistViewOrderDefault(self):
254+
# Create an object and multiple revisions.
255+
with reversion.create_revision():
256+
obj = TestModelParent.objects.create(name="v1", parent_name="p1")
257+
with reversion.create_revision():
258+
obj.name = "v2"
259+
obj.save()
260+
with reversion.create_revision():
261+
obj.name = "v3"
262+
obj.save()
263+
264+
# Fetch history page.
265+
response = self.client.get(resolve_url("admin:test_app_testmodelparent_history", obj.pk))
266+
content = response.content.decode()
267+
268+
# Compute expected order: default VersionAdmin orders by pk ascending (oldest first).
269+
version_ids = list(Version.objects.get_for_object(obj).values_list("pk", flat=True))
270+
expected_order = sorted(version_ids)
271+
272+
# Build the URLs as rendered in the history list and assert their order.
273+
urls_in_order = [
274+
resolve_url(
275+
"admin:test_app_testmodelparent_revision",
276+
obj.pk,
277+
vid,
278+
)
279+
for vid in expected_order
280+
]
281+
282+
# Ensure each subsequent URL appears later in the content than the previous one.
283+
last_index = -1
284+
for url in urls_in_order:
285+
index = content.find(url)
286+
self.assertNotEqual(index, -1, f"Expected to find {url} in history page")
287+
self.assertGreater(index, last_index, "History list is not ordered by ascending version pk (oldest first)")
288+
last_index = index
289+
290+
291+
class AdminHistoryViewLatestFirstTest(LoginMixin, TestBase):
292+
293+
class TestModelParentAdminLatestFirst(VersionAdmin):
294+
history_latest_first = True
295+
296+
def setUp(self):
297+
super().setUp()
298+
# Register a custom admin with history_latest_first enabled
299+
admin.site.register(TestModelParent, self.TestModelParentAdminLatestFirst)
300+
self.reloadUrls()
301+
302+
def tearDown(self):
303+
super().tearDown()
304+
admin.site.unregister(TestModelParent)
305+
self.reloadUrls()
306+
307+
def testHistorylistViewOrderLatestFirst(self):
308+
# Create an object and multiple revisions.
309+
with reversion.create_revision():
310+
obj = TestModelParent.objects.create(name="v1", parent_name="p1")
311+
with reversion.create_revision():
312+
obj.name = "v2"
313+
obj.save()
314+
with reversion.create_revision():
315+
obj.name = "v3"
316+
obj.save()
317+
318+
# Fetch history page.
319+
response = self.client.get(resolve_url("admin:test_app_testmodelparent_history", obj.pk))
320+
content = response.content.decode()
321+
322+
# Expected order: with history_latest_first=True, versions are ordered by pk descending (newest first).
323+
version_ids = list(Version.objects.get_for_object(obj).values_list("pk", flat=True))
324+
expected_order = sorted(version_ids, reverse=True)
325+
326+
urls_in_order = [
327+
resolve_url("admin:test_app_testmodelparent_revision", obj.pk, vid)
328+
for vid in expected_order
329+
]
330+
331+
last_index = -1
332+
for url in urls_in_order:
333+
index = content.find(url)
334+
self.assertNotEqual(index, -1, f"Expected to find {url} in history page")
335+
self.assertGreater(index, last_index, "History list is not ordered by descending version pk (newest first)")
336+
last_index = index
337+
338+
339+
class AdminHistoryViewOrderByDateTest(LoginMixin, TestBase):
340+
341+
class TestModelParentAdminOrderByDate(VersionAdmin):
342+
history_order_by_date = True
343+
344+
def setUp(self):
345+
super().setUp()
346+
# Register a custom admin with history_order_by_date enabled
347+
admin.site.register(TestModelParent, self.TestModelParentAdminOrderByDate)
348+
self.reloadUrls()
349+
350+
def tearDown(self):
351+
super().tearDown()
352+
admin.site.unregister(TestModelParent)
353+
self.reloadUrls()
354+
355+
def testHistorylistViewOrderByDate(self):
356+
# Create an object and multiple revisions with increasing timestamps.
357+
with reversion.create_revision():
358+
obj = TestModelParent.objects.create(name="v1", parent_name="p1")
359+
# Use an out-of-sequence date to verify correct ordering
360+
reversion.set_date_created(datetime.now() + timedelta(days=1))
361+
with reversion.create_revision():
362+
obj.name = "v2"
363+
obj.save()
364+
with reversion.create_revision():
365+
obj.name = "v3"
366+
obj.save()
367+
368+
# Fetch history page.
369+
response = self.client.get(resolve_url("admin:test_app_testmodelparent_history", obj.pk))
370+
content = response.content.decode()
371+
372+
# Expected order: ordered by revision creation date ascending (oldest date first).
373+
versions = (
374+
Version.objects.get_for_object(obj)
375+
.select_related("revision")
376+
.order_by("revision__date_created", "pk")
377+
)
378+
expected_order = list(versions.values_list("pk", flat=True))
379+
380+
urls_in_order = [
381+
resolve_url("admin:test_app_testmodelparent_revision", obj.pk, vid)
382+
for vid in expected_order
383+
]
384+
385+
last_index = -1
386+
for url in urls_in_order:
387+
index = content.find(url)
388+
self.assertNotEqual(index, -1, f"Expected to find {url} in history page")
389+
self.assertGreater(index, last_index, "History list is not ordered by revision date (oldest first)")
390+
last_index = index
391+
392+
393+
class AdminHistoryViewLatestFirstOrderByDateTest(LoginMixin, TestBase):
394+
395+
class TestModelParentAdminLatestFirstOrderByDate(VersionAdmin):
396+
history_latest_first = True
397+
history_order_by_date = True
398+
399+
def setUp(self):
400+
super().setUp()
401+
# Register a custom admin with both flags enabled
402+
admin.site.register(TestModelParent, self.TestModelParentAdminLatestFirstOrderByDate)
403+
self.reloadUrls()
404+
405+
def tearDown(self):
406+
super().tearDown()
407+
admin.site.unregister(TestModelParent)
408+
self.reloadUrls()
409+
410+
def testHistorylistViewOrderLatestFirstByDate(self):
411+
# Create an object and multiple revisions with increasing timestamps.
412+
with reversion.create_revision():
413+
obj = TestModelParent.objects.create(name="v1", parent_name="p1")
414+
# Use an out-of-sequence date to verify correct ordering
415+
reversion.set_date_created(datetime.now() + timedelta(days=1))
416+
with reversion.create_revision():
417+
obj.name = "v2"
418+
obj.save()
419+
with reversion.create_revision():
420+
obj.name = "v3"
421+
obj.save()
422+
423+
# Fetch history page.
424+
response = self.client.get(resolve_url("admin:test_app_testmodelparent_history", obj.pk))
425+
content = response.content.decode()
426+
427+
# Expected order: ordered by revision creation date descending (newest date first).
428+
versions = (
429+
Version.objects.get_for_object(obj)
430+
.select_related("revision")
431+
.order_by("-revision__date_created", "-pk")
432+
)
433+
expected_order = list(versions.values_list("pk", flat=True))
434+
435+
urls_in_order = [
436+
resolve_url("admin:test_app_testmodelparent_revision", obj.pk, vid)
437+
for vid in expected_order
438+
]
439+
440+
last_index = -1
441+
for url in urls_in_order:
442+
index = content.find(url)
443+
self.assertNotEqual(index, -1, f"Expected to find {url} in history page")
444+
self.assertGreater(index, last_index, "History list is not ordered by revision date (newest first)")
445+
last_index = index
446+
447+
448+
class AdminHistoryViewCustomOrderingTest(LoginMixin, TestBase):
449+
class TestModelParentAdminCustomOrdering(VersionAdmin):
450+
def get_version_ordering(self, request):
451+
return ("revision__comment",)
452+
453+
def setUp(self):
454+
super().setUp()
455+
# Register a custom admin with history_order_by_date enabled
456+
admin.site.register(TestModelParent, self.TestModelParentAdminCustomOrdering)
457+
self.reloadUrls()
458+
459+
def tearDown(self):
460+
super().tearDown()
461+
admin.site.unregister(TestModelParent)
462+
self.reloadUrls()
463+
464+
def testHistorylistViewCustomOrdering(self):
465+
# Create an object and multiple revisions with increasing timestamps.
466+
with reversion.create_revision():
467+
obj = TestModelParent.objects.create(name="v1", parent_name="p1")
468+
reversion.set_comment("B")
469+
with reversion.create_revision():
470+
obj.name = "v2"
471+
obj.save()
472+
reversion.set_comment("A")
473+
with reversion.create_revision():
474+
obj.name = "v3"
475+
obj.save()
476+
reversion.set_comment("C")
477+
478+
# Fetch history page.
479+
response = self.client.get(resolve_url("admin:test_app_testmodelparent_history", obj.pk))
480+
content = response.content.decode()
481+
482+
# Expected order: ordered by revision comment ascending.
483+
versions = (
484+
Version.objects.get_for_object(obj)
485+
.select_related("revision")
486+
.order_by("revision__comment")
487+
)
488+
expected_order = list(versions.values_list("pk", flat=True))
489+
490+
urls_in_order = [
491+
resolve_url("admin:test_app_testmodelparent_revision", obj.pk, vid)
492+
for vid in expected_order
493+
]
494+
495+
last_index = -1
496+
for url in urls_in_order:
497+
index = content.find(url)
498+
self.assertNotEqual(index, -1, f"Expected to find {url} in history page")
499+
self.assertGreater(index, last_index, "History list is not ordered by comment ascending")
500+
last_index = index
501+
251502

252503
class AdminQuotingTest(LoginMixin, AdminMixin, TestBase):
253504

0 commit comments

Comments
 (0)