Skip to content

Commit b8c1a0c

Browse files
authored
Merge pull request #1128 from raunaq-sailo/feat/adding-history-diff
Add history diff column to admin change history table
2 parents 4d39103 + 733f4e0 commit b8c1a0c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1921
-456
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ repos:
44
rev: 1.7.8
55
hooks:
66
- id: bandit
7-
args:
8-
- "-x *test*.py"
7+
exclude: /.*tests/
98

109
- repo: https://github.com/psf/black-pre-commit-mirror
1110
rev: 24.3.0

CHANGES.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ Unreleased
55
----------
66

77
- Support custom History ``Manager`` and ``QuerySet`` classes (gh-1280)
8+
- Renamed the (previously internal) admin template
9+
``simple_history/_object_history_list.html`` to
10+
``simple_history/object_history_list.html``, and added the field
11+
``SimpleHistoryAdmin.object_history_list_template`` for overriding it (gh-1128)
12+
- Deprecated the undocumented template tag ``simple_history_admin_list.display_list()``;
13+
it will be removed in version 3.8 (gh-1128)
14+
- Added ``SimpleHistoryAdmin.get_history_queryset()`` for overriding which ``QuerySet``
15+
is used to list the historical records (gh-1128)
16+
- Added ``SimpleHistoryAdmin.get_history_list_display()`` which returns
17+
``history_list_display`` by default, and made the latter into an actual field (gh-1128)
18+
- ``ModelDelta`` and ``ModelChange`` (in ``simple_history.models``) are now immutable
19+
dataclasses; their signatures remain unchanged (gh-1128)
20+
- ``ModelDelta``'s ``changes`` and ``changed_fields`` are now sorted alphabetically by
21+
field name. Also, if ``ModelChange`` is for an M2M field, its ``old`` and ``new``
22+
lists are sorted by the related object. This should help prevent flaky tests. (gh-1128)
23+
- ``diff_against()`` has a new keyword argument, ``foreign_keys_are_objs``;
24+
see usage in the docs under "History Diffing" (gh-1128)
25+
- Added a "Changes" column to ``SimpleHistoryAdmin``'s object history table, listing
26+
the changes between each historical record of the object; see the docs under
27+
"Customizing the History Admin Templates" for overriding its template context (gh-1128)
828

929
3.5.0 (2024-02-19)
1030
------------------

docs/admin.rst

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,43 @@ admin class
6969
7070
.. image:: screens/5_history_list_display.png
7171

72+
73+
Customizing the History Admin Templates
74+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
75+
76+
If you'd like to customize the HTML of ``SimpleHistoryAdmin``'s object history pages,
77+
you can override the following attributes with the names of your own templates:
78+
79+
- ``object_history_template``: The main object history page, which includes (inserts)
80+
``object_history_list_template``.
81+
- ``object_history_list_template``: The table listing an object's historical records and
82+
the changes made between them.
83+
- ``object_history_form_template``: The form pre-filled with the details of an object's
84+
historical record, which also allows you to revert the object to a previous version.
85+
86+
If you'd like to only customize certain parts of the mentioned templates, look for
87+
``block`` template tags in the source code that you can override - like the
88+
``history_delta_changes`` block in ``simple_history/object_history_list.html``,
89+
which lists the changes made between each historical record.
90+
91+
Customizing Context
92+
^^^^^^^^^^^^^^^^^^^
93+
94+
You can also customize the template context by overriding the following methods:
95+
96+
- ``render_history_view()``: Called by both ``history_view()`` and
97+
``history_form_view()`` before the templates are rendered. Customize the context by
98+
changing the ``context`` parameter.
99+
- ``history_view()``: Returns a rendered ``object_history_template``.
100+
Inject context by calling the super method with the ``extra_context`` argument.
101+
- ``get_historical_record_context_helper()``: Returns an instance of
102+
``simple_history.template_utils.HistoricalRecordContextHelper`` that's used to format
103+
some template context for each historical record displayed through ``history_view()``.
104+
Customize the context by extending the mentioned class and overriding its methods.
105+
- ``history_form_view()``: Returns a rendered ``object_history_form_template``.
106+
Inject context by calling the super method with the ``extra_context`` argument.
107+
108+
72109
Disabling the option to revert an object
73110
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
74111

