Skip to content

Commit 143d116

Browse files
authored
Historical records: set the change reason explicitly on the instance (#8627)
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 dac5532 commit 143d116

File tree

5 files changed

+58
-24
lines changed

5 files changed

+58
-24
lines changed

readthedocs/api/v3/mixins.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from rest_framework.response import Response
44

55
from readthedocs.builds.models import Version
6-
from readthedocs.core.history import safe_update_change_reason
6+
from readthedocs.core.history import (
7+
safe_update_change_reason,
8+
set_change_reason,
9+
)
710
from readthedocs.organizations.models import Organization
811
from readthedocs.projects.models import Project
912

@@ -38,13 +41,13 @@ def perform_create(self, serializer):
3841
return obj
3942

4043
def perform_update(self, serializer):
44+
set_change_reason(serializer.instance, self.get_change_reason())
4145
obj = serializer.save()
42-
safe_update_change_reason(obj, self.get_change_reason())
4346
return obj
4447

4548
def perform_destroy(self, instance):
49+
set_change_reason(instance, self.get_change_reason())
4650
super().perform_destroy(instance)
47-
safe_update_change_reason(instance, self.get_change_reason())
4851

4952

5053
class NestedParentObjectMixin:

readthedocs/core/forms.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
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
10+
from readthedocs.core.history import set_change_reason
1111

1212
from .models import UserProfile
1313

@@ -39,10 +39,10 @@ def save(self, commit=True):
3939
user = profile.user
4040
user.first_name = first_name
4141
user.last_name = last_name
42-
user.save()
4342
# SimpleHistoryModelForm isn't used here
4443
# because the model of this form is `UserProfile`, not `User`.
45-
safe_update_change_reason(user, self.get_change_reason())
44+
set_change_reason(user, self.get_change_reason())
45+
user.save()
4646
return profile
4747

4848
def get_change_reason(self):

readthedocs/core/history.py

+41-11
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,37 @@
1111
log = logging.getLogger(__name__)
1212

1313

14+
def set_change_reason(instance, reason):
15+
"""
16+
Set the change reason for the historical record created from the instance.
17+
18+
This method should be called before calling ``save()`` or ``delete``.
19+
It sets `reason` to the `_change_reason` attribute of the instance,
20+
that's used to create the historical record on the save/delete signals.
21+
22+
https://django-simple-history.readthedocs.io/en/latest/historical_model.html#change-reason # noqa
23+
"""
24+
instance._change_reason = reason
25+
26+
1427
def safe_update_change_reason(instance, reason):
15-
"""Wrapper around update_change_reason to catch exceptions."""
28+
"""
29+
Wrapper around update_change_reason to catch exceptions.
30+
31+
.. warning::
32+
33+
The implementation of django-simple-history's `update_change_reason`
34+
is very brittle, as it queries for a previous historical record
35+
that matches the attributes of the instance to update the ``change_reason``,
36+
which could end up updating the wrong record, or not finding it.
37+
38+
If you already have control over the object, use `set_change_reason`
39+
before updating/deleting the object instead.
40+
That's more safe, since the attribute is passed to the signal
41+
and used at the creation time of the record.
42+
43+
https://django-simple-history.readthedocs.io/en/latest/historical_model.html#change-reason # noqa
44+
"""
1645
try:
1746
update_change_reason(instance=instance, reason=reason)
1847
except Exception:
@@ -79,12 +108,14 @@ def get_change_reason(self):
79108
return f'origin=admin class={klass}'
80109

81110
def save_model(self, request, obj, form, change):
111+
if obj:
112+
set_change_reason(obj, self.get_change_reason())
82113
super().save_model(request, obj, form, change)
83-
safe_update_change_reason(obj, self.get_change_reason())
84114

85115
def delete_model(self, request, obj):
116+
if obj:
117+
set_change_reason(obj, self.get_change_reason())
86118
super().delete_model(request, obj)
87-
safe_update_change_reason(obj, self.change_reason)
88119

89120

90121
class SimpleHistoryModelForm(forms.ModelForm):
@@ -100,9 +131,9 @@ def get_change_reason(self):
100131
return f'origin=form class={klass}'
101132

102133
def save(self, commit=True):
103-
obj = super().save(commit=commit)
104-
safe_update_change_reason(obj, self.get_change_reason())
105-
return obj
134+
if self.instance:
135+
set_change_reason(self.instance, self.get_change_reason())
136+
return super().save(commit=commit)
106137

107138

108139
class UpdateChangeReasonPostView:
@@ -121,8 +152,7 @@ def get_change_reason(self):
121152
klass = self.__class__.__name__
122153
return f'origin=form class={klass}'
123154

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
155+
def get_object(self):
156+
obj = super().get_object()
157+
set_change_reason(obj, self.get_change_reason())
158+
return obj

readthedocs/profiles/views.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
UserDeleteForm,
2929
UserProfileForm,
3030
)
31-
from readthedocs.core.history import safe_update_change_reason
31+
from readthedocs.core.history import set_change_reason
3232
from readthedocs.core.mixins import PrivateViewMixin
3333
from readthedocs.core.models import UserProfile
3434
from readthedocs.core.utils.extend import SettingsOverrideObject
@@ -86,8 +86,8 @@ def get_object(self):
8686
def form_valid(self, form):
8787
user = self.get_object()
8888
logout(self.request)
89+
set_change_reason(user, self.get_change_reason())
8990
user.delete()
90-
safe_update_change_reason(user, 'Changed from: form')
9191
return super().form_valid(form)
9292

9393
def get_form(self, data=None, files=None, **kwargs):
@@ -98,6 +98,10 @@ def get_form(self, data=None, files=None, **kwargs):
9898
def get_success_url(self):
9999
return reverse('homepage')
100100

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

102106
class ProfileDetailBase(DetailView):
103107

readthedocs/projects/admin.py

+2-5
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,7 @@
88
from django.utils.translation import ugettext_lazy as _
99

1010
from readthedocs.builds.models import Version
11-
from readthedocs.core.history import (
12-
ExtraSimpleHistoryAdmin,
13-
safe_update_change_reason,
14-
)
11+
from readthedocs.core.history import ExtraSimpleHistoryAdmin, set_change_reason
1512
from readthedocs.core.utils import trigger_build
1613
from readthedocs.notifications.views import SendNotificationView
1714
from readthedocs.redirects.models import Redirect
@@ -274,8 +271,8 @@ def ban_owner(self, request, queryset):
274271
if project.users.count() == 1:
275272
user = project.users.first()
276273
user.profile.banned = True
274+
set_change_reason(user.profile, self.get_change_reason())
277275
user.profile.save()
278-
safe_update_change_reason(user.profile, self.get_change_reason())
279276
total += 1
280277
else:
281278
messages.add_message(

0 commit comments

Comments
 (0)