Skip to content

Commit 5dfeefe

Browse files
authored
History view pagination (#1277)
* Implement history_view pagination Borrowed from Django's object_history.html * Move pagination logic below the permission check This avoids performing any database queries before we know that the user should be able to view data. * Reworded the docs around changing the page size A developer is more likely to look for the term page size. ---------
1 parent 98462de commit 5dfeefe

File tree

7 files changed

+176
-6
lines changed

7 files changed

+176
-6
lines changed

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ Authors
8383
- Jordon Wing (`jordonwii <https://github.com/jordonwii>`_)
8484
- Josh Fyne
8585
- Josh Thomas (`joshuadavidthomas <https://github.com/joshuadavidthomas>`_)
86+
- Jurrian Tromp (`jurrian <https://github.com/jurrian>`_)
8687
- Keith Hackbarth
8788
- Kevin Foster
8889
- Kira (`kiraware <https://github.com/kiraware>`_)

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Unreleased
1212
- Updated all djangoproject.com links to reference the stable version (gh-1420)
1313
- Dropped support for Python 3.8, which reached end-of-life on 2024-10-07 (gh-1421)
1414
- Added support for Django 5.1 (gh-1388)
15+
- Added pagination to ``SimpleHistoryAdmin`` (gh-1277)
1516

1617
3.7.0 (2024-05-29)
1718
------------------

docs/admin.rst

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ By default, the history log displays one line per change containing
4949

5050
You can add other columns (for example the object's status to see
5151
how it evolved) by adding a ``history_list_display`` array of fields to the
52-
admin class
52+
admin class.
5353

5454
.. code-block:: python
5555
@@ -62,6 +62,7 @@ admin class
6262
list_display = ["id", "name", "status"]
6363
history_list_display = ["status"]
6464
search_fields = ['name', 'user__username']
65+
history_list_per_page = 100
6566
6667
admin.site.register(Poll, PollHistoryAdmin)
6768
admin.site.register(Choice, SimpleHistoryAdmin)
@@ -70,6 +71,27 @@ admin class
7071
.. image:: screens/5_history_list_display.png
7172

7273

74+
Changing the page size in the admin history list view
75+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
76+
77+
By default, the history list view of ``SimpleHistoryAdmin`` shows the last 100 records.
78+
You can change this by adding a `history_list_per_page` attribute to the admin class.
79+
80+
81+
.. code-block:: python
82+
83+
from django.contrib import admin
84+
from simple_history.admin import SimpleHistoryAdmin
85+
from .models import Poll
86+
87+
88+
class PollHistoryAdmin(SimpleHistoryAdmin):
89+
# history_list_per_page defaults to 100
90+
history_list_per_page = 200
91+
92+
admin.site.register(Poll, PollHistoryAdmin)
93+
94+
7395
Customizing the History Admin Templates
7496
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7597

simple_history/admin.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
from django.contrib import admin
88
from django.contrib.admin import helpers
99
from django.contrib.admin.utils import unquote
10+
from django.contrib.admin.views.main import PAGE_VAR
1011
from django.contrib.auth import get_permission_codename, get_user_model
1112
from django.core.exceptions import PermissionDenied
13+
from django.core.paginator import Paginator
1214
from django.db.models import QuerySet
1315
from django.shortcuts import get_object_or_404, render
1416
from django.urls import re_path, reverse
@@ -31,6 +33,7 @@ class SimpleHistoryAdmin(admin.ModelAdmin):
3133
object_history_template = "simple_history/object_history.html"
3234
object_history_list_template = "simple_history/object_history_list.html"
3335
object_history_form_template = "simple_history/object_history_form.html"
36+
history_list_per_page = 100
3437

3538
def get_urls(self):
3639
"""Returns the additional urls used by the Reversion admin."""
@@ -72,14 +75,19 @@ def history_view(self, request, object_id, extra_context=None):
7275
if not self.has_view_history_or_change_history_permission(request, obj):
7376
raise PermissionDenied
7477

78+
# Use the same pagination as in Django admin, with history_list_per_page items
79+
paginator = Paginator(historical_records, self.history_list_per_page)
80+
page_obj = paginator.get_page(request.GET.get(PAGE_VAR))
81+
page_range = paginator.get_elided_page_range(page_obj.number)
82+
7583
# Set attribute on each historical record from admin methods
7684
for history_list_entry in history_list_display:
7785
value_for_entry = getattr(self, history_list_entry, None)
7886
if value_for_entry and callable(value_for_entry):
79-
for record in historical_records:
87+
for record in page_obj.object_list:
8088
setattr(record, history_list_entry, value_for_entry(record))
8189

82-
self.set_history_delta_changes(request, historical_records)
90+
self.set_history_delta_changes(request, page_obj)
8391

8492
content_type = self.content_type_model_cls.objects.get_for_model(
8593
get_user_model()
@@ -92,7 +100,10 @@ def history_view(self, request, object_id, extra_context=None):
92100
context = {
93101
"title": self.history_view_title(request, obj),
94102
"object_history_list_template": self.object_history_list_template,
95-
"historical_records": historical_records,
103+
"page_obj": page_obj,
104+
"page_range": page_range,
105+
"page_var": PAGE_VAR,
106+
"pagination_required": paginator.count > self.history_list_per_page,
96107
"module_name": capfirst(force_str(opts.verbose_name_plural)),
97108
"object": obj,
98109
"root_path": getattr(self.admin_site, "root_path", None),

simple_history/templates/simple_history/object_history.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
{% if not revert_disabled %}<p>
88
{% blocktrans %}Choose a date from the list below to revert to a previous version of this object.{% endblocktrans %}</p>{% endif %}
99
<div class="module">
10-
{% if historical_records %}
10+
{% if page_obj.object_list %}
1111
{% include object_history_list_template %}
1212
{% else %}
1313
<p>{% trans "This object doesn't have a change history." %}</p>

simple_history/templates/simple_history/object_history_list.html

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
</tr>
1919
</thead>
2020
<tbody>
21-
{% for record in historical_records %}
21+
{% for record in page_obj %}
2222
<tr>
2323
<td>
2424
<a href="{% url opts|admin_urlname:'simple_history' object.pk record.pk %}">
@@ -65,3 +65,18 @@
6565
{% endfor %}
6666
</tbody>
6767
</table>
68+
69+
<p class="paginator" style="border-top: 0">
70+
{% if pagination_required %}
71+
{% for i in page_range %}
72+
{% if i == page_obj.paginator.ELLIPSIS %}
73+
{{ page_obj.paginator.ELLIPSIS }}
74+
{% elif i == page_obj.number %}
75+
<span class="this-page">{{ i }}</span>
76+
{% else %}
77+
<a href="?{{ page_var }}={{ i }}" {% if i == page_obj.paginator.num_pages %} class="end" {% endif %}>{{ i }}</a>
78+
{% endif %}
79+
{% endfor %}
80+
{% endif %}
81+
{{ page_obj.paginator.count }} {% blocktranslate count counter=page_obj.paginator.count %}entry{% plural %}entries{% endblocktranslate %}
82+
</p>

simple_history/tests/tests/test_admin.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import django
55
from django.contrib.admin import AdminSite
66
from django.contrib.admin.utils import quote
7+
from django.contrib.admin.views.main import PAGE_VAR
78
from django.contrib.auth import get_user_model
89
from django.contrib.auth.models import Permission
910
from django.contrib.messages.storage.fallback import FallbackStorage
@@ -556,6 +557,125 @@ def test_response_change(self):
556557

557558
self.assertEqual(response["Location"], "/awesome/url/")
558559

560+
def test_history_view_pagination(self):
561+
"""
562+
Ensure the history_view handles pagination correctly.
563+
The default history_list_per_page is 100 so page 2 should have 1 record.
564+
"""
565+
# Create a Poll object and make more than 100 changes to ensure pagination
566+
poll = Poll.objects.create(question="what?", pub_date=today)
567+
for i in range(100):
568+
poll.question = f"change_{i}"
569+
poll.save()
570+
571+
# Verify that there are 100+1 (initial creation) historical records
572+
self.assertEqual(poll.history.count(), 101)
573+
574+
admin_site = AdminSite()
575+
admin = SimpleHistoryAdmin(Poll, admin_site)
576+
577+
self.login(superuser=True)
578+
579+
# Simulate a request to the second page
580+
request = RequestFactory().get("/", {PAGE_VAR: "2"})
581+
request.user = self.user
582+
583+
# Patch the render function
584+
with patch("simple_history.admin.render") as mock_render:
585+
admin.history_view(request, str(poll.id))
586+
587+
# Ensure the render function was called
588+
self.assertTrue(mock_render.called)
589+
590+
# Extract context passed to render function
591+
action_list_count = len(mock_render.call_args[0][2]["page_obj"].object_list)
592+
593+
# Check if only 1 (101 - 100 from the first page)
594+
# objects are present in the context
595+
self.assertEqual(action_list_count, 1)
596+
597+
def test_history_view_pagination_no_pagination(self):
598+
"""
599+
When all records fit on one page because the history_list_per_page is
600+
higher than the number of records, ensure that the pagination is not set.
601+
But it should show the number of entries.
602+
"""
603+
# Create a Poll object and make more than 50 changes to ensure pagination
604+
poll = Poll.objects.create(question="what?", pub_date=today)
605+
for i in range(60):
606+
poll.question = f"change_{i}"
607+
poll.save()
608+
609+
# Verify that there are 60+1 (initial creation) historical records
610+
self.assertEqual(poll.history.count(), 61)
611+
612+
# Create an admin with more per page than the number of records
613+
class CustomSimpleHistoryAdmin(SimpleHistoryAdmin):
614+
history_list_per_page = 200
615+
616+
admin_site = AdminSite()
617+
admin = CustomSimpleHistoryAdmin(Poll, admin_site)
618+
619+
self.login(superuser=True)
620+
621+
# Simulate a request to the second page
622+
request = RequestFactory().get("/", {PAGE_VAR: "2"})
623+
request.user = self.user
624+
625+
response = admin.history_view(request, str(poll.id))
626+
627+
expected = '<p class="paginator" style="border-top: 0">61 entries</p>'
628+
self.assertInHTML(expected, response.content.decode())
629+
630+
def test_history_view_pagination_last_page(self):
631+
"""
632+
With 31 records, the last page should have 1 record. Non-existing pages
633+
also end up on the last page.
634+
"""
635+
# Create a Poll object and make more than 30 changes to ensure pagination
636+
poll = Poll.objects.create(question="what?", pub_date=today)
637+
for i in range(30):
638+
poll.question = f"change_{i}"
639+
poll.save()
640+
641+
expected_entry_count = 31
642+
643+
# Verify that there are 30+1 (initial creation) historical records
644+
self.assertEqual(poll.history.count(), expected_entry_count)
645+
646+
# Create an admin with less per page than the number of records
647+
class CustomSimpleHistoryAdmin(SimpleHistoryAdmin):
648+
history_list_per_page = 10
649+
650+
admin_site = AdminSite()
651+
admin = CustomSimpleHistoryAdmin(Poll, admin_site)
652+
653+
self.login(superuser=True)
654+
655+
# Simulate a request to the 4th and last page
656+
request = RequestFactory().get("/", {PAGE_VAR: "4"})
657+
request.user = self.user
658+
659+
response = admin.history_view(request, str(poll.id))
660+
661+
expected = (
662+
'<p class="paginator" style="border-top: 0">'
663+
'<a href="?p=1" >1</a>'
664+
'<a href="?p=2" >2</a>'
665+
'<a href="?p=3" >3</a>'
666+
'<span class="this-page">4</span>'
667+
f"{expected_entry_count} entries"
668+
"</p>"
669+
)
670+
self.assertInHTML(expected, response.content.decode())
671+
672+
# Also a non-existent page should return the last page
673+
request = RequestFactory().get("/", {PAGE_VAR: "5"})
674+
request.user = self.user
675+
676+
response = admin.history_view(request, str(poll.id))
677+
self.assertInHTML(expected, response.content.decode())
678+
559679
def test_response_change_change_history_setting_off(self):
560680
"""
561681
Test the response_change method that it works with a _change_history

0 commit comments

Comments
 (0)