diff --git a/AUTHORS b/AUTHORS index 88ad4d3e0f..f47bdb4175 100644 --- a/AUTHORS +++ b/AUTHORS @@ -56,6 +56,7 @@ answer newbie questions, and generally made Django that much better: Ned Batchelder batiste@dosimple.ch Batman + Brian Beck Shannon -jj Behrens Esdras Beleza Chris Bennett @@ -268,6 +269,7 @@ answer newbie questions, and generally made Django that much better: Daniel Lindsley Trey Long msaelices + Martin Mahner Matt McClanahan Frantisek Malina Martin Maney diff --git a/django/contrib/admin/__init__.py b/django/contrib/admin/__init__.py index b2eeeebb1e..f8bfde1f5b 100644 --- a/django/contrib/admin/__init__.py +++ b/django/contrib/admin/__init__.py @@ -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 diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py index aaa2e304ce..5cb8f1f07b 100644 --- a/django/contrib/admin/helpers.py +++ b/django/contrib/admin/helpers.py @@ -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): @@ -132,11 +140,11 @@ class InlineAdminForm(AdminForm): self.original.content_type_id = ContentType.objects.get_for_model(original).pk self.show_url = original and hasattr(original, 'get_absolute_url') super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields) - + def __iter__(self): for name, options in self.fieldsets: yield InlineFieldset(self.formset, self.form, name, **options) - + def field_count(self): # tabular.html uses this function for colspan value. num_of_fields = 1 # always has at least one field @@ -149,7 +157,7 @@ class InlineAdminForm(AdminForm): def pk_field(self): return AdminField(self.form, self.formset._pk_field.name, False) - + def fk_field(self): fk = getattr(self.formset, "fk", None) if fk: @@ -169,14 +177,14 @@ class InlineFieldset(Fieldset): def __init__(self, formset, *args, **kwargs): self.formset = formset super(InlineFieldset, self).__init__(*args, **kwargs) - + def __iter__(self): fk = getattr(self.formset, "fk", None) for field in self.fields: if fk and fk.name == field: continue yield Fieldline(self.form, field) - + class AdminErrorList(forms.util.ErrorList): """ Stores all errors for the form/formsets in an add/change stage view. diff --git a/django/contrib/admin/media/css/changelists.css b/django/contrib/admin/media/css/changelists.css index 40142f50ca..649cff7ae9 100644 --- a/django/contrib/admin/media/css/changelists.css +++ b/django/contrib/admin/media/css/changelists.css @@ -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; +} diff --git a/django/contrib/admin/media/js/actions.js b/django/contrib/admin/media/js/actions.js new file mode 100644 index 0000000000..febb0c18c2 --- /dev/null +++ b/django/contrib/admin/media/js/actions.js @@ -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); diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 69f52aadad..3c712a55c8 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -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('') + 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: %s' % (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 [ diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py index 5171e71583..872e4a02c0 100644 --- a/django/contrib/admin/sites.py +++ b/django/contrib/admin/sites.py @@ -28,11 +28,11 @@ class AdminSite(object): register() method, and the root() method can then be used as a Django view function that presents a full admin interface for the collection of registered models. """ - + index_template = None login_template = None app_index_template = None - + def __init__(self, name=None): self._registry = {} # model_class class -> admin_class instance # TODO Root path is used to calculate urls under the old root() method @@ -44,17 +44,19 @@ class AdminSite(object): else: 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. - + The model(s) should be Model classes, not instances. - + If an admin class isn't given, it will use ModelAdmin (the default admin options). If keyword arguments are given -- e.g., list_display -- they'll be applied as options to the admin class. - + If a model is already registered, this will raise AlreadyRegistered. """ if not admin_class: @@ -65,13 +67,13 @@ class AdminSite(object): from django.contrib.admin.validation import validate else: validate = lambda model, adminclass: None - + if isinstance(model_or_iterable, ModelBase): model_or_iterable = [model_or_iterable] for model in model_or_iterable: if model in self._registry: raise AlreadyRegistered('The model %s is already registered' % model.__name__) - + # If we got **options then dynamically construct a subclass of # admin_class with those **options. if options: @@ -80,17 +82,17 @@ class AdminSite(object): # which causes issues later on. options['__module__'] = __name__ admin_class = type("%sAdmin" % model.__name__, (admin_class,), options) - + # Validate (which might be a no-op) validate(admin_class, model) - + # Instantiate the admin class to save in the registry self._registry[model] = admin_class(model, self) - + def unregister(self, model_or_iterable): """ Unregisters the given model(s). - + If a model isn't already registered, this will raise NotRegistered. """ if isinstance(model_or_iterable, ModelBase): @@ -99,44 +101,49 @@ class AdminSite(object): if model not in self._registry: 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 *at least one* page in the admin site. """ return request.user.is_authenticated() and request.user.is_staff - + def check_dependencies(self): """ Check that all things needed to run the admin have been correctly installed. - + The default implementation checks that LogEntry, ContentType and the auth context processor are installed. """ from django.contrib.admin.models import LogEntry from django.contrib.contenttypes.models import ContentType - + if not LogEntry._meta.installed: raise ImproperlyConfigured("Put 'django.contrib.admin' in your INSTALLED_APPS setting in order to use the admin application.") if not ContentType._meta.installed: raise ImproperlyConfigured("Put 'django.contrib.contenttypes' in your INSTALLED_APPS setting in order to use the admin application.") if 'django.core.context_processors.auth' not in settings.TEMPLATE_CONTEXT_PROCESSORS: raise ImproperlyConfigured("Put 'django.core.context_processors.auth' in your TEMPLATE_CONTEXT_PROCESSORS setting in order to use the admin application.") - + def admin_view(self, view): """ Decorator to create an "admin view attached to this ``AdminSite``. This wraps the view and provides permission checking by calling ``self.has_permission``. - + You'll want to use this from within ``AdminSite.get_urls()``: - + class MyAdminSite(AdminSite): - + def get_urls(self): from django.conf.urls.defaults import patterns, url - + urls = super(MyAdminSite, self).get_urls() urls += patterns('', url(r'^my_view/$', self.protected_view(some_view)) @@ -148,15 +155,15 @@ class AdminSite(object): return self.login(request) return view(request, *args, **kwargs) return update_wrapper(inner, view) - + def get_urls(self): from django.conf.urls.defaults import patterns, url, include - + def wrap(view): def wrapper(*args, **kwargs): return self.admin_view(view)(*args, **kwargs) return update_wrapper(wrapper, view) - + # Admin-site-wide views. urlpatterns = patterns('', url(r'^$', @@ -180,7 +187,7 @@ class AdminSite(object): wrap(self.app_index), name='%sadmin_app_list' % self.name), ) - + # Add in each model's views. for model, model_admin in self._registry.iteritems(): urlpatterns += patterns('', @@ -188,11 +195,11 @@ class AdminSite(object): include(model_admin.urls)) ) return urlpatterns - + def urls(self): return self.get_urls() urls = property(urls) - + def password_change(self, request): """ Handles the "change password" task -- both form display and validation. @@ -200,18 +207,18 @@ class AdminSite(object): from django.contrib.auth.views import password_change return password_change(request, post_change_redirect='%spassword_change/done/' % self.root_path) - + def password_change_done(self, request): """ Displays the "success" page after a password change. """ from django.contrib.auth.views import password_change_done return password_change_done(request) - + def i18n_javascript(self, request): """ Displays the i18n JavaScript that the Django admin requires. - + This takes into account the USE_I18N setting. If it's set to False, the generated JavaScript will be leaner and faster. """ @@ -220,23 +227,23 @@ class AdminSite(object): else: from django.views.i18n import null_javascript_catalog as javascript_catalog return javascript_catalog(request, packages='django.conf') - + def logout(self, request): """ Logs out the user for the given HttpRequest. - + This should *not* assume the user is already logged in. """ from django.contrib.auth.views import logout return logout(request) logout = never_cache(logout) - + def login(self, request): """ Displays the login form for the given HttpRequest. """ from django.contrib.auth.models import User - + # If this isn't already the login page, display it. if not request.POST.has_key(LOGIN_FORM_KEY): if request.POST: @@ -244,14 +251,14 @@ class AdminSite(object): else: message = "" return self.display_login_form(request, message) - + # Check that the user accepts cookies. if not request.session.test_cookie_worked(): message = _("Looks like your browser isn't configured to accept cookies. Please enable cookies, reload this page, and try again.") return self.display_login_form(request, message) else: request.session.delete_test_cookie() - + # Check the password. username = request.POST.get('username', None) password = request.POST.get('password', None) @@ -271,7 +278,7 @@ class AdminSite(object): else: message = _("Usernames cannot contain the '@' character.") return self.display_login_form(request, message) - + # The user data is correct; log in the user in and continue. else: if user.is_active and user.is_staff: @@ -280,7 +287,7 @@ class AdminSite(object): else: return self.display_login_form(request, ERROR_MESSAGE) login = never_cache(login) - + def index(self, request, extra_context=None): """ Displays the main admin index page, which lists all of the installed @@ -291,14 +298,14 @@ class AdminSite(object): for model, model_admin in self._registry.items(): app_label = model._meta.app_label has_module_perms = user.has_module_perms(app_label) - + if has_module_perms: perms = { 'add': model_admin.has_add_permission(request), 'change': model_admin.has_change_permission(request), 'delete': model_admin.has_delete_permission(request), } - + # Check whether user has any perm for this module. # If so, add the module to the model_list. if True in perms.values(): @@ -316,15 +323,15 @@ class AdminSite(object): 'has_module_perms': has_module_perms, 'models': [model_dict], } - + # Sort the apps alphabetically. app_list = app_dict.values() app_list.sort(lambda x, y: cmp(x['name'], y['name'])) - + # Sort the models alphabetically within each app. for app in app_list: app['models'].sort(lambda x, y: cmp(x['name'], y['name'])) - + context = { 'title': _('Site administration'), 'app_list': app_list, @@ -335,7 +342,7 @@ class AdminSite(object): context_instance=template.RequestContext(request) ) index = never_cache(index) - + def display_login_form(self, request, error_message='', extra_context=None): request.session.set_test_cookie() context = { @@ -348,7 +355,7 @@ class AdminSite(object): return render_to_response(self.login_template or 'admin/login.html', context, context_instance=template.RequestContext(request) ) - + def app_index(self, request, app_label, extra_context=None): user = request.user has_module_perms = user.has_module_perms(app_label) @@ -394,46 +401,46 @@ class AdminSite(object): return render_to_response(self.app_index_template or 'admin/app_index.html', context, context_instance=template.RequestContext(request) ) - + def root(self, request, url): """ DEPRECATED. This function is the old way of handling URL resolution, and is deprecated in favor of real URL resolution -- see ``get_urls()``. - + This function still exists for backwards-compatibility; it will be removed in Django 1.3. """ import warnings warnings.warn( - "AdminSite.root() is deprecated; use include(admin.site.urls) instead.", + "AdminSite.root() is deprecated; use include(admin.site.urls) instead.", PendingDeprecationWarning ) - + # # Again, remember that the following only exists for # backwards-compatibility. Any new URLs, changes to existing URLs, or # whatever need to be done up in get_urls(), above! # - + if request.method == 'GET' and not request.path.endswith('/'): return http.HttpResponseRedirect(request.path + '/') - + if settings.DEBUG: self.check_dependencies() - + # Figure out the admin base URL path and stash it for later use self.root_path = re.sub(re.escape(url) + '$', '', request.path) - + url = url.rstrip('/') # Trim trailing slash, if it exists. - + # The 'logout' view doesn't require that the person is logged in. if url == 'logout': return self.logout(request) - + # Check permission to continue or display login form. if not self.has_permission(request): return self.login(request) - + if url == '': return self.index(request) elif url == 'password_change': @@ -451,9 +458,9 @@ class AdminSite(object): return self.model_page(request, *url.split('/', 2)) else: return self.app_index(request, url) - + raise http.Http404('The requested admin page does not exist.') - + def model_page(self, request, app_label, model_name, rest_of_url=None): """ DEPRECATED. This is the old way of handling a model view on the admin @@ -468,7 +475,7 @@ class AdminSite(object): except KeyError: raise http.Http404("This model exists but has not been registered with the admin site.") return admin_obj(request, rest_of_url) - model_page = never_cache(model_page) + model_page = never_cache(model_page) # This global object represents the default admin site, for the common case. # You can instantiate AdminSite in your own code to create a custom admin site. diff --git a/django/contrib/admin/templates/admin/actions.html b/django/contrib/admin/templates/admin/actions.html new file mode 100644 index 0000000000..bf4b975dfb --- /dev/null +++ b/django/contrib/admin/templates/admin/actions.html @@ -0,0 +1,5 @@ +{% load i18n %} +
+ {% for field in action_form %}{% endfor %} + +
diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html index dca5b80245..63254b868e 100644 --- a/django/contrib/admin/templates/admin/change_list.html +++ b/django/contrib/admin/templates/admin/change_list.html @@ -7,8 +7,8 @@ {% if cl.formset %} - {{ media }} {% endif %} + {{ media }} {% endblock %} {% block bodyclass %}change-list{% endblock %} @@ -63,14 +63,18 @@ {% endif %} {% endblock %} +
{% 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 %}
{% endif %} + {% endblock %} diff --git a/django/contrib/admin/templates/admin/delete_selected_confirmation.html b/django/contrib/admin/templates/admin/delete_selected_confirmation.html new file mode 100644 index 0000000000..a6258b94c0 --- /dev/null +++ b/django/contrib/admin/templates/admin/delete_selected_confirmation.html @@ -0,0 +1,37 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +{% if perms_lacking %} +

