Fixed #342 -- added readonly_fields to ModelAdmin. Thanks Alex Gaynor for bootstrapping the patch.

ModelAdmin has been given a readonly_fields that allow field and calculated
values to be displayed alongside editable fields. This works on model
add/change pages and inlines.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@11965 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
Brian Rosner 2009-12-22 18:29:00 +00:00
parent 9233d04265
commit bcd9482a20
13 changed files with 504 additions and 162 deletions

View File

@ -1,13 +1,18 @@
from django import forms
from django.conf import settings
from django.utils.html import escape
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.admin.util import flatten_fieldsets, lookup_field
from django.contrib.admin.util import display_for_field, label_for_field
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.fields import FieldDoesNotExist
from django.db.models.fields.related import ManyToManyRel
from django.forms.util import flatatt
from django.utils.encoding import force_unicode, smart_unicode
from django.utils.html import escape, conditional_escape
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
ACTION_CHECKBOX_NAME = '_selected_action'
class ActionForm(forms.Form):
@ -16,16 +21,24 @@ class ActionForm(forms.Form):
checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
class AdminForm(object):
def __init__(self, form, fieldsets, prepopulated_fields):
def __init__(self, form, fieldsets, prepopulated_fields, readonly_fields=None, model_admin=None):
self.form, self.fieldsets = form, normalize_fieldsets(fieldsets)
self.prepopulated_fields = [{
'field': form[field_name],
'dependencies': [form[f] for f in dependencies]
} for field_name, dependencies in prepopulated_fields.items()]
self.model_admin = model_admin
if readonly_fields is None:
readonly_fields = ()
self.readonly_fields = readonly_fields
def __iter__(self):
for name, options in self.fieldsets:
yield Fieldset(self.form, name, **options)
yield Fieldset(self.form, name,
readonly_fields=self.readonly_fields,
model_admin=self.model_admin,
**options
)
def first_field(self):
try:
@ -49,11 +62,14 @@ class AdminForm(object):
media = property(_media)
class Fieldset(object):
def __init__(self, form, name=None, fields=(), classes=(), description=None):
def __init__(self, form, name=None, readonly_fields=(), fields=(), classes=(),
description=None, model_admin=None):
self.form = form
self.name, self.fields = name, fields
self.classes = u' '.join(classes)
self.description = description
self.model_admin = model_admin
self.readonly_fields = readonly_fields
def _media(self):
if 'collapse' in self.classes:
@ -63,22 +79,30 @@ class Fieldset(object):
def __iter__(self):
for field in self.fields:
yield Fieldline(self.form, field)
yield Fieldline(self.form, field, self.readonly_fields, model_admin=self.model_admin)
class Fieldline(object):
def __init__(self, form, field):
def __init__(self, form, field, readonly_fields=None, model_admin=None):
self.form = form # A django.forms.Form instance
if isinstance(field, basestring):
if not hasattr(field, "__iter__"):
self.fields = [field]
else:
self.fields = field
self.model_admin = model_admin
if readonly_fields is None:
readonly_fields = ()
self.readonly_fields = readonly_fields
def __iter__(self):
for i, field in enumerate(self.fields):
yield AdminField(self.form, field, is_first=(i == 0))
if field in self.readonly_fields:
yield AdminReadonlyField(self.form, field, is_first=(i == 0),
model_admin=self.model_admin)
else:
yield AdminField(self.form, field, is_first=(i == 0))
def errors(self):
return mark_safe(u'\n'.join([self.form[f].errors.as_ul() for f in self.fields]).strip('\n'))
return mark_safe(u'\n'.join([self.form[f].errors.as_ul() for f in self.fields if f not in self.readonly_fields]).strip('\n'))
class AdminField(object):
def __init__(self, form, field, is_first):
@ -100,27 +124,88 @@ class AdminField(object):
attrs = classes and {'class': u' '.join(classes)} or {}
return self.field.label_tag(contents=contents, attrs=attrs)
class AdminReadonlyField(object):
def __init__(self, form, field, is_first, model_admin=None):
self.field = field
self.form = form
self.model_admin = model_admin
self.is_first = is_first
self.is_checkbox = False
self.is_readonly = True
def label_tag(self):
attrs = {}
if not self.is_first:
attrs["class"] = "inline"
name = forms.forms.pretty_name(
label_for_field(self.field, self.model_admin.model, self.model_admin)
)
contents = force_unicode(escape(name)) + u":"
return mark_safe('<label%(attrs)s>%(contents)s</label>' % {
"attrs": flatatt(attrs),
"contents": contents,
})
def contents(self):
from django.contrib.admin.templatetags.admin_list import _boolean_icon
from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
field, obj, model_admin = self.field, self.form.instance, self.model_admin
try:
f, attr, value = lookup_field(field, obj, model_admin)
except (AttributeError, ObjectDoesNotExist):
result_repr = EMPTY_CHANGELIST_VALUE
else:
if f is None:
boolean = getattr(attr, "boolean", False)
if boolean:
result_repr = _boolean_icon(value)
else:
result_repr = smart_unicode(value)
if getattr(attr, "allow_tags", False):
result_repr = mark_safe(result_repr)
else:
if value is None:
result_repr = EMPTY_CHANGELIST_VALUE
elif isinstance(f.rel, ManyToManyRel):
result_repr = ", ".join(map(unicode, value.all()))
else:
result_repr = display_for_field(value, f)
return conditional_escape(result_repr)
class InlineAdminFormSet(object):
"""
A wrapper around an inline formset for use in the admin system.
"""
def __init__(self, inline, formset, fieldsets):
def __init__(self, inline, formset, fieldsets, readonly_fields=None, model_admin=None):
self.opts = inline
self.formset = formset
self.fieldsets = fieldsets
self.model_admin = model_admin
if readonly_fields is None:
readonly_fields = ()
self.readonly_fields = readonly_fields
def __iter__(self):
for form, original in zip(self.formset.initial_forms, self.formset.get_queryset()):
yield InlineAdminForm(self.formset, form, self.fieldsets, self.opts.prepopulated_fields, original)
yield InlineAdminForm(self.formset, form, self.fieldsets,
self.opts.prepopulated_fields, original, self.readonly_fields,
model_admin=self.model_admin)
for form in self.formset.extra_forms:
yield InlineAdminForm(self.formset, form, self.fieldsets, self.opts.prepopulated_fields, None)
yield InlineAdminForm(self.formset, form, self.fieldsets,
self.opts.prepopulated_fields, None, self.readonly_fields,
model_admin=self.model_admin)
def fields(self):
fk = getattr(self.formset, "fk", None)
for field_name in flatten_fieldsets(self.fieldsets):
if fk and fk.name == field_name:
for i, field in enumerate(flatten_fieldsets(self.fieldsets)):
if fk and fk.name == field:
continue
yield self.formset.form.base_fields[field_name]
if field in self.readonly_fields:
label = label_for_field(field, self.opts.model, self.model_admin)
yield (False, forms.forms.pretty_name(label))
else:
field = self.formset.form.base_fields[field]
yield (field.widget.is_hidden, field.label)
def _media(self):
media = self.opts.media + self.formset.media
@ -133,17 +218,21 @@ class InlineAdminForm(AdminForm):
"""
A wrapper around an inline form for use in the admin system.
"""
def __init__(self, formset, form, fieldsets, prepopulated_fields, original):
def __init__(self, formset, form, fieldsets, prepopulated_fields, original,
readonly_fields=None, model_admin=None):
self.formset = formset
self.model_admin = model_admin
self.original = original
if original is not None:
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)
super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields,
readonly_fields)
def __iter__(self):
for name, options in self.fieldsets:
yield InlineFieldset(self.formset, self.form, name, **options)
yield InlineFieldset(self.formset, self.form, name,
self.readonly_fields, model_admin=self.model_admin, **options)
def has_auto_field(self):
if self.form._meta.model._meta.has_auto_field:
@ -194,7 +283,8 @@ class InlineFieldset(Fieldset):
for field in self.fields:
if fk and fk.name == field:
continue
yield Fieldline(self.form, field)
yield Fieldline(self.form, field, self.readonly_fields,
model_admin=self.model_admin)
class AdminErrorList(forms.util.ErrorList):
"""

View File

@ -344,7 +344,7 @@ table.orderable-initalized .order-cell, body>tr>td.order-cell {
/* FORM DEFAULTS */
input, textarea, select {
input, textarea, select, .form-row p {
margin: 2px 0;
padding: 2px 3px;
vertical-align: middle;

View File

@ -67,6 +67,7 @@ class BaseModelAdmin(object):
radio_fields = {}
prepopulated_fields = {}
formfield_overrides = {}
readonly_fields = ()
def __init__(self):
self.formfield_overrides = dict(FORMFIELD_FOR_DBFIELD_DEFAULTS, **self.formfield_overrides)
@ -178,6 +179,9 @@ class BaseModelAdmin(object):
return None
declared_fieldsets = property(_declared_fieldsets)
def get_readonly_fields(self, request, obj=None):
return self.readonly_fields
class ModelAdmin(BaseModelAdmin):
"Encapsulates all admin options and functionality for a given model."
__metaclass__ = forms.MediaDefiningClass
@ -327,7 +331,8 @@ class ModelAdmin(BaseModelAdmin):
if self.declared_fieldsets:
return self.declared_fieldsets
form = self.get_form(request, obj)
return [(None, {'fields': form.base_fields.keys()})]
fields = form.base_fields.keys() + list(self.get_readonly_fields(request, obj))
return [(None, {'fields': fields})]
def get_form(self, request, obj=None, **kwargs):
"""
@ -342,12 +347,15 @@ class ModelAdmin(BaseModelAdmin):
exclude = []
else:
exclude = list(self.exclude)
exclude.extend(kwargs.get("exclude", []))
exclude.extend(self.get_readonly_fields(request, obj))
# if exclude is an empty list we pass None to be consistant with the
# default on modelform_factory
exclude = exclude or None
defaults = {
"form": self.form,
"fields": fields,
"exclude": (exclude + kwargs.get("exclude", [])) or None,
"exclude": exclude,
"formfield_callback": curry(self.formfield_for_dbfield, request=request),
}
defaults.update(kwargs)
@ -782,13 +790,17 @@ class ModelAdmin(BaseModelAdmin):
queryset=inline.queryset(request))
formsets.append(formset)
adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)), self.prepopulated_fields)
adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)),
self.prepopulated_fields, self.get_readonly_fields(request),
model_admin=self)
media = self.media + adminForm.media
inline_admin_formsets = []
for inline, formset in zip(self.inline_instances, formsets):
fieldsets = list(inline.get_fieldsets(request))
inline_admin_formset = helpers.InlineAdminFormSet(inline, formset, fieldsets)
readonly = list(inline.get_readonly_fields(request))
inline_admin_formset = helpers.InlineAdminFormSet(inline, formset,
fieldsets, readonly, model_admin=self)
inline_admin_formsets.append(inline_admin_formset)
media = media + inline_admin_formset.media
@ -875,13 +887,17 @@ class ModelAdmin(BaseModelAdmin):
queryset=inline.queryset(request))
formsets.append(formset)
adminForm = helpers.AdminForm(form, self.get_fieldsets(request, obj), self.prepopulated_fields)
adminForm = helpers.AdminForm(form, self.get_fieldsets(request, obj),
self.prepopulated_fields, self.get_readonly_fields(request, obj),
model_admin=self)
media = self.media + adminForm.media
inline_admin_formsets = []
for inline, formset in zip(self.inline_instances, formsets):
fieldsets = list(inline.get_fieldsets(request, obj))
inline_admin_formset = helpers.InlineAdminFormSet(inline, formset, fieldsets)
readonly = list(inline.get_readonly_fields(request, obj))
inline_admin_formset = helpers.InlineAdminFormSet(inline, formset,
fieldsets, readonly, model_admin=self)
inline_admin_formsets.append(inline_admin_formset)
media = media + inline_admin_formset.media
@ -1174,14 +1190,17 @@ class InlineModelAdmin(BaseModelAdmin):
exclude = []
else:
exclude = list(self.exclude)
exclude.extend(kwargs.get("exclude", []))
exclude.extend(self.get_readonly_fields(request, obj))
# if exclude is an empty list we use None, since that's the actual
# default
exclude = exclude or None
defaults = {
"form": self.form,
"formset": self.formset,
"fk_name": self.fk_name,
"fields": fields,
"exclude": (exclude + kwargs.get("exclude", [])) or None,
"exclude": exclude,
"formfield_callback": curry(self.formfield_for_dbfield, request=request),
"extra": self.extra,
"max_num": self.max_num,
@ -1193,7 +1212,8 @@ class InlineModelAdmin(BaseModelAdmin):
if self.declared_fieldsets:
return self.declared_fieldsets
form = self.get_formset(request).form
return [(None, {'fields': form.base_fields.keys()})]
fields = form.base_fields.keys() + list(self.get_readonly_fields(request, obj))
return [(None, {'fields': fields})]
def queryset(self, request):
return self.model._default_manager.all()

View File

@ -7,10 +7,10 @@
{{ inline_admin_formset.formset.non_form_errors }}
<table>
<thead><tr>
{% for field in inline_admin_formset.fields %}
{% if not field.is_hidden %}
<th {% if forloop.first %}colspan="2"{% endif %}>{{ field.label|capfirst }}</th>
{% endif %}
{% for is_hidden, label in inline_admin_formset.fields %}
{% if not is_hidden %}
<th {% if forloop.first %}colspan="2"{% endif %}>{{ label|capfirst }}</th>
{% endif %}
{% endfor %}
{% if inline_admin_formset.formset.can_delete %}<th>{% trans "Delete?" %}</th>{% endif %}
</tr></thead>
@ -44,8 +44,12 @@
{% for line in fieldset %}
{% for field in line %}
<td class="{{ field.field.name }}">
{{ field.field.errors.as_ul }}
{{ field.field }}
{% if field.is_readonly %}
<p>{{ field.contents }}</p>
{% else %}
{{ field.field.errors.as_ul }}
{{ field.field }}
{% endif %}
</td>
{% endfor %}
{% endfor %}

View File

@ -1,19 +1,28 @@
<fieldset class="module aligned {{ fieldset.classes }}">
{% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %}
{% if fieldset.description %}<div class="description">{{ fieldset.description|safe }}</div>{% endif %}
{% for line in fieldset %}
<div class="form-row{% if line.errors %} errors{% endif %} {% for field in line %}{{ field.field.name }} {% endfor %} ">
{{ line.errors }}
{% for field in line %}
<div{% if not line.fields|length_is:"1" %} class="field-box"{% endif %}>
{% if field.is_checkbox %}
{{ field.field }}{{ field.label_tag }}
{% else %}
{{ field.label_tag }}{{ field.field }}
{% endif %}
{% if field.field.field.help_text %}<p class="help">{{ field.field.field.help_text|safe }}</p>{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
{% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %}
{% if fieldset.description %}
<div class="description">{{ fieldset.description|safe }}</div>
{% endif %}
{% for line in fieldset %}
<div class="form-row{% if line.errors %} errors{% endif %} {% for field in line %}{{ field.field.name }} {% endfor %} ">
{{ line.errors }}
{% for field in line %}
<div{% if not line.fields|length_is:"1" %} class="field-box"{% endif %}>
{% if field.is_checkbox %}
{{ field.field }}{{ field.label_tag }}
{% else %}
{{ field.label_tag }}
{% if field.is_readonly %}
<p>{{ field.contents }}</p>
{% else %}
{{ field.field }}
{% endif %}
{% endif %}
{% if field.field.field.help_text %}
<p class="help">{{ field.field.field.help_text|safe }}</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</fieldset>

View File

@ -1,16 +1,20 @@
import datetime
from django.conf import settings
from django.contrib.admin.util import lookup_field, display_for_field, label_for_field
from django.contrib.admin.views.main import ALL_VAR, EMPTY_CHANGELIST_VALUE
from django.contrib.admin.views.main import ORDER_VAR, ORDER_TYPE_VAR, PAGE_VAR, SEARCH_VAR
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.forms.forms import pretty_name
from django.utils import formats
from django.utils.html import escape, conditional_escape
from django.utils.text import capfirst
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import ugettext as _
from django.utils.encoding import smart_unicode, smart_str, force_unicode
from django.utils.encoding import smart_unicode, force_unicode
from django.template import Library
import datetime
register = Library()
@ -76,41 +80,15 @@ def result_headers(cl):
try:
f = lookup_opts.get_field(field_name)
admin_order_field = None
header = f.verbose_name
except models.FieldDoesNotExist:
# For non-field list_display values, check for the function
# attribute "short_description". If that doesn't exist, fall back
# to the method name. And __str__ and __unicode__ are special-cases.
if field_name == '__unicode__':
header = force_unicode(lookup_opts.verbose_name)
elif field_name == '__str__':
header = smart_str(lookup_opts.verbose_name)
else:
if callable(field_name):
attr = field_name # field_name can be a callable
else:
try:
attr = getattr(cl.model_admin, field_name)
except AttributeError:
try:
attr = getattr(cl.model, field_name)
except AttributeError:
raise AttributeError, \
"'%s' model or '%s' objects have no attribute '%s'" % \
(lookup_opts.object_name, cl.model_admin.__class__, field_name)
try:
header = attr.short_description
except AttributeError:
if callable(field_name):
header = field_name.__name__
else:
header = field_name
header = header.replace('_', ' ')
header = label_for_field(field_name, cl.model, cl.model_admin)
# if the field is the action checkbox: no sorting and special class
if field_name == 'action_checkbox':
yield {"text": header,
"class_attrib": mark_safe(' class="action-checkbox-column"')}
continue
header = pretty_name(header)
# It is a non-field, but perhaps one that is sortable
admin_order_field = getattr(attr, "admin_order_field", None)
@ -120,8 +98,6 @@ def result_headers(cl):
# So this _is_ a sortable non-field. Go to the yield
# after the else clause.
else:
header = f.verbose_name
th_classes = []
new_order_type = 'asc'
@ -129,10 +105,12 @@ def result_headers(cl):
th_classes.append('sorted %sending' % cl.order_type.lower())
new_order_type = {'asc': 'desc', 'desc': 'asc'}[cl.order_type.lower()]
yield {"text": header,
"sortable": True,
"url": cl.get_query_string({ORDER_VAR: i, ORDER_TYPE_VAR: new_order_type}),
"class_attrib": mark_safe(th_classes and ' class="%s"' % ' '.join(th_classes) or '')}
yield {
"text": header,
"sortable": True,
"url": cl.get_query_string({ORDER_VAR: i, ORDER_TYPE_VAR: new_order_type}),
"class_attrib": mark_safe(th_classes and ' class="%s"' % ' '.join(th_classes) or '')
}
def _boolean_icon(field_val):
BOOLEAN_MAPPING = {True: 'yes', False: 'no', None: 'unknown'}
@ -144,24 +122,11 @@ def items_for_result(cl, result, form):
for field_name in cl.list_display:
row_class = ''
try:
f = cl.lookup_opts.get_field(field_name)
except models.FieldDoesNotExist:
# For non-field list_display values, the value is either a method,
# property or returned via a callable.
try:
if callable(field_name):
attr = field_name
value = attr(result)
elif hasattr(cl.model_admin, field_name) and \
not field_name == '__str__' and not field_name == '__unicode__':
attr = getattr(cl.model_admin, field_name)
value = attr(result)
else:
attr = getattr(result, field_name)
if callable(attr):
value = attr()
else:
value = attr
f, attr, value = lookup_field(field_name, result, cl.model_admin)
except (AttributeError, ObjectDoesNotExist):
result_repr = EMPTY_CHANGELIST_VALUE
else:
if f is None:
allow_tags = getattr(attr, 'allow_tags', False)
boolean = getattr(attr, 'boolean', False)
if boolean:
@ -169,50 +134,21 @@ def items_for_result(cl, result, form):
result_repr = _boolean_icon(value)
else:
result_repr = smart_unicode(value)
except (AttributeError, ObjectDoesNotExist):
result_repr = EMPTY_CHANGELIST_VALUE
else:
# Strip HTML tags in the resulting text, except if the
# function has an "allow_tags" attribute set to True.
if not allow_tags:
result_repr = escape(result_repr)
else:
result_repr = mark_safe(result_repr)
else:
field_val = getattr(result, f.attname)
if isinstance(f.rel, models.ManyToOneRel):
if field_val is not None:
else:
if value is None:
result_repr = EMPTY_CHANGELIST_VALUE
if isinstance(f.rel, models.ManyToOneRel):
result_repr = escape(getattr(result, f.name))
else:
result_repr = EMPTY_CHANGELIST_VALUE
# Dates and times are special: They're formatted in a certain way.
elif isinstance(f, models.DateField) or isinstance(f, models.TimeField):
if field_val:
result_repr = formats.localize(field_val)
else:
result_repr = EMPTY_CHANGELIST_VALUE
elif isinstance(f, models.DecimalField):
if field_val:
result_repr = formats.number_format(field_val, f.decimal_places)
else:
result_repr = EMPTY_CHANGELIST_VALUE
row_class = ' class="nowrap"'
elif isinstance(f, models.FloatField):
if field_val:
result_repr = formats.number_format(field_val)
else:
result_repr = EMPTY_CHANGELIST_VALUE
row_class = ' class="nowrap"'
# Booleans are special: We use images.
elif isinstance(f, models.BooleanField) or isinstance(f, models.NullBooleanField):
result_repr = _boolean_icon(field_val)
# Fields with choices are special: Use the representation
# of the choice.
elif f.flatchoices:
result_repr = dict(f.flatchoices).get(field_val, EMPTY_CHANGELIST_VALUE)
else:
result_repr = escape(field_val)
result_repr = display_for_field(value, f)
if isinstance(f, models.DateField) or isinstance(f, models.TimeField):
row_class = ' class="nowrap"'
if force_unicode(result_repr) == '':
result_repr = mark_safe('&nbsp;')
# If list_display_links not defined, add the link tag to the first field

View File

@ -1,12 +1,14 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils import formats
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.encoding import force_unicode, smart_unicode, smart_str
from django.utils.translation import ungettext, ugettext as _
from django.core.urlresolvers import reverse, NoReverseMatch
def quote(s):
"""
Ensure that primary key values do not confuse the admin URLs by escaping
@ -221,3 +223,74 @@ def model_ngettext(obj, n=None):
d = model_format_dict(obj)
singular, plural = d["verbose_name"], d["verbose_name_plural"]
return ungettext(singular, plural, n or 0)
def lookup_field(name, obj, model_admin=None):
opts = obj._meta
try:
f = opts.get_field(name)
except models.FieldDoesNotExist:
# For non-field values, the value is either a method, property or
# returned via a callable.
if callable(name):
attr = name
value = attr(obj)
elif (model_admin is not None and hasattr(model_admin, name) and
not name == '__str__' and not name == '__unicode__'):
attr = getattr(model_admin, name)
value = attr(obj)
else:
attr = getattr(obj, name)
if callable(attr):
value = attr()
else:
value = attr
f = None
else:
attr = None
value = getattr(obj, f.attname)
return f, attr, value
def label_for_field(name, model, model_admin):
try:
model._meta.get_field_by_name(name)[0]
return name
except models.FieldDoesNotExist:
if name == "__unicode__":
return force_unicode(model._meta.verbose_name)
if name == "__str__":
return smart_str(model._meta.verbose_name)
if callable(name):
attr = name
elif hasattr(model_admin, name):
attr = getattr(model_admin, name)
elif hasattr(model, name):
attr = getattr(model, name)
else:
raise AttributeError
if hasattr(attr, "short_description"):
return attr.short_description
elif callable(attr):
if attr.__name__ == "<lambda>":
return "--"
else:
return attr.__name__
else:
return name
def display_for_field(value, field):
from django.contrib.admin.templatetags.admin_list import _boolean_icon
from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
if isinstance(field, models.DateField) or isinstance(field, models.TimeField):
return formats.localize(value)
elif isinstance(field, models.BooleanField) or isinstance(field, models.NullBooleanField):
return _boolean_icon(value)
elif isinstance(field, models.DecimalField):
return formats.number_format(value, field.decimal_places)
elif isinstance(field, models.FloatField):
return formats.number_format(value)
elif field.flatchoices:
return dict(field.flatchoices).get(value, EMPTY_CHANGELIST_VALUE)
else:
return smart_unicode(value)

View File

@ -1,13 +1,11 @@
try:
set
except NameError:
from sets import Set as set # Python 2.3 fallback
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.forms.models import BaseModelForm, BaseModelFormSet, fields_for_model, _get_foreign_key
from django.forms.models import (BaseModelForm, BaseModelFormSet, fields_for_model,
_get_foreign_key)
from django.contrib.admin.options import flatten_fieldsets, BaseModelAdmin
from django.contrib.admin.options import HORIZONTAL, VERTICAL
from django.contrib.admin.util import lookup_field
__all__ = ['validate']
@ -123,6 +121,18 @@ def validate(cls, model):
continue
get_field(cls, model, opts, 'ordering[%d]' % idx, field)
if hasattr(cls, "readonly_fields"):
check_isseq(cls, "readonly_fields", cls.readonly_fields)
for idx, field in enumerate(cls.readonly_fields):
if not callable(field):
if not hasattr(cls, field):
if not hasattr(model, field):
try:
opts.get_field(field)
except models.FieldDoesNotExist:
raise ImproperlyConfigured("%s.readonly_fields[%d], %r is not a callable or an attribute of %r or found in the model %r."
% (cls.__name__, idx, field, cls.__name__, model._meta.object_name))
# list_select_related = False
# save_as = False
# save_on_top = False
@ -195,6 +205,11 @@ def validate_base(cls, model):
if cls.fields: # default value is None
check_isseq(cls, 'fields', cls.fields)
for field in cls.fields:
if field in cls.readonly_fields:
# Stuff can be put in fields that isn't actually a model field
# if it's in readonly_fields, readonly_fields will handle the
# validation of such things.
continue
check_formfield(cls, model, opts, 'fields', field)
f = get_field(cls, model, opts, 'fields', field)
if isinstance(f, models.ManyToManyField) and not f.rel.through._meta.auto_created:

View File

@ -172,6 +172,11 @@ The ``field_options`` dictionary can have the following keys:
'fields': (('first_name', 'last_name'), 'address', 'city', 'state'),
}
.. versionadded:: 1.2
``fields`` can contain values defined in
:attr:`ModelAdmin.readonly_fields` to be displayed as read-only.
* ``classes``
A list containing extra CSS classes to apply to the fieldset.
@ -210,6 +215,11 @@ the ``django.contrib.flatpages.FlatPage`` model as follows::
In the above example, only the fields 'url', 'title' and 'content' will be
displayed, sequentially, in the form.
.. versionadded:: 1.2
``fields`` can contain values defined in :attr:`ModelAdmin.readonly_fields`
to be displayed as read-only.
.. admonition:: Note
This ``fields`` option should not be confused with the ``fields``
@ -540,6 +550,21 @@ into a ``Input`` widget for either a ``ForeignKey`` or ``ManyToManyField``::
class ArticleAdmin(admin.ModelAdmin):
raw_id_fields = ("newspaper",)
.. attribute:: ModelAdmin.readonly_fields
.. versionadded:: 1.2
By default the admin shows all fields as editable. Any fields in this option
(which should be a ``list`` or ``tuple``) will display its data as-is and
non-editable. This option behaves nearly identical to :attr:`ModelAdmin.list_display`.
Usage is the same, however, when you specify :attr:`ModelAdmin.fields` or
:attr:`ModelAdmin.fieldsets` the read-only fields must be present to be shown
(they are ignored otherwise).
If ``readonly_fields`` is used without defining explicit ordering through
:attr:`ModelAdmin.fields` or :attr:`ModelAdmin.fieldsets` they will be added
last after all editable fields.
.. attribute:: ModelAdmin.save_as
Set ``save_as`` to enable a "save as" feature on admin change forms.
@ -744,6 +769,15 @@ model instance::
instance.save()
formset.save_m2m()
.. method:: ModelAdmin.get_readonly_fields(self, request, obj=None)
.. versionadded:: 1.2
The ``get_readonly_fields`` method is given the ``HttpRequest`` and the
``obj`` being edited (or ``None`` on an add form) and is expected to return a
``list`` or ``tuple`` of field names that will be displayed as read-only, as
described above in the :attr:`ModelAdmin.readonly_fields` section.
.. method:: ModelAdmin.get_urls(self)
.. versionadded:: 1.1

View File

@ -485,3 +485,10 @@ enabled, dates and numbers on templates will be displayed using the format
specified for the current locale. Django will also use localized formats
when parsing data in forms.
See :ref:`Format localization <format-localization>` for more details.
Added ``readonly_fields`` to ``ModelAdmin``
-------------------------------------------
:attr:`django.contrib.admin.ModelAdmin.readonly_fields` has been added to
enable non-editable fields in add/change pages for models and inlines. Field
and calculated values can be displayed along side editable fields.

View File

@ -19,6 +19,10 @@ class Song(models.Model):
def __unicode__(self):
return self.title
def readonly_method_on_model(self):
# does nothing
pass
class TwoAlbumFKAndAnE(models.Model):
album1 = models.ForeignKey(Album, related_name="album1_set")
@ -110,6 +114,63 @@ Exception: <class 'regressiontests.admin_validation.models.TwoAlbumFKAndAnE'> ha
>>> validate_inline(TwoAlbumFKAndAnEInline, None, Album)
>>> class SongAdmin(admin.ModelAdmin):
... readonly_fields = ("title",)
>>> validate(SongAdmin, Song)
>>> def my_function(obj):
... # does nothing
... pass
>>> class SongAdmin(admin.ModelAdmin):
... readonly_fields = (my_function,)
>>> validate(SongAdmin, Song)
>>> class SongAdmin(admin.ModelAdmin):
... readonly_fields = ("readonly_method_on_modeladmin",)
...
... def readonly_method_on_modeladmin(self, obj):
... # does nothing
... pass
>>> validate(SongAdmin, Song)
>>> class SongAdmin(admin.ModelAdmin):
... readonly_fields = ("readonly_method_on_model",)
>>> validate(SongAdmin, Song)
>>> class SongAdmin(admin.ModelAdmin):
... readonly_fields = ("title", "nonexistant")
>>> validate(SongAdmin, Song)
Traceback (most recent call last):
...
ImproperlyConfigured: SongAdmin.readonly_fields[1], 'nonexistant' is not a callable or an attribute of 'SongAdmin' or found in the model 'Song'.
>>> class SongAdmin(admin.ModelAdmin):
... readonly_fields = ("title", "awesome_song")
... fields = ("album", "title", "awesome_song")
>>> validate(SongAdmin, Song)
Traceback (most recent call last):
...
ImproperlyConfigured: SongAdmin.readonly_fields[1], 'awesome_song' is not a callable or an attribute of 'SongAdmin' or found in the model 'Song'.
>>> class SongAdmin(SongAdmin):
... def awesome_song(self, instance):
... if instance.title == "Born to Run":
... return "Best Ever!"
... return "Status unknown."
>>> validate(SongAdmin, Song)
>>> class SongAdmin(admin.ModelAdmin):
... readonly_fields = (lambda obj: "test",)
>>> validate(SongAdmin, Song)
# Regression test for #12203/#12237 - Fail more gracefully when a M2M field that
# specifies the 'through' option is included in the 'fields' or the 'fieldsets'
# ModelAdmin options.

View File

@ -1,11 +1,14 @@
# -*- coding: utf-8 -*-
import datetime
import tempfile
import os
from django.core.files.storage import FileSystemStorage
from django.db import models
from django.contrib import admin
from django.core.files.storage import FileSystemStorage
from django.contrib.admin.views.main import ChangeList
from django.core.mail import EmailMessage
from django.db import models
class Section(models.Model):
"""
@ -419,7 +422,47 @@ class CategoryInline(admin.StackedInline):
model = Category
class CollectorAdmin(admin.ModelAdmin):
inlines = [WidgetInline, DooHickeyInline, GrommetInline, WhatsitInline, FancyDoodadInline, CategoryInline]
inlines = [
WidgetInline, DooHickeyInline, GrommetInline, WhatsitInline,
FancyDoodadInline, CategoryInline
]
class Link(models.Model):
posted = models.DateField(
default=lambda: datetime.date.today() - datetime.timedelta(days=7)
)
url = models.URLField()
post = models.ForeignKey("Post")
class LinkInline(admin.TabularInline):
model = Link
extra = 1
readonly_fields = ("posted",)
class Post(models.Model):
title = models.CharField(max_length=100)
content = models.TextField()
posted = models.DateField(default=datetime.date.today)
def awesomeness_level(self):
return "Very awesome."
class PostAdmin(admin.ModelAdmin):
readonly_fields = ('posted', 'awesomeness_level', 'coolness', lambda obj: "foo")
inlines = [
LinkInline
]
def coolness(self, instance):
if instance.pk:
return "%d amount of cool." % instance.pk
else:
return "Unkown coolness."
class Gadget(models.Model):
name = models.CharField(max_length=100)
@ -458,6 +501,7 @@ admin.site.register(Recommendation, RecommendationAdmin)
admin.site.register(Recommender)
admin.site.register(Collector, CollectorAdmin)
admin.site.register(Category, CategoryAdmin)
admin.site.register(Post, PostAdmin)
admin.site.register(Gadget, GadgetAdmin)
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.

View File

@ -10,20 +10,18 @@ from django.contrib.admin.models import LogEntry, DELETION
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 import formats
from django.utils.cache import get_max_age
from django.utils.html import escape
from django.utils.translation import get_date_formats
# local test models
from models import Article, BarAccount, CustomArticle, EmptyModel, \
ExternalSubscriber, FooAccount, Gallery, ModelWithStringPrimaryKey, \
Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \
Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \
Category
Category, Post
try:
set
except NameError:
from sets import Set as set
class AdminViewBasicTest(TestCase):
fixtures = ['admin-views-users.xml', 'admin-views-colors.xml', 'admin-views-fabrics.xml']
@ -1688,3 +1686,54 @@ class NeverCacheTests(TestCase):
"Check the never-cache status of the Javascript i18n view"
response = self.client.get('/test_admin/jsi18n/')
self.failUnlessEqual(get_max_age(response), None)
class ReadonlyTest(TestCase):
fixtures = ['admin-views-users.xml']
def setUp(self):
self.client.login(username='super', password='secret')
def tearDown(self):
self.client.logout()
def test_readonly_get(self):
response = self.client.get('/test_admin/admin/admin_views/post/add/')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'name="posted"')
# 3 fields + 2 submit buttons + 2 inline management form fields, + 2
# hidden fields for inlines + 1 field for the inline
self.assertEqual(response.content.count("input"), 10)
self.assertContains(response, formats.localize(datetime.date.today()))
self.assertContains(response,
"<label>Awesomeness level:</label>")
self.assertContains(response, "Very awesome.")
self.assertContains(response, "Unkown coolness.")
self.assertContains(response, "foo")
self.assertContains(response,
formats.localize(datetime.date.today() - datetime.timedelta(days=7))
)
p = Post.objects.create(title="I worked on readonly_fields", content="Its good stuff")
response = self.client.get('/test_admin/admin/admin_views/post/%d/' % p.pk)
self.assertContains(response, "%d amount of cool" % p.pk)
def test_readonly_post(self):
data = {
"title": "Django Got Readonly Fields",
"content": "This is an incredible development.",
"link_set-TOTAL_FORMS": "1",
"link_set-INITIAL_FORMS": "0",
}
response = self.client.post('/test_admin/admin/admin_views/post/add/', data)
self.assertEqual(response.status_code, 302)
self.assertEqual(Post.objects.count(), 1)
p = Post.objects.get()
self.assertEqual(p.posted, datetime.date.today())
data["posted"] = "10-8-1990" # some date that's not today
response = self.client.post('/test_admin/admin/admin_views/post/add/', data)
self.assertEqual(response.status_code, 302)
self.assertEqual(Post.objects.count(), 2)
p = Post.objects.order_by('-id')[0]
self.assertEqual(p.posted, datetime.date.today())