Skip to content

Commit 0589ac2

Browse files
committed
diff_against() returns PK wrapper for deleted objs
...when called with `foreign_keys_are_objs=True`. This special dataclass (`DeletedObject`) will signal to the user that the related object was deleted, and provides useful info - i.e. the object's PK and model - and a verbose `__str__()` implementation. (An alternative solution of returning `None` for deleted objects, would introduce ambiguity if the `ForeignKey` field is nullable.)
1 parent a842b98 commit 0589ac2

File tree

5 files changed

+108
-28
lines changed

5 files changed

+108
-28
lines changed

docs/history_diffing.rst

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ the model modifications.
4848
``ForeignKey`` fields.
4949
- If ``True``: The diff will contain the actual related model objects instead of just
5050
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+
5156
Note that this will add extra database queries for each related field that's been
5257
changed - as long as the related objects have not been prefetched
5358
(using e.g. ``select_related()``).
@@ -77,7 +82,9 @@ the model modifications.
7782
7883
# Deleting all the polls:
7984
Poll.objects.all().delete()
80-
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) # Will raise `Place.DoesNotExist`
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)'
8188
8289
8390
# --- Effect on many-to-many fields ---
@@ -100,4 +107,4 @@ the model modifications.
100107
Category.objects.all().delete()
101108
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True)
102109
# Printing the changes of `delta_with_objs` will now output:
103-
# 'categories' changed from [] to [{'poll': <Poll: what's up?>, 'category': None}]
110+
# 'categories' changed from [] to [{'poll': <Poll: what's up?>, 'category': DeletedObject(model=<class 'models.Category'>, pk=63)}]
60 Bytes
Binary file not shown.

simple_history/locale/nb/LC_MESSAGES/django.po

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ msgstr ""
88
"Project-Id-Version: django-simple-history\n"
99
"Report-Msgid-Bugs-To: \n"
1010
"POT-Creation-Date: 2023-07-09 13:55+0200\n"
11-
"PO-Revision-Date: 2023-07-09 12:31+0200\n"
11+
"PO-Revision-Date: 2024-04-11 19:34+0200\n"
1212
"Last-Translator: Anders <[email protected]>\n"
1313
"Language-Team: Norwegian Bokmål <[email protected]>\n"
1414
"Language: nb\n"
@@ -60,6 +60,11 @@ msgstr "Endret"
6060
msgid "Deleted"
6161
msgstr "Slettet"
6262

63+
#: simple_history/models.py:1124
64+
#, python-format
65+
msgid "Deleted %(type_name)s"
66+
msgstr "Slettet %(type_name)s"
67+
6368
#: simple_history/templates/simple_history/object_history.html:11
6469
msgid ""
6570
"Choose a date from the list below to revert to a previous version of this "

simple_history/models.py

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import warnings
55
from dataclasses import dataclass
66
from functools import partial
7-
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence, Union
7+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence, Type, Union
88

