Skip to content

Commit 1daa2ae

Browse files
authored
Merge pull request #1017 from erikvw/master
evaluate history model permissions explicitly
2 parents f376a76 + e77fbcf commit 1daa2ae

File tree

7 files changed

+348
-44
lines changed

7 files changed

+348
-44
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ Changes
44
Unreleased
55
----------
66

7+
- Added feature to evaluate ``history`` model permissions explicitly when
8+
``SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS`` is set to ``True``
9+
in ``settings`` (gh-1017).
710

811
3.3.0 (2023-03-08)
912
------------------

docs/admin.rst

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,88 @@ admin class
7272
Disabling the option to revert an object
7373
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7474

75-
By default, an object can be reverted to its previous version. To disable this option update your settings with the following:
75+
By default, an object can be reverted to its previous version. To disable this option
76+
globally, update your settings with the following:
7677

7778
.. code-block:: python
7879
79-
SIMPLE_HISTORY_REVERT_DISABLED=True
80+
SIMPLE_HISTORY_REVERT_DISABLED = True
8081
8182
When ``SIMPLE_HISTORY_REVERT_DISABLED`` is set to ``True``, the revert button is removed from the form.
8283

8384
.. image:: screens/10_revert_disabled.png
85+
86+
Enforcing history model permissions in Admin
87+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
88+
89+
To make the Django admin site evaluate history model permissions explicitly,
90+
update your settings with the following:
91+
92+
.. code-block:: python
93+
94+
SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True
95+
96+
By default, ``SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS`` is set to ``False``.
97+
When set to ``False``, permissions applied to the ``Poll`` model
98+
(from the examples above), also apply to the history model.
99+
That is, granting view and change permissions to the ``Poll`` model
100+
implicitly grants view and change permissions to the ``Poll`` history model.
101+
102+
The user below has view and change permissions to the ``Poll`` model and the ``Poll``
103+
history model in admin.
104+
105+
.. code-block:: python
106+
107+
user.user_permissions.clear()
108+
user.user_permissions.add(
109+
Permission.objects.get(codename="view_poll"),
110+
Permission.objects.get(codename="change_poll"),
111+
)
112+
113+
The user below has view permission to the ``Poll`` model and the ``Poll`` history model
114+
in admin.
115+
116+
.. code-block:: python
117+
118+
user.user_permissions.clear()
119+
user.user_permissions.add(
120+
Permission.objects.get(codename="view_poll"),
121+
)
122+
123+
When ``SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS`` is set to ``True``,
124+
permissions to history models are assigned and evaluated explicitly.
125+
126+
The user below *does not have* view permission to the ``Poll`` history model in admin,
127+
even though they *have* view permission to the ``Poll`` model.
128+
129+
.. code-block:: python
130+
131+
# SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True in settings
132+
user.user_permissions.clear()
133+
user.user_permissions.add(
134+
Permission.objects.get(codename="view_poll"),
135+
)
136+
137+
The user below has view permission to the ``Poll`` model and the ``Poll``
138+
history model.
139+
140+
.. code-block:: python
141+
142+
# SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True in settings
143+
user.user_permissions.clear()
144+
user.user_permissions.add(
145+
Permission.objects.get(codename="view_poll"),
146+
Permission.objects.get(codename="view_historicalpoll"),
147+
)
148+
149+
The user below has view permission to the ``Poll`` history model, but will need to
150+
access the page with a direct URL, since the ``Poll`` model will not be listed on
151+
the admin application index page, nor the ``Poll`` changelist.
152+
153+
.. code-block:: python
154+
155+
# SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True in settings
156+
user.user_permissions.clear()
157+
user.user_permissions.add(
158+
Permission.objects.get(codename="view_historicalpoll"),
159+
)

