Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Fixed #8939: added a `list_editable` option to `ModelAdmin`; fields d…

…eclared `list_editable` may be edited, in bulk, on the changelist page. Thanks, Alex Gaynor.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@10077 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
commit 7bc0878922d9d93ab8f4ef8a5c5ba7a1c671279f 1 parent a7d1c73
Jacob Kaplan-Moss jacobian authored
74 django/contrib/admin/options.py
View
@@ -1,6 +1,6 @@
from django import forms, template
from django.forms.formsets import all_valid
-from django.forms.models import modelform_factory, inlineformset_factory
+from django.forms.models import modelform_factory, modelformset_factory, inlineformset_factory
from django.forms.models import BaseInlineFormSet
from django.contrib.contenttypes.models import ContentType
from django.contrib.admin import widgets
@@ -16,6 +16,7 @@
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.encoding import force_unicode
try:
set
@@ -108,7 +109,7 @@ def formfield_for_dbfield(self, db_field, **kwargs):
if db_field.__class__ in self.formfield_overrides:
kwargs = dict(self.formfield_overrides[db_field.__class__], **kwargs)
return db_field.formfield(**kwargs)
-
+
# For any other type of field, just call its formfield() method.
return db_field.formfield(**kwargs)
@@ -177,6 +178,7 @@ class ModelAdmin(BaseModelAdmin):
list_filter = ()
list_select_related = False
list_per_page = 100
+ list_editable = ()
search_fields = ()
date_hierarchy = None
save_as = False
@@ -313,6 +315,29 @@ def get_form(self, request, obj=None, **kwargs):
defaults.update(kwargs)
return modelform_factory(self.model, **defaults)
+ def get_changelist_form(self, request, **kwargs):
+ """
+ Returns a Form class for use in the Formset on the changelist page.
+ """
+ defaults = {
+ "formfield_callback": curry(self.formfield_for_dbfield, request=request),
+ }
+ defaults.update(kwargs)
+ return modelform_factory(self.model, **defaults)
+
+ def get_changelist_formset(self, request, **kwargs):
+ """
+ Returns a FormSet class for use on the changelist page if list_editable
+ is used.
+ """
+ defaults = {
+ "formfield_callback": curry(self.formfield_for_dbfield, request=request),
+ }
+ defaults.update(kwargs)
+ return modelformset_factory(self.model,
+ self.get_changelist_form(request), extra=0,
+ fields=self.list_editable, **defaults)
+
def get_formsets(self, request, obj=None):
for inline in self.inline_instances:
yield inline.get_formset(request, obj)
@@ -685,7 +710,7 @@ def changelist_view(self, request, extra_context=None):
raise PermissionDenied
try:
cl = ChangeList(request, self.model, self.list_display, self.list_display_links, self.list_filter,
- self.date_hierarchy, self.search_fields, self.list_select_related, self.list_per_page, self)
+ self.date_hierarchy, self.search_fields, self.list_select_related, self.list_per_page, self.list_editable, self)
except IncorrectLookupParameters:
# Wacky lookup parameters were given, so redirect to the main
# changelist page, without parameters, and pass an 'invalid=1'
@@ -696,10 +721,53 @@ def changelist_view(self, request, extra_context=None):
return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
+ # 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.
+ formset = cl.formset = None
+
+ # Handle POSTed bulk-edit data.
+ if request.method == "POST" and self.list_editable:
+ FormSet = self.get_changelist_formset(request)
+ formset = cl.formset = FormSet(request.POST, request.FILES, queryset=cl.result_list)
+ if formset.is_valid():
+ changecount = 0
+ for form in formset.forms:
+ if form.has_changed():
+ obj = self.save_form(request, form, change=True)
+ self.save_model(request, obj, form, change=True)
+ form.save_m2m()
+ change_msg = self.construct_change_message(request, form, None)
+ self.log_change(request, obj, change_msg)
+ changecount += 1
+
+ if changecount:
+ msg = ngettext("%(count)s %(singular)s was changed successfully.",
+ "%(count)s %(plural)s were changed successfully.",
+ changecount) % {'count': changecount,
+ 'singular': force_unicode(opts.verbose_name),
+ 'plural': force_unicode(opts.verbose_name_plural),
+ 'obj': force_unicode(obj)}
+ self.message_user(request, msg)
+
+ return HttpResponseRedirect(request.get_full_path())
+
+ # Handle GET -- construct a formset for display.
+ elif self.list_editable:
+ FormSet = self.get_changelist_formset(request)
+ formset = cl.formset = FormSet(queryset=cl.result_list)
+
+ # Build the list of media to be used by the formset.
+ if formset:
+ media = self.media + formset.media
+ else:
+ media = None
+
context = {
'title': cl.title,
'is_popup': cl.is_popup,
'cl': cl,
+ 'media': media,
'has_add_permission': self.has_add_permission(request),
'root_path': self.admin_site.root_path,
'app_label': app_label,
88 django/contrib/admin/templates/admin/change_list.html
View
@@ -1,38 +1,76 @@
{% extends "admin/base_site.html" %}
{% load adminmedia admin_list i18n %}
-{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/changelists.css" />{% endblock %}
+{% block extrastyle %}
+ {{ block.super }}
+ <link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/changelists.css" />
+ {% 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 %}
+{% endblock %}
{% block bodyclass %}change-list{% endblock %}
-{% if not is_popup %}{% block breadcrumbs %}<div class="breadcrumbs"><a href="../../">{% trans "Home" %}</a> &rsaquo; <a href="../">{{ app_label|capfirst }}</a> &rsaquo; {{ cl.opts.verbose_name_plural|capfirst }}</div>{% endblock %}{% endif %}
+{% if not is_popup %}
+ {% block breadcrumbs %}
+ <div class="breadcrumbs">
+ <a href="../../">
+ {% trans "Home" %}
+ </a>
+ &rsaquo;
+ <a href="../">
+ {{ app_label|capfirst }}
+ </a>
+ &rsaquo;
+ {{ cl.opts.verbose_name_plural|capfirst }}
+ </div>
+ {% endblock %}
+{% endif %}
{% block coltype %}flex{% endblock %}
{% block content %}
-<div id="content-main">
-{% block object-tools %}
-{% if has_add_permission %}
-<ul class="object-tools"><li><a href="add/{% if is_popup %}?_popup=1{% endif %}" class="addlink">{% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %}</a></li></ul>
-{% endif %}
-{% endblock %}
-<div class="module{% if cl.has_filters %} filtered{% endif %}" id="changelist">
-{% block search %}{% search_form cl %}{% endblock %}
-{% block date_hierarchy %}{% date_hierarchy cl %}{% endblock %}
+ <div id="content-main">
+ {% block object-tools %}
+ {% if has_add_permission %}
+ <ul class="object-tools">
+ <li>
+ <a href="add/{% if is_popup %}?_popup=1{% endif %}" class="addlink">
+ {% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %}
+ </a>
+ </li>
+ </ul>
+ {% endif %}
+ {% endblock %}
+ {% if cl.formset.errors %}
+ <p class="errornote">
+ {% blocktrans count cl.formset.errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %}
+ </p>
+ <ul class="errorlist">{% for error in cl.formset.non_field_errors %}<li>{{ error }}</li>{% endfor %}</ul>
+ {% endif %}
+ <div class="module{% if cl.has_filters %} filtered{% endif %}" id="changelist">
+ {% block search %}{% search_form cl %}{% endblock %}
+ {% block date_hierarchy %}{% date_hierarchy cl %}{% endblock %}
-{% block filters %}
-{% if cl.has_filters %}
-<div id="changelist-filter">
-<h2>{% trans 'Filter' %}</h2>
-{% for spec in cl.filter_specs %}
- {% admin_list_filter cl spec %}
-{% endfor %}
-</div>
-{% endif %}
-{% endblock %}
+ {% block filters %}
+ {% if cl.has_filters %}
+ <div id="changelist-filter">
+ <h2>{% trans 'Filter' %}</h2>
+ {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
+ </div>
+ {% endif %}
+ {% endblock %}
+
+ {% if cl.formset %}
+ <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
+ {{ cl.formset.management_form }}
+ {% endif %}
-{% block result_list %}{% result_list cl %}{% endblock %}
-{% block pagination %}{% pagination cl %}{% endblock %}
-</div>
-</div>
+ {% block result_list %}{% result_list cl %}{% endblock %}
+ {% block pagination %}{% pagination cl %}{% endblock %}
+ {% if cl.formset %}</form>{% endif %}
+ </div>
+ </div>
{% endblock %}
1  django/contrib/admin/templates/admin/pagination.html
View
@@ -8,4 +8,5 @@
{% endif %}
{{ cl.result_count }} {% ifequal cl.result_count 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endifequal %}
{% if show_all_url %}&nbsp;&nbsp;<a href="{{ show_all_url }}" class="showall">{% trans 'Show all' %}</a>{% endif %}
+{% if cl.formset %}<input type="submit" name="_save" class="default" value="Save"/>{% endif %}
</p>
22 django/contrib/admin/templatetags/admin_list.py
View
@@ -133,7 +133,7 @@ def _boolean_icon(field_val):
BOOLEAN_MAPPING = {True: 'yes', False: 'no', None: 'unknown'}
return mark_safe(u'<img src="%simg/admin/icon-%s.gif" alt="%s" />' % (settings.ADMIN_MEDIA_PREFIX, BOOLEAN_MAPPING[field_val], field_val))
-def items_for_result(cl, result):
+def items_for_result(cl, result, form):
first = True
pk = cl.lookup_opts.pk.attname
for field_name in cl.list_display:
@@ -227,11 +227,25 @@ def items_for_result(cl, result):
yield mark_safe(u'<%s%s><a href="%s"%s>%s</a></%s>' % \
(table_tag, row_class, url, (cl.is_popup and ' onclick="opener.dismissRelatedLookupPopup(window, %s); return false;"' % result_id or ''), conditional_escape(result_repr), table_tag))
else:
- yield mark_safe(u'<td%s>%s</td>' % (row_class, conditional_escape(result_repr)))
+ # By default the fields come from ModelAdmin.list_editable, but if we pull
+ # the fields out of the form instead of list_editable custom admins
+ # can provide fields on a per request basis
+ if form and field_name in form.fields:
+ bf = form[field_name]
+ result_repr = mark_safe(force_unicode(bf.errors) + force_unicode(bf))
+ else:
+ result_repr = conditional_escape(result_repr)
+ yield mark_safe(u'<td%s>%s</td>' % (row_class, result_repr))
+ if form:
+ yield mark_safe(force_unicode(form[cl.model._meta.pk.attname]))
def results(cl):
- for res in cl.result_list:
- yield list(items_for_result(cl,res))
+ if cl.formset:
+ for res, form in zip(cl.result_list, cl.formset.forms):
+ yield list(items_for_result(cl, res, form))
+ else:
+ for res in cl.result_list:
+ yield list(items_for_result(cl, res, None))
def result_list(cl):
return {'cl': cl,
23 django/contrib/admin/validation.py
View
@@ -63,6 +63,29 @@ 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)
+ if not (opts.ordering or cls.ordering):
+ raise ImproperlyConfigured("'%s.list_editable' cannot be used "
+ "without a default ordering. Please define ordering on either %s or %s."
+ % (cls.__name__, cls.__name__, model.__name__))
+ for idx, field in enumerate(cls.list_editable):
+ try:
+ opts.get_field_by_name(field)
+ except models.FieldDoesNotExist:
+ raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a "
+ "field, '%s', not defiend on %s." % (cls.__name__, idx, field, model.__name__))
+ if field not in cls.list_display:
+ raise ImproperlyConfigured("'%s.list_editable[%d]' refers to "
+ "'%s' which is not defined in 'list_display'."
+ % (cls.__name__, idx, field))
+ if field in cls.list_display_links:
+ raise ImproperlyConfigured("'%s' cannot be in both '%s.list_editable'"
+ " and '%s.list_display_links'"
+ % (field, cls.__name__, cls.__name__))
+
# search_fields = ()
if hasattr(cls, 'search_fields'):
3  django/contrib/admin/views/main.py
View
@@ -32,7 +32,7 @@
EMPTY_CHANGELIST_VALUE = '(None)'
class ChangeList(object):
- def __init__(self, request, model, list_display, list_display_links, list_filter, date_hierarchy, search_fields, list_select_related, list_per_page, model_admin):
+ def __init__(self, request, model, list_display, list_display_links, list_filter, date_hierarchy, search_fields, list_select_related, list_per_page, list_editable, model_admin):
self.model = model
self.opts = model._meta
self.lookup_opts = self.opts
@@ -44,6 +44,7 @@ def __init__(self, request, model, list_display, list_display_links, list_filter
self.search_fields = search_fields
self.list_select_related = list_select_related
self.list_per_page = list_per_page
+ self.list_editable = list_editable
self.model_admin = model_admin
# Get search parameters from the query string.
26 docs/ref/contrib/admin.txt
View
@@ -403,6 +403,32 @@ the change list page::
Finally, note that in order to use ``list_display_links``, you must define
``list_display``, too.
+``list_editable``
+~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 1.1
+
+Set ``list_editable`` to a list of field names on the model which will allow
+editing on the change list page. That is, fields listed in ``list_editable``
+will be displayed as form widgets on the change list page, allowing users to
+edit and save multiple rows at once.
+
+.. note::
+
+ ``list_editable`` interacts with a couple of other options in particular
+ ways; you should note the following rules:
+
+ * To use ``list_editable`` you must have defined ``ordering`` defined on
+ either your model or your ``ModelAdmin``.
+
+ * Any field in ``list_editable`` must also be in ``list_display``. You
+ can't edit a field that's not displayed!
+
+ * The same field can't be listed in both ``list_editable`` and
+ ``list_display_links`` -- a field can't be both a form and a link.
+
+ You'll get a validation error if any of these rules are broken.
+
``list_filter``
~~~~~~~~~~~~~~~
18 tests/regressiontests/admin_views/fixtures/admin-views-person.xml
View
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<django-objects version="1.0">
+ <object pk="1" model="admin_views.person">
+ <field type="CharField" name="name">John Mauchly</field>
+ <field type="IntegerField" name="gender">1</field>
+ <field type="BooleanField" name="alive">True</field>
+ </object>
+ <object pk="2" model="admin_views.person">
+ <field type="CharField" name="name">Grace Hooper</field>
+ <field type="IntegerField" name="gender">1</field>
+ <field type="BooleanField" name="alive">False</field>
+ </object>
+ <object pk="3" model="admin_views.person">
+ <field type="CharField" name="name">Guido van Rossum</field>
+ <field type="IntegerField" name="gender">1</field>
+ <field type="BooleanField" name="alive">True</field>
+ </object>
+</django-objects>
24 tests/regressiontests/admin_views/models.py
View
@@ -134,6 +134,28 @@ def __unicode__(self):
class ThingAdmin(admin.ModelAdmin):
list_filter = ('color',)
+class Person(models.Model):
+ GENDER_CHOICES = (
+ (1, "Male"),
+ (2, "Female"),
+ )
+ name = models.CharField(max_length=100)
+ gender = models.IntegerField(choices=GENDER_CHOICES)
+ alive = models.BooleanField()
+
+ def __unicode__(self):
+ return self.name
+
+ class Meta:
+ ordering = ["id"]
+
+class PersonAdmin(admin.ModelAdmin):
+ list_display = ('name', 'gender', 'alive')
+ list_editable = ('gender', 'alive')
+ list_filter = ('gender',)
+ search_fields = ('name',)
+ ordering = ["id"]
+
class Persona(models.Model):
"""
A simple persona associated with accounts, to test inlining of related
@@ -177,12 +199,14 @@ class PersonaAdmin(admin.ModelAdmin):
BarAccountAdmin
)
+
admin.site.register(Article, ArticleAdmin)
admin.site.register(CustomArticle, CustomArticleAdmin)
admin.site.register(Section, inlines=[ArticleInline])
admin.site.register(ModelWithStringPrimaryKey)
admin.site.register(Color)
admin.site.register(Thing, ThingAdmin)
+admin.site.register(Person, PersonAdmin)
admin.site.register(Persona, PersonaAdmin)
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
72 tests/regressiontests/admin_views/tests.py
View
@@ -11,7 +11,7 @@
from django.utils.html import escape
# local test models
-from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Persona, FooAccount, BarAccount
+from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount
try:
set
@@ -729,6 +729,76 @@ def testUnicodeDelete(self):
response = self.client.post('/test_admin/admin/admin_views/book/1/delete/', delete_dict)
self.assertRedirects(response, '/test_admin/admin/admin_views/book/')
+
+class AdminViewListEditable(TestCase):
+ fixtures = ['admin-views-users.xml', 'admin-views-person.xml']
+
+ def setUp(self):
+ self.client.login(username='super', password='secret')
+
+ 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
+ # 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)
+ # 1 select per object = 3 selects
+ self.failUnlessEqual(response.content.count("<select"), 3)
+
+ def test_post_submission(self):
+ data = {
+ "form-TOTAL_FORMS": "3",
+ "form-INITIAL_FORMS": "3",
+
+ "form-0-gender": "1",
+ "form-0-id": "1",
+
+ "form-1-gender": "2",
+ "form-1-id": "2",
+
+ "form-2-alive": "checked",
+ "form-2-gender": "1",
+ "form-2-id": "3",
+ }
+ self.client.post('/test_admin/admin/admin_views/person/', data)
+
+ self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, False)
+ self.failUnlessEqual(Person.objects.get(name="Grace Hooper").gender, 2)
+
+ # test a filtered page
+ data = {
+ "form-TOTAL_FORMS": "2",
+ "form-INITIAL_FORMS": "2",
+
+ "form-0-id": "1",
+ "form-0-gender": "1",
+ "form-0-alive": "checked",
+
+ "form-1-id": "3",
+ "form-1-gender": "1",
+ "form-1-alive": "checked",
+ }
+ self.client.post('/test_admin/admin/admin_views/person/?gender__exact=1', data)
+
+ self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, True)
+
+ # test a searched page
+ data = {
+ "form-TOTAL_FORMS": "1",
+ "form-INITIAL_FORMS": "1",
+
+ "form-0-id": "1",
+ "form-0-gender": "1"
+ }
+ self.client.post('/test_admin/admin/admin_views/person/?q=mauchly', data)
+
+ self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, False)
+
class AdminInheritedInlinesTest(TestCase):
fixtures = ['admin-views-users.xml',]
Please sign in to comment.
Something went wrong with that request. Please try again.