99
import django
1010
from django.apps import apps
@@ -961,9 +961,11 @@ def diff_against(
961961
:param foreign_keys_are_objs: If ``False``, the returned diff will only contain
962962
the raw PKs of any ``ForeignKey`` fields.
963963
If ``True``, the diff will contain the actual related model objects
964-
instead of just the PKs.
965-
The latter case will necessarily query the database if the related
966-
objects have not been prefetched (using e.g. ``select_related()``).
964+
instead of just the PKs; deleted related objects will be instances of
965+
``DeletedObject``.
966+
Note that passing ``True`` will necessarily query the database if the
967+
related objects have not been prefetched (using e.g.
968+
``select_related()``).
967969
"""
968970
if not isinstance(old_history, type(self)):
969971
raise TypeError(
@@ -1016,13 +1018,24 @@ def _get_field_changes_for_diff(
10161018
new_value = new_values[field]
10171019

10181020
if old_value != new_value:
1019-
if foreign_keys_are_objs and isinstance(
1020-
self._meta.get_field(field), ForeignKey
1021-
):
1021+
field_meta = self._meta.get_field(field)
1022+
if foreign_keys_are_objs and isinstance(field_meta, ForeignKey):
10221023
# Set the fields to their related model objects instead of
10231024
# the raw PKs from `model_to_dict()`
1024-
old_value = getattr(old_history, field)
1025-
new_value = getattr(self, field)
1025+
def get_value(record, foreign_key):
1026+
try:
1027+
value = getattr(record, field)
1028+
# `value` seems to be None (without raising this exception)
1029+
# if the object has not been refreshed from the database
1030+
except ObjectDoesNotExist:
1031+
value = None
1032+
1033+
if value is None:
1034+
value = DeletedObject(field_meta.related_model, foreign_key)
1035+
return value
1036+
1037+
old_value = get_value(old_history, old_value)
1038+
new_value = get_value(self, new_value)
10261039

10271040
change = ModelChange(field, old_value, new_value)
10281041
changes.append(change)
@@ -1069,10 +1082,24 @@ def _get_m2m_field_changes_for_diff(
10691082
# Set the through fields to their related model objects instead of
10701083
# the raw PKs from `values()`
10711084
def rows_with_foreign_key_objs(m2m_qs):
1085+
def get_value(obj, through_field):
1086+
try:
1087+
value = getattr(obj, through_field)
1088+
# If the related object has been deleted, `value` seems to
1089+
# usually already be None instead of raising this exception
1090+
except ObjectDoesNotExist:
1091+
value = None
1092+
1093+
if value is None:
1094+
meta = m2m_through_model_opts.get_field(through_field)
1095+
foreign_key = getattr(obj, meta.attname)
1096+
value = DeletedObject(meta.related_model, foreign_key)
1097+
return value
1098+
10721099
# Replicate the format of the return value of QuerySet.values()
10731100
return [
10741101
{
1075-
through_field: getattr(through_obj, through_field)
1102+
through_field: get_value(through_obj, through_field)
10761103
for through_field in through_model_fields
10771104
}
10781105
for through_obj in m2m_qs.select_related(*fk_fields)
@@ -1087,11 +1114,41 @@ def rows_with_foreign_key_objs(m2m_qs):
10871114
return changes
10881115

10891116

1117+
@dataclass(frozen=True)
1118+
class DeletedObject:
1119+
model: Type[models.Model]
1120+
pk: Any
1121+
1122+
def __str__(self):
1123+
deleted_model_str = _("Deleted %(type_name)s") % {
1124+
"type_name": self.model._meta.verbose_name,
1125+
}
1126+
return f"{deleted_model_str} (pk={self.pk})"
1127+
1128+
1129+
# Either:
1130+
# - The value of a foreign key field:
1131+
# - If ``foreign_keys_are_objs=True`` is passed to ``diff_against()``:
1132+
# Either the related object or ``DeletedObject``.
1133+
# - Otherwise:
1134+
# The PK of the related object.
1135+
#
1136+
# - The value of a many-to-many field:
1137+
# A list of dicts from the through model field names to either:
1138+
# - If ``foreign_keys_are_objs=True`` is passed to ``diff_against()``:
1139+
# Either the through model's related objects or ``DeletedObject``.
1140+
# - Otherwise:
1141+
# The PK of the through model's related objects.
1142+
#
1143+
# - Any of the other possible values of a model field.
1144+
ModelChangeValue = Union[Any, DeletedObject, List[Dict[str, Union[Any, DeletedObject]]]]
1145+
1146+
10901147
@dataclass(frozen=True)
10911148
class ModelChange:
10921149
field: str
1093-
old: Union[Any, List[Dict[str, Any]]]
1094-
new: Union[Any, List[Dict[str, Any]]]
1150+
old: ModelChangeValue
1151+
new: ModelChangeValue
10951152

10961153

10971154
@dataclass(frozen=True)

simple_history/tests/tests/test_models.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import warnings
55
from datetime import datetime, timedelta
66

7-
import django
87
from django.apps import apps
98
from django.conf import settings
109
from django.contrib.auth import get_user_model
@@ -20,6 +19,7 @@
2019
from simple_history.exceptions import RelatedNameConflictError
2120
from simple_history.models import (
2221
SIMPLE_HISTORY_REVERSE_ATTR_NAME,
22+
DeletedObject,
2323
HistoricalRecords,
2424
ModelChange,
2525
ModelDelta,
@@ -30,7 +30,6 @@
3030
pre_create_historical_m2m_records,
3131
pre_create_historical_record,
3232
)
33-
from simple_history.tests.custom_user.models import CustomUser
3433
from simple_history.tests.tests.utils import (
3534
database_router_override_settings,
3635
database_router_override_settings_history_in_diff_db,
@@ -88,7 +87,6 @@
8887
ModelWithSingleNoDBIndexUnique,
8988
MultiOneToOne,
9089
MyOverrideModelNameRegisterMethod1,
91-
OverrideModelNameAsCallable,
9290
OverrideModelNameUsingBaseModel1,
9391
Person,
9492
Place,
@@ -734,7 +732,9 @@ def test_history_diff_includes_changed_fields_of_base_model(self):
734732

735733
def test_history_diff_arg__foreign_keys_are_objs__returns_expected_fk_values(self):
736734
poll1 = Poll.objects.create(question="why?", pub_date=today)
735+
poll1_pk = poll1.pk
737736
poll2 = Poll.objects.create(question="how?", pub_date=tomorrow)
737+
poll2_pk = poll2.pk
738738
choice = Choice.objects.create(poll=poll1, choice="hmm", votes=3)
739739
choice.poll = poll2
740740
choice.choice = "idk"
@@ -747,7 +747,7 @@ def test_history_diff_arg__foreign_keys_are_objs__returns_expected_fk_values(sel
747747
delta = new_record.diff_against(old_record)
748748
expected_pk_changes = [
749749
ModelChange("choice", "hmm", "idk"),
750-
ModelChange("poll", poll1.pk, poll2.pk),
750+
ModelChange("poll", poll1_pk, poll2_pk),
751751
ModelChange("votes", 3, 0),
752752
]
753753
expected_pk_delta = ModelDelta(
@@ -782,15 +782,27 @@ def test_history_diff_arg__foreign_keys_are_objs__returns_expected_fk_values(sel
782782
self.assertEqual(delta, expected_pk_delta)
783783

784784
# Test with `foreign_keys_are_objs=True`
785-
# (Getting instances of deleted related objects is not possible - unless they
786-
# happen to be history-tracked as well, but that's not currently detected)
787-
with self.assertRaises(Poll.DoesNotExist):
785+
with self.assertNumQueries(2): # Once for each poll in the new record
788786
delta = new_record.diff_against(old_record, foreign_keys_are_objs=True)
787+
# The model objects should now instead be instances of `DeletedObject`
788+
expected_obj_changes = [
789+
choice_changes,
790+
ModelChange(
791+
"poll", DeletedObject(Poll, poll1_pk), DeletedObject(Poll, poll2_pk)
792+
),
793+
votes_changes,
794+
]
795+
expected_obj_delta = dataclasses.replace(
796+
expected_pk_delta, changes=expected_obj_changes
797+
)
798+
self.assertEqual(delta, expected_obj_delta)
789799

790800
def test_history_diff_arg__foreign_keys_are_objs__returns_expected_m2m_values(self):
791801
poll = PollWithManyToMany.objects.create(question="why?", pub_date=today)
792802
place1 = Place.objects.create(name="Here")
803+
place1_pk = place1.pk
793804
place2 = Place.objects.create(name="There")
805+
place2_pk = place2.pk
794806
poll.places.add(place1, place2)
795807
new_record, old_record = poll.history.all()
796808

@@ -801,8 +813,8 @@ def test_history_diff_arg__foreign_keys_are_objs__returns_expected_m2m_values(se
801813
"places",
802814
[],
803815
[
804-
{"pollwithmanytomany": poll.pk, "place": place1.pk},
805-
{"pollwithmanytomany": poll.pk, "place": place2.pk},
816+
{"pollwithmanytomany": poll.pk, "place": place1_pk},
817+
{"pollwithmanytomany": poll.pk, "place": place2_pk},
806818
],
807819
)
808820
expected_pk_delta = ModelDelta(
@@ -840,13 +852,12 @@ def test_history_diff_arg__foreign_keys_are_objs__returns_expected_m2m_values(se
840852
# Test with `foreign_keys_are_objs=True`
841853
with self.assertNumQueries(2 * 2): # Twice for each record
842854
delta = new_record.diff_against(old_record, foreign_keys_are_objs=True)
843-
# (Getting instances of deleted related objects is not possible - unless they
844-
# happen to be history-tracked as well, but that's not currently detected)
855+
# The model objects should now instead be instances of `DeletedObject`
845856
expected_obj_change = dataclasses.replace(
846857
expected_obj_change,
847858
new=[
848-
{"pollwithmanytomany": poll, "place": None},
849-
{"pollwithmanytomany": poll, "place": None},
859+
{"pollwithmanytomany": poll, "place": DeletedObject(Place, place1_pk)},
860+
{"pollwithmanytomany": poll, "place": DeletedObject(Place, place2_pk)},
850861
],
851862
)
852863
expected_obj_delta = dataclasses.replace(

0 commit comments

Comments
 (0)