Fixed #8939: added a `list_editable` option to `ModelAdmin`; fields declared `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
This commit is contained in:
Jacob Kaplan-Moss 2009-03-17 20:51:47 +00:00
parent a7d1c73ad9
commit 7bc0878922
10 changed files with 317 additions and 34 deletions

View File

@ -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.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.encoding import force_unicode
try:
set
@ -108,7 +109,7 @@ class BaseModelAdmin(object):
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 @@ class ModelAdmin(BaseModelAdmin):
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 @@ class ModelAdmin(BaseModelAdmin):
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 @@ class ModelAdmin(BaseModelAdmin):
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,

View File

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

View File

@ -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>

View File

@ -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,

View File

@ -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'):

View File

@ -32,7 +32,7 @@ ERROR_FLAG = 'e'
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 @@ class ChangeList(object):
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.

View File

@ -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``
~~~~~~~~~~~~~~~

View File

@ -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>

View File

@ -134,6 +134,28 @@ class Thing(models.Model):
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.

View File

@ -11,7 +11,7 @@ from django.contrib.admin.util import quote
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 @@ class AdminViewUnicodeTest(TestCase):
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',]