Skip to content

Commit ee17548

Browse files
committed
Historical records: set the change reason explicitly on the instance
The implementation of django-simple-history's `update_change_reason` is very brittle, as it queries for a previous historical record that matches the attributes of the instance to update the ``change_reason``, which could end up updating the wrong record, or not finding it. https://github.com/jazzband/django-simple-history/blob/b8e43df626a03ce4817beaf6a20995b40a209f37/simple_history/utils.py#L27 I saw this mostly happening when: - Saving a team without changing anything (update_change_reason won't find the record for some reason) - Deleting a team (update_change_reason will override the previous record instead) But simple history also mentions that you can set the change reason explicitly by setting the `_change_reason` attribute, which is more safer as it sets the change_reason at the creation time of the record. https://django-simple-history.readthedocs.io/en/latest/historical_model.html#change-reason
1 parent 02a42b9 commit ee17548

File tree

4 files changed

+36
-18
lines changed

4 files changed

+36
-18
lines changed

readthedocs/api/v3/mixins.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ def perform_update(self, serializer):
4343
return obj
4444

4545
def perform_destroy(self, instance):
46+
instance._change_reason = self.get_change_reason()
4647
super().perform_destroy(instance)
47-
safe_update_change_reason(instance, self.get_change_reason())
4848

4949

5050
class NestedParentObjectMixin:

readthedocs/core/forms.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
from django.forms.fields import CharField
88
from django.utils.translation import ugettext_lazy as _
99

10-
from readthedocs.core.history import safe_update_change_reason
11-
1210
from .models import UserProfile
1311

1412
log = logging.getLogger(__name__)
@@ -39,10 +37,10 @@ def save(self, commit=True):
3937
user = profile.user
4038
user.first_name = first_name
4139
user.last_name = last_name
42-
user.save()
4340
# SimpleHistoryModelForm isn't used here
4441
# because the model of this form is `UserProfile`, not `User`.
45-
safe_update_change_reason(user, self.get_change_reason())
42+
user._change_reason = self.get_change_reason()
43+
user.save()
4644
return profile
4745

4846
def get_change_reason(self):

readthedocs/core/history.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,23 @@
1212

1313

1414
def safe_update_change_reason(instance, reason):
15-
"""Wrapper around update_change_reason to catch exceptions."""
15+
"""
16+
Wrapper around update_change_reason to catch exceptions.
17+
18+
.. warning::
19+
20+
The implementation of django-simple-history's `update_change_reason`
21+
is very brittle, as it queries for a previous historical record
22+
that matches the attributes of the instance to update the ``change_reason``,
23+
which could end up updating the wrong record, or not finding it.
24+
25+
If you already have control over the object, set the ``_change_reason``
26+
attribute before updating/deleting the object instead.
27+
That's more safe, since the attribute is passed to the signal
28+
and used at the creation time of the record.
29+
30+
https://django-simple-history.readthedocs.io/en/latest/historical_model.html#change-reason # noqa
31+
"""
1632
try:
1733
update_change_reason(instance=instance, reason=reason)
1834
except Exception:
@@ -79,12 +95,14 @@ def get_change_reason(self):
7995
return f'origin=admin class={klass}'
8096

8197
def save_model(self, request, obj, form, change):
98+
if obj:
99+
obj._change_reason = self.get_change_reason()
82100
super().save_model(request, obj, form, change)
83-
safe_update_change_reason(obj, self.get_change_reason())
84101

85102
def delete_model(self, request, obj):
103+
if obj:
104+
obj._change_reason = self.get_change_reason()
86105
super().delete_model(request, obj)
87-
safe_update_change_reason(obj, self.change_reason)
88106

89107

90108
class SimpleHistoryModelForm(forms.ModelForm):
@@ -100,9 +118,9 @@ def get_change_reason(self):
100118
return f'origin=form class={klass}'
101119

102120
def save(self, commit=True):
103-
obj = super().save(commit=commit)
104-
safe_update_change_reason(obj, self.get_change_reason())
105-
return obj
121+
if self.instance:
122+
self.instance._change_reason = self.get_change_reason()
123+
return super().save(commit=commit)
106124

107125

108126
class UpdateChangeReasonPostView:
@@ -121,8 +139,7 @@ def get_change_reason(self):
121139
klass = self.__class__.__name__
122140
return f'origin=form class={klass}'
123141

124-
def post(self, request, *args, **kwargs):
125-
obj = self.get_object()
126-
response = super().post(request, *args, **kwargs)
127-
safe_update_change_reason(obj, self.get_change_reason())
128-
return response
142+
def get_object(self):
143+
obj = super().get_object()
144+
obj._change_reason = self.get_change_reason()
145+
return obj

readthedocs/profiles/views.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
UserDeleteForm,
3131
UserProfileForm,
3232
)
33-
from readthedocs.core.history import safe_update_change_reason
3433
from readthedocs.core.mixins import PrivateViewMixin
3534
from readthedocs.core.models import UserProfile
3635
from readthedocs.core.utils.extend import SettingsOverrideObject
@@ -88,8 +87,8 @@ def get_object(self):
8887
def form_valid(self, form):
8988
user = self.get_object()
9089
logout(self.request)
90+
user._change_reason = self.get_change_reason()
9191
user.delete()
92-
safe_update_change_reason(user, 'Changed from: form')
9392
return super().form_valid(form)
9493

9594
def get_form(self, data=None, files=None, **kwargs):
@@ -100,6 +99,10 @@ def get_form(self, data=None, files=None, **kwargs):
10099
def get_success_url(self):
101100
return reverse('homepage')
102101

102+
def get_change_reason(self):
103+
klass = self.__class__.__name__
104+
return f'origin=form class={klass}'
105+
103106

104107
class ProfileDetailBase(DetailView):
105108

0 commit comments

Comments
 (0)