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 363 364 365 366 367 368 369 370 371
|
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
DEFAULT_METHODS_ALLOWED = ("GET", "POST")
DEFAULT_BUTTON_TYPE = "a"
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=getattr(tool, "button_type", DEFAULT_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")
allowed_methods = getattr(view, "methods", DEFAULT_METHODS_ALLOWED)
if request.method.upper() not in allowed_methods:
return HttpResponseNotAllowed(allowed_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=DEFAULT_METHODS_ALLOWED,
button_type=DEFAULT_BUTTON_TYPE,
):
"""
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)
|