Fixed #10505: added support for bulk admin actions, including a globally-available "delete selected" action. See the documentation for details.
This work started life as Brian Beck's "django-batchadmin." It was rewritten for inclusion in Django by Alex Gaynor, Jannis Leidel (jezdez), and Martin Mahner (bartTC). Thanks, guys! git-svn-id: http://code.djangoproject.com/svn/django/trunk@10121 bcc190cf-cafb-0310-a4f2-bffc1f526a37
2
AUTHORS
@ -56,6 +56,7 @@ answer newbie questions, and generally made Django that much better:
|
||||
Ned Batchelder <http://www.nedbatchelder.com/>
|
||||
batiste@dosimple.ch
|
||||
Batman
|
||||
Brian Beck <http://blog.brianbeck.com/>
|
||||
Shannon -jj Behrens <http://jjinux.blogspot.com/>
|
||||
Esdras Beleza <linux@esdrasbeleza.com>
|
||||
Chris Bennett <chrisrbennett@yahoo.com>
|
||||
@ -268,6 +269,7 @@ answer newbie questions, and generally made Django that much better:
|
||||
Daniel Lindsley <polarcowz@gmail.com>
|
||||
Trey Long <trey@ktrl.com>
|
||||
msaelices <msaelices@gmail.com>
|
||||
Martin Mahner <http://www.mahner.org/>
|
||||
Matt McClanahan <http://mmcc.cx/>
|
||||
Frantisek Malina <vizualbod@vizualbod.com>
|
||||
Martin Maney <http://www.chipy.org/Martin_Maney>
|
||||
|
@ -1,3 +1,4 @@
|
||||
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
||||
from django.contrib.admin.options import ModelAdmin, HORIZONTAL, VERTICAL
|
||||
from django.contrib.admin.options import StackedInline, TabularInline
|
||||
from django.contrib.admin.sites import AdminSite, site
|
||||
|
@ -6,6 +6,14 @@ from django.utils.safestring import mark_safe
|
||||
from django.utils.encoding import force_unicode
|
||||
from django.contrib.admin.util import flatten_fieldsets
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
ACTION_CHECKBOX_NAME = '_selected_action'
|
||||
|
||||
class ActionForm(forms.Form):
|
||||
action = forms.ChoiceField(label=_('Action:'))
|
||||
|
||||
checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
|
||||
|
||||
class AdminForm(object):
|
||||
def __init__(self, form, fieldsets, prepopulated_fields):
|
||||
|
@ -50,12 +50,24 @@
|
||||
|
||||
#changelist table thead th {
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#changelist table thead th:first-child {
|
||||
width: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#changelist table tbody td {
|
||||
border-left: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#changelist table tbody td:first-child {
|
||||
border-left: 0;
|
||||
border-right: 1px solid #ddd;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#changelist table tfoot {
|
||||
color: #666;
|
||||
}
|
||||
@ -209,3 +221,35 @@
|
||||
border-color: #036;
|
||||
}
|
||||
|
||||
/* ACTIONS */
|
||||
|
||||
.filtered .actions {
|
||||
margin-right: 160px !important;
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#changelist .actions {
|
||||
color: #666;
|
||||
padding: 3px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x;
|
||||
}
|
||||
|
||||
#changelist .actions:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#changelist .actions select {
|
||||
border: 1px solid #aaa;
|
||||
margin: 0 0.5em;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
#changelist .actions label {
|
||||
font-size: 11px;
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
|
||||
#changelist #action-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
19
django/contrib/admin/media/js/actions.js
Normal file
@ -0,0 +1,19 @@
|
||||
var Actions = {
|
||||
init: function() {
|
||||
selectAll = document.getElementById('action-toggle');
|
||||
if (selectAll) {
|
||||
selectAll.style.display = 'inline';
|
||||
addEvent(selectAll, 'change', function() {
|
||||
Actions.checker(this.checked);
|
||||
});
|
||||
}
|
||||
},
|
||||
checker: function(checked) {
|
||||
actionCheckboxes = document.getElementsBySelector('tr input.action-select');
|
||||
for(var i = 0; i < actionCheckboxes.length; i++) {
|
||||
actionCheckboxes[i].checked = checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addEvent(window, 'load', Actions.init);
|
@ -5,9 +5,10 @@ from django.forms.models import BaseInlineFormSet
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.admin import widgets
|
||||
from django.contrib.admin import helpers
|
||||
from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects
|
||||
from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_ngettext, model_format_dict
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models, transaction
|
||||
from django.db.models.fields import BLANK_CHOICE_DASH
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render_to_response
|
||||
from django.utils.functional import update_wrapper
|
||||
@ -16,7 +17,7 @@ from django.utils.safestring import mark_safe
|
||||
from django.utils.functional import curry
|
||||
from django.utils.text import capfirst, get_text_list
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ngettext
|
||||
from django.utils.translation import ngettext, ugettext_lazy
|
||||
from django.utils.encoding import force_unicode
|
||||
try:
|
||||
set
|
||||
@ -192,6 +193,12 @@ class ModelAdmin(BaseModelAdmin):
|
||||
delete_confirmation_template = None
|
||||
object_history_template = None
|
||||
|
||||
# Actions
|
||||
actions = ['delete_selected']
|
||||
action_form = helpers.ActionForm
|
||||
actions_on_top = True
|
||||
actions_on_bottom = False
|
||||
|
||||
def __init__(self, model, admin_site):
|
||||
self.model = model
|
||||
self.opts = model._meta
|
||||
@ -200,6 +207,13 @@ class ModelAdmin(BaseModelAdmin):
|
||||
for inline_class in self.inlines:
|
||||
inline_instance = inline_class(self.model, self.admin_site)
|
||||
self.inline_instances.append(inline_instance)
|
||||
if 'action_checkbox' not in self.list_display:
|
||||
self.list_display = ['action_checkbox'] + list(self.list_display)
|
||||
if not self.list_display_links:
|
||||
for name in self.list_display:
|
||||
if name != 'action_checkbox':
|
||||
self.list_display_links = [name]
|
||||
break
|
||||
super(ModelAdmin, self).__init__()
|
||||
|
||||
def get_urls(self):
|
||||
@ -239,6 +253,8 @@ class ModelAdmin(BaseModelAdmin):
|
||||
from django.conf import settings
|
||||
|
||||
js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
|
||||
if self.actions:
|
||||
js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
|
||||
if self.prepopulated_fields:
|
||||
js.append('js/urlify.js')
|
||||
if self.opts.get_ordered_objects():
|
||||
@ -390,6 +406,121 @@ class ModelAdmin(BaseModelAdmin):
|
||||
action_flag = DELETION
|
||||
)
|
||||
|
||||
def action_checkbox(self, obj):
|
||||
"""
|
||||
A list_display column containing a checkbox widget.
|
||||
"""
|
||||
return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, force_unicode(obj.pk))
|
||||
action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
|
||||
action_checkbox.allow_tags = True
|
||||
|
||||
def get_actions(self, request=None):
|
||||
"""
|
||||
Return a dictionary mapping the names of all actions for this
|
||||
ModelAdmin to a tuple of (callable, name, description) for each action.
|
||||
"""
|
||||
actions = {}
|
||||
for klass in [self.admin_site] + self.__class__.mro()[::-1]:
|
||||
for action in getattr(klass, 'actions', []):
|
||||
func, name, description = self.get_action(action)
|
||||
actions[name] = (func, name, description)
|
||||
return actions
|
||||
|
||||
def get_action_choices(self, request=None, default_choices=BLANK_CHOICE_DASH):
|
||||
"""
|
||||
Return a list of choices for use in a form object. Each choice is a
|
||||
tuple (name, description).
|
||||
"""
|
||||
choices = [] + default_choices
|
||||
for func, name, description in self.get_actions(request).itervalues():
|
||||
choice = (name, description % model_format_dict(self.opts))
|
||||
choices.append(choice)
|
||||
return choices
|
||||
|
||||
def get_action(self, action):
|
||||
"""
|
||||
Return a given action from a parameter, which can either be a calable,
|
||||
or the name of a method on the ModelAdmin. Return is a tuple of
|
||||
(callable, name, description).
|
||||
"""
|
||||
if callable(action):
|
||||
func = action
|
||||
action = action.__name__
|
||||
elif hasattr(self, action):
|
||||
func = getattr(self, action)
|
||||
if hasattr(func, 'short_description'):
|
||||
description = func.short_description
|
||||
else:
|
||||
description = capfirst(action.replace('_', ' '))
|
||||
return func, action, description
|
||||
|
||||
def delete_selected(self, request, queryset):
|
||||
"""
|
||||
Default action which deletes the selected objects.
|
||||
|
||||
In the first step, it displays a confirmation page whichs shows all
|
||||
the deleteable objects or, if the user has no permission one of the
|
||||
related childs (foreignkeys) it displays a "permission denied" message.
|
||||
|
||||
In the second step delete all selected objects and display the change
|
||||
list again.
|
||||
"""
|
||||
opts = self.model._meta
|
||||
app_label = opts.app_label
|
||||
|
||||
# Check that the user has delete permission for the actual model
|
||||
if not self.has_delete_permission(request):
|
||||
raise PermissionDenied
|
||||
|
||||
# Populate deletable_objects, a data structure of all related objects that
|
||||
# will also be deleted.
|
||||
|
||||
# deletable_objects must be a list if we want to use '|unordered_list' in the template
|
||||
deletable_objects = []
|
||||
perms_needed = set()
|
||||
i = 0
|
||||
for obj in queryset:
|
||||
deletable_objects.append([mark_safe(u'%s: <a href="%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []])
|
||||
get_deleted_objects(deletable_objects[i], perms_needed, request.user, obj, opts, 1, self.admin_site, levels_to_root=2)
|
||||
i=i+1
|
||||
|
||||
# The user has already confirmed the deletion.
|
||||
# Do the deletion and return a None to display the change list view again.
|
||||
if request.POST.get('post'):
|
||||
if perms_needed:
|
||||
raise PermissionDenied
|
||||
n = queryset.count()
|
||||
if n:
|
||||
for obj in queryset:
|
||||
obj_display = force_unicode(obj)
|
||||
self.log_deletion(request, obj, obj_display)
|
||||
queryset.delete()
|
||||
self.message_user(request, _("Successfully deleted %d %s.") % (
|
||||
n, model_ngettext(self.opts, n)
|
||||
))
|
||||
# Return None to display the change list page again.
|
||||
return None
|
||||
|
||||
context = {
|
||||
"title": _("Are you sure?"),
|
||||
"object_name": force_unicode(opts.verbose_name),
|
||||
"deletable_objects": deletable_objects,
|
||||
'queryset': queryset,
|
||||
"perms_lacking": perms_needed,
|
||||
"opts": opts,
|
||||
"root_path": self.admin_site.root_path,
|
||||
"app_label": app_label,
|
||||
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
|
||||
}
|
||||
|
||||
# Display the confirmation page
|
||||
return render_to_response(self.delete_confirmation_template or [
|
||||
"admin/%s/%s/delete_selected_confirmation.html" % (app_label, opts.object_name.lower()),
|
||||
"admin/%s/delete_selected_confirmation.html" % app_label,
|
||||
"admin/delete_selected_confirmation.html"
|
||||
], context, context_instance=template.RequestContext(request))
|
||||
|
||||
delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s")
|
||||
|
||||
def construct_change_message(self, request, form, formsets):
|
||||
"""
|
||||
@ -529,6 +660,48 @@ class ModelAdmin(BaseModelAdmin):
|
||||
self.message_user(request, msg)
|
||||
return HttpResponseRedirect("../")
|
||||
|
||||
def response_action(self, request, queryset):
|
||||
"""
|
||||
Handle an admin action. This is called if a request is POSTed to the
|
||||
changelist; it returns an HttpResponse if the action was handled, and
|
||||
None otherwise.
|
||||
"""
|
||||
# There can be multiple action forms on the page (at the top
|
||||
# and bottom of the change list, for example). Get the action
|
||||
# whose button was pushed.
|
||||
try:
|
||||
action_index = int(request.POST.get('index', 0))
|
||||
except ValueError:
|
||||
action_index = 0
|
||||
|
||||
# Construct the action form.
|
||||
data = request.POST.copy()
|
||||
data.pop(helpers.ACTION_CHECKBOX_NAME, None)
|
||||
data.pop("index", None)
|
||||
action_form = self.action_form(data, auto_id=None)
|
||||
action_form.fields['action'].choices = self.get_action_choices(request)
|
||||
|
||||
# If the form's valid we can handle the action.
|
||||
if action_form.is_valid():
|
||||
action = action_form.cleaned_data['action']
|
||||
func, name, description = self.get_actions(request)[action]
|
||||
|
||||
# Get the list of selected PKs. If nothing's selected, we can't
|
||||
# perform an action on it, so bail.
|
||||
selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
|
||||
if not selected:
|
||||
return None
|
||||
|
||||
response = func(request, queryset.filter(pk__in=selected))
|
||||
|
||||
# Actions may return an HttpResponse, which will be used as the
|
||||
# response from the POST. If not, we'll be a good little HTTP
|
||||
# citizen and redirect back to the changelist page.
|
||||
if isinstance(response, HttpResponse):
|
||||
return response
|
||||
else:
|
||||
return HttpResponseRedirect(".")
|
||||
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
"The 'add' admin view for this model."
|
||||
model = self.model
|
||||
@ -721,6 +894,14 @@ class ModelAdmin(BaseModelAdmin):
|
||||
return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
|
||||
return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
|
||||
|
||||
# If the request was POSTed, this might be a bulk action or a bulk edit.
|
||||
# Try to look up an action first, but if this isn't an action the POST
|
||||
# will fall through to the bulk edit check, below.
|
||||
if request.method == 'POST':
|
||||
response = self.response_action(request, queryset=cl.get_query_set())
|
||||
if response:
|
||||
return response
|
||||
|
||||
# If we're allowing changelist editing, we need to construct a formset
|
||||
# for the changelist given all the fields to be edited. Then we'll
|
||||
# use the formset to validate/process POSTed data.
|
||||
@ -764,7 +945,11 @@ class ModelAdmin(BaseModelAdmin):
|
||||
if formset:
|
||||
media = self.media + formset.media
|
||||
else:
|
||||
media = None
|
||||
media = self.media
|
||||
|
||||
# Build the action form and populate it with available actions.
|
||||
action_form = self.action_form(auto_id=None)
|
||||
action_form.fields['action'].choices = self.get_action_choices(request)
|
||||
|
||||
context = {
|
||||
'title': cl.title,
|
||||
@ -774,6 +959,9 @@ class ModelAdmin(BaseModelAdmin):
|
||||
'has_add_permission': self.has_add_permission(request),
|
||||
'root_path': self.admin_site.root_path,
|
||||
'app_label': app_label,
|
||||
'action_form': action_form,
|
||||
'actions_on_top': self.actions_on_top,
|
||||
'actions_on_bottom': self.actions_on_bottom,
|
||||
}
|
||||
context.update(extra_context or {})
|
||||
return render_to_response(self.change_list_template or [
|
||||
|
@ -45,6 +45,8 @@ class AdminSite(object):
|
||||
name += '_'
|
||||
self.name = name
|
||||
|
||||
self.actions = []
|
||||
|
||||
def register(self, model_or_iterable, admin_class=None, **options):
|
||||
"""
|
||||
Registers the given model(s) with the given admin class.
|
||||
@ -100,6 +102,11 @@ class AdminSite(object):
|
||||
raise NotRegistered('The model %s is not registered' % model.__name__)
|
||||
del self._registry[model]
|
||||
|
||||
def add_action(self, action):
|
||||
if not callable(action):
|
||||
raise TypeError("You can only register callable actions through an admin site")
|
||||
self.actions.append(action)
|
||||
|
||||
def has_permission(self, request):
|
||||
"""
|
||||
Returns True if the given HttpRequest has permission to view
|
||||
|
5
django/contrib/admin/templates/admin/actions.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% load i18n %}
|
||||
<div class="actions">
|
||||
{% for field in action_form %}<label>{{ field.label }} {{ field }}</label>{% endfor %}
|
||||
<button type="submit" class="button" title="{% trans "Run the selected action" %}" name="index" value="{{ action_index|default:0 }}">{% trans "Go" %}</button>
|
||||
</div>
|
@ -7,8 +7,8 @@
|
||||
{% if cl.formset %}
|
||||
<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
|
||||
<script type="text/javascript" src="../../jsi18n/"></script>
|
||||
{{ media }}
|
||||
{% endif %}
|
||||
{{ media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block bodyclass %}change-list{% endblock %}
|
||||
@ -63,14 +63,18 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% if cl.formset %}
|
||||
<form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
|
||||
{% if cl.formset %}
|
||||
{{ cl.formset.management_form }}
|
||||
{% endif %}
|
||||
|
||||
{% block result_list %}{% result_list cl %}{% endblock %}
|
||||
{% block result_list %}
|
||||
{% if actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
|
||||
{% result_list cl %}
|
||||
{% if actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
|
||||
{% endblock %}
|
||||
{% block pagination %}{% pagination cl %}{% endblock %}
|
||||
{% if cl.formset %}</form>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -0,0 +1,37 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="../../">{% trans "Home" %}</a> ›
|
||||
<a href="../">{{ app_label|capfirst }}</a> ›
|
||||
<a href="./">{{ opts.verbose_name_plural|capfirst }}</a> ›
|
||||
{% trans 'Delete multiple objects' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if perms_lacking %}
|
||||
<p>{% blocktrans %}Deleting the {{ object_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for obj in perms_lacking %}
|
||||
<li>{{ obj }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the selected {{ object_name }} objects? All of the following objects and it's related items will be deleted:{% endblocktrans %}</p>
|
||||
{% for deleteable_object in deletable_objects %}
|
||||
<ul>{{ deleteable_object|unordered_list }}</ul>
|
||||
{% endfor %}
|
||||
<form action="" method="post">
|
||||
<div>
|
||||
{% for obj in queryset %}
|
||||
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}" />
|
||||
{% endfor %}
|
||||
<input type="hidden" name="action" value="delete_selected" />
|
||||
<input type="hidden" name="post" value="yes" />
|
||||
<input type="submit" value="{% trans "Yes, I'm sure" %}" />
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -325,3 +325,12 @@ search_form = register.inclusion_tag('admin/search_form.html')(search_form)
|
||||
def admin_list_filter(cl, spec):
|
||||
return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
|
||||
admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
|
||||
|
||||
def admin_actions(context):
|
||||
"""
|
||||
Track the number of times the action field has been rendered on the page,
|
||||
so we know which value to use.
|
||||
"""
|
||||
context['action_index'] = context.get('action_index', -1) + 1
|
||||
return context
|
||||
admin_actions = register.inclusion_tag("admin/actions.html", takes_context=True)(admin_actions)
|
||||
|
@ -4,7 +4,8 @@ from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.encoding import force_unicode
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ungettext, ugettext as _
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||
|
||||
def quote(s):
|
||||
"""
|
||||
@ -60,8 +61,27 @@ def _nest_help(obj, depth, val):
|
||||
current = current[-1]
|
||||
current.append(val)
|
||||
|
||||
def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site):
|
||||
"Helper function that recursively populates deleted_objects."
|
||||
def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
|
||||
"""
|
||||
Returns the url to the admin change view for the given app_label,
|
||||
module_name and primary key.
|
||||
"""
|
||||
try:
|
||||
return reverse('%sadmin_%s_%s_change' % (admin_site.name, app_label, module_name), None, (pk,))
|
||||
except NoReverseMatch:
|
||||
return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, module_name, pk)
|
||||
|
||||
def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site, levels_to_root=4):
|
||||
"""
|
||||
Helper function that recursively populates deleted_objects.
|
||||
|
||||
`levels_to_root` defines the number of directories (../) to reach the
|
||||
admin root path. In a change_view this is 4, in a change_list view 2.
|
||||
|
||||
This is for backwards compatibility since the options.delete_selected
|
||||
method uses this function also from a change_list view.
|
||||
This will not be used if we can reverse the URL.
|
||||
"""
|
||||
nh = _nest_help # Bind to local variable for performance
|
||||
if current_depth > 16:
|
||||
return # Avoid recursing too deep.
|
||||
@ -91,11 +111,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
|
||||
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
|
||||
else:
|
||||
# Display a link to the admin page.
|
||||
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' %
|
||||
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
|
||||
(escape(capfirst(related.opts.verbose_name)),
|
||||
related.opts.app_label,
|
||||
get_change_view_url(related.opts.app_label,
|
||||
related.opts.object_name.lower(),
|
||||
sub_obj._get_pk_val(),
|
||||
admin_site,
|
||||
levels_to_root),
|
||||
escape(sub_obj))), []])
|
||||
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
|
||||
else:
|
||||
@ -109,11 +131,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
|
||||
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
|
||||
else:
|
||||
# Display a link to the admin page.
|
||||
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' %
|
||||
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
|
||||
(escape(capfirst(related.opts.verbose_name)),
|
||||
related.opts.app_label,
|
||||
get_change_view_url(related.opts.app_label,
|
||||
related.opts.object_name.lower(),
|
||||
sub_obj._get_pk_val(),
|
||||
admin_site,
|
||||
levels_to_root),
|
||||
escape(sub_obj))), []])
|
||||
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
|
||||
# If there were related objects, and the user doesn't have
|
||||
@ -147,11 +171,52 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
|
||||
# Display a link to the admin page.
|
||||
nh(deleted_objects, current_depth, [
|
||||
mark_safe((_('One or more %(fieldname)s in %(name)s:') % {'fieldname': escape(force_unicode(related.field.verbose_name)), 'name': escape(force_unicode(related.opts.verbose_name))}) + \
|
||||
(u' <a href="../../../../%s/%s/%s/">%s</a>' % \
|
||||
(related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []])
|
||||
(u' <a href="%s">%s</a>' % \
|
||||
(get_change_view_url(related.opts.app_label,
|
||||
related.opts.object_name.lower(),
|
||||
sub_obj._get_pk_val(),
|
||||
admin_site,
|
||||
levels_to_root),
|
||||
escape(sub_obj)))), []])
|
||||
# If there were related objects, and the user doesn't have
|
||||
# permission to change them, add the missing perm to perms_needed.
|
||||
if has_admin and has_related_objs:
|
||||
p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
|
||||
if not user.has_perm(p):
|
||||
perms_needed.add(related.opts.verbose_name)
|
||||
|
||||
def model_format_dict(obj):
|
||||
"""
|
||||
Return a `dict` with keys 'verbose_name' and 'verbose_name_plural',
|
||||
typically for use with string formatting.
|
||||
|
||||
`obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
|
||||
|
||||
"""
|
||||
if isinstance(obj, (models.Model, models.base.ModelBase)):
|
||||
opts = obj._meta
|
||||
elif isinstance(obj, models.query.QuerySet):
|
||||
opts = obj.model._meta
|
||||
else:
|
||||
opts = obj
|
||||
return {
|
||||
'verbose_name': force_unicode(opts.verbose_name),
|
||||
'verbose_name_plural': force_unicode(opts.verbose_name_plural)
|
||||
}
|
||||
|
||||
def model_ngettext(obj, n=None):
|
||||
"""
|
||||
Return the appropriate `verbose_name` or `verbose_name_plural` for `obj`
|
||||
depending on the count `n`.
|
||||
|
||||
`obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
|
||||
If `obj` is a `QuerySet` instance, `n` is optional and the length of the
|
||||
`QuerySet` is used.
|
||||
|
||||
"""
|
||||
if isinstance(obj, models.query.QuerySet):
|
||||
if n is None:
|
||||
n = obj.count()
|
||||
obj = obj.model
|
||||
d = model_format_dict(obj)
|
||||
return ungettext(d['verbose_name'], d['verbose_name_plural'], n or 0)
|
||||
|
@ -127,6 +127,14 @@ def validate(cls, model):
|
||||
continue
|
||||
get_field(cls, model, opts, 'ordering[%d]' % idx, field)
|
||||
|
||||
if cls.actions:
|
||||
check_isseq(cls, 'actions', cls.actions)
|
||||
for idx, item in enumerate(cls.actions):
|
||||
if (not callable(item)) and (not hasattr(cls, item)):
|
||||
raise ImproperlyConfigured("'%s.actions[%d]' is neither a "
|
||||
"callable nor a method on %s" % (cls.__name__, idx, cls.__name__))
|
||||
|
||||
|
||||
# list_select_related = False
|
||||
# save_as = False
|
||||
# save_on_top = False
|
||||
@ -135,6 +143,7 @@ def validate(cls, model):
|
||||
raise ImproperlyConfigured("'%s.%s' should be a boolean."
|
||||
% (cls.__name__, attr))
|
||||
|
||||
|
||||
# inlines = []
|
||||
if hasattr(cls, 'inlines'):
|
||||
check_isseq(cls, 'inlines', cls.inlines)
|
||||
|
@ -78,7 +78,7 @@ The development process
|
||||
Other batteries included
|
||||
========================
|
||||
|
||||
* :ref:`Admin site <ref-contrib-admin>`
|
||||
* :ref:`Admin site <ref-contrib-admin>` | :ref:`Admin actions <ref-contrib-admin-actions>`
|
||||
* :ref:`Authentication <topics-auth>`
|
||||
* :ref:`Cache system <topics-cache>`
|
||||
* :ref:`Conditional content processing <topics-conditional-processing>`
|
||||
|
BIN
docs/ref/contrib/admin/_images/article_actions.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
docs/ref/contrib/admin/_images/article_actions_message.png
Normal file
After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
BIN
docs/ref/contrib/admin/_images/user_actions.png
Normal file
After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
239
docs/ref/contrib/admin/actions.txt
Normal file
@ -0,0 +1,239 @@
|
||||
.. _ref-contrib-admin-actions:
|
||||
|
||||
=============
|
||||
Admin actions
|
||||
=============
|
||||
|
||||
.. versionadded:: 1.1
|
||||
|
||||
.. currentmodule:: django.contrib.admin
|
||||
|
||||
The basic workflow of Django's admin is, in a nutshell, "select an object,
|
||||
then change it." This works well for a majority of use cases. However, if you
|
||||
need to make the same change to many objects at once, this workflow can be
|
||||
quite tedious.
|
||||
|
||||
In these cases, Django's admin lets you write and register "actions" -- simple
|
||||
functions that get called with a list of objects selected on the change list
|
||||
page.
|
||||
|
||||
If you look at any change list in the admin, you'll see this feature in
|
||||
action; Django ships with a "delete selected objects" action available to all
|
||||
models. For example, here's the user module from Django's built-in
|
||||
:mod:`django.contrib.auth` app:
|
||||
|
||||
.. image:: _images/user_actions.png
|
||||
|
||||
Read on to find out how to add your own actions to this list.
|
||||
|
||||
Writing actions
|
||||
===============
|
||||
|
||||
The easiest way to explain actions is by example, so let's dive in.
|
||||
|
||||
A common use case for admin actions is the bulk updating of a model. Imagine a simple
|
||||
news application with an ``Article`` model::
|
||||
|
||||
from django.db import models
|
||||
|
||||
STATUS_CHOICES = (
|
||||
('d', 'Draft'),
|
||||
('p', 'Published'),
|
||||
('w', 'Withdrawn'),
|
||||
)
|
||||
|
||||
class Article(models.Model):
|
||||
title = models.CharField(max_length=100)
|
||||
body = models.TextField()
|
||||
status = models.CharField(max_length=1, choices=STATUS_CHOICES)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.title
|
||||
|
||||
A common task we might perform with a model like this is to update an
|
||||
article's status from "draft" to "published". We could easily do this in the
|
||||
admin one article at a time, but if we wanted to bulk-publish a group of
|
||||
articles, it'd be tedious. So, let's write an action that lets us change an
|
||||
article's status to "published."
|
||||
|
||||
Writing action functions
|
||||
------------------------
|
||||
|
||||
First, we'll need to write a function that gets called when the action is
|
||||
trigged from the admin. Action functions are just regular functions that take
|
||||
two arguments: an :class:`~django.http.HttpRequest` representing the current
|
||||
request, and a :class:`~django.db.models.QuerySet` containing the set of
|
||||
objects selected by the user. Our publish-these-articles function won't need
|
||||
the request object, but we will use the queryset::
|
||||
|
||||
def make_published(request, queryset):
|
||||
queryset.update(status='p')
|
||||
|
||||
.. note::
|
||||
|
||||
For the best performance, we're using the queryset's :ref:`update method
|
||||
<topics-db-queries-update>`. Other types of actions might need to deal
|
||||
with each object individually; in these cases we'd just iterate over the
|
||||
queryset::
|
||||
|
||||
for obj in queryset:
|
||||
do_something_with(obj)
|
||||
|
||||
That's actually all there is to writing an action! However, we'll take one
|
||||
more optional-but-useful step and give the action a "nice" title in the admin.
|
||||
By default, this action would appear in the action list as "Make published" --
|
||||
the function name, with underscores replaced by spaces. That's fine, but we
|
||||
can provide a better, more human-friendly name by giving the
|
||||
``make_published`` function a ``short_description`` attribute::
|
||||
|
||||
def make_published(request, queryset):
|
||||
queryset.update(status='p')
|
||||
make_published.short_description = "Mark selected stories as published"
|
||||
|
||||
.. note::
|
||||
|
||||
This might look familiar; the admin's ``list_display`` option uses the
|
||||
same technique to provide human-readable descriptions for callback
|
||||
functions registered there, too.
|
||||
|
||||
Adding actions to the :class:`ModelAdmin`
|
||||
-----------------------------------------
|
||||
|
||||
Next, we'll need to inform our :class:`ModelAdmin` of the action. This works
|
||||
just like any other configuration option. So, the complete ``admin.py`` with
|
||||
the action and its registration would look like::
|
||||
|
||||
from django.contrib import admin
|
||||
from myapp.models import Article
|
||||
|
||||
def make_published(request, queryset):
|
||||
queryset.update(status='p')
|
||||
make_published.short_description = "Mark selected stories as published"
|
||||
|
||||
class ArticleAdmin(admin.ModelAdmin):
|
||||
list_display = ['title', 'status']
|
||||
ordering = ['title']
|
||||
actions = [make_published]
|
||||
|
||||
admin.site.register(Article, ArticleAdmin)
|
||||
|
||||
That code will give us an admin change list that looks something like this:
|
||||
|
||||
.. image:: _images/article_actions.png
|
||||
|
||||
That's really all there is to it! If you're itching to write your own actions,
|
||||
you now know enough to get started. The rest of this document just covers more
|
||||
advanced techniques.
|
||||
|
||||
Advanced action techniques
|
||||
==========================
|
||||
|
||||
There's a couple of extra options and possibilities you can exploit for more
|
||||
advanced options.
|
||||
|
||||
Actions as :class:`ModelAdmin` methods
|
||||
--------------------------------------
|
||||
|
||||
The example above shows the ``make_published`` action defined as a simple
|
||||
function. That's perfectly fine, but it's not perfect from a code design point
|
||||
of view: since the action is tightly coupled to the ``Article`` object, it
|
||||
makes sense to hook the action to the ``ArticleAdmin`` object itself.
|
||||
|
||||
That's easy enough to do::
|
||||
|
||||
class ArticleAdmin(admin.ModelAdmin):
|
||||
...
|
||||
|
||||
actions = ['make_published']
|
||||
|
||||
def make_published(self, request, queryset):
|
||||
queryset.update(status='p')
|
||||
make_published.short_description = "Mark selected stories as published"
|
||||
|
||||
Notice first that we've moved ``make_published`` into a method (remembering to
|
||||
add the ``self`` argument!), and second that we've now put the string
|
||||
``'make_published'`` in ``actions`` instead of a direct function reference.
|
||||
This tells the :class:`ModelAdmin` to look up the action as a method.
|
||||
|
||||
Defining actions as methods is especially nice because it gives the action
|
||||
access to the :class:`ModelAdmin` itself, allowing the action to call any of
|
||||
the methods provided by the admin.
|
||||
|
||||
For example, we can use ``self`` to flash a message to the user informing her
|
||||
that the action was successful::
|
||||
|
||||
class ArticleAdmin(admin.ModelAdmin):
|
||||
...
|
||||
|
||||
def make_published(self, request, queryset):
|
||||
rows_updated = queryset.update(status='p')
|
||||
if rows_updated == 1:
|
||||
message_bit = "1 story was"
|
||||
else:
|
||||
message_bit = "%s stories were" % rows_updated
|
||||
self.message_user(request, "%s successfully marked as published." % message_bit)
|
||||
|
||||
This make the action match what the admin itself does after successfully
|
||||
performing an action:
|
||||
|
||||
.. image:: _images/article_actions_message.png
|
||||
|
||||
Actions that provide intermediate pages
|
||||
---------------------------------------
|
||||
|
||||
By default, after an action is performed the user is simply redirected back
|
||||
the the original change list page. However, some actions, especially more
|
||||
complex ones, will need to return intermediate pages. For example, the
|
||||
built-in delete action asks for confirmation before deleting the selected
|
||||
objects.
|
||||
|
||||
To provide an intermediary page, simply return an
|
||||
:class:`~django.http.HttpResponse` (or subclass) from your action. For
|
||||
example, you might write a simple export function that uses Django's
|
||||
:ref:`serialization functions <topics-serialization>` to dump some selected
|
||||
objects as JSON::
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.core import serializers
|
||||
|
||||
def export_as_json(request, queryset):
|
||||
response = HttpResponse(mimetype="text/javascript")
|
||||
serialize.serialize(queryset, stream=response)
|
||||
return response
|
||||
|
||||
Generally, something like the above isn't considered a great idea. Most of the
|
||||
time, the best practice will be to return an
|
||||
:class:`~django.http.HttpResponseRedirect` and redirect the user to a view
|
||||
you've written, passing the list of selected objects in the GET query string.
|
||||
This allows you to provide complex interaction logic on the intermediary
|
||||
pages. For example, if you wanted to provide a more complete export function,
|
||||
you'd want to let the user choose a format, and possibly a list of fields to
|
||||
include in the export. The best thing to do would be to write a small action that simply redirects
|
||||
to your custom export view::
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
def export_selected_objects(request, queryset):
|
||||
selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME)
|
||||
ct = ContentType.objects.get_for_model(queryset.model)
|
||||
return HttpResponseRedirect("/export/?ct=%s&ids=%s" % (ct.pk, ",".join(selected)))
|
||||
|
||||
As you can see, the action is the simple part; all the complex logic would
|
||||
belong in your export view. This would need to deal with objects of any type,
|
||||
hence the business with the ``ContentType``.
|
||||
|
||||
Writing this view is left as an exercise to the reader.
|
||||
|
||||
Making actions available globally
|
||||
---------------------------------
|
||||
|
||||
Some actions are best if they're made available to *any* object in the admin
|
||||
-- the export action defined above would be a good candidate. You can make an
|
||||
action globally available using :meth:`AdminSite.add_action()`::
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
admin.site.add_action(export_selected_objects)
|
||||
|
@ -39,6 +39,14 @@ There are five steps in activating the Django admin site:
|
||||
|
||||
5. Hook the ``AdminSite`` instance into your URLconf.
|
||||
|
||||
Other topics
|
||||
------------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
actions
|
||||
|
||||
``ModelAdmin`` objects
|
||||
======================
|
||||
|
||||
@ -664,6 +672,19 @@ The value is another dictionary; these arguments will be passed to
|
||||
that have ``raw_id_fields`` or ``radio_fields`` set. That's because
|
||||
``raw_id_fields`` and ``radio_fields`` imply custom widgets of their own.
|
||||
|
||||
``actions``
|
||||
~~~~~~~~~~~
|
||||
|
||||
A list of actions to make available on the change list page. See
|
||||
:ref:`ref-contrib-admin-actions` for details.
|
||||
|
||||
``actions_on_top``, ``actions_on_buttom``
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Controls where on the page the actions bar appears. By default, the admin
|
||||
changelist displays actions at the top of the page (``actions_on_top = True;
|
||||
actions_on_bottom = False``).
|
||||
|
||||
``ModelAdmin`` methods
|
||||
----------------------
|
||||
|
||||
@ -1138,7 +1159,6 @@ or add anything you like. Then, simply create an instance of your
|
||||
Python class), and register your models and ``ModelAdmin`` subclasses
|
||||
with it instead of using the default.
|
||||
|
||||
|
||||
Hooking ``AdminSite`` instances into your URLconf
|
||||
-------------------------------------------------
|
||||
|
||||
@ -1177,7 +1197,6 @@ There is really no need to use autodiscover when using your own ``AdminSite``
|
||||
instance since you will likely be importing all the per-app admin.py modules
|
||||
in your ``myproject.admin`` module.
|
||||
|
||||
|
||||
Multiple admin sites in the same URLconf
|
||||
----------------------------------------
|
||||
|
@ -24,7 +24,7 @@ those packages have.
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
admin
|
||||
admin/index
|
||||
auth
|
||||
comments/index
|
||||
contenttypes
|
||||
|
@ -49,7 +49,7 @@ AlreadyRegistered: The model Person is already registered
|
||||
>>> site._registry[Person].search_fields
|
||||
['name']
|
||||
>>> site._registry[Person].list_display
|
||||
['__str__']
|
||||
['action_checkbox', '__str__']
|
||||
>>> site._registry[Person].save_on_top
|
||||
True
|
||||
|
||||
|
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<django-objects version="1.0">
|
||||
<object pk="1" model="admin_views.subscriber">
|
||||
<field type="CharField" name="name">John Doe</field>
|
||||
<field type="CharField" name="email">john@example.org</field>
|
||||
</object>
|
||||
<object pk="2" model="admin_views.subscriber">
|
||||
<field type="CharField" name="name">Max Mustermann</field>
|
||||
<field type="CharField" name="email">max@example.org</field>
|
||||
</object>
|
||||
<object pk="1" model="admin_views.externalsubscriber">
|
||||
<field type="CharField" name="name">John Doe</field>
|
||||
<field type="CharField" name="email">john@example.org</field>
|
||||
</object>
|
||||
</django-objects>
|
@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.db import models
|
||||
from django.contrib import admin
|
||||
from django.core.mail import EmailMessage
|
||||
|
||||
class Section(models.Model):
|
||||
"""
|
||||
@ -199,6 +200,41 @@ class PersonaAdmin(admin.ModelAdmin):
|
||||
BarAccountAdmin
|
||||
)
|
||||
|
||||
class Subscriber(models.Model):
|
||||
name = models.CharField(blank=False, max_length=80)
|
||||
email = models.EmailField(blank=False, max_length=175)
|
||||
|
||||
def __unicode__(self):
|
||||
return "%s (%s)" % (self.name, self.email)
|
||||
|
||||
class SubscriberAdmin(admin.ModelAdmin):
|
||||
actions = ['delete_selected', 'mail_admin']
|
||||
|
||||
def mail_admin(self, request, selected):
|
||||
EmailMessage(
|
||||
'Greetings from a ModelAdmin action',
|
||||
'This is the test email from a admin action',
|
||||
'from@example.com',
|
||||
['to@example.com']
|
||||
).send()
|
||||
|
||||
class ExternalSubscriber(Subscriber):
|
||||
pass
|
||||
|
||||
def external_mail(request, selected):
|
||||
EmailMessage(
|
||||
'Greetings from a function action',
|
||||
'This is the test email from a function action',
|
||||
'from@example.com',
|
||||
['to@example.com']
|
||||
).send()
|
||||
|
||||
def redirect_to(request, selected):
|
||||
from django.http import HttpResponseRedirect
|
||||
return HttpResponseRedirect('/some-where-else/')
|
||||
|
||||
class ExternalSubscriberAdmin(admin.ModelAdmin):
|
||||
actions = [external_mail, redirect_to]
|
||||
|
||||
admin.site.register(Article, ArticleAdmin)
|
||||
admin.site.register(CustomArticle, CustomArticleAdmin)
|
||||
@ -208,6 +244,8 @@ admin.site.register(Color)
|
||||
admin.site.register(Thing, ThingAdmin)
|
||||
admin.site.register(Person, PersonAdmin)
|
||||
admin.site.register(Persona, PersonaAdmin)
|
||||
admin.site.register(Subscriber, SubscriberAdmin)
|
||||
admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
|
||||
|
||||
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
|
||||
# That way we cover all four cases:
|
||||
|
@ -8,10 +8,11 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.admin.models import LogEntry
|
||||
from django.contrib.admin.sites import LOGIN_FORM_KEY
|
||||
from django.contrib.admin.util import quote
|
||||
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
||||
from django.utils.html import escape
|
||||
|
||||
# local test models
|
||||
from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount
|
||||
from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount, Subscriber, ExternalSubscriber
|
||||
|
||||
try:
|
||||
set
|
||||
@ -516,7 +517,7 @@ class AdminViewStringPrimaryKeyTest(TestCase):
|
||||
def test_changelist_to_changeform_link(self):
|
||||
"The link from the changelist referring to the changeform of the object should be quoted"
|
||||
response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/')
|
||||
should_contain = """<tr class="row1"><th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
|
||||
should_contain = """<th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
|
||||
self.assertContains(response, should_contain)
|
||||
|
||||
def test_recentactions_link(self):
|
||||
@ -743,12 +744,13 @@ class AdminViewListEditable(TestCase):
|
||||
response = self.client.get('/test_admin/admin/admin_views/person/')
|
||||
# 2 inputs per object(the field and the hidden id field) = 6
|
||||
# 2 management hidden fields = 2
|
||||
# 4 action inputs (3 regular checkboxes, 1 checkbox to select all)
|
||||
# main form submit button = 1
|
||||
# search field and search submit button = 2
|
||||
# 6 + 2 + 1 + 2 = 11 inputs
|
||||
self.failUnlessEqual(response.content.count("<input"), 11)
|
||||
self.failUnlessEqual(response.content.count("<input"), 15)
|
||||
# 1 select per object = 3 selects
|
||||
self.failUnlessEqual(response.content.count("<select"), 3)
|
||||
self.failUnlessEqual(response.content.count("<select"), 4)
|
||||
|
||||
def test_post_submission(self):
|
||||
data = {
|
||||
@ -875,3 +877,65 @@ class AdminInheritedInlinesTest(TestCase):
|
||||
self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
|
||||
self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
|
||||
self.failUnlessEqual(Persona.objects.all()[0].accounts.count(), 2)
|
||||
|
||||
from django.core import mail
|
||||
|
||||
class AdminActionsTest(TestCase):
|
||||
fixtures = ['admin-views-users.xml', 'admin-views-actions.xml']
|
||||
|
||||
def setUp(self):
|
||||
self.client.login(username='super', password='secret')
|
||||
|
||||
def tearDown(self):
|
||||
self.client.logout()
|
||||
|
||||
def test_model_admin_custom_action(self):
|
||||
"Tests a custom action defined in a ModelAdmin method"
|
||||
action_data = {
|
||||
ACTION_CHECKBOX_NAME: [1],
|
||||
'action' : 'mail_admin',
|
||||
'index': 0,
|
||||
}
|
||||
response = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
self.assertEquals(mail.outbox[0].subject, 'Greetings from a ModelAdmin action')
|
||||
|
||||
def test_model_admin_default_delete_action(self):
|
||||
"Tests the default delete action defined as a ModelAdmin method"
|
||||
action_data = {
|
||||
ACTION_CHECKBOX_NAME: [1, 2],
|
||||
'action' : 'delete_selected',
|
||||
'index': 0,
|
||||
}
|
||||
delete_confirmation_data = {
|
||||
ACTION_CHECKBOX_NAME: [1, 2],
|
||||
'action' : 'delete_selected',
|
||||
'index': 0,
|
||||
'post': 'yes',
|
||||
}
|
||||
confirmation = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
|
||||
self.assertContains(confirmation, "Are you sure you want to delete the selected subscriber objects")
|
||||
self.failUnless(confirmation.content.count(ACTION_CHECKBOX_NAME) == 2)
|
||||
response = self.client.post('/test_admin/admin/admin_views/subscriber/', delete_confirmation_data)
|
||||
self.failUnlessEqual(Subscriber.objects.count(), 0)
|
||||
|
||||
def test_custom_function_mail_action(self):
|
||||
"Tests a custom action defined in a function"
|
||||
action_data = {
|
||||
ACTION_CHECKBOX_NAME: [1],
|
||||
'action' : 'external_mail',
|
||||
'index': 0,
|
||||
}
|
||||
response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
self.assertEquals(mail.outbox[0].subject, 'Greetings from a function action')
|
||||
|
||||
def test_custom_function_action_with_redirect(self):
|
||||
"Tests a custom action defined in a function"
|
||||
action_data = {
|
||||
ACTION_CHECKBOX_NAME: [1],
|
||||
'action' : 'redirect_to',
|
||||
'index': 0,
|
||||
}
|
||||
response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
|
||||
self.failUnlessEqual(response.status_code, 302)
|
||||
|