forked from crccheck/django-object-actions
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathutils.py
362 lines (304 loc) · 12.2 KB
/
utils.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
from functools import wraps
from itertools import chain
from django.contrib import messages
from django.contrib.admin.utils import unquote
from django.db.models.query import QuerySet
from django.http import Http404, HttpResponseRedirect
from django.http.response import HttpResponseBase, HttpResponseNotAllowed
from django.views.generic import View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.list import MultipleObjectMixin
from django.urls import re_path, reverse
class BaseDjangoObjectActions(object):
"""
ModelAdmin mixin to add new actions just like adding admin actions.
Attributes
----------
model : django.db.models.Model
The Django Model these actions work on. This is populated by Django.
change_actions : list of str
Write the names of the methods of the model admin that can be used as
tools in the change view.
changelist_actions : list of str
Write the names of the methods of the model admin that can be used as
tools in the changelist view.
tools_view_name : str
The name of the Django Object Actions admin view, including the 'admin'
namespace. Populated by `_get_action_urls`.
"""
change_actions = []
changelist_actions = []
tools_view_name = None
# EXISTING ADMIN METHODS MODIFIED
#################################
def get_urls(self):
"""Prepend `get_urls` with our own patterns."""
urls = super(BaseDjangoObjectActions, self).get_urls()
return self._get_action_urls() + urls
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context.update(
{
"objectactions": [
self._get_tool_dict(action)
for action in self.get_change_actions(request, object_id, form_url)
],
"tools_view_name": self.tools_view_name,
}
)
return super(BaseDjangoObjectActions, self).change_view(
request, object_id, form_url, extra_context
)
def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
extra_context.update(
{
"objectactions": [
self._get_tool_dict(action)
for action in self.get_changelist_actions(request)
],
"tools_view_name": self.tools_view_name,
}
)
return super(BaseDjangoObjectActions, self).changelist_view(
request, extra_context
)
# USER OVERRIDABLE
##################
def get_change_actions(self, request, object_id, form_url):
"""
Override this to customize what actions get to the change view.
This takes the same parameters as `change_view`.
For example, to restrict actions to superusers, you could do:
class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin):
def get_change_actions(self, request, **kwargs):
if request.user.is_superuser:
return super(ChoiceAdmin, self).get_change_actions(
request, **kwargs
)
return []
"""
return self.change_actions
def get_changelist_actions(self, request):
"""
Override this to customize what actions get to the changelist view.
"""
return self.changelist_actions
# INTERNAL METHODS
##################
def _get_action_urls(self):
"""Get the url patterns that route each action to a view."""
actions = {}
model_name = self.model._meta.model_name
# e.g.: polls_poll
base_url_name = "%s_%s" % (self.model._meta.app_label, model_name)
# e.g.: polls_poll_actions
model_actions_url_name = "%s_actions" % base_url_name
self.tools_view_name = "admin:" + model_actions_url_name
# WISHLIST use get_change_actions and get_changelist_actions
# TODO separate change and changelist actions
for action in chain(self.change_actions, self.changelist_actions):
actions[action] = getattr(self, action)
return [
# change, supports the same pks the admin does
# https://github.com/django/django/blob/stable/1.10.x/django/contrib/admin/options.py#L555
re_path(
r"^(?P<pk>.+)/actions/(?P<tool>\w+)/$",
self.admin_site.admin_view( # checks permissions
ChangeActionView.as_view(
model=self.model,
actions=actions,
back="admin:%s_change" % base_url_name,
current_app=self.admin_site.name,
)
),
name=model_actions_url_name,
),
# changelist
re_path(
r"^actions/(?P<tool>\w+)/$",
self.admin_site.admin_view( # checks permissions
ChangeListActionView.as_view(
model=self.model,
actions=actions,
back="admin:%s_changelist" % base_url_name,
current_app=self.admin_site.name,
)
),
# Dupe name is fine. https://code.djangoproject.com/ticket/14259
name=model_actions_url_name,
),
]
def _get_tool_dict(self, tool_name):
"""Represents the tool as a dict with extra meta."""
tool = getattr(self, tool_name)
standard_attrs, custom_attrs = self._get_button_attrs(tool)
return dict(
name=tool_name,
label=getattr(tool, "label", tool_name.replace("_", " ").capitalize()),
standard_attrs=standard_attrs,
custom_attrs=custom_attrs,
button_type=tool.button_type,
)
def _get_button_attrs(self, tool):
"""
Get the HTML attributes associated with a tool.
There are some standard attributes (class and title) that the template
will always want. Any number of additional attributes can be specified
and passed on. This is kinda awkward and due for a refactor for
readability.
"""
attrs = getattr(tool, "attrs", {})
# href is not allowed to be set. should an exception be raised instead?
if "href" in attrs:
attrs.pop("href")
# title is not allowed to be set. should an exception be raised instead?
# `short_description` should be set instead to parallel django admin
# actions
if "title" in attrs:
attrs.pop("title")
default_attrs = {
"class": attrs.get("class", ""),
"title": getattr(tool, "short_description", ""),
}
standard_attrs = {}
custom_attrs = {}
for k, v in dict(default_attrs, **attrs).items():
if k in default_attrs:
standard_attrs[k] = v
else:
custom_attrs[k] = v
return standard_attrs, custom_attrs
class DjangoObjectActions(BaseDjangoObjectActions):
change_form_template = "django_object_actions/change_form.html"
change_list_template = "django_object_actions/change_list.html"
class BaseActionView(View):
"""
The view that runs a change/changelist action callable.
Attributes
----------
back : str
The urlpattern name to send users back to. This is set in
`_get_action_urls` and turned into a url with the `back_url` property.
model : django.db.model.Model
The model this tool operates on.
actions : dict
A mapping of action names to callables.
"""
back = None
model = None
actions = None
current_app = None
@property
def view_args(self):
"""
tuple: The argument(s) to send to the action (excluding `request`).
Change actions are called with `(request, obj)` while changelist
actions are called with `(request, queryset)`.
"""
raise NotImplementedError
@property
def back_url(self):
"""
str: The url path the action should send the user back to.
If an action does not return a http response, we automagically send
users back to either the change or the changelist page.
"""
raise NotImplementedError
def dispatch(self, request, tool, **kwargs):
# Fix for case if there are special symbols in object pk
for k, v in self.kwargs.items():
self.kwargs[k] = unquote(v)
try:
view = self.actions[tool]
except KeyError:
raise Http404("Action does not exist")
if request.method not in view.methods:
return HttpResponseNotAllowed(view.methods)
ret = view(request, *self.view_args)
if isinstance(ret, HttpResponseBase):
return ret
return HttpResponseRedirect(self.back_url)
def message_user(self, request, message):
"""
Mimic Django admin actions's `message_user`.
Like the second example:
https://docs.djangoproject.com/en/1.9/ref/contrib/admin/actions/#custom-admin-action
"""
messages.info(request, message)
class ChangeActionView(SingleObjectMixin, BaseActionView):
@property
def view_args(self):
return (self.get_object(),)
@property
def back_url(self):
return reverse(
self.back, args=(self.kwargs["pk"],), current_app=self.current_app
)
class ChangeListActionView(MultipleObjectMixin, BaseActionView):
@property
def view_args(self):
return (self.get_queryset(),)
@property
def back_url(self):
return reverse(self.back, current_app=self.current_app)
def takes_instance_or_queryset(func):
"""Decorator that makes standard Django admin actions compatible."""
@wraps(func)
def decorated_function(self, request, queryset):
# func follows the prototype documented at:
# https://docs.djangoproject.com/en/dev/ref/contrib/admin/actions/#writing-action-functions
if not isinstance(queryset, QuerySet):
try:
# Django >=1.8
queryset = self.get_queryset(request).filter(pk=queryset.pk)
except AttributeError:
try:
# Django >=1.6,<1.8
model = queryset._meta.model
except AttributeError: # pragma: no cover
# Django <1.6
model = queryset._meta.concrete_model
queryset = model.objects.filter(pk=queryset.pk)
return func(self, request, queryset)
return decorated_function
def action(
function=None, *, permissions=None, description=None, label=None, attrs=None,
methods=('GET', 'POST'),
button_type='a',
):
"""
Conveniently add attributes to an action function:
@action(
permissions=['publish'],
description='Mark selected stories as published',
label='Publish'
)
def make_published(self, request, queryset):
queryset.update(status='p')
This is equivalent to setting some attributes (with the original, longer
names) on the function directly:
def make_published(self, request, queryset):
queryset.update(status='p')
make_published.allowed_permissions = ['publish']
make_published.short_description = 'Mark selected stories as published'
make_published.label = 'Publish'
This is the django-object-actions equivalent of
https://docs.djangoproject.com/en/stable/ref/contrib/admin/actions/#django.contrib.admin.action
"""
def decorator(func):
if permissions is not None:
func.allowed_permissions = permissions
if description is not None:
func.short_description = description
if label is not None:
func.label = label
if attrs is not None:
func.attrs = attrs
func.methods = methods
func.button_type = button_type
return func
if function is None:
return decorator
else:
return decorator(function)