Skip to content

Commit 6d170da

Browse files
Add support for m2m changes in AbstractLogEntry.changes_str (#798)
* Add test case to test log entry changes_str property for m2m changes. * Add support for m2m field changes and generic changes in AbstractLogEntry.changes_str property. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add more test cases for changes_str. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add chengelog note * Validate type and length of changes_dict values. * Restructure change iterator. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 198c060 commit 6d170da

3 files changed

Lines changed: 58 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Next Release
44

5+
#### Fixes
6+
7+
- `KeyError` when calling `changes_str` on a log entry that tracks many-to-many field changes ([#798](https://github.com/jazzband/django-auditlog/pull/798))
8+
59
## 3.4.1 (2025-12-13)
610

711
#### Fixes

auditlog/models.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -427,21 +427,29 @@ def changes_str(self, colon=": ", arrow=" \u2192 ", separator="; "):
427427
not satisfying, please use :py:func:`LogEntry.changes_dict` and format the string yourself.
428428
429429
:param colon: The string to place between the field name and the values.
430-
:param arrow: The string to place between each old and new value.
430+
:param arrow: The string to place between each old and new value (non-m2m field changes only).
431431
:param separator: The string to place between each field.
432432
:return: A readable string of the changes in this log entry.
433433
"""
434434
substrings = []
435435

436-
for field, values in self.changes_dict.items():
437-
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
438-
field_name=field,
439-
colon=colon,
440-
old=values[0],
441-
arrow=arrow,
442-
new=values[1],
443-
)
444-
substrings.append(substring)
436+
for field, value in sorted(self.changes_dict.items()):
437+
if isinstance(value, (list, tuple)) and len(value) == 2:
438+
# handle regular field change
439+
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
440+
field_name=field,
441+
colon=colon,
442+
old=value[0],
443+
arrow=arrow,
444+
new=value[1],
445+
)
446+
substrings.append(substring)
447+
elif isinstance(value, dict) and value.get("type") == "m2m":
448+
# handle m2m change
449+
substring = (
450+
f"{field}{colon}{value['operation']} {sorted(value['objects'])}"
451+
)
452+
substrings.append(substring)
445453

446454
return separator.join(substrings)
447455

auditlog_tests/tests.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ def check_update_log_entry(self, obj, history):
131131
{"boolean": ["False", "True"]},
132132
msg="The change is correctly logged",
133133
)
134+
self.assertEqual(
135+
history.changes_str,
136+
"boolean: False → True",
137+
msg="Changes string is correct",
138+
)
134139

135140
def test_update_specific_field_supplied_via_save_method(self):
136141
obj = self.obj
@@ -149,6 +154,11 @@ def test_update_specific_field_supplied_via_save_method(self):
149154
"when using the `update_fields`."
150155
),
151156
)
157+
self.assertEqual(
158+
obj.history.get(action=LogEntry.Action.UPDATE).changes_str,
159+
"boolean: False → True",
160+
msg="Changes string is correct",
161+
)
152162

153163
def test_django_update_fields_edge_cases(self):
154164
"""
@@ -179,6 +189,11 @@ def test_django_update_fields_edge_cases(self):
179189
{"boolean": ["False", "True"], "integer": ["None", "1"]},
180190
msg="The 2 fields changed are correctly logged",
181191
)
192+
self.assertEqual(
193+
obj.history.get(action=LogEntry.Action.UPDATE).changes_str,
194+
"boolean: False → True; integer: None → 1",
195+
msg="Changes string is correct",
196+
)
182197

183198
def test_delete(self):
184199
"""Deletion is logged correctly."""
@@ -494,6 +509,13 @@ def test_changes(self):
494509
},
495510
)
496511

512+
def test_changes_str(self):
513+
self.obj.related.add(self.related)
514+
log_entry = self.obj.history.first()
515+
self.assertEqual(
516+
log_entry.changes_str, f"related: add {[smart_str(self.related)]}"
517+
)
518+
497519
def test_adding_existing_related_obj(self):
498520
self.obj.related.add(self.related)
499521
log_entry = self.obj.history.first()
@@ -725,6 +747,11 @@ def test_specified_save_fields_are_ignored_if_not_included(self):
725747
{"label": ["Initial label", "New label"]},
726748
msg="Only the label was logged, regardless of multiple entries in `update_fields`",
727749
)
750+
self.assertEqual(
751+
obj.history.get(action=LogEntry.Action.UPDATE).changes_str,
752+
"label: Initial label → New label",
753+
msg="Changes string is correct",
754+
)
728755

729756
def test_register_include_fields(self):
730757
sim = SimpleIncludeModel(label="Include model", text="Looong text")
@@ -2061,6 +2088,11 @@ def test_update(self):
20612088
{"json": ["{}", '{"quantity": "1"}']},
20622089
msg="The change is correctly logged",
20632090
)
2091+
self.assertEqual(
2092+
history.changes_str,
2093+
'json: {} → {"quantity": "1"}',
2094+
msg="Changes string is correct",
2095+
)
20642096

20652097
def test_update_with_no_changes(self):
20662098
"""No changes are logged."""
@@ -2697,6 +2729,7 @@ def test_access_log(self):
26972729
)
26982730
self.assertIsNone(log_entry.changes)
26992731
self.assertEqual(log_entry.changes_dict, {})
2732+
self.assertEqual(log_entry.changes_str, "")
27002733

27012734

27022735
class SignalTests(TestCase):
@@ -3120,6 +3153,9 @@ def test_use_base_manager_setting_changes(self):
31203153
}
31213154
},
31223155
)
3156+
self.assertEqual(
3157+
log_entry.changes_str, f"m2m_related: add {[smart_str(obj_two)]}"
3158+
)
31233159

31243160

31253161
class TestMaskStr(TestCase):

0 commit comments

Comments
 (0)