{% 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 %}

+
    + {% for obj in perms_lacking %} +
  • {{ obj }}
  • + {% endfor %} +
+{% else %} +

{% 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 %}

+ {% for deleteable_object in deletable_objects %} +
    {{ deleteable_object|unordered_list }}
+ {% endfor %} +
+
+ {% for obj in queryset %} + + {% endfor %} + + + +
+
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py index 063ef0e4a1..a374bf58f7 100644 --- a/django/contrib/admin/templatetags/admin_list.py +++ b/django/contrib/admin/templatetags/admin_list.py @@ -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) diff --git a/django/contrib/admin/util.py b/django/contrib/admin/util.py index 4164c8ad9c..38f86e3d58 100644 --- a/django/contrib/admin/util.py +++ b/django/contrib/admin/util.py @@ -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: %s' % + nh(deleted_objects, current_depth, [mark_safe(u'%s: %s' % (escape(capfirst(related.opts.verbose_name)), - related.opts.app_label, - related.opts.object_name.lower(), - sub_obj._get_pk_val(), + 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: %s' % + nh(deleted_objects, current_depth, [mark_safe(u'%s: %s' % (escape(capfirst(related.opts.verbose_name)), - related.opts.app_label, - related.opts.object_name.lower(), - sub_obj._get_pk_val(), + 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' %s' % \ - (related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []]) + (u' %s' % \ + (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) diff --git a/django/contrib/admin/validation.py b/django/contrib/admin/validation.py index 92485cac7b..24fa9aa978 100644 --- a/django/contrib/admin/validation.py +++ b/django/contrib/admin/validation.py @@ -63,7 +63,7 @@ def validate(cls, model): if hasattr(cls, 'list_per_page') and not isinstance(cls.list_per_page, int): raise ImproperlyConfigured("'%s.list_per_page' should be a integer." % cls.__name__) - + # list_editable if hasattr(cls, 'list_editable') and cls.list_editable: check_isseq(cls, 'list_editable', cls.list_editable) @@ -76,7 +76,7 @@ def validate(cls, model): field = opts.get_field_by_name(field_name)[0] except models.FieldDoesNotExist: raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a " - "field, '%s', not defiend on %s." + "field, '%s', not defiend on %s." % (cls.__name__, idx, field_name, model.__name__)) if field_name not in cls.list_display: raise ImproperlyConfigured("'%s.list_editable[%d]' refers to " @@ -89,7 +89,7 @@ def validate(cls, model): if not cls.list_display_links and cls.list_display[0] in cls.list_editable: raise ImproperlyConfigured("'%s.list_editable[%d]' refers to" " the first field in list_display, '%s', which can't be" - " used unless list_display_links is set." + " used unless list_display_links is set." % (cls.__name__, idx, cls.list_display[0])) if not field.editable: raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a " @@ -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) diff --git a/docs/index.txt b/docs/index.txt index 9e96422ccb..4b295558a8 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -78,7 +78,7 @@ The development process Other batteries included ======================== - * :ref:`Admin site ` + * :ref:`Admin site ` | :ref:`Admin actions ` * :ref:`Authentication ` * :ref:`Cache system ` * :ref:`Conditional content processing ` diff --git a/docs/ref/contrib/admin/_images/article_actions.png b/docs/ref/contrib/admin/_images/article_actions.png new file mode 100644 index 0000000000..254a8ad557 Binary files /dev/null and b/docs/ref/contrib/admin/_images/article_actions.png differ diff --git a/docs/ref/contrib/admin/_images/article_actions_message.png b/docs/ref/contrib/admin/_images/article_actions_message.png new file mode 100644 index 0000000000..31c5637469 Binary files /dev/null and b/docs/ref/contrib/admin/_images/article_actions_message.png differ diff --git a/docs/ref/contrib/_images/flatfiles_admin.png b/docs/ref/contrib/admin/_images/flatfiles_admin.png similarity index 100% rename from docs/ref/contrib/_images/flatfiles_admin.png rename to docs/ref/contrib/admin/_images/flatfiles_admin.png diff --git a/docs/ref/contrib/admin/_images/user_actions.png b/docs/ref/contrib/admin/_images/user_actions.png new file mode 100644 index 0000000000..9a62f70685 Binary files /dev/null and b/docs/ref/contrib/admin/_images/user_actions.png differ diff --git a/docs/ref/contrib/_images/users_changelist.png b/docs/ref/contrib/admin/_images/users_changelist.png similarity index 100% rename from docs/ref/contrib/_images/users_changelist.png rename to docs/ref/contrib/admin/_images/users_changelist.png diff --git a/docs/ref/contrib/admin/actions.txt b/docs/ref/contrib/admin/actions.txt new file mode 100644 index 0000000000..4969e97a99 --- /dev/null +++ b/docs/ref/contrib/admin/actions.txt @@ -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 + `. 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 ` 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) + diff --git a/docs/ref/contrib/admin.txt b/docs/ref/contrib/admin/index.txt similarity index 98% rename from docs/ref/contrib/admin.txt rename to docs/ref/contrib/admin/index.txt index 1813a191f6..1bcd31bc01 100644 --- a/docs/ref/contrib/admin.txt +++ b/docs/ref/contrib/admin/index.txt @@ -38,6 +38,14 @@ There are five steps in activating the Django admin site: ``ModelAdmin`` classes. 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 ---------------------------------------- diff --git a/docs/ref/contrib/index.txt b/docs/ref/contrib/index.txt index 82a895523c..4f401d6836 100644 --- a/docs/ref/contrib/index.txt +++ b/docs/ref/contrib/index.txt @@ -24,7 +24,7 @@ those packages have. .. toctree:: :maxdepth: 1 - admin + admin/index auth comments/index contenttypes diff --git a/tests/regressiontests/admin_registration/models.py b/tests/regressiontests/admin_registration/models.py index fdfa3691b8..35cf8afce8 100644 --- a/tests/regressiontests/admin_registration/models.py +++ b/tests/regressiontests/admin_registration/models.py @@ -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 diff --git a/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml b/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml new file mode 100644 index 0000000000..316e750577 --- /dev/null +++ b/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml @@ -0,0 +1,15 @@ + + + + John Doe + john@example.org + + + Max Mustermann + max@example.org + + + John Doe + john@example.org + + diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py index eeaf039444..e5e112fa43 100644 --- a/tests/regressiontests/admin_views/models.py +++ b/tests/regressiontests/admin_views/models.py @@ -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: diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index 33000d4f5a..ce6356726f 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -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 = """%s""" % (quote(self.pk), escape(self.pk)) + should_contain = """%s""" % (quote(self.pk), escape(self.pk)) self.assertContains(response, should_contain) def test_recentactions_link(self): @@ -738,29 +739,30 @@ class AdminViewListEditable(TestCase): def tearDown(self): self.client.logout() - + def test_changelist_input_html(self): 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("