Skip to content

Commit d2e425f

Browse files
authored
Merge pull request #992 from micmarc/custom-version-sort
Implement date-based ordering and custom version sort
2 parents abe3535 + 2ae8d84 commit d2e425f

3 files changed

Lines changed: 302 additions & 10 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(request)``
119+
120+
Method that returns a tuple specifying the field names (relative to the ``Version`` model) for ordering. Semantics are similar to the built-in ``get_ordering`` method in Django's ``ModelAdmin``.
121+
122+
Implementations may override this method to achieve custom or dynamic ordering of the version queryset. The return value must be a list or tuple. Calling ``super()`` returns the default ordering which takes ``history_latest_first`` and ``history_order_by_date`` into account; this call may be omitted if the default ordering is not required.
123+
124+
.. code:: python
125+
126+
def get_version_ordering(self, request):
127+
if request.user.is_superuser:
128+
return ("-revision__date_created", "revision__comment")
129+
return super().get_version_ordering(request)
130+
131+
``request``
132+
The current request.

reversion/admin.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
from contextlib import contextmanager, nullcontext
2-
from django.db import models, transaction, connections
3-
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed
2+
43
from django.contrib import admin, messages
54
from django.contrib.admin import options
65
from django.contrib.admin.utils import unquote, quote
76
from django.contrib.contenttypes.admin import GenericInlineModelAdmin
87
from django.contrib.contenttypes.fields import GenericRelation
98
from django.core.exceptions import PermissionDenied, ImproperlyConfigured
9+
from django.db import models, transaction, connections
10+
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed
1011
from django.shortcuts import get_object_or_404, render, redirect
1112
from django.urls import reverse, re_path
13+
from django.utils.encoding import force_str
14+
from django.utils.formats import localize
1215
from django.utils.text import capfirst
1316
from django.utils.timezone import template_localtime
1417
from django.utils.translation import gettext as _
15-
from django.utils.encoding import force_str
16-
from django.utils.formats import localize
18+
1719
from reversion.errors import RevertError
1820
from reversion.models import Version
1921
from reversion.revisions import is_active, register, is_registered, set_comment, create_revision, set_user
@@ -40,10 +42,25 @@ class VersionAdmin(admin.ModelAdmin):
4042

4143
history_latest_first = False
4244

45+
history_order_by_date = False
46+
4347
def reversion_register(self, model, **kwargs):
4448
"""Registers the model with reversion."""
4549
register(model, **kwargs)
4650

51+
def get_version_ordering(self, request):
52+
"""Hook for specifying custom field ordering for the version queryset."""
53+
# Default ordering logic uses version ID only
54+
order_fields = ["pk"]
55+
# Setting history_order_by_date causes revision date to be used as the primary sort key
56+
# Keep version ID as secondary in case of identical revision dates
57+
if self.history_order_by_date:
58+
order_fields.insert(0, "revision__date_created")
59+
# Setting history_latest_first causes order to be reversed on all fields
60+
if self.history_latest_first:
61+
order_fields = [f"-{field}" for field in order_fields]
62+
return tuple(order_fields)
63+
4764
@contextmanager
4865
def create_revision(self, request):
4966
with create_revision():
@@ -60,11 +77,10 @@ def _reversion_get_template_list(self, template_name):
6077
"reversion/%s" % template_name,
6178
)
6279

63-
def _reversion_order_version_queryset(self, queryset):
80+
def _reversion_order_version_queryset(self, request, queryset):
6481
"""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
82+
ordering = self.get_version_ordering(request) or ()
83+
return queryset.order_by(*ordering)
6884

6985
# Messages.
7086

@@ -258,6 +274,7 @@ def recoverlist_view(self, request, extra_context=None):
258274
model = self.model
259275
opts = model._meta
260276
deleted = self._reversion_order_version_queryset(
277+
request,
261278
Version.objects.get_deleted(self.model).select_related("revision")
262279
)
263280
# Set the app name.
@@ -298,10 +315,10 @@ def history_view(self, request, object_id, extra_context=None):
298315
),
299316
}
300317
for version
301-
in self._reversion_order_version_queryset(Version.objects.get_for_object_reference(
318+
in self._reversion_order_version_queryset(request, Version.objects.get_for_object_reference(
302319
self.model,
303320
unquote(object_id), # Underscores in primary key get quoted to "_5F"
304-
).select_related("revision__user"))
321+
).select_related("revision", "revision__user"))
305322
]
306323
# Compile the context.
307324
context = {"action_list": action_list}

tests/test_app/tests/test_admin.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
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
8+
69
import reversion
710
from reversion.admin import VersionAdmin
811
from reversion.models import Version
@@ -248,6 +251,255 @@ def testHistorylistView(self):
248251
Version.objects.get_for_model(TestModelParent).get().pk,
249252
))
250253

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

252504
class AdminQuotingTest(LoginMixin, AdminMixin, TestBase):
253505

0 commit comments

Comments
 (0)