simple_history/admin.py

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.contrib import admin
55
from django.contrib.admin import helpers
66
from django.contrib.admin.utils import unquote
7+
from django.contrib.auth import get_permission_codename
78
from django.core.exceptions import PermissionDenied
89
from django.shortcuts import get_object_or_404, render
910
from django.urls import re_path, reverse
@@ -12,7 +13,7 @@
1213
from django.utils.text import capfirst
1314
from django.utils.translation import gettext as _
1415

15-
from . import utils
16+
from .utils import get_history_manager_for_model, get_history_model_for_model
1617

1718
USER_NATURAL_KEY = tuple(key.lower() for key in settings.AUTH_USER_MODEL.split(".", 1))
1819

@@ -61,7 +62,7 @@ def history_view(self, request, object_id, extra_context=None):
6162
except action_list.model.DoesNotExist:
6263
raise http.Http404
6364

64-
if not self.has_change_permission(request, obj):
65+
if not self.has_view_history_or_change_history_permission(request, obj):
6566
raise PermissionDenied
6667

6768
# Set attribute on each action_list entry from admin methods
@@ -79,7 +80,7 @@ def history_view(self, request, object_id, extra_context=None):
7980
content_type.model,
8081
)
8182
context = {
82-
"title": self.history_view_title(obj),
83+
"title": self.history_view_title(request, obj),
8384
"action_list": action_list,
8485
"module_name": capfirst(force_str(opts.verbose_name_plural)),
8586
"object": obj,
@@ -88,7 +89,7 @@ def history_view(self, request, object_id, extra_context=None):
8889
"opts": opts,
8990
"admin_user_view": admin_user_view,
9091
"history_list_display": history_list_display,
91-
"revert_disabled": self.revert_disabled,
92+
"revert_disabled": self.revert_disabled(request, obj),
9293
}
9394
context.update(self.admin_site.each_context(request))
9495
context.update(extra_context or {})
@@ -97,8 +98,8 @@ def history_view(self, request, object_id, extra_context=None):
9798
request, self.object_history_template, context, **extra_kwargs
9899
)
99100

100-
def history_view_title(self, obj):
101-
if self.revert_disabled and not SIMPLE_HISTORY_EDIT:
101+
def history_view_title(self, request, obj):
102+
if self.revert_disabled(request, obj) and not SIMPLE_HISTORY_EDIT:
102103
return _("View history: %s") % force_str(obj)
103104
else:
104105
return _("Change history: %s") % force_str(obj)
@@ -131,7 +132,7 @@ def history_form_view(self, request, object_id, version_id, extra_context=None):
131132
).instance
132133
obj._state.adding = False
133134

134-
if not self.has_change_permission(request, obj):
135+
if not self.has_view_history_or_change_history_permission(request, obj):
135136
raise PermissionDenied
136137

137138
if SIMPLE_HISTORY_EDIT:
@@ -140,7 +141,7 @@ def history_form_view(self, request, object_id, version_id, extra_context=None):
140141
change_history = False
141142

142143
if "_change_history" in request.POST and SIMPLE_HISTORY_EDIT:
143-
history = utils.get_history_manager_for_model(obj)
144+
history = get_history_manager_for_model(obj)
144145
obj = history.get(pk=version_id).instance
145146

146147
formsets = []
@@ -173,7 +174,7 @@ def history_form_view(self, request, object_id, version_id, extra_context=None):
173174
model_name = original_opts.model_name
174175
url_triplet = self.admin_site.name, original_opts.app_label, model_name
175176
context = {
176-
"title": self.history_form_view_title(obj),
177+
"title": self.history_form_view_title(request, obj),
177178
"adminform": admin_form,
178179
"object_id": object_id,
179180
"original": obj,
@@ -186,12 +187,13 @@ def history_form_view(self, request, object_id, version_id, extra_context=None):
186187
"change_url": reverse("%s:%s_%s_change" % url_triplet, args=(obj.pk,)),
187188
"history_url": reverse("%s:%s_%s_history" % url_triplet, args=(obj.pk,)),
188189
"change_history": change_history,
189-
"revert_disabled": self.revert_disabled,
190+
"revert_disabled": self.revert_disabled(request, obj),
190191
# Context variables copied from render_change_form
191192
"add": False,
192193
"change": True,
193194
"has_add_permission": self.has_add_permission(request),
194-
"has_change_permission": self.has_change_permission(request, obj),
195+
"has_view_permission": self.has_view_history_permission(request, obj),
196+
"has_change_permission": self.has_change_history_permission(request, obj),
195197
"has_delete_permission": self.has_delete_permission(request, obj),
196198
"has_file_field": True,
197199
"has_absolute_url": False,
@@ -211,8 +213,8 @@ def history_form_view(self, request, object_id, version_id, extra_context=None):
211213
request, self.object_history_form_template, context, **extra_kwargs
212214
)
213215