docs/historical_model.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,6 @@ You will see the many to many changes when diffing between two historical record
554554
informal = Category.objects.create(name="informal questions")
555555
official = Category.objects.create(name="official questions")
556556
p = Poll.objects.create(question="what's up?")
557-
p.save()
558557
p.categories.add(informal, official)
559558
p.categories.remove(informal)
560559

docs/history_diffing.rst

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,110 @@
11
History Diffing
22
===============
33

4-
When you have two instances of the same ``HistoricalRecord`` (such as the ``HistoricalPoll`` example above),
5-
you can perform diffs to see what changed. This will result in a ``ModelDelta`` containing the following properties:
4+
When you have two instances of the same historical model
5+
(such as the ``HistoricalPoll`` example above),
6+
you can perform a diff using the ``diff_against()`` method to see what changed.
7+
This will return a ``ModelDelta`` object with the following attributes:
68

7-
1. A list with each field changed between the two historical records
8-
2. A list with the names of all fields that incurred changes from one record to the other
9-
3. the old and new records.
9+
- ``old_record`` and ``new_record``: The old and new history records
10+
- ``changed_fields``: A list of the names of all fields that were changed between
11+
``old_record`` and ``new_record``, in alphabetical order
12+
- ``changes``: A list of ``ModelChange`` objects - one for each field in
13+
``changed_fields``, in the same order.
14+
These objects have the following attributes:
1015

11-
This may be useful when you want to construct timelines and need to get only the model modifications.
16+
- ``field``: The name of the changed field
17+
(this name is equal to the corresponding field in ``changed_fields``)
18+
- ``old`` and ``new``: The old and new values of the changed field
19+
20+
- For many-to-many fields, these values will be lists of dicts from the through
21+
model field names to the primary keys of the through model's related objects.
22+
The lists are sorted by the value of the many-to-many related object.
23+
24+
This may be useful when you want to construct timelines and need to get only
25+
the model modifications.
1226

1327
.. code-block:: python
1428
15-
p = Poll.objects.create(question="what's up?")
16-
p.question = "what's up, man?"
17-
p.save()
29+
poll = Poll.objects.create(question="what's up?")
30+
poll.question = "what's up, man?"
31+
poll.save()
1832
19-
new_record, old_record = p.history.all()
33+
new_record, old_record = poll.history.all()
2034
delta = new_record.diff_against(old_record)
2135
for change in delta.changes:
22-
print("{} changed from {} to {}".format(change.field, change.old, change.new))
36+
print(f"'{change.field}' changed from '{change.old}' to '{change.new}'")
37+
38+
# Output:
39+
# 'question' changed from 'what's up?' to 'what's up, man?'
40+
41+
``diff_against()`` also accepts the following additional arguments:
42+
43+
- ``excluded_fields`` and ``included_fields``: These can be used to either explicitly
44+
exclude or include fields from being diffed, respectively.
45+
- ``foreign_keys_are_objs``:
46+
47+
- If ``False`` (default): The diff will only contain the raw primary keys of any
48+
``ForeignKey`` fields.
49+
- If ``True``: The diff will contain the actual related model objects instead of just
50+
the primary keys.
51+
Deleted related objects (both foreign key objects and many-to-many objects)
52+
will be instances of ``DeletedObject``, which only contain a ``model`` field with a
53+
reference to the deleted object's model, as well as a ``pk`` field with the value of
54+
the deleted object's primary key.
55+
56+
Note that this will add extra database queries for each related field that's been
57+
changed - as long as the related objects have not been prefetched
58+
(using e.g. ``select_related()``).
59+
60+
A couple examples showing the difference:
61+
62+
.. code-block:: python
63+
64+
# --- Effect on foreign key fields ---
65+
66+
whats_up = Poll.objects.create(pk=15, name="what's up?")
67+
still_around = Poll.objects.create(pk=31, name="still around?")
68+
69+
choice = Choice.objects.create(poll=whats_up)
70+
choice.poll = still_around
71+
choice.save()
72+
73+
new, old = choice.history.all()
74+
75+
default_delta = new.diff_against(old)
76+
# Printing the changes of `default_delta` will output:
77+
# 'poll' changed from '15' to '31'
78+
79+
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True)
80+
# Printing the changes of `delta_with_objs` will output:
81+
# 'poll' changed from 'what's up?' to 'still around?'
82+
83+
# Deleting all the polls:
84+
Poll.objects.all().delete()
85+
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True)
86+
# Printing the changes of `delta_with_objs` will now output:
87+
# 'poll' changed from 'Deleted poll (pk=15)' to 'Deleted poll (pk=31)'
88+
89+
90+
# --- Effect on many-to-many fields ---
91+
92+
informal = Category.objects.create(pk=63, name="informal questions")
93+
whats_up.categories.add(informal)
94+
95+
new = whats_up.history.latest()
96+
old = new.prev_record
97+
98+
default_delta = new.diff_against(old)
99+
# Printing the changes of `default_delta` will output:
100+
# 'categories' changed from [] to [{'poll': 15, 'category': 63}]
101+
102+
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True)
103+
# Printing the changes of `delta_with_objs` will output:
104+
# 'categories' changed from [] to [{'poll': <Poll: what's up?>, 'category': <Category: informal questions>}]
23105
24-
``diff_against`` also accepts 2 arguments ``excluded_fields`` and ``included_fields`` to either explicitly include or exclude fields from being diffed.
106+
# Deleting all the categories:
107+
Category.objects.all().delete()
108+
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True)
109+
# Printing the changes of `delta_with_objs` will now output:
110+
# 'categories' changed from [] to [{'poll': <Poll: what's up?>, 'category': DeletedObject(model=<class 'models.Category'>, pk=63)}]

docs/screens/10_revert_disabled.png

-70.8 KB
Loading

docs/screens/1_poll_history.png

-79.6 KB
Loading

docs/screens/2_revert.png

-91.3 KB
Loading

docs/screens/3_poll_reverted.png

-71.9 KB
Loading
-86.2 KB
Loading
-23 KB
Loading

simple_history/admin.py

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any, Sequence
2+
13
from django import http
24
from django.apps import apps as django_apps
35
from django.conf import settings
@@ -6,20 +8,27 @@
68
from django.contrib.admin.utils import unquote
79
from django.contrib.auth import get_permission_codename, get_user_model
810
from django.core.exceptions import PermissionDenied
11+
from django.db.models import QuerySet
912
from django.shortcuts import get_object_or_404, render
1013
from django.urls import re_path, reverse
1114
from django.utils.encoding import force_str
1215
from django.utils.html import mark_safe
1316
from django.utils.text import capfirst
1417
from django.utils.translation import gettext as _
1518

19+
from .manager import HistoricalQuerySet, HistoryManager
20+
from .models import HistoricalChanges
21+
from .template_utils import HistoricalRecordContextHelper
1622
from .utils import get_history_manager_for_model, get_history_model_for_model
1723

1824
SIMPLE_HISTORY_EDIT = getattr(settings, "SIMPLE_HISTORY_EDIT", False)
1925

2026

2127
class SimpleHistoryAdmin(admin.ModelAdmin):
28+
history_list_display = []
29+
2230
object_history_template = "simple_history/object_history.html"
31+
object_history_list_template = "simple_history/object_history_list.html"
2332
object_history_form_template = "simple_history/object_history_form.html"
2433

2534
def get_urls(self):
@@ -46,41 +55,43 @@ def history_view(self, request, object_id, extra_context=None):
4655
pk_name = opts.pk.attname
4756
history = getattr(model, model._meta.simple_history_manager_attribute)
4857
object_id = unquote(object_id)
49-
action_list = history.filter(**{pk_name: object_id})
50-
if not isinstance(history.model.history_user, property):
51-
# Only select_related when history_user is a ForeignKey (not a property)
52-
action_list = action_list.select_related("history_user")
53-
history_list_display = getattr(self, "history_list_display", [])
58+
historical_records = self.get_history_queryset(
59+
request, history, pk_name, object_id
60+
)
61+
history_list_display = self.get_history_list_display(request)
5462
# If no history was found, see whether this object even exists.
5563
try:
5664
obj = self.get_queryset(request).get(**{pk_name: object_id})
5765
except model.DoesNotExist:
5866
try:
59-
obj = action_list.latest("history_date").instance
60-
except action_list.model.DoesNotExist:
67+
obj = historical_records.latest("history_date").instance
68+
except historical_records.model.DoesNotExist:
6169
raise http.Http404
6270