214-
def history_form_view_title(self, obj):
215-
if self.revert_disabled:
216+
def history_form_view_title(self, request, obj):
217+
if self.revert_disabled(request, obj):
216218
return _("View %s") % force_str(obj)
217219
else:
218220
return _("Revert %s") % force_str(obj)
@@ -231,6 +233,54 @@ def content_type_model_cls(self):
231233
"""Returns the ContentType model class."""
232234
return django_apps.get_model("contenttypes.contenttype")
233235

236+
def revert_disabled(self, request, obj=None):
237+
"""If `True`, hides the "Revert" button in the `submit_line.html` template."""
238+
if getattr(settings, "SIMPLE_HISTORY_REVERT_DISABLED", False):
239+
return True
240+
elif self.has_view_history_permission(
241+
request, obj
242+
) and not self.has_change_history_permission(request, obj):
243+
return True
244+
return False
245+
246+
def has_view_permission(self, request, obj=None):
247+
return super().has_view_permission(request, obj)
248+
249+
def has_change_permission(self, request, obj=None):
250+
return super().has_change_permission(request, obj)
251+
252+
def has_view_or_change_permission(self, request, obj=None):
253+
return self.has_view_permission(request, obj) or self.has_change_permission(
254+
request, obj
255+
)
256+
257+
def has_view_history_or_change_history_permission(self, request, obj=None):
258+
if self.enforce_history_permissions:
259+
return self.has_view_history_permission(
260+
request, obj
261+
) or self.has_change_history_permission(request, obj)
262+
return self.has_view_or_change_permission(request, obj)
263+
264+
def has_view_history_permission(self, request, obj=None):
265+
if self.enforce_history_permissions:
266+
opts_history = get_history_model_for_model(self.model)._meta
267+
codename_view_history = get_permission_codename("view", opts_history)
268+
return request.user.has_perm(
269+
f"{opts_history.app_label}.{codename_view_history}"
270+
)
271+
return self.has_view_permission(request, obj)
272+
273+
def has_change_history_permission(self, request, obj=None):
274+
if self.enforce_history_permissions:
275+
opts_history = get_history_model_for_model(self.model)._meta
276+
codename_change_history = get_permission_codename("change", opts_history)
277+
return request.user.has_perm(
278+
f"{opts_history.app_label}.{codename_change_history}"
279+
)
280+
return self.has_change_permission(request, obj)
281+
234282
@property
235-
def revert_disabled(self):
236-
return getattr(settings, "SIMPLE_HISTORY_REVERT_DISABLED", False)
283+
def enforce_history_permissions(self):
284+
return getattr(
285+
settings, "SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS", False
286+
)

simple_history/tests/admin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class PersonAdmin(SimpleHistoryAdmin):
2121
def has_change_permission(self, request, obj=None):
2222
return False
2323

24+
def has_view_permission(self, request, obj=None):
25+
return False
26+
2427

2528
class ChoiceAdmin(SimpleHistoryAdmin):
2629
history_list_display = ["votes"]

simple_history/tests/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ def __str__(self):
549549

550550
class Meta:
551551
verbose_name = "Planet"
552+
verbose_name_plural = "Planets"
552553

553554

554555
class Contact(models.Model):

0 commit comments

Comments
 (0)