6371
if not self.has_view_history_or_change_history_permission(request, obj):
6472
raise PermissionDenied
6573

66-
# Set attribute on each action_list entry from admin methods
74+
# Set attribute on each historical record from admin methods
6775
for history_list_entry in history_list_display:
6876
value_for_entry = getattr(self, history_list_entry, None)
6977
if value_for_entry and callable(value_for_entry):
70-
for list_entry in action_list:
71-
setattr(list_entry, history_list_entry, value_for_entry(list_entry))
78+
for record in historical_records:
79+
setattr(record, history_list_entry, value_for_entry(record))
80+
81+
self.set_history_delta_changes(request, historical_records)
7282

7383
content_type = self.content_type_model_cls.objects.get_for_model(
7484
get_user_model()
7585
)
76-
7786
admin_user_view = "admin:{}_{}_change".format(
7887
content_type.app_label,
7988
content_type.model,
8089
)
90+
8191
context = {
8292
"title": self.history_view_title(request, obj),
83-
"action_list": action_list,
93+
"object_history_list_template": self.object_history_list_template,
94+
"historical_records": historical_records,
8495
"module_name": capfirst(force_str(opts.verbose_name_plural)),
8596
"object": obj,
8697
"root_path": getattr(self.admin_site, "root_path", None),
@@ -97,6 +108,73 @@ def history_view(self, request, object_id, extra_context=None):
97108
request, self.object_history_template, context, **extra_kwargs
98109
)
99110

111+
def get_history_queryset(
112+
self, request, history_manager: HistoryManager, pk_name: str, object_id: Any
113+
) -> QuerySet:
114+
"""
115+
Return a ``QuerySet`` of all historical records that should be listed in the
116+
``object_history_list_template`` template.
117+
This is used by ``history_view()``.
118+
119+
:param request:
120+
:param history_manager:
121+
:param pk_name: The name of the original model's primary key field.
122+
:param object_id: The primary key of the object whose history is listed.
123+
"""
124+
qs: HistoricalQuerySet = history_manager.filter(**{pk_name: object_id})
125+
if not isinstance(history_manager.model.history_user, property):
126+
# Only select_related when history_user is a ForeignKey (not a property)
127+
qs = qs.select_related("history_user")
128+
# Prefetch related objects to reduce the number of DB queries when diffing
129+
qs = qs._select_related_history_tracked_objs()
130+
return qs
131+
132+
def get_history_list_display(self, request) -> Sequence[str]:
133+
"""
134+
Return a sequence containing the names of additional fields to be displayed on
135+
the object history page. These can either be fields or properties on the model
136+
or the history model, or methods on the admin class.
137+
"""
138+
return self.history_list_display
139+
140+
def get_historical_record_context_helper(
141+
self, request, historical_record: HistoricalChanges
142+
) -> HistoricalRecordContextHelper:
143+
"""
144+
Return an instance of ``HistoricalRecordContextHelper`` for formatting
145+
the template context for ``historical_record``.
146+
"""
147+
return HistoricalRecordContextHelper(self.model, historical_record)
148+
149+
def set_history_delta_changes(
150+
self,
151+
request,
152+
historical_records: Sequence[HistoricalChanges],
153+
foreign_keys_are_objs=True,
154+
):
155+
"""
156+
Add a ``history_delta_changes`` attribute to all historical records
157+
except the first (oldest) one.
158+
159+
:param request:
160+
:param historical_records:
161+
:param foreign_keys_are_objs: Passed to ``diff_against()`` when calculating
162+
the deltas; see its docstring for details.
163+
"""
164+
previous = None
165+
for current in historical_records:
166+
if previous is None:
167+
previous = current
168+
continue
169+
# Related objects should have been prefetched in `get_history_queryset()`
170+
delta = previous.diff_against(
171+
current, foreign_keys_are_objs=foreign_keys_are_objs
172+
)
173+
helper = self.get_historical_record_context_helper(request, previous)
174+
previous.history_delta_changes = helper.context_for_delta_changes(delta)
175+
176+
previous = current
177+
100178
def history_view_title(self, request, obj):
101179
if self.revert_disabled(request, obj) and not SIMPLE_HISTORY_EDIT:
102180
return _("View history: %s") % force_str(obj)

0 commit comments

Comments
 (0)