diff options
121 files changed, 8050 insertions, 2680 deletions
@@ -74,6 +74,7 @@ answer newbie questions, and generally made Django that much better: Arvis Bickovskis <viestards.lists@gmail.com> Paul Bissex <http://e-scribe.com/> Simon Blanchard + David Blewett <david@dawninglight.net> Matt Boersma <ogghead@gmail.com> boobsd@gmail.com Andrew Brehaut <http://brehaut.net/blog> @@ -172,6 +173,7 @@ answer newbie questions, and generally made Django that much better: Espen Grindhaug <http://grindhaug.org/> Thomas Güttler <hv@tbz-pariv.de> dAniel hAhler + hambaloney Brian Harring <ferringb@gmail.com> Brant Harris Hawkeye @@ -194,6 +196,7 @@ answer newbie questions, and generally made Django that much better: Baurzhan Ismagulov <ibr@radix50.net> james_027@yahoo.com jcrasta@gmail.com + jdetaeye Zak Johnson <zakj@nox.cx> Nis Jørgensen <nis@superlativ.dk> Michael Josephson <http://www.sdjournal.com/> @@ -241,11 +244,13 @@ answer newbie questions, and generally made Django that much better: Waylan Limberg <waylan@gmail.com> limodou Philip Lindborg <philip.lindborg@gmail.com> + Simon Litchfield <simon@quo.com.au> Daniel Lindsley <polarcowz@gmail.com> Trey Long <trey@ktrl.com> msaelices <msaelices@gmail.com> Matt McClanahan <http://mmcc.cx/> Martin Maney <http://www.chipy.org/Martin_Maney> + Petr Marhoun <petr.marhoun@gmail.com> masonsimon+django@gmail.com Manuzhai Petr Marhoun <petr.marhoun@gmail.com> @@ -258,6 +263,7 @@ answer newbie questions, and generally made Django that much better: mattycakes@gmail.com Jason McBrayer <http://www.carcosa.net/jason/> mccutchen@gmail.com + Christian Metts michael.mcewan@gmail.com michal@plovarna.cz Slawek Mikula <slawek dot mikula at gmail dot com> @@ -270,6 +276,7 @@ answer newbie questions, and generally made Django that much better: Eric Moritz <http://eric.themoritzfamily.com/> mrmachine <real.human@mrmachine.net> Robin Munn <http://www.geekforgod.com/> + msundstr Robert Myers <myer0052@gmail.com> Nebojša Dorđević Doug Napoleone <doug@dougma.com> @@ -290,6 +297,7 @@ answer newbie questions, and generally made Django that much better: peter@mymart.com pgross@thoughtworks.com phaedo <http://phaedo.cx/> + Julien Phalip <http://www.julienphalip.com> phil@produxion.net phil.h.smith@gmail.com Gustavo Picon @@ -298,6 +306,7 @@ answer newbie questions, and generally made Django that much better: Mihai Preda <mihai_preda@yahoo.com> Daniel Poelzleithner <http://poelzi.org/> polpak@yahoo.com + Matthias Pronk <django@masida.nl> Jyrki Pulliainen <jyrki.pulliainen@gmail.com> Johann Queuniet <johann.queuniet@adh.naellia.eu> Jan Rademaker @@ -314,6 +323,7 @@ answer newbie questions, and generally made Django that much better: Matt Riggott Henrique Romano <onaiort@gmail.com> Armin Ronacher + Daniel Roseman <http://roseman.org.uk/> Brian Rosner <brosner@gmail.com> Oliver Rutherfurd <http://rutherfurd.net/> ryankanno diff --git a/django/__init__.py b/django/__init__.py index de473fa4e9..9c5fda133d 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (0, 97, 'pre') +VERSION = (0, 97, 'newforms-admin') def get_version(): "Returns the version as a human-format string." diff --git a/django/conf/project_template/urls.py b/django/conf/project_template/urls.py index 402dd6536b..98335a151c 100644 --- a/django/conf/project_template/urls.py +++ b/django/conf/project_template/urls.py @@ -1,9 +1,15 @@ from django.conf.urls.defaults import * +# Uncomment this for admin: +#from django.contrib import admin + urlpatterns = patterns('', # Example: # (r'^{{ project_name }}/', include('{{ project_name }}.foo.urls')), + # Uncomment this for admin docs: + #(r'^admin/doc/', include('django.contrib.admindocs.urls')), + # Uncomment this for admin: -# (r'^admin/', include('django.contrib.admin.urls')), + #('^admin/(.*)', admin.site.root), ) diff --git a/django/contrib/admin/__init__.py b/django/contrib/admin/__init__.py index e69de29bb2..56b64faacb 100644 --- a/django/contrib/admin/__init__.py +++ b/django/contrib/admin/__init__.py @@ -0,0 +1,16 @@ +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 + +def autodiscover(): + """ + Auto-discover INSTALLED_APPS admin.py modules and fail silently when + not present. This forces an import on them to register any admin bits they + may want. + """ + from django.conf import settings + for app in settings.INSTALLED_APPS: + try: + __import__("%s.admin" % app) + except ImportError: + pass diff --git a/django/contrib/admin/filterspecs.py b/django/contrib/admin/filterspecs.py index a4f92c986a..d6a4a0bc48 100644 --- a/django/contrib/admin/filterspecs.py +++ b/django/contrib/admin/filterspecs.py @@ -15,7 +15,7 @@ import datetime class FilterSpec(object): filter_specs = [] - def __init__(self, f, request, params, model): + def __init__(self, f, request, params, model, model_admin): self.field = f self.params = params @@ -23,10 +23,10 @@ class FilterSpec(object): cls.filter_specs.append((test, factory)) register = classmethod(register) - def create(cls, f, request, params, model): + def create(cls, f, request, params, model, model_admin): for test, factory in cls.filter_specs: if test(f): - return factory(f, request, params, model) + return factory(f, request, params, model, model_admin) create = classmethod(create) def has_output(self): @@ -52,8 +52,8 @@ class FilterSpec(object): return mark_safe("".join(t)) class RelatedFilterSpec(FilterSpec): - def __init__(self, f, request, params, model): - super(RelatedFilterSpec, self).__init__(f, request, params, model) + def __init__(self, f, request, params, model, model_admin): + super(RelatedFilterSpec, self).__init__(f, request, params, model, model_admin) if isinstance(f, models.ManyToManyField): self.lookup_title = f.rel.to._meta.verbose_name else: @@ -81,8 +81,8 @@ class RelatedFilterSpec(FilterSpec): FilterSpec.register(lambda f: bool(f.rel), RelatedFilterSpec) class ChoicesFilterSpec(FilterSpec): - def __init__(self, f, request, params, model): - super(ChoicesFilterSpec, self).__init__(f, request, params, model) + def __init__(self, f, request, params, model, model_admin): + super(ChoicesFilterSpec, self).__init__(f, request, params, model, model_admin) self.lookup_kwarg = '%s__exact' % f.name self.lookup_val = request.GET.get(self.lookup_kwarg, None) @@ -98,8 +98,8 @@ class ChoicesFilterSpec(FilterSpec): FilterSpec.register(lambda f: bool(f.choices), ChoicesFilterSpec) class DateFieldFilterSpec(FilterSpec): - def __init__(self, f, request, params, model): - super(DateFieldFilterSpec, self).__init__(f, request, params, model) + def __init__(self, f, request, params, model, model_admin): + super(DateFieldFilterSpec, self).__init__(f, request, params, model, model_admin) self.field_generic = '%s__' % self.field.name @@ -133,8 +133,8 @@ class DateFieldFilterSpec(FilterSpec): FilterSpec.register(lambda f: isinstance(f, models.DateField), DateFieldFilterSpec) class BooleanFieldFilterSpec(FilterSpec): - def __init__(self, f, request, params, model): - super(BooleanFieldFilterSpec, self).__init__(f, request, params, model) + def __init__(self, f, request, params, model, model_admin): + super(BooleanFieldFilterSpec, self).__init__(f, request, params, model, model_admin) self.lookup_kwarg = '%s__exact' % f.name self.lookup_kwarg2 = '%s__isnull' % f.name self.lookup_val = request.GET.get(self.lookup_kwarg, None) @@ -159,10 +159,10 @@ FilterSpec.register(lambda f: isinstance(f, models.BooleanField) or isinstance(f # if a field is eligible to use the BooleanFieldFilterSpec, that'd be much # more appropriate, and the AllValuesFilterSpec won't get used for it. class AllValuesFilterSpec(FilterSpec): - def __init__(self, f, request, params, model): - super(AllValuesFilterSpec, self).__init__(f, request, params, model) + def __init__(self, f, request, params, model, model_admin): + super(AllValuesFilterSpec, self).__init__(f, request, params, model, model_admin) self.lookup_val = request.GET.get(f.name, None) - self.lookup_choices = model._meta.admin.manager.distinct().order_by(f.name).values(f.name) + self.lookup_choices = model_admin.queryset(request).distinct().order_by(f.name).values(f.name) def title(self): return self.field.verbose_name diff --git a/django/contrib/admin/media/css/forms.css b/django/contrib/admin/media/css/forms.css index 72e57501e9..2a1a0995a0 100644 --- a/django/contrib/admin/media/css/forms.css +++ b/django/contrib/admin/media/css/forms.css @@ -58,3 +58,24 @@ fieldset.monospace textarea { font-family:"Bitstream Vera Sans Mono",Monaco,"Cou .vLargeTextField, .vXMLLargeTextField { width:48em; } .flatpages-flatpage #id_content { height:40.2em; } .module table .vPositiveSmallIntegerField { width:2.2em; } + +/* x unsorted */ +.inline-group {padding:10px; padding-bottom:5px; background:#eee; margin:10px 0;} +.inline-group h3.header {margin:-5px -10px 5px -10px; background:#bbb; color:#fff; padding:2px 5px 3px 5px; font-size:11px} +.inline-related {margin-bottom:15px; position:relative;} +.last-related {margin-bottom:0px;} +.inline-related h2 { margin:0; padding:2px 5px 3px 5px; font-size:11px; text-align:left; font-weight:bold; color:#888; } +.inline-related h2 b {font-weight:normal; color:#aaa;} +.inline-related h2 span.delete {padding-left:20px; position:absolute; top:0px; right:5px;} +.inline-related h2 span.delete label {margin-left:2px; padding-top:1px;} +.inline-related fieldset {background:#fbfbfb;} +.inline-related fieldset.module h2 { margin:0; padding:2px 5px 3px 5px; font-size:11px; text-align:left; font-weight:bold; background:#bcd; color:#fff; } +.inline-related.tabular fieldset.module table {width:100%;} + +.inline-group .tabular tr.has_original td {padding-top:2em;} +.inline-group .tabular tr td.original { padding:2px 0 0 0; width:0; _position:relative; } +.inline-group .tabular th.original {width:0px; padding:0;} +.inline-group .tabular td.original p {position:absolute; left:0; height:1.1em; padding:2px 7px; overflow:hidden; font-size:9px; font-weight:bold; color:#666; _width:700px; } +.inline-group ul.tools {padding:0; margin: 0; list-style:none;} +.inline-group ul.tools li {display:inline; padding:0 5px;} +.inline-group ul.tools a.add {background:url(../img/admin/icon_addlink.gif) 0 50% no-repeat; padding-left:14px;}
\ No newline at end of file diff --git a/django/contrib/admin/media/js/SelectFilter.js b/django/contrib/admin/media/js/SelectFilter.js deleted file mode 100644 index 0501920608..0000000000 --- a/django/contrib/admin/media/js/SelectFilter.js +++ /dev/null @@ -1,81 +0,0 @@ -/* -SelectFilter - Turns a multiple-select box into a filter interface. - -Requires SelectBox.js and addevent.js. -*/ - -function findForm(node) { - // returns the node of the form containing the given node - if (node.tagName.toLowerCase() != 'form') { - return findForm(node.parentNode); - } - return node; -} - -var SelectFilter = { - init: function(field_id) { - var from_box = document.getElementById(field_id); - from_box.id += '_from'; // change its ID - // Create the INPUT input box - var input_box = document.createElement('input'); - input_box.id = field_id + '_input'; - input_box.setAttribute('type', 'text'); - from_box.parentNode.insertBefore(input_box, from_box); - from_box.parentNode.insertBefore(document.createElement('br'), input_box.nextSibling); - // Create the TO box - var to_box = document.createElement('select'); - to_box.id = field_id + '_to'; - to_box.setAttribute('multiple', 'multiple'); - to_box.setAttribute('size', from_box.size); - from_box.parentNode.insertBefore(to_box, from_box.nextSibling); - to_box.setAttribute('name', from_box.getAttribute('name')); - from_box.setAttribute('name', from_box.getAttribute('name') + '_old'); - // Give the filters a CSS hook - from_box.setAttribute('class', 'filtered'); - to_box.setAttribute('class', 'filtered'); - // Set up the JavaScript event handlers for the select box filter interface - addEvent(input_box, 'keyup', function(e) { SelectFilter.filter_key_up(e, field_id); }); - addEvent(input_box, 'keydown', function(e) { SelectFilter.filter_key_down(e, field_id); }); - addEvent(from_box, 'dblclick', function() { SelectBox.move(field_id + '_from', field_id + '_to'); }); - addEvent(from_box, 'focus', function() { input_box.focus(); }); - addEvent(to_box, 'dblclick', function() { SelectBox.move(field_id + '_to', field_id + '_from'); }); - addEvent(findForm(from_box), 'submit', function() { SelectBox.select_all(field_id + '_to'); }); - SelectBox.init(field_id + '_from'); - SelectBox.init(field_id + '_to'); - // Move selected from_box options to to_box - SelectBox.move(field_id + '_from', field_id + '_to'); - }, - filter_key_up: function(event, field_id) { - from = document.getElementById(field_id + '_from'); - // don't submit form if user pressed Enter - if ((event.which && event.which == 13) || (event.keyCode && event.keyCode == 13)) { - from.selectedIndex = 0; - SelectBox.move(field_id + '_from', field_id + '_to'); - from.selectedIndex = 0; - return false; - } - var temp = from.selectedIndex; - SelectBox.filter(field_id + '_from', document.getElementById(field_id + '_input').value); - from.selectedIndex = temp; - return true; - }, - filter_key_down: function(event, field_id) { - from = document.getElementById(field_id + '_from'); - // right arrow -- move across - if ((event.which && event.which == 39) || (event.keyCode && event.keyCode == 39)) { - var old_index = from.selectedIndex; - SelectBox.move(field_id + '_from', field_id + '_to'); - from.selectedIndex = (old_index == from.length) ? from.length - 1 : old_index; - return false; - } - // down arrow -- wrap around - if ((event.which && event.which == 40) || (event.keyCode && event.keyCode == 40)) { - from.selectedIndex = (from.length == from.selectedIndex + 1) ? 0 : from.selectedIndex + 1; - } - // up arrow -- wrap around - if ((event.which && event.which == 38) || (event.keyCode && event.keyCode == 38)) { - from.selectedIndex = (from.selectedIndex == 0) ? from.length - 1 : from.selectedIndex - 1; - } - return true; - } -} diff --git a/django/contrib/admin/media/js/admin/CollapsedFieldsets.js b/django/contrib/admin/media/js/admin/CollapsedFieldsets.js index c8426db228..d66bec0d97 100644 --- a/django/contrib/admin/media/js/admin/CollapsedFieldsets.js +++ b/django/contrib/admin/media/js/admin/CollapsedFieldsets.js @@ -47,7 +47,7 @@ var CollapsedFieldsets = { // Returns true if any fields in the fieldset have validation errors. var divs = fs.getElementsByTagName('div'); for (var i=0; i<divs.length; i++) { - if (divs[i].className.match(/\berror\b/)) { + if (divs[i].className.match(/\berrors\b/)) { return true; } } diff --git a/django/contrib/admin/media/js/admin/RelatedObjectLookups.js b/django/contrib/admin/media/js/admin/RelatedObjectLookups.js index f6a39ca091..ca578cc28a 100644 --- a/django/contrib/admin/media/js/admin/RelatedObjectLookups.js +++ b/django/contrib/admin/media/js/admin/RelatedObjectLookups.js @@ -1,4 +1,4 @@ -// Handles related-objects functionality: lookup link for raw_id_admin=True +// Handles related-objects functionality: lookup link for raw_id_fields // and Add Another links. function html_unescape(text) { @@ -29,7 +29,7 @@ function showRelatedObjectLookupPopup(triggeringLink) { function dismissRelatedLookupPopup(win, chosenId) { var name = win.name.replace(/___/g, '.'); var elem = document.getElementById(name); - if (elem.className.indexOf('vRawIdAdminField') != -1 && elem.value) { + if (elem.className.indexOf('vManyToManyRawIdAdminField') != -1 && elem.value) { elem.value += ',' + chosenId; } else { document.getElementById(name).value = chosenId; diff --git a/django/contrib/admin/models.py b/django/contrib/admin/models.py index 23c8661336..259884faba 100644 --- a/django/contrib/admin/models.py +++ b/django/contrib/admin/models.py @@ -1,6 +1,7 @@ from django.db import models from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User +from django.contrib.admin.util import quote from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_unicode from django.utils.safestring import mark_safe @@ -50,4 +51,4 @@ class LogEntry(models.Model): Returns the admin URL to edit the object represented by this log entry. This is relative to the Django admin index page. """ - return mark_safe(u"%s/%s/%s/" % (self.content_type.app_label, self.content_type.model, self.object_id)) + return mark_safe(u"%s/%s/%s/" % (self.content_type.app_label, self.content_type.model, quote(self.object_id))) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py new file mode 100644 index 0000000000..3b26f7b262 --- /dev/null +++ b/django/contrib/admin/options.py @@ -0,0 +1,795 @@ +from django import oldforms, template +from django import newforms as forms +from django.newforms.formsets import all_valid +from django.newforms.models import modelform_factory, inlineformset_factory +from django.newforms.models import BaseInlineFormset +from django.contrib.contenttypes.models import ContentType +from django.contrib.admin import widgets +from django.contrib.admin.util import quote, unquote, get_deleted_objects +from django.core.exceptions import ImproperlyConfigured, PermissionDenied +from django.db import models, transaction +from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render_to_response +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.text import capfirst, get_text_list +from django.utils.translation import ugettext as _ +from django.utils.encoding import force_unicode +import sets + +HORIZONTAL, VERTICAL = 1, 2 +# returns the <ul> class for a given radio_admin field +get_ul_class = lambda x: 'radiolist%s' % ((x == HORIZONTAL) and ' inline' or '') + +class IncorrectLookupParameters(Exception): + pass + +def flatten_fieldsets(fieldsets): + """Returns a list of field names from an admin fieldsets structure.""" + field_names = [] + for name, opts in fieldsets: + for field in opts['fields']: + # type checking feels dirty, but it seems like the best way here + if type(field) == tuple: + field_names.extend(field) + else: + field_names.append(field) + return field_names + +class AdminForm(object): + def __init__(self, form, fieldsets, prepopulated_fields): + self.form, self.fieldsets = form, fieldsets + self.prepopulated_fields = [{ + 'field': form[field_name], + 'dependencies': [form[f] for f in dependencies] + } for field_name, dependencies in prepopulated_fields.items()] + + def __iter__(self): + for name, options in self.fieldsets: + yield Fieldset(self.form, name, **options) + + def first_field(self): + for bf in self.form: + return bf + + def _media(self): + media = self.form.media + for fs in self: + media = media + fs.media + return media + media = property(_media) + +class Fieldset(object): + def __init__(self, form, name=None, fields=(), classes=(), description=None): + self.form = form + self.name, self.fields = name, fields + self.classes = u' '.join(classes) + self.description = description + + def _media(self): + from django.conf import settings + if 'collapse' in self.classes: + return forms.Media(js=['%sjs/admin/CollapsedFieldsets.js' % settings.ADMIN_MEDIA_PREFIX]) + return forms.Media() + media = property(_media) + + def __iter__(self): + for field in self.fields: + yield Fieldline(self.form, field) + +class Fieldline(object): + def __init__(self, form, field): + self.form = form # A django.forms.Form instance + if isinstance(field, basestring): + self.fields = [field] + else: + self.fields = field + + def __iter__(self): + for i, field in enumerate(self.fields): + 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])) + +class AdminField(object): + def __init__(self, form, field, is_first): + self.field = form[field] # A django.forms.BoundField instance + self.is_first = is_first # Whether this field is first on the line + self.is_checkbox = isinstance(self.field.field.widget, forms.CheckboxInput) + + def label_tag(self): + classes = [] + if self.is_checkbox: + classes.append(u'vCheckboxLabel') + contents = escape(self.field.label) + else: + contents = force_unicode(escape(self.field.label)) + u':' + if self.field.field.required: + classes.append(u'required') + if not self.is_first: + classes.append(u'inline') + attrs = classes and {'class': u' '.join(classes)} or {} + return self.field.label_tag(contents=contents, attrs=attrs) + +class BaseModelAdmin(object): + """Functionality common to both ModelAdmin and InlineAdmin.""" + raw_id_fields = () + fields = None + fieldsets = None + form = forms.ModelForm + filter_vertical = () + filter_horizontal = () + radio_fields = {} + prepopulated_fields = {} + + def formfield_for_dbfield(self, db_field, **kwargs): + """ + Hook for specifying the form Field instance for a given database Field + instance. + + If kwargs are given, they're passed to the form Field's constructor. + """ + # For DateTimeFields, use a special field and widget. + if isinstance(db_field, models.DateTimeField): + kwargs['form_class'] = forms.SplitDateTimeField + kwargs['widget'] = widgets.AdminSplitDateTime() + return db_field.formfield(**kwargs) + + # For DateFields, add a custom CSS class. + if isinstance(db_field, models.DateField): + kwargs['widget'] = widgets.AdminDateWidget + return db_field.formfield(**kwargs) + + # For TimeFields, add a custom CSS class. + if isinstance(db_field, models.TimeField): + kwargs['widget'] = widgets.AdminTimeWidget + return db_field.formfield(**kwargs) + + # For FileFields and ImageFields add a link to the current file. + if isinstance(db_field, models.ImageField) or isinstance(db_field, models.FileField): + kwargs['widget'] = widgets.AdminFileWidget + return db_field.formfield(**kwargs) + + # For ForeignKey or ManyToManyFields, use a special widget. + if isinstance(db_field, (models.ForeignKey, models.ManyToManyField)): + if isinstance(db_field, models.ForeignKey) and db_field.name in self.raw_id_fields: + kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.rel) + elif isinstance(db_field, models.ForeignKey) and db_field.name in self.radio_fields: + kwargs['widget'] = widgets.AdminRadioSelect(attrs={ + 'class': get_ul_class(self.radio_fields[db_field.name]), + }) + kwargs['empty_label'] = db_field.blank and _('None') or None + else: + if isinstance(db_field, models.ManyToManyField): + if db_field.name in self.raw_id_fields: + kwargs['widget'] = widgets.ManyToManyRawIdWidget(db_field.rel) + kwargs['help_text'] = '' + elif db_field.name in (self.filter_vertical + self.filter_horizontal): + kwargs['widget'] = widgets.FilteredSelectMultiple(db_field.verbose_name, (db_field.name in self.filter_vertical)) + # Wrap the widget's render() method with a method that adds + # extra HTML to the end of the rendered output. + formfield = db_field.formfield(**kwargs) + # Don't wrap raw_id fields. Their add function is in the popup window. + if not db_field.name in self.raw_id_fields: + formfield.widget = widgets.RelatedFieldWidgetWrapper(formfield.widget, db_field.rel, self.admin_site) + return formfield + + if db_field.choices and db_field.name in self.radio_fields: + kwargs['widget'] = widgets.AdminRadioSelect( + choices=db_field.get_choices(include_blank=db_field.blank, + blank_choice=[('', _('None'))]), + attrs={ + 'class': get_ul_class(self.radio_fields[db_field.name]), + } + ) + + # For any other type of field, just call its formfield() method. + return db_field.formfield(**kwargs) + + def _declared_fieldsets(self): + if self.fieldsets: + return self.fieldsets + elif self.fields: + return [(None, {'fields': self.fields})] + return None + declared_fieldsets = property(_declared_fieldsets) + +class ModelAdmin(BaseModelAdmin): + "Encapsulates all admin options and functionality for a given model." + __metaclass__ = forms.MediaDefiningClass + + list_display = ('__str__',) + list_display_links = () + list_filter = () + list_select_related = False + list_per_page = 100 + search_fields = () + date_hierarchy = None + save_as = False + save_on_top = False + ordering = None + inlines = [] + + # Custom templates (designed to be over-ridden in subclasses) + change_form_template = None + change_list_template = None + delete_confirmation_template = None + object_history_template = None + + def __init__(self, model, admin_site): + self.model = model + self.opts = model._meta + self.admin_site = admin_site + self.inline_instances = [] + for inline_class in self.inlines: + inline_instance = inline_class(self.model, self.admin_site) + self.inline_instances.append(inline_instance) + super(ModelAdmin, self).__init__() + + def __call__(self, request, url): + # Check that LogEntry, ContentType and the auth context processor are installed. + from django.conf import settings + if settings.DEBUG: + from django.contrib.admin.models import LogEntry + 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.") + + # Delegate to the appropriate method, based on the URL. + if url is None: + return self.changelist_view(request) + elif url.endswith('add'): + return self.add_view(request) + elif url.endswith('history'): + return self.history_view(request, unquote(url[:-8])) + elif url.endswith('delete'): + return self.delete_view(request, unquote(url[:-7])) + else: + return self.change_view(request, unquote(url)) + + def _media(self): + from django.conf import settings + + js = ['js/core.js', 'js/admin/RelatedObjectLookups.js'] + if self.prepopulated_fields: + js.append('js/urlify.js') + if self.opts.get_ordered_objects(): + js.extend(['js/getElementsBySelector.js', 'js/dom-drag.js' , 'js/admin/ordering.js']) + if self.filter_vertical or self.filter_horizontal: + js.extend(['js/SelectBox.js' , 'js/SelectFilter2.js']) + + return forms.Media(js=['%s%s' % (settings.ADMIN_MEDIA_PREFIX, url) for url in js]) + media = property(_media) + + def has_add_permission(self, request): + "Returns True if the given request has permission to add an object." + opts = self.opts + return request.user.has_perm(opts.app_label + '.' + opts.get_add_permission()) + + def has_change_permission(self, request, obj=None): + """ + Returns True if the given request has permission to change the given + Django model instance. + + If `obj` is None, this should return True if the given request has + permission to change *any* object of the given type. + """ + opts = self.opts + return request.user.has_perm(opts.app_label + '.' + opts.get_change_permission()) + + def has_delete_permission(self, request, obj=None): + """ + Returns True if the given request has permission to change the given + Django model instance. + + If `obj` is None, this should return True if the given request has + permission to delete *any* object of the given type. + """ + opts = self.opts + return request.user.has_perm(opts.app_label + '.' + opts.get_delete_permission()) + + def queryset(self, request): + """ + Returns a QuerySet of all model instances that can be edited by the + admin site. This is used by changelist_view. + """ + qs = self.model._default_manager.get_query_set() + # TODO: this should be handled by some parameter to the ChangeList. + ordering = self.ordering or () # otherwise we might try to *None, which is bad ;) + if ordering: + qs = qs.order_by(*ordering) + return qs + + def get_fieldsets(self, request, obj=None): + "Hook for specifying fieldsets for the add form." + if self.declared_fieldsets: + return self.declared_fieldsets + form = self.get_form(request) + return [(None, {'fields': form.base_fields.keys()})] + + def get_form(self, request, obj=None): + """ + Returns a Form class for use in the admin add view. This is used by + add_view and change_view. + """ + if self.declared_fieldsets: + fields = flatten_fieldsets(self.declared_fieldsets) + else: + fields = None + return modelform_factory(self.model, form=self.form, fields=fields, formfield_callback=self.formfield_for_dbfield) + + def get_formsets(self, request, obj=None): + for inline in self.inline_instances: + yield inline.get_formset(request, obj) + + def save_add(self, request, form, formsets, post_url_continue): + """ + Saves the object in the "add" stage and returns an HttpResponseRedirect. + + `form` is a bound Form instance that's verified to be valid. + """ + from django.contrib.admin.models import LogEntry, ADDITION + opts = self.model._meta + new_object = form.save(commit=True) + + if formsets: + for formset in formsets: + # HACK: it seems like the parent obejct should be passed into + # a method of something, not just set as an attribute + formset.instance = new_object + formset.save() + + pk_value = new_object._get_pk_val() + LogEntry.objects.log_action(request.user.id, ContentType.objects.get_for_model(self.model).id, pk_value, force_unicode(new_object), ADDITION) + msg = _('The %(name)s "%(obj)s" was added successfully.') % {'name': opts.verbose_name, 'obj': new_object} + # Here, we distinguish between different save types by checking for + # the presence of keys in request.POST. + if request.POST.has_key("_continue"): + request.user.message_set.create(message=msg + ' ' + _("You may edit it again below.")) + if request.POST.has_key("_popup"): + post_url_continue += "?_popup=1" + return HttpResponseRedirect(post_url_continue % pk_value) + + if request.POST.has_key("_popup"): + return HttpResponse('<script type="text/javascript">opener.dismissAddAnotherPopup(window, "%s", "%s");</script>' % \ + # escape() calls force_unicode. + (escape(pk_value), escape(new_object))) + elif request.POST.has_key("_addanother"): + request.user.message_set.create(message=msg + ' ' + (_("You may add another %s below.") % opts.verbose_name)) + return HttpResponseRedirect(request.path) + else: + request.user.message_set.create(message=msg) + # Figure out where to redirect. If the user has change permission, + # redirect to the change-list page for this object. Otherwise, + # redirect to the admin index. + if self.has_change_permission(request, None): + post_url = '../' + else: + post_url = '../../../' + return HttpResponseRedirect(post_url) + save_add = transaction.commit_on_success(save_add) + + def save_change(self, request, form, formsets=None): + """ + Saves the object in the "change" stage and returns an HttpResponseRedirect. + + `form` is a bound Form instance that's verified to be valid. + + `formsets` is a sequence of InlineFormSet instances that are verified to be valid. + """ + from django.contrib.admin.models import LogEntry, CHANGE + opts = self.model._meta + new_object = form.save(commit=True) + pk_value = new_object._get_pk_val() + + if formsets: + for formset in formsets: + formset.save() + + # Construct the change message. + change_message = [] + if form.changed_data: + change_message.append(_('Changed %s.') % get_text_list(form.changed_data, _('and'))) + + if formsets: + for formset in formsets: + for added_object in formset.new_objects: + change_message.append(_('Added %(name)s "%(object)s".') + % {'name': added_object._meta.verbose_name, + 'object': added_object}) + for changed_object, changed_fields in formset.changed_objects: + change_message.append(_('Changed %(list)s for %(name)s "%(object)s".') + % {'list': get_text_list(changed_fields, _('and')), + 'name': changed_object._meta.verbose_name, + 'object': changed_object}) + for deleted_object in formset.deleted_objects: + change_message.append(_('Deleted %(name)s "%(object)s".') + % {'name': deleted_object._meta.verbose_name, + 'object': deleted_object}) + change_message = ' '.join(change_message) + if not change_message: + change_message = _('No fields changed.') + LogEntry.objects.log_action(request.user.id, ContentType.objects.get_for_model(self.model).id, pk_value, force_unicode(new_object), CHANGE, change_message) + + msg = _('The %(name)s "%(obj)s" was changed successfully.') % {'name': opts.verbose_name, 'obj': new_object} + if request.POST.has_key("_continue"): + request.user.message_set.create(message=msg + ' ' + _("You may edit it again below.")) + if request.REQUEST.has_key('_popup'): + return HttpResponseRedirect(request.path + "?_popup=1") + else: + return HttpResponseRedirect(request.path) + elif request.POST.has_key("_saveasnew"): + request.user.message_set.create(message=_('The %(name)s "%(obj)s" was added successfully. You may edit it again below.') % {'name': opts.verbose_name, 'obj': new_object}) + return HttpResponseRedirect("../%s/" % pk_value) + elif request.POST.has_key("_addanother"): + request.user.message_set.create(message=msg + ' ' + (_("You may add another %s below.") % opts.verbose_name)) + return HttpResponseRedirect("../add/") + else: + request.user.message_set.create(message=msg) + return HttpResponseRedirect("../") + save_change = transaction.commit_on_success(save_change) + + def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): + opts = self.model._meta + app_label = opts.app_label + ordered_objects = opts.get_ordered_objects() + context.update({ + 'add': add, + 'change': change, + 'has_add_permission': self.has_add_permission(request), + 'has_change_permission': self.has_change_permission(request, obj), + 'has_delete_permission': self.has_delete_permission(request, obj), + 'has_file_field': True, # FIXME - this should check if form or formsets have a FileField, + 'has_absolute_url': hasattr(self.model, 'get_absolute_url'), + 'ordered_objects': ordered_objects, + 'form_url': mark_safe(form_url), + 'opts': opts, + 'content_type_id': ContentType.objects.get_for_model(self.model).id, + 'save_as': self.save_as, + 'save_on_top': self.save_on_top, + 'root_path': self.admin_site.root_path, + }) + return render_to_response(self.change_form_template or [ + "admin/%s/%s/change_form.html" % (app_label, opts.object_name.lower()), + "admin/%s/change_form.html" % app_label, + "admin/change_form.html" + ], context, context_instance=template.RequestContext(request)) + + def add_view(self, request, form_url='', extra_context=None): + "The 'add' admin view for this model." + model = self.model + opts = model._meta + app_label = opts.app_label + + if not self.has_add_permission(request): + raise PermissionDenied + + if self.has_change_permission(request, None): + # redirect to list view + post_url = '../' + else: + # Object list will give 'Permission Denied', so go back to admin home + post_url = '../../../' + + ModelForm = self.get_form(request) + inline_formsets = [] + obj = self.model() + if request.method == 'POST': + form = ModelForm(request.POST, request.FILES) + for FormSet in self.get_formsets(request): + inline_formset = FormSet(data=request.POST, files=request.FILES, + instance=obj, save_as_new=request.POST.has_key("_saveasnew")) + inline_formsets.append(inline_formset) + if all_valid(inline_formsets) and form.is_valid(): + return self.save_add(request, form, inline_formsets, '../%s/') + else: + form = ModelForm(initial=dict(request.GET.items())) + for FormSet in self.get_formsets(request): + inline_formset = FormSet(instance=obj) + inline_formsets.append(inline_formset) + + adminForm = AdminForm(form, list(self.get_fieldsets(request)), self.prepopulated_fields) + media = self.media + adminForm.media + for fs in inline_formsets: + media = media + fs.media + + inline_admin_formsets = [] + for inline, formset in zip(self.inline_instances, inline_formsets): + fieldsets = list(inline.get_fieldsets(request)) + inline_admin_formset = InlineAdminFormSet(inline, formset, fieldsets) + inline_admin_formsets.append(inline_admin_formset) + + context = { + 'title': _('Add %s') % opts.verbose_name, + 'adminform': adminForm, + 'is_popup': request.REQUEST.has_key('_popup'), + 'show_delete': False, + 'media': mark_safe(media), + 'inline_admin_formsets': inline_admin_formsets, + 'errors': AdminErrorList(form, inline_formsets), + 'root_path': self.admin_site.root_path, + } + context.update(extra_context or {}) + return self.render_change_form(request, context, add=True) + + def change_view(self, request, object_id, extra_context=None): + "The 'change' admin view for this model." + model = self.model + opts = model._meta + app_label = opts.app_label + + try: + obj = model._default_manager.get(pk=object_id) + except model.DoesNotExist: + # Don't raise Http404 just yet, because we haven't checked + # permissions yet. We don't want an unauthenticated user to be able + # to determine whether a given object exists. + obj = None + + if not self.has_change_permission(request, obj): + raise PermissionDenied + + if obj is None: + raise Http404('%s object with primary key %r does not exist.' % (opts.verbose_name, escape(object_id))) + + if request.POST and request.POST.has_key("_saveasnew"): + return self.add_view(request, form_url='../../add/') + + ModelForm = self.get_form(request, obj) + inline_formsets = [] + if request.method == 'POST': + form = ModelForm(request.POST, request.FILES, instance=obj) + for FormSet in self.get_formsets(request, obj): + inline_formset = FormSet(request.POST, request.FILES, instance=obj) + inline_formsets.append(inline_formset) + + if all_valid(inline_formsets) and form.is_valid(): + return self.save_change(request, form, inline_formsets) + else: + form = ModelForm(instance=obj) + for FormSet in self.get_formsets(request, obj): + inline_formset = FormSet(instance=obj) + inline_formsets.append(inline_formset) + + adminForm = AdminForm(form, self.get_fieldsets(request, obj), self.prepopulated_fields) + media = self.media + adminForm.media + for fs in inline_formsets: + media = media + fs.media + + inline_admin_formsets = [] + for inline, formset in zip(self.inline_instances, inline_formsets): + fieldsets = list(inline.get_fieldsets(request, obj)) + inline_admin_formset = InlineAdminFormSet(inline, formset, fieldsets) + inline_admin_formsets.append(inline_admin_formset) + + context = { + 'title': _('Change %s') % opts.verbose_name, + 'adminform': adminForm, + 'object_id': object_id, + 'original': obj, + 'is_popup': request.REQUEST.has_key('_popup'), + 'media': mark_safe(media), + 'inline_admin_formsets': inline_admin_formsets, + 'errors': AdminErrorList(form, inline_formsets), + 'root_path': self.admin_site.root_path, + } + context.update(extra_context or {}) + return self.render_change_form(request, context, change=True, obj=obj) + + def changelist_view(self, request, extra_context=None): + "The 'change list' admin view for this model." + from django.contrib.admin.views.main import ChangeList, ERROR_FLAG + opts = self.model._meta + app_label = opts.app_label + if not self.has_change_permission(request, 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) + except IncorrectLookupParameters: + # Wacky lookup parameters were given, so redirect to the main + # changelist page, without parameters, and pass an 'invalid=1' + # parameter via the query string. If wacky parameters were given and + # the 'invalid=1' parameter was already in the query string, something + # is screwed up with the database, so display an error page. + if ERROR_FLAG in request.GET.keys(): + return render_to_response('admin/invalid_setup.html', {'title': _('Database error')}) + return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1') + + context = { + 'title': cl.title, + 'is_popup': cl.is_popup, + 'cl': cl, + 'has_add_permission': self.has_add_permission(request), + 'root_path': self.admin_site.root_path, + } + context.update(extra_context or {}) + return render_to_response(self.change_list_template or [ + 'admin/%s/%s/change_list.html' % (app_label, opts.object_name.lower()), + 'admin/%s/change_list.html' % app_label, + 'admin/change_list.html' + ], context, context_instance=template.RequestContext(request)) + + def delete_view(self, request, object_id, extra_context=None): + "The 'delete' admin view for this model." + from django.contrib.admin.models import LogEntry, DELETION + opts = self.model._meta + app_label = opts.app_label + + try: + obj = self.model._default_manager.get(pk=object_id) + except self.model.DoesNotExist: + # Don't raise Http404 just yet, because we haven't checked + # permissions yet. We don't want an unauthenticated user to be able + # to determine whether a given object exists. + obj = None + + if not self.has_delete_permission(request, obj): + raise PermissionDenied + + if obj is None: + raise Http404('%s object with primary key %r does not exist.' % (opts.verbose_name, escape(object_id))) + + # Populate deleted_objects, a data structure of all related objects that + # will also be deleted. + deleted_objects = [mark_safe(u'%s: <a href="../../%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), quote(object_id), escape(obj))), []] + perms_needed = sets.Set() + get_deleted_objects(deleted_objects, perms_needed, request.user, obj, opts, 1, self.admin_site) + + if request.POST: # The user has already confirmed the deletion. + if perms_needed: + raise PermissionDenied + obj_display = str(obj) + obj.delete() + LogEntry.objects.log_action(request.user.id, ContentType.objects.get_for_model(self.model).id, object_id, obj_display, DELETION) + request.user.message_set.create(message=_('The %(name)s "%(obj)s" was deleted successfully.') % {'name': force_unicode(opts.verbose_name), 'obj': force_unicode(obj_display)}) + if not self.has_change_permission(request, None): + return HttpResponseRedirect("../../../../") + return HttpResponseRedirect("../../") + + context = { + "title": _("Are you sure?"), + "object_name": opts.verbose_name, + "object": obj, + "deleted_objects": deleted_objects, + "perms_lacking": perms_needed, + "opts": opts, + "root_path": self.admin_site.root_path, + } + context.update(extra_context or {}) + return render_to_response(self.delete_confirmation_template or [ + "admin/%s/%s/delete_confirmation.html" % (app_label, opts.object_name.lower()), + "admin/%s/delete_confirmation.html" % app_label, + "admin/delete_confirmation.html" + ], context, context_instance=template.RequestContext(request)) + + def history_view(self, request, object_id, extra_context=None): + "The 'history' admin view for this model." + from django.contrib.admin.models import LogEntry + model = self.model + opts = model._meta + action_list = LogEntry.objects.filter( + object_id = object_id, + content_type__id__exact = ContentType.objects.get_for_model(model).id + ).select_related().order_by('action_time') + # If no history was found, see whether this object even exists. + obj = get_object_or_404(model, pk=object_id) + context = { + 'title': _('Change history: %s') % force_unicode(obj), + 'action_list': action_list, + 'module_name': capfirst(opts.verbose_name_plural), + 'object': obj, + 'root_path': self.admin_site.root_path, + } + context.update(extra_context or {}) + return render_to_response(self.object_history_template or [ + "admin/%s/%s/object_history.html" % (opts.app_label, opts.object_name.lower()), + "admin/%s/object_history.html" % opts.app_label, + "admin/object_history.html" + ], context, context_instance=template.RequestContext(request)) + +class InlineModelAdmin(BaseModelAdmin): + """ + Options for inline editing of ``model`` instances. + + Provide ``name`` to specify the attribute name of the ``ForeignKey`` from + ``model`` to its parent. This is required if ``model`` has more than one + ``ForeignKey`` to its parent. + """ + model = None + fk_name = None + formset = BaseInlineFormset + extra = 3 + max_num = 0 + template = None + verbose_name = None + verbose_name_plural = None + + def __init__(self, parent_model, admin_site): + self.admin_site = admin_site + self.parent_model = parent_model + self.opts = self.model._meta + super(InlineModelAdmin, self).__init__() + if self.verbose_name is None: + self.verbose_name = self.model._meta.verbose_name + if self.verbose_name_plural is None: + self.verbose_name_plural = self.model._meta.verbose_name_plural + + def get_formset(self, request, obj=None): + """Returns a BaseInlineFormSet class for use in admin add/change views.""" + if self.declared_fieldsets: + fields = flatten_fieldsets(self.declared_fieldsets) + else: + fields = None + return inlineformset_factory(self.parent_model, self.model, + form=self.form, formset=self.formset, fk_name=self.fk_name, + fields=fields, formfield_callback=self.formfield_for_dbfield, + extra=self.extra, max_num=self.max_num) + + def get_fieldsets(self, request, obj=None): + if self.declared_fieldsets: + return self.declared_fieldsets + form = self.get_formset(request).form + return [(None, {'fields': form.base_fields.keys()})] + +class StackedInline(InlineModelAdmin): + template = 'admin/edit_inline/stacked.html' + +class TabularInline(InlineModelAdmin): + template = 'admin/edit_inline/tabular.html' + +class InlineAdminFormSet(object): + """ + A wrapper around an inline formset for use in the admin system. + """ + def __init__(self, inline, formset, fieldsets): + self.opts = inline + self.formset = formset + self.fieldsets = fieldsets + + 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) + for form in self.formset.extra_forms: + yield InlineAdminForm(self.formset, form, self.fieldsets, self.opts.prepopulated_fields, None) + + def fields(self): + for field_name in flatten_fieldsets(self.fieldsets): + yield self.formset.form.base_fields[field_name] + +class InlineAdminForm(AdminForm): + """ + A wrapper around an inline form for use in the admin system. + """ + def __init__(self, formset, form, fieldsets, prepopulated_fields, original): + self.formset = formset + self.original = original + self.show_url = original and hasattr(original, 'get_absolute_url') + super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields) + + def pk_field(self): + return AdminField(self.form, self.formset._pk_field_name, False) + + def deletion_field(self): + from django.newforms.formsets import DELETION_FIELD_NAME + return AdminField(self.form, DELETION_FIELD_NAME, False) + + def ordering_field(self): + from django.newforms.formsets import ORDERING_FIELD_NAME + return AdminField(self.form, ORDERING_FIELD_NAME, False) + +class AdminErrorList(forms.util.ErrorList): + """ + Stores all errors for the form/formsets in an add/change stage view. + """ + def __init__(self, form, inline_formsets): + if form.is_bound: + self.extend(form.errors.values()) + for inline_formset in inline_formsets: + self.extend(inline_formset.non_form_errors()) + for errors_in_inline_form in inline_formset.errors: + self.extend(errors_in_inline_form.values()) diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py new file mode 100644 index 0000000000..bb4dc58ece --- /dev/null +++ b/django/contrib/admin/sites.py @@ -0,0 +1,349 @@ +from django import http, template +from django.contrib.admin import ModelAdmin +from django.contrib.auth import authenticate, login +from django.db.models.base import ModelBase +from django.shortcuts import render_to_response +from django.utils.safestring import mark_safe +from django.utils.text import capfirst +from django.utils.translation import ugettext_lazy, ugettext as _ +from django.views.decorators.cache import never_cache +from django.conf import settings +import base64 +import cPickle as pickle +import datetime +import md5 +import re + +ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. Note that both fields are case-sensitive.") +LOGIN_FORM_KEY = 'this_is_the_login_form' + +USER_CHANGE_PASSWORD_URL_RE = re.compile('auth/user/(\d+)/password') + +class AlreadyRegistered(Exception): + pass + +class NotRegistered(Exception): + pass + +def _encode_post_data(post_data): + from django.conf import settings + pickled = pickle.dumps(post_data) + pickled_md5 = md5.new(pickled + settings.SECRET_KEY).hexdigest() + return base64.encodestring(pickled + pickled_md5) + +def _decode_post_data(encoded_data): + from django.conf import settings + encoded_data = base64.decodestring(encoded_data) + pickled, tamper_check = encoded_data[:-32], encoded_data[-32:] + if md5.new(pickled + settings.SECRET_KEY).hexdigest() != tamper_check: + from django.core.exceptions import SuspiciousOperation + raise SuspiciousOperation, "User may have tampered with session cookie." + return pickle.loads(pickled) + +class AdminSite(object): + """ + An AdminSite object encapsulates an instance of the Django admin application, ready + to be hooked in to your URLConf. Models are registered with the AdminSite using the + 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 + + def __init__(self): + self._registry = {} # model_class class -> admin_class instance + + 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. + """ + do_validate = admin_class and settings.DEBUG + if do_validate: + # don't import the humongous validation code unless required + from django.contrib.admin.validation import validate + admin_class = admin_class or ModelAdmin + # TODO: Handle options + 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 do_validate: + validate(admin_class, model) + 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): + model_or_iterable = [model_or_iterable] + for model in model_or_iterable: + if model not in self._registry: + raise NotRegistered('The model %s is not registered' % model.__name__) + del self._registry[model] + + 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 root(self, request, url): + """ + Handles main URL routing for the admin app. + + `url` is the remainder of the URL -- e.g. 'comments/comment/'. + """ + if request.method == 'GET' and not request.path.endswith('/'): + return http.HttpResponseRedirect(request.path + '/') + + # 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': + return self.password_change(request) + elif url == 'password_change/done': + return self.password_change_done(request) + elif url == 'jsi18n': + return self.i18n_javascript(request) + # urls starting with 'r/' are for the "show in web" links + elif url.startswith('r/'): + from django.views.defaults import shortcut + return shortcut(request, *url.split('/')[1:]) + else: + match = USER_CHANGE_PASSWORD_URL_RE.match(url) + if match: + return self.user_change_password(request, match.group(1)) + + if '/' in url: + return self.model_page(request, *url.split('/', 2)) + + raise http.Http404('The requested admin page does not exist.') + + def model_page(self, request, app_label, model_name, rest_of_url=None): + """ + Handles the model-specific functionality of the admin site, delegating + to the appropriate ModelAdmin class. + """ + from django.db import models + model = models.get_model(app_label, model_name) + if model is None: + raise http.Http404("App %r, model %r, not found." % (app_label, model_name)) + try: + admin_obj = self._registry[model] + 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) + + def password_change(self, request): + """ + Handles the "change password" task -- both form display and validation. + """ + from django.contrib.auth.views import password_change + return password_change(request) + + 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 user_change_password(self, request, id): + """ + Handles the "user change password" task + """ + from django.contrib.auth.views import user_change_password + return user_change_password(request, id) + + 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. + """ + from django.conf import settings + if settings.USE_I18N: + from django.views.i18n import javascript_catalog + 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: + message = _("Please log in again, because your session has expired. Don't worry: Your submission has been saved.") + 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) + + # Check the password. + username = request.POST.get('username', None) + password = request.POST.get('password', None) + user = authenticate(username=username, password=password) + if user is None: + message = ERROR_MESSAGE + if u'@' in username: + # Mistakenly entered e-mail address instead of username? Look it up. + try: + user = User.objects.get(email=username) + except (User.DoesNotExist, User.MultipleObjectsReturned): + message = _("Usernames cannot contain the '@' character.") + else: + if user.check_password(password): + message = _("Your e-mail address is not your username." + " Try '%s' instead.") % user.username + 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: + login(request, user) + # TODO: set last_login with an event. + user.last_login = datetime.datetime.now() + user.save() + if request.POST.has_key('post_data'): + post_data = _decode_post_data(request.POST['post_data']) + if post_data and not post_data.has_key(LOGIN_FORM_KEY): + # overwrite request.POST with the saved post_data, and continue + request.POST = post_data + request.user = user + return self.root(request, request.path.split(self.root_path)[-1]) + else: + request.session.delete_test_cookie() + return http.HttpResponseRedirect(request.path) + 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 + apps that have been registered in this site. + """ + app_dict = {} + user = request.user + 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(): + model_dict = { + 'name': capfirst(model._meta.verbose_name_plural), + 'admin_url': mark_safe('%s/%s/' % (app_label, model.__name__.lower())), + 'perms': perms, + } + if app_label in app_dict: + app_dict[app_label]['models'].append(model_dict) + else: + app_dict[app_label] = { + 'name': app_label.title(), + '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, + 'root_path': self.root_path, + } + context.update(extra_context or {}) + return render_to_response(self.index_template or 'admin/index.html', context, + 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() + if request.POST and request.POST.has_key('post_data'): + # User has failed login BUT has previously saved post data. + post_data = request.POST['post_data'] + elif request.POST: + # User's session must have expired; save their post data. + post_data = _encode_post_data(request.POST) + else: + post_data = _encode_post_data({}) + + context = { + 'title': _('Log in'), + 'app_path': request.path, + 'post_data': post_data, + 'error_message': error_message, + 'root_path': self.root_path, + } + context.update(extra_context or {}) + return render_to_response(self.login_template or 'admin/login.html', context, + context_instance=template.RequestContext(request) + ) + + +# 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. +site = AdminSite() diff --git a/django/contrib/admin/templates/admin/auth/user/add_form.html b/django/contrib/admin/templates/admin/auth/user/add_form.html index 139fa6a75e..65824a6b7d 100644 --- a/django/contrib/admin/templates/admin/auth/user/add_form.html +++ b/django/contrib/admin/templates/admin/auth/user/add_form.html @@ -8,21 +8,26 @@ <fieldset class="module aligned"> <div class="form-row"> - {{ form.username.html_error_list }} + {{ form.username.errors }} + {# TODO: get required class on label_tag #} <label for="id_username" class="required">{% trans 'Username' %}:</label> {{ form.username }} - <p class="help">{{ username_help_text }}</p> + <p class="help">{{ form.username.help_text }}</p> </div> <div class="form-row"> - {{ form.password1.html_error_list }} + {{ form.password1.errors }} + {# TODO: get required class on label_tag #} <label for="id_password1" class="required">{% trans 'Password' %}:</label> {{ form.password1 }} </div> <div class="form-row"> - {{ form.password2.html_error_list }} + {{ form.password2.errors }} + {# TODO: get required class on label_tag #} <label for="id_password2" class="required">{% trans 'Password (again)' %}:</label> {{ form.password2 }} <p class="help">{% trans 'Enter the same password as above, for verification.' %}</p> </div> +<script type="text/javascript">document.getElementById("id_username").focus();</script> + </fieldset> {% endblock %} diff --git a/django/contrib/admin/templates/admin/auth/user/change_password.html b/django/contrib/admin/templates/admin/auth/user/change_password.html index 28e342da86..f1c4a8d34a 100644 --- a/django/contrib/admin/templates/admin/auth/user/change_password.html +++ b/django/contrib/admin/templates/admin/auth/user/change_password.html @@ -2,7 +2,6 @@ {% load i18n admin_modify adminmedia %} {% block extrahead %}{{ block.super }} <script type="text/javascript" src="../../../../jsi18n/"></script> -{% for js in javascript_imports %}{% include_admin_script js %}{% endfor %} {% endblock %} {% block stylesheet %}{% admin_media_prefix %}css/forms.css{% endblock %} {% block bodyclass %}{{ opts.app_label }}-{{ opts.object_name.lower }} change-form{% endblock %} @@ -18,9 +17,9 @@ <form action="{{ form_url }}" method="post" id="{{ opts.module_name }}_form">{% block form_top %}{% endblock %} <div> {% if is_popup %}<input type="hidden" name="_popup" value="1" />{% endif %} -{% if form.error_dict %} +{% if form.errors %} <p class="errornote"> - {% blocktrans count form.error_dict.items|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} + {% blocktrans count form.errors.items|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} </p> {% endif %} @@ -29,12 +28,14 @@ <fieldset class="module aligned"> <div class="form-row"> - {{ form.password1.html_error_list }} + {{ form.password1.errors }} + {# TODO: get required class on label_tag #} <label for="id_password1" class="required">{% trans 'Password' %}:</label> {{ form.password1 }} </div> <div class="form-row"> - {{ form.password2.html_error_list }} + {{ form.password2.errors }} + {# TODO: get required class on label_tag #} <label for="id_password2" class="required">{% trans 'Password (again)' %}:</label> {{ form.password2 }} <p class="help">{% trans 'Enter the same password as above, for verification.' %}</p> </div> @@ -45,7 +46,7 @@ <input type="submit" value="{% trans 'Change password' %}" class="default" /> </div> -<script type="text/javascript">document.getElementById("{{ first_form_field_id }}").focus();</script> +<script type="text/javascript">document.getElementById("id_password1").focus();</script> </div> </form></div> {% endblock %} diff --git a/django/contrib/admin/templates/admin/base.html b/django/contrib/admin/templates/admin/base.html index cdd3561ab9..479e18b2ee 100644 --- a/django/contrib/admin/templates/admin/base.html +++ b/django/contrib/admin/templates/admin/base.html @@ -22,14 +22,7 @@ {% block branding %}{% endblock %} </div> {% if user.is_authenticated and user.is_staff %} - <div id="user-tools"> - {% trans 'Welcome,' %} <strong>{% if user.first_name %}{{ user.first_name|escape }}{% else %}{{ user.username }}{% endif %}</strong>. - {% block userlinks %} - <a href="{% url django.contrib.admin.views.doc.doc_index %}">{% trans 'Documentation' %}</a> - / <a href="{% url django.contrib.auth.views.password_change %}">{% trans 'Change password' %}</a> - / <a href="{% url django.contrib.auth.views.logout %}">{% trans 'Log out' %}</a> - {% endblock %} - </div> + <div id="user-tools">{% trans 'Welcome,' %} <strong>{% if user.first_name %}{{ user.first_name|escape }}{% else %}{{ user.username }}{% endif %}</strong>. {% block userlinks %}<a href="{{ root_path }}doc/">{% trans 'Documentation' %}</a> / <a href="{{ root_path }}password_change/">{% trans 'Change password' %}</a> / <a href="{{ root_path }}logout/">{% trans 'Log out' %}</a>{% endblock %}</div> {% endif %} {% block nav-global %}{% endblock %} </div> diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html index d540cf6e79..e8df6b99f5 100644 --- a/django/contrib/admin/templates/admin/change_form.html +++ b/django/contrib/admin/templates/admin/change_form.html @@ -1,12 +1,17 @@ {% extends "admin/base_site.html" %} {% load i18n admin_modify adminmedia %} + {% block extrahead %}{{ block.super }} <script type="text/javascript" src="../../../jsi18n/"></script> -{% for js in javascript_imports %}{% include_admin_script js %}{% endfor %} +{{ media }} {% endblock %} + {% block stylesheet %}{% admin_media_prefix %}css/forms.css{% endblock %} + {% block coltype %}{% if ordered_objects %}colMS{% else %}colM{% endif %}{% endblock %} + {% block bodyclass %}{{ opts.app_label }}-{{ opts.object_name.lower }} change-form{% endblock %} + {% block breadcrumbs %}{% if not is_popup %} <div class="breadcrumbs"> <a href="../../../">{% trans "Home" %}</a> › @@ -14,6 +19,7 @@ {% if add %}{% trans "Add" %} {{ opts.verbose_name }}{% else %}{{ original|truncatewords:"18" }}{% endif %} </div> {% endif %}{% endblock %} + {% block content %}<div id="content-main"> {% block object-tools %} {% if change %}{% if not is_popup %} @@ -25,45 +31,48 @@ <form {% if has_file_field %}enctype="multipart/form-data" {% endif %}action="{{ form_url }}" method="post" id="{{ opts.module_name }}_form">{% block form_top %}{% endblock %} <div> {% if is_popup %}<input type="hidden" name="_popup" value="1" />{% endif %} -{% if opts.admin.save_on_top %}{% submit_row %}{% endif %} -{% if form.error_dict %} +{% if save_on_top %}{% submit_row %}{% endif %} +{% if errors %} <p class="errornote"> - {% blocktrans count form.error_dict.items|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} + {% blocktrans count errors.items|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} </p> + <ul class="errorlist">{% for error in adminform.form.non_field_errors %}<li>{{ error }}</li>{% endfor %}</ul> {% endif %} -{% for bound_field_set in bound_field_sets %} - <fieldset class="module aligned {{ bound_field_set.classes }}"> - {% if bound_field_set.name %}<h2>{{ bound_field_set.name }}</h2>{% endif %} - {% if bound_field_set.description %}<div class="description">{{ bound_field_set.description|safe }}</div>{% endif %} - {% for bound_field_line in bound_field_set %} - {% admin_field_line bound_field_line %} - {% for bound_field in bound_field_line %} - {% filter_interface_script_maybe bound_field %} - {% endfor %} - {% endfor %} - </fieldset> + +{% for fieldset in adminform %} + {% include "admin/includes/fieldset.html" %} {% endfor %} + {% block after_field_sets %}{% endblock %} -{% if change %} - {% if ordered_objects %} - <fieldset class="module"><h2>{% trans "Ordering" %}</h2> - <div class="form-row{% if form.order_.errors %} error{% endif %} "> - {% if form.order_.errors %}{{ form.order_.html_error_list }}{% endif %} - <p><label for="id_order_">{% trans "Order:" %}</label> {{ form.order_ }}</p> - </div></fieldset> - {% endif %} -{% endif %} -{% for related_object in inline_related_objects %}{% edit_inline related_object %}{% endfor %} + +{% for inline_admin_formset in inline_admin_formsets %} + {% include inline_admin_formset.opts.template %} +{% endfor %} + {% block after_related_objects %}{% endblock %} + {% submit_row %} + {% if add %} - <script type="text/javascript">document.getElementById("{{ first_form_field_id }}").focus();</script> + <script type="text/javascript">document.getElementById("{{ adminform.first_field.auto_id }}").focus();</script> {% endif %} -{% if auto_populated_fields %} - <script type="text/javascript"> - {% auto_populated_field_script auto_populated_fields change %} - </script> + +{# JavaScript for prepopulated fields #} + +{% if add %} +<script type="text/javascript"> +{% for field in adminform.prepopulated_fields %} + document.getElementById("{{ field.field.auto_id }}").onchange = function() { this._changed = true; }; + {% for dependency in field.dependencies %} + document.getElementById("{{ dependency.auto_id }}").onkeyup = function() { + var e = document.getElementById("{{ field.field.auto_id }}"); + if (!e._changed) { e.value = URLify({% for innerdep in field.dependencies %}document.getElementById("{{ innerdep.auto_id }}").value{% if not forloop.last %} + ' ' + {% endif %}{% endfor %}, {{ field.field.field.max_length }}); } + } + {% endfor %} +{% endfor %} +</script> {% endif %} + </div> </form></div> {% endblock %} diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html index 611c5ff8fc..24286a51a7 100644 --- a/django/contrib/admin/templates/admin/change_list.html +++ b/django/contrib/admin/templates/admin/change_list.html @@ -1,9 +1,14 @@ {% extends "admin/base_site.html" %} {% load adminmedia admin_list i18n %} + {% block stylesheet %}{% admin_media_prefix %}css/changelists.css{% endblock %} + {% block bodyclass %}change-list{% endblock %} + {% if not is_popup %}{% block breadcrumbs %}<div class="breadcrumbs"><a href="../../">{% trans "Home" %}</a> › {{ cl.opts.verbose_name_plural|capfirst|escape }}</div>{% endblock %}{% endif %} + {% block coltype %}flex{% endblock %} + {% block content %} <div id="content-main"> {% block object-tools %} @@ -14,7 +19,18 @@ <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 %}{% filters 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 result_list %}{% result_list cl %}{% endblock %} {% block pagination %}{% pagination cl %}{% endblock %} </div> diff --git a/django/contrib/admin/templates/admin/delete_confirmation.html b/django/contrib/admin/templates/admin/delete_confirmation.html index f2126882fa..386e134b96 100644 --- a/django/contrib/admin/templates/admin/delete_confirmation.html +++ b/django/contrib/admin/templates/admin/delete_confirmation.html @@ -1,5 +1,6 @@ {% extends "admin/base_site.html" %} {% load i18n %} + {% block breadcrumbs %} <div class="breadcrumbs"> <a href="../../../../">{% trans "Home" %}</a> › @@ -8,6 +9,7 @@ {% trans 'Delete' %} </div> {% endblock %} + {% block content %} {% if perms_lacking %} <p>{% blocktrans with object|escape as escaped_object %}Deleting the {{ object_name }} '{{ escaped_object }}' would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}</p> diff --git a/django/contrib/admin/templates/admin/edit_inline/stacked.html b/django/contrib/admin/templates/admin/edit_inline/stacked.html new file mode 100644 index 0000000000..c726b0fcda --- /dev/null +++ b/django/contrib/admin/templates/admin/edit_inline/stacked.html @@ -0,0 +1,26 @@ +{% load i18n %} +<div class="inline-group"> +{{ inline_admin_formset.formset.management_form }} +{# <h3 class="header">{{ inline_admin_formset.opts.verbose_name_plural|title }}</h3> #} +{{ inline_admin_formset.formset.non_form_errors }} + +{% for inline_admin_form in inline_admin_formset %} +<div class="inline-related {% if forloop.last %}last-related{% endif %}"> + <h2><b>{{ inline_admin_formset.opts.verbose_name|title }}:</b> {% if inline_admin_form.original %}{{ inline_admin_form.original }}{% else %} #{{ forloop.counter }}{% endif %} + {% if inline_admin_formset.formset.can_delete and inline_admin_form.original %}<span class="delete">{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}</span>{% endif %} + </h2> + {% if inline_admin_form.show_url %} + <p><a href="/r/{{ inline_admin_form.original.content_type_id }}/{{ inline_admin_form.original.id }}/">View on site</a></p> + {% endif %} + + {% for fieldset in inline_admin_form %} + {% include "admin/includes/fieldset.html" %} + {% endfor %} + {{ inline_admin_form.pk_field.field }} +</div> +{% endfor %} + +{# <ul class="tools"> #} +{# <li><a class="add" href="">Add another {{ inline_admin_formset.opts.verbose_name|title }}</a></li> #} +{# </ul> #} +</div> diff --git a/django/contrib/admin/templates/admin/edit_inline/tabular.html b/django/contrib/admin/templates/admin/edit_inline/tabular.html new file mode 100644 index 0000000000..f6332fafbc --- /dev/null +++ b/django/contrib/admin/templates/admin/edit_inline/tabular.html @@ -0,0 +1,64 @@ +{% load i18n %} +<div class="inline-group"> + <div class="tabular inline-related {% if forloop.last %}last-related{% endif %}"> +{{ inline_admin_formset.formset.management_form }} +<fieldset class="module"> + <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst|escape }}</h2> + {{ 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|escape }}</th> + {% endif %} + {% endfor %} + {% if inline_admin_formset.formset.can_delete %}<th>{% trans "Delete" %}?</th>{% endif %} + </tr></thead> + + {% for inline_admin_form in inline_admin_formset %} + + <tr class="{% cycle row1,row2 %} {% if inline_admin_form.original or inline_admin_form.show_url %}has_original{% endif %}"> + + <td class="original">{% if inline_admin_form.original or inline_admin_form.show_url %}<p> + {% if inline_admin_form.original %} {{ inline_admin_form.original }}{% endif %} + {% if inline_admin_form.show_url %}<a href="/r/{{ inline_admin_form.original.content_type_id }}/{{ inline_admin_form.original.id }}/">View on site</a>{% endif %} + </p>{% endif %} + {{ inline_admin_form.pk_field.field }} + {% spaceless %} + {% for fieldset in inline_admin_form %} + {% for line in fieldset %} + {% for field in line %} + {% if field.is_hidden %} {{ field.field }} {% endif %} + {% endfor %} + {% endfor %} + {% endfor %} + {% endspaceless %} + </td> + + {% for fieldset in inline_admin_form %} + {% for line in fieldset %} + {% for field in line %} + <td class="{{ field.field.name }}"> + {{ field.field.errors.as_ul }} + {{ field.field }} + </td> + {% endfor %} + {% endfor %} + {% endfor %} + + {% if inline_admin_formset.formset.can_delete %}<td class="delete">{% if inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }}{% endif %}</td>{% endif %} + + </tr> + + {% endfor %} + + </table> + +</fieldset> + </div> + + {# <ul class="tools"> #} + {# <li><a class="add" href="">Add another {{ inline_admin_formset.opts.verbose_name|title }}</a></li> #} + {# </ul> #} + +</div> diff --git a/django/contrib/admin/templates/admin/edit_inline_stacked.html b/django/contrib/admin/templates/admin/edit_inline_stacked.html deleted file mode 100644 index 45aa0a4f58..0000000000 --- a/django/contrib/admin/templates/admin/edit_inline_stacked.html +++ /dev/null @@ -1,16 +0,0 @@ -{% load admin_modify %} -<fieldset class="module aligned"> - {% for fcw in bound_related_object.form_field_collection_wrappers %} - <h2>{{ bound_related_object.relation.opts.verbose_name|capfirst }} #{{ forloop.counter }}</h2> - {% if bound_related_object.show_url %}{% if fcw.obj.original %} - <p><a href="/r/{{ fcw.obj.original.content_type_id }}/{{ fcw.obj.original.id }}/">View on site</a></p> - {% endif %}{% endif %} - {% for bound_field in fcw.bound_fields %} - {% if bound_field.hidden %} - {% field_widget bound_field %} - {% else %} - {% admin_field_line bound_field %} - {% endif %} - {% endfor %} - {% endfor %} -</fieldset> diff --git a/django/contrib/admin/templates/admin/edit_inline_tabular.html b/django/contrib/admin/templates/admin/edit_inline_tabular.html deleted file mode 100644 index e2dbcbaed2..0000000000 --- a/django/contrib/admin/templates/admin/edit_inline_tabular.html +++ /dev/null @@ -1,44 +0,0 @@ -{% load admin_modify %} -<fieldset class="module"> - <h2>{{ bound_related_object.relation.opts.verbose_name_plural|capfirst }}</h2><table> - <thead><tr> - {% for fw in bound_related_object.field_wrapper_list %} - {% if fw.needs_header %} - <th{{ fw.header_class_attribute }}>{{ fw.field.verbose_name|capfirst }}</th> - {% endif %} - {% endfor %} - </tr></thead> - {% for fcw in bound_related_object.form_field_collection_wrappers %} - {% if change %}{% if original_row_needed %} - {% if fcw.obj.original %} - <tr class="row-label {% cycle row1,row2 %}"><td colspan="{{ num_headers }}"><strong>{{ fcw.obj.original }}</strong></tr> - {% endif %} - {% endif %}{% endif %} - {% if fcw.obj.errors %} - <tr class="errorlist"><td colspan="{{ num_headers }}"> - {{ fcw.obj.html_combined_error_list }} - </tr> - {% endif %} - <tr class="{% cycle row1,row2 %}"> - {% for bound_field in fcw.bound_fields %} - {% if not bound_field.hidden %} - <td {{ bound_field.cell_class_attribute }}> - {% field_widget bound_field %} - </td> - {% endif %} - {% endfor %} - {% if bound_related_object.show_url %}<td> - {% if fcw.obj.original %}<a href="/r/{{ fcw.obj.original.content_type_id }}/{{ fcw.obj.original.id }}/">View on site</a>{% endif %} - </td>{% endif %} - </tr> - - {% endfor %} </table> - - {% for fcw in bound_related_object.form_field_collection_wrappers %} - {% for bound_field in fcw.bound_fields %} - {% if bound_field.hidden %} - {% field_widget bound_field %} - {% endif %} - {% endfor %} - {% endfor %} -</fieldset> diff --git a/django/contrib/admin/templates/admin/field_line.html b/django/contrib/admin/templates/admin/field_line.html deleted file mode 100644 index f4b53fff67..0000000000 --- a/django/contrib/admin/templates/admin/field_line.html +++ /dev/null @@ -1,10 +0,0 @@ -{% load admin_modify %} -<div class="{{ class_names }}" > -{% for bound_field in bound_fields %}{{ bound_field.html_error_list }}{% endfor %} -{% for bound_field in bound_fields %} - {% if bound_field.has_label_first %}{% field_label bound_field %}{% endif %} - {% field_widget bound_field %} - {% if not bound_field.has_label_first %}{% field_label bound_field %}{% endif %} - {% if bound_field.field.help_text %}<p class="help">{{ bound_field.field.help_text|safe }}</p>{% endif %} -{% endfor %} -</div> diff --git a/django/contrib/admin/templates/admin/filters.html b/django/contrib/admin/templates/admin/filters.html deleted file mode 100644 index 3ca763cce3..0000000000 --- a/django/contrib/admin/templates/admin/filters.html +++ /dev/null @@ -1,7 +0,0 @@ -{% load admin_list %} -{% load i18n %} -{% if cl.has_filters %}<div id="changelist-filter"> -<h2>{% trans 'Filter' %}</h2> -{% for spec in cl.filter_specs %} - {% filter cl spec %} -{% endfor %}</div>{% endif %} diff --git a/django/contrib/admin/templates/admin/includes/fieldset.html b/django/contrib/admin/templates/admin/includes/fieldset.html new file mode 100644 index 0000000000..a61795cfe4 --- /dev/null +++ b/django/contrib/admin/templates/admin/includes/fieldset.html @@ -0,0 +1,17 @@ +<fieldset class="module aligned {{ fieldset.classes }}"> + {% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %} + {% if fieldset.description %}<div class="description">{{ fieldset.description }}</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 %} + {% 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 %} + {% endfor %} + </div> + {% endfor %} +</fieldset>
\ No newline at end of file diff --git a/django/contrib/admin/templates/admin/index.html b/django/contrib/admin/templates/admin/index.html index 2f406a1754..074be03208 100644 --- a/django/contrib/admin/templates/admin/index.html +++ b/django/contrib/admin/templates/admin/index.html @@ -2,15 +2,16 @@ {% load i18n %} {% block stylesheet %}{% load adminmedia %}{% admin_media_prefix %}css/dashboard.css{% endblock %} + {% block coltype %}colMS{% endblock %} + {% block bodyclass %}dashboard{% endblock %} + {% block breadcrumbs %}{% endblock %} + {% block content %} <div id="content-main"> -{% load adminapplist %} - -{% get_admin_app_list as app_list %} {% if app_list %} {% for app in app_list %} <div class="module"> diff --git a/django/contrib/admin/templates/admin/invalid_setup.html b/django/contrib/admin/templates/admin/invalid_setup.html index 1fa0d32358..f09b316b06 100644 --- a/django/contrib/admin/templates/admin/invalid_setup.html +++ b/django/contrib/admin/templates/admin/invalid_setup.html @@ -4,7 +4,5 @@ {% block breadcrumbs %}<div class="breadcrumbs"><a href="../../">{% trans 'Home' %}</a> › {{ title }}</div>{% endblock %} {% block content %} - <p>{% trans "Something's wrong with your database installation. Make sure the appropriate database tables have been created, and make sure the database is readable by the appropriate user." %}</p> - {% endblock %} diff --git a/django/contrib/admin/templates/admin/login.html b/django/contrib/admin/templates/admin/login.html index 0773132cec..5dd953bc23 100644 --- a/django/contrib/admin/templates/admin/login.html +++ b/django/contrib/admin/templates/admin/login.html @@ -2,12 +2,14 @@ {% load i18n %} {% block stylesheet %}{% load adminmedia %}{% admin_media_prefix %}css/login.css{% endblock %} + {% block bodyclass %}login{% endblock %} + {% block content_title %}{% endblock %} + {% block breadcrumbs %}{% endblock %} {% block content %} - {% if error_message %} <p class="errornote">{{ error_message }}</p> {% endif %} diff --git a/django/contrib/admin/templates/admin/object_history.html b/django/contrib/admin/templates/admin/object_history.html index 00e47259bf..19c037cda9 100644 --- a/django/contrib/admin/templates/admin/object_history.html +++ b/django/contrib/admin/templates/admin/object_history.html @@ -1,16 +1,15 @@ {% extends "admin/base_site.html" %} {% load i18n %} + {% block breadcrumbs %} <div class="breadcrumbs"><a href="../../../../">{% trans 'Home' %}</a> › <a href="../../">{{ module_name }}</a> › <a href="../">{{ object|truncatewords:"18" }}</a> › {% trans 'History' %}</div> {% endblock %} {% block content %} - <div id="content-main"> <div class="module"> {% if action_list %} - <table id="change-history"> <thead> <tr> @@ -29,14 +28,9 @@ {% endfor %} </tbody> </table> - {% else %} - <p>{% trans "This object doesn't have a change history. It probably wasn't added via this admin site." %}</p> - {% endif %} - </div> </div> - {% endblock %} diff --git a/django/contrib/admin/templates/admin/search_form.html b/django/contrib/admin/templates/admin/search_form.html index 445cca3089..b232aa917d 100644 --- a/django/contrib/admin/templates/admin/search_form.html +++ b/django/contrib/admin/templates/admin/search_form.html @@ -1,6 +1,6 @@ {% load adminmedia %} {% load i18n %} -{% if cl.lookup_opts.admin.search_fields %} +{% if cl.search_fields %} <div id="toolbar"><form id="changelist-search" action="" method="get"> <div><!-- DIV needed for valid HTML --> <label for="searchbar"><img src="{% admin_media_prefix %}img/admin/icon_searchbox.png" alt="Search" /></label> diff --git a/django/contrib/admin/templates/admin_doc/index.html b/django/contrib/admin/templates/admin_doc/index.html index 750dd2f5ac..242fc7339a 100644 --- a/django/contrib/admin/templates/admin_doc/index.html +++ b/django/contrib/admin/templates/admin_doc/index.html @@ -25,3 +25,4 @@ </div> {% endblock %} + diff --git a/django/contrib/admin/templates/admin_doc/view_index.html b/django/contrib/admin/templates/admin_doc/view_index.html index 716e2d1a91..4099005828 100644 --- a/django/contrib/admin/templates/admin_doc/view_index.html +++ b/django/contrib/admin/templates/admin_doc/view_index.html @@ -40,3 +40,4 @@ </div> {% endblock %} + diff --git a/django/contrib/admin/templates/registration/password_change_done.html b/django/contrib/admin/templates/registration/password_change_done.html index 4498c63a37..252572001d 100644 --- a/django/contrib/admin/templates/registration/password_change_done.html +++ b/django/contrib/admin/templates/registration/password_change_done.html @@ -1,5 +1,6 @@ {% extends "admin/base_site.html" %} {% load i18n %} +{% block userlinks %}<a href="../../doc/">{% trans 'Documentation' %}</a> / {% trans 'Change password' %} / <a href="../../logout/">{% trans 'Log out' %}</a>{% endblock %} {% block breadcrumbs %}<div class="breadcrumbs"><a href="../../">{% trans 'Home' %}</a> › {% trans 'Password change' %}</div>{% endblock %} {% block title %}{% trans 'Password change successful' %}{% endblock %} diff --git a/django/contrib/admin/templates/registration/password_change_form.html b/django/contrib/admin/templates/registration/password_change_form.html index cd2b1e0c8a..036d56212c 100644 --- a/django/contrib/admin/templates/registration/password_change_form.html +++ b/django/contrib/admin/templates/registration/password_change_form.html @@ -1,5 +1,6 @@ {% extends "admin/base_site.html" %} {% load i18n %} +{% block userlinks %}<a href="../doc/">{% trans 'Documentation' %}</a> / {% trans 'Change password' %} / <a href="../logout/">{% trans 'Log out' %}</a>{% endblock %} {% block breadcrumbs %}<div class="breadcrumbs"><a href="../">{% trans 'Home' %}</a> › {% trans 'Password change' %}</div>{% endblock %} {% block title %}{% trans 'Password change' %}{% endblock %} diff --git a/django/contrib/admin/templates/registration/password_reset_form.html b/django/contrib/admin/templates/registration/password_reset_form.html index 423821ba60..d8c7d03f93 100644 --- a/django/contrib/admin/templates/registration/password_reset_form.html +++ b/django/contrib/admin/templates/registration/password_reset_form.html @@ -12,7 +12,7 @@ <p>{% trans "Forgotten your password? Enter your e-mail address below, and we'll reset your password and e-mail the new one to you." %}</p> <form action="" method="post"> -{% if form.email.errors %}{{ form.email.html_error_list }}{% endif %} +{% if form.email.errors %}{{ form.email.errors }}{% endif %} <p><label for="id_email">{% trans 'E-mail address:' %}</label> {{ form.email }} <input type="submit" value="{% trans 'Reset my password' %}" /></p> </form> diff --git a/django/contrib/admin/templates/widget/date_time.html b/django/contrib/admin/templates/widget/date_time.html deleted file mode 100644 index cbd4a2e1c6..0000000000 --- a/django/contrib/admin/templates/widget/date_time.html +++ /dev/null @@ -1,5 +0,0 @@ -{% load i18n %} -<p class="datetime"> - {% trans "Date:" %} {{ bound_field.form_fields.0 }}<br /> - {% trans "Time:" %} {{ bound_field.form_fields.1 }} -</p> diff --git a/django/contrib/admin/templates/widget/default.html b/django/contrib/admin/templates/widget/default.html deleted file mode 100644 index 0af231ddcb..0000000000 --- a/django/contrib/admin/templates/widget/default.html +++ /dev/null @@ -1 +0,0 @@ -{% load admin_modify %}{% output_all bound_field.form_fields %} diff --git a/django/contrib/admin/templates/widget/file.html b/django/contrib/admin/templates/widget/file.html deleted file mode 100644 index e584abf956..0000000000 --- a/django/contrib/admin/templates/widget/file.html +++ /dev/null @@ -1,4 +0,0 @@ -{% load admin_modify i18n %}{% if bound_field.original_value %} -{% trans "Currently:" %} <a href="{{ bound_field.original_url }}" > {{ bound_field.original_value|escape }} </a><br /> -{% trans "Change:" %}{% output_all bound_field.form_fields %} -{% else %} {% output_all bound_field.form_fields %} {% endif %} diff --git a/django/contrib/admin/templates/widget/foreign.html b/django/contrib/admin/templates/widget/foreign.html deleted file mode 100644 index 6b43d044bd..0000000000 --- a/django/contrib/admin/templates/widget/foreign.html +++ /dev/null @@ -1,20 +0,0 @@ -{% load admin_modify adminmedia %} -{% output_all bound_field.form_fields %} -{% if bound_field.raw_id_admin %} - {% if bound_field.field.rel.limit_choices_to %} - <a href="{{ bound_field.related_url }}?{% for limit_choice in bound_field.field.rel.limit_choices_to.items %}{% if not forloop.first %}&{% endif %}{{ limit_choice|join:"=" }}{% endfor %}" class="related-lookup" id="lookup_{{ bound_field.element_id }}" onclick="return showRelatedObjectLookupPopup(this);"> <img src="{% admin_media_prefix %}img/admin/selector-search.gif" width="16" height="16" alt="Lookup"></a> - {% else %} - <a href="{{ bound_field.related_url }}" class="related-lookup" id="lookup_{{ bound_field.element_id }}" onclick="return showRelatedObjectLookupPopup(this);"> <img src="{% admin_media_prefix %}img/admin/selector-search.gif" width="16" height="16" alt="Lookup"></a> - {% endif %} -{% else %} -{% if bound_field.needs_add_label %} - <a href="{{ bound_field.related_url }}add/" class="add-another" id="add_{{ bound_field.element_id }}" onclick="return showAddAnotherPopup(this);"> <img src="{% admin_media_prefix %}img/admin/icon_addlink.gif" width="10" height="10" alt="Add Another"/></a> -{% endif %}{% endif %} -{% if change %} - {% if bound_field.field.primary_key %} - {{ bound_field.original_value }} - {% endif %} - {% if bound_field.raw_id_admin %} - {% if bound_field.existing_display %} <strong>{{ bound_field.existing_display|truncatewords:"14" }}</strong>{% endif %} - {% endif %} -{% endif %} diff --git a/django/contrib/admin/templates/widget/many_to_many.html b/django/contrib/admin/templates/widget/many_to_many.html deleted file mode 100644 index a93aa65f73..0000000000 --- a/django/contrib/admin/templates/widget/many_to_many.html +++ /dev/null @@ -1 +0,0 @@ -{% include "widget/foreign.html" %} diff --git a/django/contrib/admin/templates/widget/one_to_one.html b/django/contrib/admin/templates/widget/one_to_one.html deleted file mode 100644 index a79a12314f..0000000000 --- a/django/contrib/admin/templates/widget/one_to_one.html +++ /dev/null @@ -1,2 +0,0 @@ -{% if add %}{% include "widget/foreign.html" %}{% endif %} -{% if change %}{% if bound_field.existing_display %} <strong>{{ bound_field.existing_display|truncatewords:"14" }}</strong>{% endif %}{% endif %} diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py index 6db59ea338..87fad70ec3 100644 --- a/django/contrib/admin/templatetags/admin_list.py +++ b/django/contrib/admin/templatetags/admin_list.py @@ -71,7 +71,7 @@ pagination = register.inclusion_tag('admin/pagination.html')(pagination) def result_headers(cl): lookup_opts = cl.lookup_opts - for i, field_name in enumerate(lookup_opts.admin.list_display): + for i, field_name in enumerate(cl.list_display): try: f = lookup_opts.get_field(field_name) admin_order_field = None @@ -123,7 +123,7 @@ def _boolean_icon(field_val): def items_for_result(cl, result): first = True pk = cl.lookup_opts.pk.attname - for field_name in cl.lookup_opts.admin.list_display: + for field_name in cl.list_display: row_class = '' try: f = cl.lookup_opts.get_field(field_name) @@ -189,7 +189,7 @@ def items_for_result(cl, result): if force_unicode(result_repr) == '': result_repr = mark_safe(' ') # If list_display_links not defined, add the link tag to the first field - if (first and not cl.lookup_opts.admin.list_display_links) or field_name in cl.lookup_opts.admin.list_display_links: + if (first and not cl.list_display_links) or field_name in cl.list_display_links: table_tag = {True:'th', False:'td'}[first] first = False url = cl.url_for_result(result) @@ -212,8 +212,8 @@ def result_list(cl): result_list = register.inclusion_tag("admin/change_list_results.html")(result_list) def date_hierarchy(cl): - if cl.lookup_opts.admin.date_hierarchy: - field_name = cl.lookup_opts.admin.date_hierarchy + if cl.date_hierarchy: + field_name = cl.date_hierarchy year_field = '%s__year' % field_name month_field = '%s__month' % field_name day_field = '%s__day' % field_name @@ -280,10 +280,6 @@ def search_form(cl): } search_form = register.inclusion_tag('admin/search_form.html')(search_form) -def filter(cl, spec): +def admin_list_filter(cl, spec): return {'title': spec.title(), 'choices' : list(spec.choices(cl))} -filter = register.inclusion_tag('admin/filter.html')(filter) - -def filters(cl): - return {'cl': cl} -filters = register.inclusion_tag('admin/filters.html')(filters) +admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter) diff --git a/django/contrib/admin/templatetags/admin_modify.py b/django/contrib/admin/templatetags/admin_modify.py index ef33bb33b0..25d2d6774a 100644 --- a/django/contrib/admin/templatetags/admin_modify.py +++ b/django/contrib/admin/templatetags/admin_modify.py @@ -1,253 +1,21 @@ from django import template -from django.contrib.admin.views.main import AdminBoundField -from django.template import loader -from django.utils.text import capfirst -from django.utils.encoding import force_unicode -from django.utils.safestring import mark_safe -from django.utils.html import escape -from django.db import models -from django.db.models.fields import Field -from django.db.models.related import BoundRelatedObject -from django.conf import settings -import re register = template.Library() -word_re = re.compile('[A-Z][a-z]+') -absolute_url_re = re.compile(r'^(?:http(?:s)?:/)?/', re.IGNORECASE) - -def class_name_to_underscored(name): - return u'_'.join([s.lower() for s in word_re.findall(name)[:-1]]) - -def include_admin_script(script_path): - """ - Returns an HTML script element for including a script from the admin - media url (or other location if an absolute url is given). - - Example usage:: - - {% include_admin_script "js/calendar.js" %} - - could return:: - - <script type="text/javascript" src="/media/admin/js/calendar.js"> - """ - if not absolute_url_re.match(script_path): - script_path = '%s%s' % (settings.ADMIN_MEDIA_PREFIX, script_path) - return mark_safe(u'<script type="text/javascript" src="%s"></script>' - % script_path) -include_admin_script = register.simple_tag(include_admin_script) - def submit_row(context): opts = context['opts'] change = context['change'] is_popup = context['is_popup'] + save_as = context['save_as'] return { 'onclick_attrib': (opts.get_ordered_objects() and change and 'onclick="submitOrderForm();"' or ''), 'show_delete_link': (not is_popup and context['has_delete_permission'] and (change or context['show_delete'])), - 'show_save_as_new': not is_popup and change and opts.admin.save_as, - 'show_save_and_add_another': not is_popup and (not opts.admin.save_as or context['add']), + 'show_save_as_new': not is_popup and change and save_as, + 'show_save_and_add_another': context['has_add_permission'] and + not is_popup and (not save_as or context['add']), 'show_save_and_continue': not is_popup and context['has_change_permission'], 'show_save': True } submit_row = register.inclusion_tag('admin/submit_line.html', takes_context=True)(submit_row) - -def field_label(bound_field): - class_names = [] - if isinstance(bound_field.field, models.BooleanField): - class_names.append("vCheckboxLabel") - colon = "" - else: - if not bound_field.field.blank: - class_names.append('required') - if not bound_field.first: - class_names.append('inline') - colon = ":" - class_str = class_names and u' class="%s"' % u' '.join(class_names) or u'' - return mark_safe(u'<label for="%s"%s>%s%s</label> ' % - (bound_field.element_id, class_str, - escape(force_unicode(capfirst(bound_field.field.verbose_name))), - colon)) -field_label = register.simple_tag(field_label) - -class FieldWidgetNode(template.Node): - nodelists = {} - default = None - - def __init__(self, bound_field_var): - self.bound_field_var = template.Variable(bound_field_var) - - def get_nodelist(cls, klass): - if klass not in cls.nodelists: - try: - field_class_name = klass.__name__ - template_name = u"widget/%s.html" % class_name_to_underscored(field_class_name) - nodelist = loader.get_template(template_name).nodelist - except template.TemplateDoesNotExist: - super_klass = bool(klass.__bases__) and klass.__bases__[0] or None - if super_klass and super_klass != Field: - nodelist = cls.get_nodelist(super_klass) - else: - if not cls.default: - cls.default = loader.get_template("widget/default.html").nodelist - nodelist = cls.default - - cls.nodelists[klass] = nodelist - return nodelist - else: - return cls.nodelists[klass] - get_nodelist = classmethod(get_nodelist) - - def render(self, context): - bound_field = self.bound_field_var.resolve(context) - - context.push() - context['bound_field'] = bound_field - - output = self.get_nodelist(bound_field.field.__class__).render(context) - context.pop() - return output - -class FieldWrapper(object): - def __init__(self, field ): - self.field = field - - def needs_header(self): - return not isinstance(self.field, models.AutoField) - - def header_class_attribute(self): - return self.field.blank and mark_safe(' class="optional"') or '' - - def use_raw_id_admin(self): - return isinstance(self.field.rel, (models.ManyToOneRel, models.ManyToManyRel)) \ - and self.field.rel.raw_id_admin - -class FormFieldCollectionWrapper(object): - def __init__(self, field_mapping, fields, index): - self.field_mapping = field_mapping - self.fields = fields - self.bound_fields = [AdminBoundField(field, self.field_mapping, field_mapping['original']) - for field in self.fields] - self.index = index - -class TabularBoundRelatedObject(BoundRelatedObject): - def __init__(self, related_object, field_mapping, original): - super(TabularBoundRelatedObject, self).__init__(related_object, field_mapping, original) - self.field_wrapper_list = [FieldWrapper(field) for field in self.relation.editable_fields()] - - fields = self.relation.editable_fields() - - self.form_field_collection_wrappers = [FormFieldCollectionWrapper(field_mapping, fields, i) - for (i,field_mapping) in self.field_mappings.items() ] - self.original_row_needed = max([fw.use_raw_id_admin() for fw in self.field_wrapper_list]) - self.show_url = original and hasattr(self.relation.opts, 'get_absolute_url') - - def template_name(self): - return "admin/edit_inline_tabular.html" - -class StackedBoundRelatedObject(BoundRelatedObject): - def __init__(self, related_object, field_mapping, original): - super(StackedBoundRelatedObject, self).__init__(related_object, field_mapping, original) - fields = self.relation.editable_fields() - self.field_mappings.fill() - self.form_field_collection_wrappers = [FormFieldCollectionWrapper(field_mapping ,fields, i) - for (i,field_mapping) in self.field_mappings.items()] - self.show_url = original and hasattr(self.relation.opts, 'get_absolute_url') - - def template_name(self): - return "admin/edit_inline_stacked.html" - -class EditInlineNode(template.Node): - def __init__(self, rel_var): - self.rel_var = template.Variable(rel_var) - - def render(self, context): - relation = self.rel_var.resolve(context) - context.push() - if relation.field.rel.edit_inline == models.TABULAR: - bound_related_object_class = TabularBoundRelatedObject - elif relation.field.rel.edit_inline == models.STACKED: - bound_related_object_class = StackedBoundRelatedObject - else: - bound_related_object_class = relation.field.rel.edit_inline - original = context.get('original', None) - bound_related_object = relation.bind(context['form'], original, bound_related_object_class) - context['bound_related_object'] = bound_related_object - t = loader.get_template(bound_related_object.template_name()) - output = t.render(context) - context.pop() - return output - -def output_all(form_fields): - return u''.join([force_unicode(f) for f in form_fields]) -output_all = register.simple_tag(output_all) - -def auto_populated_field_script(auto_pop_fields, change = False): - t = [] - for field in auto_pop_fields: - if change: - t.append(u'document.getElementById("id_%s")._changed = true;' % field.name) - else: - t.append(u'document.getElementById("id_%s").onchange = function() { this._changed = true; };' % field.name) - - add_values = u' + " " + '.join([u'document.getElementById("id_%s").value' % g for g in field.prepopulate_from]) - for f in field.prepopulate_from: - t.append(u'document.getElementById("id_%s").onkeyup = function() {' \ - ' var e = document.getElementById("id_%s");' \ - ' if(!e._changed) { e.value = URLify(%s, %s);} }; ' % ( - f, field.name, add_values, field.max_length)) - return mark_safe(u''.join(t)) -auto_populated_field_script = register.simple_tag(auto_populated_field_script) - -def filter_interface_script_maybe(bound_field): - f = bound_field.field - if f.rel and isinstance(f.rel, models.ManyToManyRel) and f.rel.filter_interface: - return mark_safe(u'<script type="text/javascript">addEvent(window, "load", function(e) {' \ - ' SelectFilter.init("id_%s", "%s", %s, "%s"); });</script>\n' % ( - f.name, escape(f.verbose_name.replace('"', '\\"')), f.rel.filter_interface-1, settings.ADMIN_MEDIA_PREFIX)) - else: - return '' -filter_interface_script_maybe = register.simple_tag(filter_interface_script_maybe) - -def field_widget(parser, token): - bits = token.contents.split() - if len(bits) != 2: - raise template.TemplateSyntaxError, "%s takes 1 argument" % bits[0] - return FieldWidgetNode(bits[1]) -field_widget = register.tag(field_widget) - -def edit_inline(parser, token): - bits = token.contents.split() - if len(bits) != 2: - raise template.TemplateSyntaxError, "%s takes 1 argument" % bits[0] - return EditInlineNode(bits[1]) -edit_inline = register.tag(edit_inline) - -def admin_field_line(context, argument_val): - if isinstance(argument_val, AdminBoundField): - bound_fields = [argument_val] - else: - bound_fields = [bf for bf in argument_val] - add = context['add'] - change = context['change'] - - class_names = ['form-row'] - for bound_field in bound_fields: - for f in bound_field.form_fields: - if f.errors(): - class_names.append('errors') - break - - # Assumes BooleanFields won't be stacked next to each other! - if isinstance(bound_fields[0].field, models.BooleanField): - class_names.append('checkbox-row') - - return { - 'add': context['add'], - 'change': context['change'], - 'bound_fields': bound_fields, - 'class_names': " ".join(class_names), - } -admin_field_line = register.inclusion_tag('admin/field_line.html', takes_context=True)(admin_field_line) diff --git a/django/contrib/admin/templatetags/adminapplist.py b/django/contrib/admin/templatetags/adminapplist.py deleted file mode 100644 index bf394e2099..0000000000 --- a/django/contrib/admin/templatetags/adminapplist.py +++ /dev/null @@ -1,81 +0,0 @@ -from django import template -from django.db.models import get_models -from django.utils.encoding import force_unicode -from django.utils.safestring import mark_safe - -register = template.Library() - -class AdminApplistNode(template.Node): - def __init__(self, varname): - self.varname = varname - - def render(self, context): - from django.db import models - from django.utils.text import capfirst - app_list = [] - user = context['user'] - - for app in models.get_apps(): - # Determine the app_label. - app_models = get_models(app) - if not app_models: - continue - app_label = app_models[0]._meta.app_label - - has_module_perms = user.has_module_perms(app_label) - - if has_module_perms: - model_list = [] - for m in app_models: - if m._meta.admin: - perms = { - 'add': user.has_perm("%s.%s" % (app_label, m._meta.get_add_permission())), - 'change': user.has_perm("%s.%s" % (app_label, m._meta.get_change_permission())), - 'delete': user.has_perm("%s.%s" % (app_label, m._meta.get_delete_permission())), - } - - # Check whether user has any perm for this module. - # If so, add the module to the model_list. - if True in perms.values(): - model_list.append({ - 'name': force_unicode(capfirst(m._meta.verbose_name_plural)), - 'admin_url': mark_safe(u'%s/%s/' % (force_unicode(app_label), m.__name__.lower())), - 'perms': perms, - }) - - if model_list: - # Sort using verbose decorate-sort-undecorate pattern - # instead of key argument to sort() for python 2.3 compatibility - decorated = [(x['name'], x) for x in model_list] - decorated.sort() - model_list = [x for key, x in decorated] - - app_list.append({ - 'name': app_label.title(), - 'has_module_perms': has_module_perms, - 'models': model_list, - }) - context[self.varname] = app_list - return '' - -def get_admin_app_list(parser, token): - """ - Returns a list of installed applications and models for which the current user - has at least one permission. - - Syntax:: - - {% get_admin_app_list as [context_var_containing_app_list] %} - - Example usage:: - - {% get_admin_app_list as admin_app_list %} - """ - tokens = token.contents.split() - if len(tokens) < 3: - raise template.TemplateSyntaxError, "'%s' tag requires two arguments" % tokens[0] - if tokens[1] != 'as': - raise template.TemplateSyntaxError, "First argument to '%s' tag must be 'as'" % tokens[0] - return AdminApplistNode(tokens[2]) - -register.tag('get_admin_app_list', get_admin_app_list) diff --git a/django/contrib/admin/urls.py b/django/contrib/admin/urls.py deleted file mode 100644 index 436e9b7b23..0000000000 --- a/django/contrib/admin/urls.py +++ /dev/null @@ -1,43 +0,0 @@ -from django.conf import settings -from django.conf.urls.defaults import * - -if settings.USE_I18N: - i18n_view = 'django.views.i18n.javascript_catalog' -else: - i18n_view = 'django.views.i18n.null_javascript_catalog' - -urlpatterns = patterns('', - ('^$', 'django.contrib.admin.views.main.index'), - ('^r/', include('django.conf.urls.shortcut')), - ('^jsi18n/$', i18n_view, {'packages': 'django.conf'}), - ('^logout/$', 'django.contrib.auth.views.logout'), - ('^password_change/$', 'django.contrib.auth.views.password_change'), - ('^password_change/done/$', 'django.contrib.auth.views.password_change_done'), - ('^template_validator/$', 'django.contrib.admin.views.template.template_validator'), - - # Documentation - ('^doc/$', 'django.contrib.admin.views.doc.doc_index'), - ('^doc/bookmarklets/$', 'django.contrib.admin.views.doc.bookmarklets'), - ('^doc/tags/$', 'django.contrib.admin.views.doc.template_tag_index'), - ('^doc/filters/$', 'django.contrib.admin.views.doc.template_filter_index'), - ('^doc/views/$', 'django.contrib.admin.views.doc.view_index'), - ('^doc/views/(?P<view>[^/]+)/$', 'django.contrib.admin.views.doc.view_detail'), - ('^doc/models/$', 'django.contrib.admin.views.doc.model_index'), - ('^doc/models/(?P<app_label>[^\.]+)\.(?P<model_name>[^/]+)/$', 'django.contrib.admin.views.doc.model_detail'), -# ('^doc/templates/$', 'django.views.admin.doc.template_index'), - ('^doc/templates/(?P<template>.*)/$', 'django.contrib.admin.views.doc.template_detail'), - - # "Add user" -- a special-case view - ('^auth/user/add/$', 'django.contrib.admin.views.auth.user_add_stage'), - # "Change user password" -- another special-case view - ('^auth/user/(\d+)/password/$', 'django.contrib.admin.views.auth.user_change_password'), - - # Add/change/delete/history - ('^([^/]+)/([^/]+)/$', 'django.contrib.admin.views.main.change_list'), - ('^([^/]+)/([^/]+)/add/$', 'django.contrib.admin.views.main.add_stage'), - ('^([^/]+)/([^/]+)/(.+)/history/$', 'django.contrib.admin.views.main.history'), - ('^([^/]+)/([^/]+)/(.+)/delete/$', 'django.contrib.admin.views.main.delete_stage'), - ('^([^/]+)/([^/]+)/(.+)/$', 'django.contrib.admin.views.main.change_stage'), -) - -del i18n_view diff --git a/django/contrib/admin/util.py b/django/contrib/admin/util.py new file mode 100644 index 0000000000..6a585b2919 --- /dev/null +++ b/django/contrib/admin/util.py @@ -0,0 +1,139 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.db import models +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 _ + + +def quote(s): + """ + Ensure that primary key values do not confuse the admin URLs by escaping + any '/', '_' and ':' characters. Similar to urllib.quote, except that the + quoting is slightly different so that it doesn't get automatically + unquoted by the Web browser. + """ + if not isinstance(s, basestring): + return s + res = list(s) + for i in range(len(res)): + c = res[i] + if c in """:/_#?;@&=+$,"<>%\\""": + res[i] = '_%02X' % ord(c) + return ''.join(res) + +def unquote(s): + """ + Undo the effects of quote(). Based heavily on urllib.unquote(). + """ + mychr = chr + myatoi = int + list = s.split('_') + res = [list[0]] + myappend = res.append + del list[0] + for item in list: + if item[1:2]: + try: + myappend(mychr(myatoi(item[:2], 16)) + item[2:]) + except ValueError: + myappend('_' + item) + else: + myappend('_' + item) + return "".join(res) + +def _nest_help(obj, depth, val): + current = obj + for i in range(depth): + 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." + nh = _nest_help # Bind to local variable for performance + if current_depth > 16: + return # Avoid recursing too deep. + opts_seen = [] + for related in opts.get_all_related_objects(): + has_admin = related.model in admin_site._registry + if related.opts in opts_seen: + continue + opts_seen.append(related.opts) + rel_opts_name = related.get_accessor_name() + if isinstance(related.field.rel, models.OneToOneRel): + try: + sub_obj = getattr(obj, rel_opts_name) + except ObjectDoesNotExist: + pass + else: + if has_admin: + p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission()) + if not user.has_perm(p): + perms_needed.add(related.opts.verbose_name) + # We don't care about populating deleted_objects now. + continue + if related.field.rel.edit_inline or not has_admin: + # Don't display link to edit, because it either has no + # admin or is edited inline. + nh(deleted_objects, current_depth, [mark_safe(u'%s: %s' % (force_unicode(capfirst(related.opts.verbose_name)), sub_obj)), []]) + else: + # Display a link to the admin page. + nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' % + (escape(force_unicode(capfirst(related.opts.verbose_name))), + related.opts.app_label, + related.opts.object_name.lower(), + sub_obj._get_pk_val(), sub_obj)), []]) + get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site) + else: + has_related_objs = False + for sub_obj in getattr(obj, rel_opts_name).all(): + has_related_objs = True + if related.field.rel.edit_inline or not has_admin: + # Don't display link to edit, because it either has no + # admin or is edited inline. + nh(deleted_objects, current_depth, [u'%s: %s' % (force_unicode(capfirst(related.opts.verbose_name)), escape(sub_obj)), []]) + else: + # Display a link to the admin page. + nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' % \ + (escape(force_unicode(capfirst(related.opts.verbose_name))), related.opts.app_label, related.opts.object_name.lower(), sub_obj._get_pk_val(), 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 + # permission to delete them, add the missing perm to perms_needed. + if has_admin and has_related_objs: + p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission()) + if not user.has_perm(p): + perms_needed.add(related.opts.verbose_name) + for related in opts.get_all_related_many_to_many_objects(): + has_admin = related.model in admin_site._registry + if related.opts in opts_seen: + continue + opts_seen.append(related.opts) + rel_opts_name = related.get_accessor_name() + has_related_objs = False + + # related.get_accessor_name() could return None for symmetrical relationships + if rel_opts_name: + rel_objs = getattr(obj, rel_opts_name, None) + if rel_objs: + has_related_objs = True + + if has_related_objs: + for sub_obj in rel_objs.all(): + if related.field.rel.edit_inline or not has_admin: + # Don't display link to edit, because it either has no + # admin or is edited inline. + nh(deleted_objects, current_depth, [_('One or more %(fieldname)s in %(name)s: %(obj)s') % \ + {'fieldname': force_unicode(related.field.verbose_name), 'name': force_unicode(related.opts.verbose_name), 'obj': escape(sub_obj)}, []]) + else: + # Display a link to the admin page. + nh(deleted_objects, current_depth, [ + mark_safe((_('One or more %(fieldname)s in %(name)s:') % {'fieldname': escape(force_unicode(related.field.verbose_name)), 'name': escape(force_unicode(related.opts.verbose_name))}) + \ + (u' <a href="../../../../%s/%s/%s/">%s</a>' % \ + (related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []]) + # 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) diff --git a/django/contrib/admin/validation.py b/django/contrib/admin/validation.py new file mode 100644 index 0000000000..f640cc5100 --- /dev/null +++ b/django/contrib/admin/validation.py @@ -0,0 +1,280 @@ + +from django.core.exceptions import ImproperlyConfigured +from django.db import models +from django.newforms.models import BaseModelForm, BaseModelFormSet, fields_for_model +from django.contrib.admin.options import flatten_fieldsets, BaseModelAdmin +from django.contrib.admin.options import HORIZONTAL, VERTICAL + +def validate(cls, model): + """ + Does basic ModelAdmin option validation. Calls custom validation + classmethod in the end if it is provided in cls. The signature of the + custom validation classmethod should be: def validate(cls, model). + """ + opts = model._meta + _validate_base(cls, model) + + # currying is expensive, use wrappers instead + def _check_istuplew(label, obj): + _check_istuple(cls, label, obj) + + def _check_isdictw(label, obj): + _check_isdict(cls, label, obj) + + def _check_field_existsw(label, field): + return _check_field_exists(cls, model, opts, label, field) + + def _check_attr_existsw(label, field): + return _check_attr_exists(cls, model, opts, label, field) + + # list_display + if hasattr(cls, 'list_display'): + _check_istuplew('list_display', cls.list_display) + for idx, field in enumerate(cls.list_display): + f = _check_attr_existsw("list_display[%d]" % idx, field) + if isinstance(f, models.ManyToManyField): + raise ImproperlyConfigured("`%s.list_display[%d]`, `%s` is a " + "ManyToManyField which is not supported." + % (cls.__name__, idx, field)) + + # list_display_links + if hasattr(cls, 'list_display_links'): + _check_istuplew('list_display_links', cls.list_display_links) + for idx, field in enumerate(cls.list_display_links): + _check_attr_existsw('list_display_links[%d]' % idx, field) + if field not in cls.list_display: + raise ImproperlyConfigured("`%s.list_display_links[%d]`" + "refers to `%s` which is not defined in `list_display`." + % (cls.__name__, idx, field)) + + # list_filter + if hasattr(cls, 'list_filter'): + _check_istuplew('list_filter', cls.list_filter) + for idx, field in enumerate(cls.list_filter): + _check_field_existsw('list_filter[%d]' % idx, field) + + # list_per_page = 100 + 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__) + + # search_fields = () + if hasattr(cls, 'search_fields'): + _check_istuplew('search_fields', cls.search_fields) + + # date_hierarchy = None + if cls.date_hierarchy: + f = _check_field_existsw('date_hierarchy', cls.date_hierarchy) + if not isinstance(f, (models.DateField, models.DateTimeField)): + raise ImproperlyConfigured("`%s.date_hierarchy is " + "neither an instance of DateField nor DateTimeField." + % cls.__name__) + + # ordering = None + if cls.ordering: + _check_istuplew('ordering', cls.ordering) + for idx, field in enumerate(cls.ordering): + if field == '?' and len(cls.ordering) != 1: + raise ImproperlyConfigured("`%s.ordering` has the random " + "ordering marker `?`, but contains other fields as " + "well. Please either remove `?` or the other fields." + % cls.__name__) + if field == '?': + continue + if field.startswith('-'): + field = field[1:] + # Skip ordering in the format field1__field2 (FIXME: checking + # this format would be nice, but it's a little fiddly). + if '__' in field: + continue + _check_field_existsw('ordering[%d]' % idx, field) + + # list_select_related = False + # save_as = False + # save_on_top = False + for attr in ('list_select_related', 'save_as', 'save_on_top'): + if not isinstance(getattr(cls, attr), bool): + raise ImproperlyConfigured("`%s.%s` should be a boolean." + % (cls.__name__, attr)) + + # inlines = [] + if hasattr(cls, 'inlines'): + _check_istuplew('inlines', cls.inlines) + for idx, inline in enumerate(cls.inlines): + if not issubclass(inline, BaseModelAdmin): + raise ImproperlyConfigured("`%s.inlines[%d]` does not inherit " + "from BaseModelAdmin." % (cls.__name__, idx)) + if not inline.model: + raise ImproperlyConfigured("`model` is a required attribute " + "of `%s.inlines[%d]`." % (cls.__name__, idx)) + if not issubclass(inline.model, models.Model): + raise ImproperlyConfigured("`%s.inlines[%d].model` does not " + "inherit from models.Model." % (cls.__name__, idx)) + _validate_base(inline, inline.model) + _validate_inline(inline) + +def _validate_inline(cls): + # model is already verified to exist and be a Model + if cls.fk_name: # default value is None + f = _check_field_exists(cls, cls.model, cls.model._meta, + 'fk_name', cls.fk_name) + if not isinstance(f, models.ForeignKey): + raise ImproperlyConfigured("`%s.fk_name is not an instance of " + "models.ForeignKey." % cls.__name__) + # extra = 3 + # max_num = 0 + for attr in ('extra', 'max_num'): + if not isinstance(getattr(cls, attr), int): + raise ImproperlyConfigured("`%s.%s` should be a integer." + % (cls.__name__, attr)) + + # formset + if hasattr(cls, 'formset') and not issubclass(cls.formset, BaseModelFormSet): + raise ImproperlyConfigured("`%s.formset` does not inherit from " + "BaseModelFormSet." % cls.__name__) + +def _validate_base(cls, model): + opts = model._meta + # currying is expensive, use wrappers instead + def _check_istuplew(label, obj): + _check_istuple(cls, label, obj) + + def _check_isdictw(label, obj): + _check_isdict(cls, label, obj) + + def _check_field_existsw(label, field): + return _check_field_exists(cls, model, opts, label, field) + + def _check_form_field_existsw(label, field): + return _check_form_field_exists(cls, model, opts, label, field) + + # raw_id_fields + if hasattr(cls, 'raw_id_fields'): + _check_istuplew('raw_id_fields', cls.raw_id_fields) + for idx, field in enumerate(cls.raw_id_fields): + f = _check_field_existsw('raw_id_fields', field) + if not isinstance(f, (models.ForeignKey, models.ManyToManyField)): + raise ImproperlyConfigured("`%s.raw_id_fields[%d]`, `%s` must " + "be either a ForeignKey or ManyToManyField." + % (cls.__name__, idx, field)) + + # fields + if cls.fields: # default value is None + _check_istuplew('fields', cls.fields) + for field in cls.fields: + _check_form_field_existsw('fields', field) + if cls.fieldsets: + raise ImproperlyConfigured('Both fieldsets and fields are specified in %s.' % cls.__name__) + + # fieldsets + if cls.fieldsets: # default value is None + _check_istuplew('fieldsets', cls.fieldsets) + for idx, fieldset in enumerate(cls.fieldsets): + _check_istuplew('fieldsets[%d]' % idx, fieldset) + if len(fieldset) != 2: + raise ImproperlyConfigured("`%s.fieldsets[%d]` does not " + "have exactly two elements." % (cls.__name__, idx)) + _check_isdictw('fieldsets[%d][1]' % idx, fieldset[1]) + if 'fields' not in fieldset[1]: + raise ImproperlyConfigured("`fields` key is required in " + "%s.fieldsets[%d][1] field options dict." + % (cls.__name__, idx)) + for field in flatten_fieldsets(cls.fieldsets): + _check_form_field_existsw("fieldsets[%d][1]['fields']" % idx, field) + + # form + if hasattr(cls, 'form') and not issubclass(cls.form, BaseModelForm): + raise ImproperlyConfigured("%s.form does not inherit from " + "BaseModelForm." % cls.__name__) + + # filter_vertical + if hasattr(cls, 'filter_vertical'): + _check_istuplew('filter_vertical', cls.filter_vertical) + for idx, field in enumerate(cls.filter_vertical): + f = _check_field_existsw('filter_vertical', field) + if not isinstance(f, models.ManyToManyField): + raise ImproperlyConfigured("`%s.filter_vertical[%d]` must be " + "a ManyToManyField." % (cls.__name__, idx)) + + # filter_horizontal + if hasattr(cls, 'filter_horizontal'): + _check_istuplew('filter_horizontal', cls.filter_horizontal) + for idx, field in enumerate(cls.filter_horizontal): + f = _check_field_existsw('filter_horizontal', field) + if not isinstance(f, models.ManyToManyField): + raise ImproperlyConfigured("`%s.filter_horizontal[%d]` must be " + "a ManyToManyField." % (cls.__name__, idx)) + + # radio_fields + if hasattr(cls, 'radio_fields'): + _check_isdictw('radio_fields', cls.radio_fields) + for field, val in cls.radio_fields.items(): + f = _check_field_existsw('radio_fields', field) + if not (isinstance(f, models.ForeignKey) or f.choices): + raise ImproperlyConfigured("`%s.radio_fields['%s']` " + "is neither an instance of ForeignKey nor does " + "have choices set." % (cls.__name__, field)) + if not val in (HORIZONTAL, VERTICAL): + raise ImproperlyConfigured("`%s.radio_fields['%s']` " + "is neither admin.HORIZONTAL nor admin.VERTICAL." + % (cls.__name__, field)) + + # prepopulated_fields + if hasattr(cls, 'prepopulated_fields'): + _check_isdictw('prepopulated_fields', cls.prepopulated_fields) + for field, val in cls.prepopulated_fields.items(): + f = _check_field_existsw('prepopulated_fields', field) + if isinstance(f, (models.DateTimeField, models.ForeignKey, + models.ManyToManyField)): + raise ImproperlyConfigured("`%s.prepopulated_fields['%s']` " + "is either a DateTimeField, ForeignKey or " + "ManyToManyField. This isn't allowed." + % (cls.__name__, field)) + _check_istuplew("prepopulated_fields['%s']" % field, val) + for idx, f in enumerate(val): + _check_field_existsw("prepopulated_fields['%s'][%d]" + % (f, idx), f) + +def _check_istuple(cls, label, obj): + if not isinstance(obj, (list, tuple)): + raise ImproperlyConfigured("`%s.%s` must be a " + "list or tuple." % (cls.__name__, label)) + +def _check_isdict(cls, label, obj): + if not isinstance(obj, dict): + raise ImproperlyConfigured("`%s.%s` must be a dictionary." + % (cls.__name__, label)) + +def _check_field_exists(cls, model, opts, label, field): + try: + return opts.get_field(field) + except models.FieldDoesNotExist: + raise ImproperlyConfigured("`%s.%s` refers to " + "field `%s` that is missing from model `%s`." + % (cls.__name__, label, field, model.__name__)) + +def _check_form_field_exists(cls, model, opts, label, field): + if hasattr(cls.form, 'base_fields'): + try: + cls.form.base_fields[field] + except KeyError: + raise ImproperlyConfigured("`%s.%s` refers to field `%s` that " + "is missing from the form." % (cls.__name__, label, field)) + else: + fields = fields_for_model(model) + try: + fields[field] + except KeyError: + raise ImproperlyConfigured("`%s.%s` refers to field `%s` that " + "is missing from the form." % (cls.__name__, label, field)) + +def _check_attr_exists(cls, model, opts, label, field): + try: + return opts.get_field(field) + except models.FieldDoesNotExist: + if not hasattr(model, field): + raise ImproperlyConfigured("`%s.%s` refers to " + "`%s` that is neither a field, method or property " + "of model `%s`." + % (cls.__name__, label, field, model.__name__)) + return getattr(model, field) diff --git a/django/contrib/admin/views/auth.py b/django/contrib/admin/views/auth.py deleted file mode 100644 index 0c8104831b..0000000000 --- a/django/contrib/admin/views/auth.py +++ /dev/null @@ -1,78 +0,0 @@ -from django.contrib.admin.views.decorators import staff_member_required -from django.contrib.auth.forms import UserCreationForm, AdminPasswordChangeForm -from django.contrib.auth.models import User -from django.core.exceptions import PermissionDenied -from django import oldforms, template -from django.shortcuts import render_to_response, get_object_or_404 -from django.http import HttpResponseRedirect -from django.utils.html import escape -from django.utils.translation import ugettext as _ - -def user_add_stage(request): - if not request.user.has_perm('auth.change_user'): - raise PermissionDenied - manipulator = UserCreationForm() - if request.method == 'POST': - new_data = request.POST.copy() - errors = manipulator.get_validation_errors(new_data) - if not errors: - new_user = manipulator.save(new_data) - msg = _('The %(name)s "%(obj)s" was added successfully.') % {'name': 'user', 'obj': new_user} - if "_addanother" in request.POST: - request.user.message_set.create(message=msg) - return HttpResponseRedirect(request.path) - else: - request.user.message_set.create(message=msg + ' ' + _("You may edit it again below.")) - return HttpResponseRedirect('../%s/' % new_user.id) - else: - errors = new_data = {} - form = oldforms.FormWrapper(manipulator, new_data, errors) - return render_to_response('admin/auth/user/add_form.html', { - 'title': _('Add user'), - 'form': form, - 'is_popup': '_popup' in request.REQUEST, - 'add': True, - 'change': False, - 'has_delete_permission': False, - 'has_change_permission': True, - 'has_file_field': False, - 'has_absolute_url': False, - 'auto_populated_fields': (), - 'bound_field_sets': (), - 'first_form_field_id': 'id_username', - 'opts': User._meta, - 'username_help_text': User._meta.get_field('username').help_text, - }, context_instance=template.RequestContext(request)) -user_add_stage = staff_member_required(user_add_stage) - -def user_change_password(request, id): - if not request.user.has_perm('auth.change_user'): - raise PermissionDenied - user = get_object_or_404(User, pk=id) - manipulator = AdminPasswordChangeForm(user) - if request.method == 'POST': - new_data = request.POST.copy() - errors = manipulator.get_validation_errors(new_data) - if not errors: - new_user = manipulator.save(new_data) - msg = _('Password changed successfully.') - request.user.message_set.create(message=msg) - return HttpResponseRedirect('..') - else: - errors = new_data = {} - form = oldforms.FormWrapper(manipulator, new_data, errors) - return render_to_response('admin/auth/user/change_password.html', { - 'title': _('Change password: %s') % escape(user.username), - 'form': form, - 'is_popup': '_popup' in request.REQUEST, - 'add': True, - 'change': False, - 'has_delete_permission': False, - 'has_change_permission': True, - 'has_absolute_url': False, - 'first_form_field_id': 'id_password1', - 'opts': User._meta, - 'original': user, - 'show_save': True, - }, context_instance=template.RequestContext(request)) -user_change_password = staff_member_required(user_change_password) diff --git a/django/contrib/admin/views/decorators.py b/django/contrib/admin/views/decorators.py index c27b2e7427..57517cc821 100644 --- a/django/contrib/admin/views/decorators.py +++ b/django/contrib/admin/views/decorators.py @@ -12,7 +12,6 @@ from django.contrib.auth.models import User from django.contrib.auth import authenticate, login from django.shortcuts import render_to_response from django.utils.translation import ugettext_lazy, ugettext as _ -from django.utils.safestring import mark_safe ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. Note that both fields are case-sensitive.") LOGIN_FORM_KEY = 'this_is_the_login_form' diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py index f1f620ed9e..926270cc68 100644 --- a/django/contrib/admin/views/main.py +++ b/django/contrib/admin/views/main.py @@ -1,20 +1,13 @@ -from django import oldforms, template -from django.conf import settings from django.contrib.admin.filterspecs import FilterSpec -from django.contrib.admin.views.decorators import staff_member_required -from django.views.decorators.cache import never_cache -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist, PermissionDenied +from django.contrib.admin.options import IncorrectLookupParameters +from django.contrib.admin.util import quote from django.core.paginator import Paginator, InvalidPage -from django.shortcuts import get_object_or_404, render_to_response from django.db import models from django.db.models.query import QuerySet -from django.http import Http404, HttpResponse, HttpResponseRedirect -from django.utils.html import escape -from django.utils.text import capfirst, get_text_list from django.utils.encoding import force_unicode, smart_str -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext from django.utils.safestring import mark_safe +from django.utils.http import urlencode import operator try: @@ -22,13 +15,6 @@ try: except NameError: from sets import Set as set # Python 2.3 fallback -from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION -if not LogEntry._meta.installed: - raise ImproperlyConfigured, "You'll need to put 'django.contrib.admin' in your INSTALLED_APPS setting before you can use the admin application." - -if 'django.core.context_processors.auth' not in settings.TEMPLATE_CONTEXT_PROCESSORS: - raise ImproperlyConfigured, "You'll need to put 'django.core.context_processors.auth' in your TEMPLATE_CONTEXT_PROCESSORS setting before you can use the admin application." - # The system will display a "Show all" link on the change list only if the # total result count is less than or equal to this setting. MAX_SHOW_ALL_ALLOWED = 200 @@ -45,523 +31,20 @@ ERROR_FLAG = 'e' # Text to display within change-list table cells if the value is blank. EMPTY_CHANGELIST_VALUE = '(None)' -use_raw_id_admin = lambda field: isinstance(field.rel, (models.ManyToOneRel, models.ManyToManyRel)) and field.rel.raw_id_admin - -class IncorrectLookupParameters(Exception): - pass - -def quote(s): - """ - Ensure that primary key values do not confuse the admin URLs by escaping - any '/', '_' and ':' characters. Similar to urllib.quote, except that the - quoting is slightly different so that it doesn't get automatically - unquoted by the Web browser. - """ - if type(s) != type(''): - return s - res = list(s) - for i in range(len(res)): - c = res[i] - if c in ':/_': - res[i] = '_%02X' % ord(c) - return ''.join(res) - -def unquote(s): - """ - Undo the effects of quote(). Based heavily on urllib.unquote(). - """ - mychr = chr - myatoi = int - list = s.split('_') - res = [list[0]] - myappend = res.append - del list[0] - for item in list: - if item[1:2]: - try: - myappend(mychr(myatoi(item[:2], 16)) + item[2:]) - except ValueError: - myappend('_' + item) - else: - myappend('_' + item) - return "".join(res) - -def get_javascript_imports(opts, auto_populated_fields, field_sets): -# Put in any necessary JavaScript imports. - js = ['js/core.js', 'js/admin/RelatedObjectLookups.js'] - if auto_populated_fields: - js.append('js/urlify.js') - if opts.has_field_type(models.DateTimeField) or opts.has_field_type(models.TimeField) or opts.has_field_type(models.DateField): - js.extend(['js/calendar.js', 'js/admin/DateTimeShortcuts.js']) - if opts.get_ordered_objects(): - js.extend(['js/getElementsBySelector.js', 'js/dom-drag.js' , 'js/admin/ordering.js']) - if opts.admin.js: - js.extend(opts.admin.js) - seen_collapse = False - for field_set in field_sets: - if not seen_collapse and 'collapse' in field_set.classes: - seen_collapse = True - js.append('js/admin/CollapsedFieldsets.js') - - for field_line in field_set: - try: - for f in field_line: - if f.rel and isinstance(f, models.ManyToManyField) and f.rel.filter_interface: - js.extend(['js/SelectBox.js' , 'js/SelectFilter2.js']) - raise StopIteration - except StopIteration: - break - return js - -class AdminBoundField(object): - def __init__(self, field, field_mapping, original): - self.field = field - self.original = original - self.form_fields = [field_mapping[name] for name in self.field.get_manipulator_field_names('')] - self.element_id = self.form_fields[0].get_id() - self.has_label_first = not isinstance(self.field, models.BooleanField) - self.raw_id_admin = use_raw_id_admin(field) - self.is_date_time = isinstance(field, models.DateTimeField) - self.is_file_field = isinstance(field, models.FileField) - self.needs_add_label = field.rel and (isinstance(field.rel, models.ManyToOneRel) or isinstance(field.rel, models.ManyToManyRel)) and field.rel.to._meta.admin - self.hidden = isinstance(self.field, models.AutoField) - self.first = False - - classes = [] - if self.raw_id_admin: - classes.append('nowrap') - if max([bool(f.errors()) for f in self.form_fields]): - classes.append('error') - if classes: - self.cell_class_attribute = u' class="%s" ' % ' '.join(classes) - self._repr_filled = False - - if field.rel: - self.related_url = mark_safe(u'../../../%s/%s/' - % (field.rel.to._meta.app_label, - field.rel.to._meta.object_name.lower())) - - def original_value(self): - if self.original: - return self.original.__dict__[self.field.attname] - - def existing_display(self): - try: - return self._display - except AttributeError: - if isinstance(self.field.rel, models.ManyToOneRel): - self._display = force_unicode(getattr(self.original, self.field.name), strings_only=True) - elif isinstance(self.field.rel, models.ManyToManyRel): - self._display = u", ".join([force_unicode(obj) for obj in getattr(self.original, self.field.name).all()]) - return self._display - - def __repr__(self): - return repr(self.__dict__) - - def html_error_list(self): - return mark_safe(" ".join([form_field.html_error_list() for form_field in self.form_fields if form_field.errors])) - - def original_url(self): - if self.is_file_field and self.original and self.field.attname: - url_method = getattr(self.original, 'get_%s_url' % self.field.attname) - if callable(url_method): - return url_method() - return '' - -class AdminBoundFieldLine(object): - def __init__(self, field_line, field_mapping, original): - self.bound_fields = [field.bind(field_mapping, original, AdminBoundField) for field in field_line] - for bound_field in self: - bound_field.first = True - break - - def __iter__(self): - for bound_field in self.bound_fields: - yield bound_field - - def __len__(self): - return len(self.bound_fields) - -class AdminBoundFieldSet(object): - def __init__(self, field_set, field_mapping, original): - self.name = field_set.name - self.classes = field_set.classes - self.description = field_set.description - self.bound_field_lines = [field_line.bind(field_mapping, original, AdminBoundFieldLine) for field_line in field_set] - - def __iter__(self): - for bound_field_line in self.bound_field_lines: - yield bound_field_line - - def __len__(self): - return len(self.bound_field_lines) - -def render_change_form(model, manipulator, context, add=False, change=False, form_url=''): - opts = model._meta - app_label = opts.app_label - auto_populated_fields = [f for f in opts.fields if f.prepopulate_from] - field_sets = opts.admin.get_field_sets(opts) - original = getattr(manipulator, 'original_object', None) - bound_field_sets = [field_set.bind(context['form'], original, AdminBoundFieldSet) for field_set in field_sets] - first_form_field_id = bound_field_sets[0].bound_field_lines[0].bound_fields[0].form_fields[0].get_id(); - ordered_objects = opts.get_ordered_objects() - inline_related_objects = opts.get_followed_related_objects(manipulator.follow) - extra_context = { - 'add': add, - 'change': change, - 'has_delete_permission': context['perms'][app_label][opts.get_delete_permission()], - 'has_change_permission': context['perms'][app_label][opts.get_change_permission()], - 'has_file_field': opts.has_field_type(models.FileField), - 'has_absolute_url': hasattr(model, 'get_absolute_url'), - 'auto_populated_fields': auto_populated_fields, - 'bound_field_sets': bound_field_sets, - 'first_form_field_id': first_form_field_id, - 'javascript_imports': get_javascript_imports(opts, auto_populated_fields, field_sets), - 'ordered_objects': ordered_objects, - 'inline_related_objects': inline_related_objects, - 'form_url': mark_safe(form_url), - 'opts': opts, - 'content_type_id': ContentType.objects.get_for_model(model).id, - } - context.update(extra_context) - return render_to_response([ - "admin/%s/%s/change_form.html" % (app_label, opts.object_name.lower()), - "admin/%s/change_form.html" % app_label, - "admin/change_form.html"], context_instance=context) - -def index(request): - return render_to_response('admin/index.html', {'title': _('Site administration')}, context_instance=template.RequestContext(request)) -index = staff_member_required(never_cache(index)) - -def add_stage(request, app_label, model_name, show_delete=False, form_url='', post_url=None, post_url_continue='../%s/', object_id_override=None): - model = models.get_model(app_label, model_name) - if model is None: - raise Http404("App %r, model %r, not found" % (app_label, model_name)) - opts = model._meta - - if not request.user.has_perm(app_label + '.' + opts.get_add_permission()): - raise PermissionDenied - - if post_url is None: - if request.user.has_perm(app_label + '.' + opts.get_change_permission()): - # redirect to list view - post_url = '../' - else: - # Object list will give 'Permission Denied', so go back to admin home - post_url = '../../../' - - manipulator = model.AddManipulator() - if request.POST: - new_data = request.POST.copy() - - if opts.has_field_type(models.FileField): - new_data.update(request.FILES) - - errors = manipulator.get_validation_errors(new_data) - manipulator.do_html2python(new_data) - - if not errors: - new_object = manipulator.save(new_data) - pk_value = new_object._get_pk_val() - LogEntry.objects.log_action(request.user.id, ContentType.objects.get_for_model(model).id, pk_value, force_unicode(new_object), ADDITION) - msg = _('The %(name)s "%(obj)s" was added successfully.') % {'name': force_unicode(opts.verbose_name), 'obj': force_unicode(new_object)} - # Here, we distinguish between different save types by checking for - # the presence of keys in request.POST. - if "_continue" in request.POST: - request.user.message_set.create(message=msg + ' ' + _("You may edit it again below.")) - if "_popup" in request.POST: - post_url_continue += "?_popup=1" - return HttpResponseRedirect(post_url_continue % pk_value) - if "_popup" in request.POST: - return HttpResponse('<script type="text/javascript">opener.dismissAddAnotherPopup(window, "%s", "%s");</script>' % \ - # escape() calls force_unicode. - (escape(pk_value), escape(new_object))) - elif "_addanother" in request.POST: - request.user.message_set.create(message=msg + ' ' + (_("You may add another %s below.") % force_unicode(opts.verbose_name))) - return HttpResponseRedirect(request.path) - else: - request.user.message_set.create(message=msg) - return HttpResponseRedirect(post_url) - else: - # Add default data. - new_data = manipulator.flatten_data() - - # Override the defaults with GET params, if they exist. - new_data.update(dict(request.GET.items())) - - errors = {} - - # Populate the FormWrapper. - form = oldforms.FormWrapper(manipulator, new_data, errors) - - c = template.RequestContext(request, { - 'title': _('Add %s') % force_unicode(opts.verbose_name), - 'form': form, - 'is_popup': '_popup' in request.REQUEST, - 'show_delete': show_delete, - }) - - if object_id_override is not None: - c['object_id'] = object_id_override - - return render_change_form(model, manipulator, c, add=True) -add_stage = staff_member_required(never_cache(add_stage)) - -def change_stage(request, app_label, model_name, object_id): - model = models.get_model(app_label, model_name) - object_id = unquote(object_id) - if model is None: - raise Http404("App %r, model %r, not found" % (app_label, model_name)) - opts = model._meta - - if not request.user.has_perm(app_label + '.' + opts.get_change_permission()): - raise PermissionDenied - - if request.POST and "_saveasnew" in request.POST: - return add_stage(request, app_label, model_name, form_url='../../add/') - - try: - manipulator = model.ChangeManipulator(object_id) - except model.DoesNotExist: - raise Http404('%s object with primary key %r does not exist' % (model_name, escape(object_id))) - - if request.POST: - new_data = request.POST.copy() - - if opts.has_field_type(models.FileField): - new_data.update(request.FILES) - - errors = manipulator.get_validation_errors(new_data) - manipulator.do_html2python(new_data) - - if not errors: - new_object = manipulator.save(new_data) - pk_value = new_object._get_pk_val() - - # Construct the change message. - change_message = [] - if manipulator.fields_added: - change_message.append(_('Added %s.') % get_text_list(manipulator.fields_added, _('and'))) - if manipulator.fields_changed: - change_message.append(_('Changed %s.') % get_text_list(manipulator.fields_changed, _('and'))) - if manipulator.fields_deleted: - change_message.append(_('Deleted %s.') % get_text_list(manipulator.fields_deleted, _('and'))) - change_message = ' '.join(change_message) - if not change_message: - change_message = _('No fields changed.') - LogEntry.objects.log_action(request.user.id, ContentType.objects.get_for_model(model).id, pk_value, force_unicode(new_object), CHANGE, change_message) - - msg = _('The %(name)s "%(obj)s" was changed successfully.') % {'name': force_unicode(opts.verbose_name), 'obj': force_unicode(new_object)} - if "_continue" in request.POST: - request.user.message_set.create(message=msg + ' ' + _("You may edit it again below.")) - if '_popup' in request.REQUEST: - return HttpResponseRedirect(request.path + "?_popup=1") - else: - return HttpResponseRedirect(request.path) - elif "_saveasnew" in request.POST: - request.user.message_set.create(message=_('The %(name)s "%(obj)s" was added successfully. You may edit it again below.') % {'name': force_unicode(opts.verbose_name), 'obj': force_unicode(new_object)}) - return HttpResponseRedirect("../%s/" % pk_value) - elif "_addanother" in request.POST: - request.user.message_set.create(message=msg + ' ' + (_("You may add another %s below.") % force_unicode(opts.verbose_name))) - return HttpResponseRedirect("../add/") - else: - request.user.message_set.create(message=msg) - return HttpResponseRedirect("../") - else: - # Populate new_data with a "flattened" version of the current data. - new_data = manipulator.flatten_data() - - # TODO: do this in flatten_data... - # If the object has ordered objects on its admin page, get the existing - # order and flatten it into a comma-separated list of IDs. - - id_order_list = [] - for rel_obj in opts.get_ordered_objects(): - id_order_list.extend(getattr(manipulator.original_object, 'get_%s_order' % rel_obj.object_name.lower())()) - if id_order_list: - new_data['order_'] = ','.join(map(str, id_order_list)) - errors = {} - - # Populate the FormWrapper. - form = oldforms.FormWrapper(manipulator, new_data, errors) - form.original = manipulator.original_object - form.order_objects = [] - - #TODO Should be done in flatten_data / FormWrapper construction - for related in opts.get_followed_related_objects(): - wrt = related.opts.order_with_respect_to - if wrt and wrt.rel and wrt.rel.to == opts: - func = getattr(manipulator.original_object, 'get_%s_list' % - related.get_accessor_name()) - orig_list = func() - form.order_objects.extend(orig_list) - - c = template.RequestContext(request, { - 'title': _('Change %s') % force_unicode(opts.verbose_name), - 'form': form, - 'object_id': object_id, - 'original': manipulator.original_object, - 'is_popup': '_popup' in request.REQUEST, - }) - return render_change_form(model, manipulator, c, change=True) -change_stage = staff_member_required(never_cache(change_stage)) - -def _nest_help(obj, depth, val): - current = obj - for i in range(depth): - current = current[-1] - current.append(val) - -def _get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth): - "Helper function that recursively populates deleted_objects." - nh = _nest_help # Bind to local variable for performance - if current_depth > 16: - return # Avoid recursing too deep. - opts_seen = [] - for related in opts.get_all_related_objects(): - if related.opts in opts_seen: - continue - opts_seen.append(related.opts) - rel_opts_name = related.get_accessor_name() - if isinstance(related.field.rel, models.OneToOneRel): - try: - sub_obj = getattr(obj, rel_opts_name) - except ObjectDoesNotExist: - pass - else: - if related.opts.admin: - p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission()) - if not user.has_perm(p): - perms_needed.add(related.opts.verbose_name) - # We don't care about populating deleted_objects now. - continue - if related.field.rel.edit_inline or not related.opts.admin: - # Don't display link to edit, because it either has no - # admin or is edited inline. - nh(deleted_objects, current_depth, [mark_safe(u'%s: %s' % (force_unicode(capfirst(related.opts.verbose_name)), sub_obj)), []]) - else: - # Display a link to the admin page. - nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' % - (escape(force_unicode(capfirst(related.opts.verbose_name))), - related.opts.app_label, - related.opts.object_name.lower(), - sub_obj._get_pk_val(), sub_obj)), []]) - _get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2) - else: - has_related_objs = False - for sub_obj in getattr(obj, rel_opts_name).all(): - has_related_objs = True - if related.field.rel.edit_inline or not related.opts.admin: - # Don't display link to edit, because it either has no - # admin or is edited inline. - nh(deleted_objects, current_depth, [u'%s: %s' % (force_unicode(capfirst(related.opts.verbose_name)), escape(sub_obj)), []]) - else: - # Display a link to the admin page. - nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' % \ - (escape(force_unicode(capfirst(related.opts.verbose_name))), related.opts.app_label, related.opts.object_name.lower(), sub_obj._get_pk_val(), escape(sub_obj))), []]) - _get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2) - # If there were related objects, and the user doesn't have - # permission to delete them, add the missing perm to perms_needed. - if related.opts.admin and has_related_objs: - p = '%s.%s' % (related.opts.app_label, related.opts.get_delete_permission()) - if not user.has_perm(p): - perms_needed.add(related.opts.verbose_name) - for related in opts.get_all_related_many_to_many_objects(): - if related.opts in opts_seen: - continue - opts_seen.append(related.opts) - rel_opts_name = related.get_accessor_name() - has_related_objs = False - - # related.get_accessor_name() could return None for symmetrical relationships - if rel_opts_name: - rel_objs = getattr(obj, rel_opts_name, None) - if rel_objs: - has_related_objs = True - - if has_related_objs: - for sub_obj in rel_objs.all(): - if related.field.rel.edit_inline or not related.opts.admin: - # Don't display link to edit, because it either has no - # admin or is edited inline. - nh(deleted_objects, current_depth, [_('One or more %(fieldname)s in %(name)s: %(obj)s') % \ - {'fieldname': force_unicode(related.field.verbose_name), 'name': force_unicode(related.opts.verbose_name), 'obj': escape(sub_obj)}, []]) - else: - # Display a link to the admin page. - nh(deleted_objects, current_depth, [ - mark_safe((_('One or more %(fieldname)s in %(name)s:') % {'fieldname': escape(force_unicode(related.field.verbose_name)), 'name': escape(force_unicode(related.opts.verbose_name))}) + \ - (u' <a href="../../../../%s/%s/%s/">%s</a>' % \ - (related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []]) - # If there were related objects, and the user doesn't have - # permission to change them, add the missing perm to perms_needed. - if related.opts.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 delete_stage(request, app_label, model_name, object_id): - model = models.get_model(app_label, model_name) - object_id = unquote(object_id) - if model is None: - raise Http404("App %r, model %r, not found" % (app_label, model_name)) - opts = model._meta - if not request.user.has_perm(app_label + '.' + opts.get_delete_permission()): - raise PermissionDenied - obj = get_object_or_404(model, pk=object_id) - - # Populate deleted_objects, a data structure of all related objects that - # will also be deleted. - deleted_objects = [mark_safe(u'%s: <a href="../../%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), force_unicode(object_id), escape(obj))), []] - perms_needed = set() - _get_deleted_objects(deleted_objects, perms_needed, request.user, obj, opts, 1) - - if request.POST: # The user has already confirmed the deletion. - if perms_needed: - raise PermissionDenied - obj_display = force_unicode(obj) - obj.delete() - LogEntry.objects.log_action(request.user.id, ContentType.objects.get_for_model(model).id, object_id, obj_display, DELETION) - request.user.message_set.create(message=_('The %(name)s "%(obj)s" was deleted successfully.') % {'name': force_unicode(opts.verbose_name), 'obj': obj_display}) - return HttpResponseRedirect("../../") - extra_context = { - "title": _("Are you sure?"), - "object_name": force_unicode(opts.verbose_name), - "object": obj, - "deleted_objects": deleted_objects, - "perms_lacking": perms_needed, - "opts": model._meta, - } - return render_to_response(["admin/%s/%s/delete_confirmation.html" % (app_label, opts.object_name.lower() ), - "admin/%s/delete_confirmation.html" % app_label , - "admin/delete_confirmation.html"], extra_context, context_instance=template.RequestContext(request)) -delete_stage = staff_member_required(never_cache(delete_stage)) - -def history(request, app_label, model_name, object_id): - model = models.get_model(app_label, model_name) - object_id = unquote(object_id) - if model is None: - raise Http404("App %r, model %r, not found" % (app_label, model_name)) - action_list = LogEntry.objects.filter(object_id=object_id, - content_type__id__exact=ContentType.objects.get_for_model(model).id).select_related().order_by('action_time') - # If no history was found, see whether this object even exists. - obj = get_object_or_404(model, pk=object_id) - extra_context = { - 'title': _('Change history: %s') % obj, - 'action_list': action_list, - 'module_name': force_unicode(capfirst(model._meta.verbose_name_plural)), - 'object': obj, - } - return render_to_response(["admin/%s/%s/object_history.html" % (app_label, model._meta.object_name.lower()), - "admin/%s/object_history.html" % app_label , - "admin/object_history.html"], extra_context, context_instance=template.RequestContext(request)) -history = staff_member_required(never_cache(history)) - class ChangeList(object): - def __init__(self, request, model): + def __init__(self, request, model, list_display, list_display_links, list_filter, date_hierarchy, search_fields, list_select_related, list_per_page, model_admin): self.model = model self.opts = model._meta self.lookup_opts = self.opts - self.manager = self.opts.admin.manager + self.root_query_set = model_admin.queryset(request) + self.list_display = list_display + self.list_display_links = list_display_links + self.list_filter = list_filter + self.date_hierarchy = date_hierarchy + self.search_fields = search_fields + self.list_select_related = list_select_related + self.list_per_page = list_per_page + self.model_admin = model_admin # Get search parameters from the query string. try: @@ -580,17 +63,16 @@ class ChangeList(object): self.query = request.GET.get(SEARCH_VAR, '') self.query_set = self.get_query_set() self.get_results(request) - self.title = (self.is_popup and _('Select %s') % force_unicode(self.opts.verbose_name) or _('Select %s to change') % force_unicode(self.opts.verbose_name)) + self.title = (self.is_popup and ugettext('Select %s') % force_unicode(self.opts.verbose_name) or ugettext('Select %s to change') % force_unicode(self.opts.verbose_name)) self.filter_specs, self.has_filters = self.get_filters(request) self.pk_attname = self.lookup_opts.pk.attname def get_filters(self, request): filter_specs = [] - if self.lookup_opts.admin.list_filter and not self.opts.one_to_one_field: - filter_fields = [self.lookup_opts.get_field(field_name) \ - for field_name in self.lookup_opts.admin.list_filter] + if self.list_filter and not self.opts.one_to_one_field: + filter_fields = [self.lookup_opts.get_field(field_name) for field_name in self.list_filter] for f in filter_fields: - spec = FilterSpec.create(f, request, self.params, self.model) + spec = FilterSpec.create(f, request, self.params, self.model, self.model_admin) if spec and spec.has_output(): filter_specs.append(spec) return filter_specs, bool(filter_specs) @@ -604,15 +86,15 @@ class ChangeList(object): if k.startswith(r): del p[k] for k, v in new_params.items(): - if k in p and v is None: - del p[k] - elif v is not None: + if v is None: + if k in p: + del p[k] + else: p[k] = v - return mark_safe('?' + '&'.join([u'%s=%s' % (k, v) for k, v in p.items()]).replace(' ', '%20')) + return '?%s' % urlencode(p) def get_results(self, request): - paginator = Paginator(self.query_set, self.lookup_opts.admin.list_per_page) - + paginator = Paginator(self.query_set, self.list_per_page) # Get the number of objects, with admin filters applied. try: result_count = paginator.count @@ -630,10 +112,10 @@ class ChangeList(object): if not self.query_set.query.where: full_result_count = result_count else: - full_result_count = self.manager.count() + full_result_count = self.root_query_set.count() can_show_all = result_count <= MAX_SHOW_ALL_ALLOWED - multi_page = result_count > self.lookup_opts.admin.list_per_page + multi_page = result_count > self.list_per_page # Get the list of objects to display on this page. if (self.show_all and can_show_all) or not multi_page: @@ -657,7 +139,7 @@ class ChangeList(object): # options, then check the object's default ordering. If neither of # those exist, order descending by ID by default. Finally, look for # manually-specified ordering from the query string. - ordering = lookup_opts.admin.ordering or lookup_opts.ordering or ['-' + lookup_opts.pk.name] + ordering = self.model_admin.ordering or lookup_opts.ordering or ['-' + lookup_opts.pk.name] if ordering[0].startswith('-'): order_field, order_type = ordering[0][1:], 'desc' @@ -665,14 +147,14 @@ class ChangeList(object): order_field, order_type = ordering[0], 'asc' if ORDER_VAR in params: try: - field_name = lookup_opts.admin.list_display[int(params[ORDER_VAR])] + field_name = self.list_display[int(params[ORDER_VAR])] try: f = lookup_opts.get_field(field_name) except models.FieldDoesNotExist: - # see if field_name is a name of a non-field - # that allows sorting + # See whether field_name is a name of a non-field + # that allows sorting. try: - attr = getattr(lookup_opts.admin.manager.model, field_name) + attr = getattr(self.model, field_name) order_field = attr.admin_order_field except AttributeError: pass @@ -686,7 +168,7 @@ class ChangeList(object): return order_field, order_type def get_query_set(self): - qs = self.manager.get_query_set() + qs = self.root_query_set lookup_params = self.params.copy() # a dictionary of the query string for i in (ALL_VAR, ORDER_VAR, ORDER_TYPE_VAR, SEARCH_VAR, IS_POPUP_VAR): if i in lookup_params: @@ -703,10 +185,10 @@ class ChangeList(object): # Use select_related() if one of the list_display options is a field # with a relationship. - if self.lookup_opts.admin.list_select_related: + if self.list_select_related: qs = qs.select_related() else: - for field_name in self.lookup_opts.admin.list_display: + for field_name in self.list_display: try: f = self.lookup_opts.get_field(field_name) except models.FieldDoesNotExist: @@ -731,13 +213,17 @@ class ChangeList(object): else: return "%s__icontains" % field_name - if self.lookup_opts.admin.search_fields and self.query: + if self.search_fields and self.query: for bit in self.query.split(): - or_queries = [models.Q(**{construct_search(field_name): bit}) for field_name in self.lookup_opts.admin.search_fields] + or_queries = [models.Q(**{construct_search(field_name): bit}) for field_name in self.search_fields] other_qs = QuerySet(self.model) other_qs.dup_select_related(qs) other_qs = other_qs.filter(reduce(operator.or_, or_queries)) qs = qs & other_qs + for field_name in self.search_fields: + if '__' in field_name: + qs = qs.distinct() + break if self.opts.one_to_one_field: qs = qs.complex_filter(self.opts.one_to_one_field.rel.limit_choices_to) @@ -746,31 +232,3 @@ class ChangeList(object): def url_for_result(self, result): return "%s/" % quote(getattr(result, self.pk_attname)) - -def change_list(request, app_label, model_name): - model = models.get_model(app_label, model_name) - if model is None: - raise Http404("App %r, model %r, not found" % (app_label, model_name)) - if not request.user.has_perm(app_label + '.' + model._meta.get_change_permission()): - raise PermissionDenied - try: - cl = ChangeList(request, model) - except IncorrectLookupParameters: - # Wacky lookup parameters were given, so redirect to the main - # changelist page, without parameters, and pass an 'invalid=1' - # parameter via the query string. If wacky parameters were given and - # the 'invalid=1' parameter was already in the query string, something - # is screwed up with the database, so display an error page. - if ERROR_FLAG in request.GET.keys(): - return render_to_response('admin/invalid_setup.html', {'title': _('Database error')}) - return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1') - c = template.RequestContext(request, { - 'title': cl.title, - 'is_popup': cl.is_popup, - 'cl': cl, - }) - c.update({'has_add_permission': c['perms'][app_label][cl.opts.get_add_permission()]}), - return render_to_response(['admin/%s/%s/change_list.html' % (app_label, cl.opts.object_name.lower()), - 'admin/%s/change_list.html' % app_label, - 'admin/change_list.html'], context_instance=c) -change_list = staff_member_required(never_cache(change_list)) diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py new file mode 100644 index 0000000000..4ae8889ac4 --- /dev/null +++ b/django/contrib/admin/widgets.py @@ -0,0 +1,215 @@ +""" +Form Widget classes specific to the Django admin site. +""" + +import copy + +from django import newforms as forms +from django.newforms.widgets import RadioFieldRenderer +from django.newforms.util import flatatt +from django.utils.datastructures import MultiValueDict +from django.utils.text import capfirst, truncate_words +from django.utils.translation import ugettext as _ +from django.utils.safestring import mark_safe +from django.utils.encoding import force_unicode +from django.conf import settings + +class FilteredSelectMultiple(forms.SelectMultiple): + """ + A SelectMultiple with a JavaScript filter interface. + + Note that the resulting JavaScript assumes that the SelectFilter2.js + library and its dependencies have been loaded in the HTML page. + """ + def __init__(self, verbose_name, is_stacked, attrs=None, choices=()): + self.verbose_name = verbose_name + self.is_stacked = is_stacked + super(FilteredSelectMultiple, self).__init__(attrs, choices) + + def render(self, name, value, attrs=None, choices=()): + from django.conf import settings + output = [super(FilteredSelectMultiple, self).render(name, value, attrs, choices)] + output.append(u'<script type="text/javascript">addEvent(window, "load", function(e) {') + # TODO: "id_" is hard-coded here. This should instead use the correct + # API to determine the ID dynamically. + output.append(u'SelectFilter.init("id_%s", "%s", %s, "%s"); });</script>\n' % \ + (name, self.verbose_name.replace('"', '\\"'), int(self.is_stacked), settings.ADMIN_MEDIA_PREFIX)) + return mark_safe(u''.join(output)) + +class AdminDateWidget(forms.TextInput): + class Media: + js = (settings.ADMIN_MEDIA_PREFIX + "js/calendar.js", + settings.ADMIN_MEDIA_PREFIX + "js/admin/DateTimeShortcuts.js") + + def __init__(self, attrs={}): + super(AdminDateWidget, self).__init__(attrs={'class': 'vDateField', 'size': '10'}) + +class AdminTimeWidget(forms.TextInput): + class Media: + js = (settings.ADMIN_MEDIA_PREFIX + "js/calendar.js", + settings.ADMIN_MEDIA_PREFIX + "js/admin/DateTimeShortcuts.js") + + def __init__(self, attrs={}): + super(AdminTimeWidget, self).__init__(attrs={'class': 'vTimeField', 'size': '8'}) + +class AdminSplitDateTime(forms.SplitDateTimeWidget): + """ + A SplitDateTime Widget that has some admin-specific styling. + """ + def __init__(self, attrs=None): + widgets = [AdminDateWidget, AdminTimeWidget] + # Note that we're calling MultiWidget, not SplitDateTimeWidget, because + # we want to define widgets. + forms.MultiWidget.__init__(self, widgets, attrs) + + def format_output(self, rendered_widgets): + return mark_safe(u'<p class="datetime">%s %s<br />%s %s</p>' % \ + (_('Date:'), rendered_widgets[0], _('Time:'), rendered_widgets[1])) + +class AdminRadioFieldRenderer(RadioFieldRenderer): + def render(self): + """Outputs a <ul> for this set of radio fields.""" + return mark_safe(u'<ul%s>\n%s\n</ul>' % ( + flatatt(self.attrs), + u'\n'.join([u'<li>%s</li>' % force_unicode(w) for w in self])) + ) + +class AdminRadioSelect(forms.RadioSelect): + renderer = AdminRadioFieldRenderer + +class AdminFileWidget(forms.FileInput): + """ + A FileField Widget that shows its current value if it has one. + """ + def __init__(self, attrs={}): + super(AdminFileWidget, self).__init__(attrs) + + def render(self, name, value, attrs=None): + from django.conf import settings + output = [] + if value: + output.append('%s <a target="_blank" href="%s%s">%s</a> <br />%s ' % \ + (_('Currently:'), settings.MEDIA_URL, value, value, _('Change:'))) + output.append(super(AdminFileWidget, self).render(name, value, attrs)) + return mark_safe(u''.join(output)) + +class ForeignKeyRawIdWidget(forms.TextInput): + """ + A Widget for displaying ForeignKeys in the "raw_id" interface rather than + in a <select> box. + """ + def __init__(self, rel, attrs=None): + self.rel = rel + super(ForeignKeyRawIdWidget, self).__init__(attrs) + + def render(self, name, value, attrs=None): + from django.conf import settings + related_url = '../../../%s/%s/' % (self.rel.to._meta.app_label, self.rel.to._meta.object_name.lower()) + if self.rel.limit_choices_to: + url = '?' + '&'.join(['%s=%s' % (k, v) for k, v in self.rel.limit_choices_to.items()]) + else: + url = '' + if not attrs.has_key('class'): + attrs['class'] = 'vForeignKeyRawIdAdminField' # The JavaScript looks for this hook. + output = [super(ForeignKeyRawIdWidget, self).render(name, value, attrs)] + # TODO: "id_" is hard-coded here. This should instead use the correct + # API to determine the ID dynamically. + output.append('<a href="%s%s" class="related-lookup" id="lookup_id_%s" onclick="return showRelatedObjectLookupPopup(this);"> ' % \ + (related_url, url, name)) + output.append('<img src="%simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>' % settings.ADMIN_MEDIA_PREFIX) + if value: + output.append(self.label_for_value(value)) + return mark_safe(u''.join(output)) + + def label_for_value(self, value): + return ' <strong>%s</strong>' % \ + truncate_words(self.rel.to.objects.get(pk=value), 14) + +class ManyToManyRawIdWidget(ForeignKeyRawIdWidget): + """ + A Widget for displaying ManyToMany ids in the "raw_id" interface rather than + in a <select multiple> box. + """ + def __init__(self, rel, attrs=None): + super(ManyToManyRawIdWidget, self).__init__(rel, attrs) + + def render(self, name, value, attrs=None): + attrs['class'] = 'vManyToManyRawIdAdminField' + if value: + value = ','.join([str(v) for v in value]) + else: + value = '' + return super(ManyToManyRawIdWidget, self).render(name, value, attrs) + + def label_for_value(self, value): + return '' + + def value_from_datadict(self, data, files, name): + value = data.get(name, None) + if value and ',' in value: + return data[name].split(',') + if value: + return [value] + return None + + def _has_changed(self, initial, data): + if initial is None: + initial = [] + if data is None: + data = [] + if len(initial) != len(data): + return True + for pk1, pk2 in zip(initial, data): + if force_unicode(pk1) != force_unicode(pk2): + return True + return False + +class RelatedFieldWidgetWrapper(forms.Widget): + """ + This class is a wrapper to a given widget to add the add icon for the + admin interface. + """ + def __init__(self, widget, rel, admin_site): + self.is_hidden = widget.is_hidden + self.needs_multipart_form = widget.needs_multipart_form + self.attrs = widget.attrs + self.choices = widget.choices + self.widget = widget + self.rel = rel + # so we can check if the related object is registered with this AdminSite + self.admin_site = admin_site + + def __deepcopy__(self, memo): + obj = copy.copy(self) + obj.widget = copy.deepcopy(self.widget, memo) + obj.attrs = self.widget.attrs + memo[id(self)] = obj + return obj + + def render(self, name, value, *args, **kwargs): + from django.conf import settings + rel_to = self.rel.to + related_url = '../../../%s/%s/' % (rel_to._meta.app_label, rel_to._meta.object_name.lower()) + self.widget.choices = self.choices + output = [self.widget.render(name, value, *args, **kwargs)] + if rel_to in self.admin_site._registry: # If the related object has an admin interface: + # TODO: "id_" is hard-coded here. This should instead use the correct + # API to determine the ID dynamically. + output.append(u'<a href="%sadd/" class="add-another" id="add_id_%s" onclick="return showAddAnotherPopup(this);"> ' % \ + (related_url, name)) + output.append(u'<img src="%simg/admin/icon_addlink.gif" width="10" height="10" alt="Add Another"/></a>' % settings.ADMIN_MEDIA_PREFIX) + return mark_safe(u''.join(output)) + + def build_attrs(self, extra_attrs=None, **kwargs): + "Helper function for building an attribute dictionary." + self.attrs = self.widget.build_attrs(extra_attrs=None, **kwargs) + return self.attrs + + def value_from_datadict(self, data, files, name): + return self.widget.value_from_datadict(data, files, name) + + def _has_changed(self, initial, data): + return self.widget._has_changed(initial, data) + + def id_for_label(self, id_): + return self.widget.id_for_label(id_) diff --git a/tests/regressiontests/invalid_admin_options/__init__.py b/django/contrib/admindocs/__init__.py index e69de29bb2..e69de29bb2 100644 --- a/tests/regressiontests/invalid_admin_options/__init__.py +++ b/django/contrib/admindocs/__init__.py diff --git a/django/contrib/admindocs/urls.py b/django/contrib/admindocs/urls.py new file mode 100644 index 0000000000..e7baa76fc0 --- /dev/null +++ b/django/contrib/admindocs/urls.py @@ -0,0 +1,15 @@ +from django.conf.urls.defaults import * +from django.contrib.admindocs import views + +urlpatterns = patterns('', + ('^$', views.doc_index), + ('^bookmarklets/$', views.bookmarklets), + ('^tags/$', views.template_tag_index), + ('^filters/$', views.template_filter_index), + ('^views/$', views.view_index), + ('^views/(?P<view>[^/]+)/$', views.view_detail), + ('^models/$', views.model_index), + ('^models/(?P<app_label>[^\.]+)\.(?P<model_name>[^/]+)/$', views.model_detail), +# ('^templates/$', views.template_index), + ('^templates/(?P<template>.*)/$', views.template_detail), +) diff --git a/django/contrib/admin/utils.py b/django/contrib/admindocs/utils.py index 4a45a622b2..4a45a622b2 100644 --- a/django/contrib/admin/utils.py +++ b/django/contrib/admindocs/utils.py diff --git a/django/contrib/admin/views/doc.py b/django/contrib/admindocs/views.py index 44a27d6cc3..e81293adcb 100644 --- a/django/contrib/admin/views/doc.py +++ b/django/contrib/admindocs/views.py @@ -5,9 +5,9 @@ from django.contrib.admin.views.decorators import staff_member_required from django.db import models from django.shortcuts import render_to_response from django.core.exceptions import ImproperlyConfigured, ViewDoesNotExist -from django.http import Http404 +from django.http import Http404, get_host from django.core import urlresolvers -from django.contrib.admin import utils +from django.contrib.admindocs import utils from django.contrib.sites.models import Site from django.utils.translation import ugettext as _ from django.utils.safestring import mark_safe @@ -23,13 +23,18 @@ class GenericSite(object): def doc_index(request): if not utils.docutils_is_available: return missing_docutils_page(request) - return render_to_response('admin_doc/index.html', context_instance=RequestContext(request)) + root_path = re.sub(re.escape('doc/') + '$', '', request.path) + return render_to_response('admin_doc/index.html', { + 'root_path': root_path, + }, context_instance=RequestContext(request)) doc_index = staff_member_required(doc_index) def bookmarklets(request): # Hack! This couples this view to the URL it lives at. admin_root = request.path[:-len('doc/bookmarklets/')] + root_path = re.sub(re.escape('doc/bookmarklets/') + '$', '', request.path) return render_to_response('admin_doc/bookmarklets.html', { + 'root_path': root_path, 'admin_url': mark_safe("%s://%s%s" % (request.is_secure() and 'https' or 'http', request.get_host(), admin_root)), }, context_instance=RequestContext(request)) bookmarklets = staff_member_required(bookmarklets) @@ -61,8 +66,11 @@ def template_tag_index(request): 'meta': metadata, 'library': tag_library, }) - - return render_to_response('admin_doc/template_tag_index.html', {'tags': tags}, context_instance=RequestContext(request)) + root_path = re.sub(re.escape('doc/tags/') + '$', '', request.path) + return render_to_response('admin_doc/template_tag_index.html', { + 'root_path': root_path, + 'tags': tags + }, context_instance=RequestContext(request)) template_tag_index = staff_member_required(template_tag_index) def template_filter_index(request): @@ -92,7 +100,11 @@ def template_filter_index(request): 'meta': metadata, 'library': tag_library, }) - return render_to_response('admin_doc/template_filter_index.html', {'filters': filters}, context_instance=RequestContext(request)) + root_path = re.sub(re.escape('doc/filters/') + '$', '', request.path) + return render_to_response('admin_doc/template_filter_index.html', { + 'root_path': root_path, + 'filters': filters + }, context_instance=RequestContext(request)) template_filter_index = staff_member_required(template_filter_index) def view_index(request): @@ -120,7 +132,11 @@ def view_index(request): 'site': site_obj, 'url': simplify_regex(regex), }) - return render_to_response('admin_doc/view_index.html', {'views': views}, context_instance=RequestContext(request)) + root_path = re.sub(re.escape('doc/views/') + '$', '', request.path) + return render_to_response('admin_doc/view_index.html', { + 'root_path': root_path, + 'views': views + }, context_instance=RequestContext(request)) view_index = staff_member_required(view_index) def view_detail(request, view): @@ -139,7 +155,9 @@ def view_detail(request, view): body = utils.parse_rst(body, 'view', _('view:') + view) for key in metadata: metadata[key] = utils.parse_rst(metadata[key], 'model', _('view:') + view) + root_path = re.sub(re.escape('doc/views/%s/' % view) + '$', '', request.path) return render_to_response('admin_doc/view_detail.html', { + 'root_path': root_path, 'name': view, 'summary': title, 'body': body, @@ -150,15 +168,18 @@ view_detail = staff_member_required(view_detail) def model_index(request): if not utils.docutils_is_available: return missing_docutils_page(request) - m_list = [m._meta for m in models.get_models()] - return render_to_response('admin_doc/model_index.html', {'models': m_list}, context_instance=RequestContext(request)) + root_path = re.sub(re.escape('doc/models/') + '$', '', request.path) + return render_to_response('admin_doc/model_index.html', { + 'root_path': root_path, + 'models': m_list + }, context_instance=RequestContext(request)) model_index = staff_member_required(model_index) def model_detail(request, app_label, model_name): if not utils.docutils_is_available: return missing_docutils_page(request) - + # Get the model class. try: app_mod = models.get_app(app_label) @@ -170,7 +191,7 @@ def model_detail(request, app_label, model_name): model = m break if model is None: - raise Http404, _("Model %(name)r not found in app %(label)r") % {'name': model_name, 'label': app_label} + raise Http404, _("Model %(model_name)r not found in app %(app_label)r") % {'model_name': model_name, 'app_label': app_label} opts = model._meta @@ -182,7 +203,7 @@ def model_detail(request, app_label, model_name): if isinstance(field, models.ForeignKey): data_type = related_object_name = field.rel.to.__name__ app_label = field.rel.to._meta.app_label - verbose = utils.parse_rst((_("the related `%(label)s.%(type)s` object") % {'label': app_label, 'type': data_type}), 'model', _('model:') + data_type) + verbose = utils.parse_rst((_("the related `%(app_label)s.%(data_type)s` object") % {'app_label': app_label, 'data_type': data_type}), 'model', _('model:') + data_type) else: data_type = get_readable_field_data_type(field) verbose = field.verbose_name @@ -213,7 +234,7 @@ def model_detail(request, app_label, model_name): # Gather related objects for rel in opts.get_all_related_objects(): - verbose = _("related `%(label)s.%(name)s` objects") % {'label': rel.opts.app_label, 'name': rel.opts.object_name} + verbose = _("related `%(app_label)s.%(object_name)s` objects") % {'app_label': rel.opts.app_label, 'object_name': rel.opts.object_name} accessor = rel.get_accessor_name() fields.append({ 'name' : "%s.all" % accessor, @@ -225,8 +246,9 @@ def model_detail(request, app_label, model_name): 'data_type' : 'Integer', 'verbose' : utils.parse_rst(_("number of %s") % verbose , 'model', _('model:') + opts.module_name), }) - + root_path = re.sub(re.escape('doc/models/%s.%s/' % (app_label, model_name)) + '$', '', request.path) return render_to_response('admin_doc/model_detail.html', { + 'root_path': root_path, 'name': '%s.%s' % (opts.app_label, opts.object_name), 'summary': _("Fields on %s objects") % opts.object_name, 'description': model.__doc__, @@ -252,7 +274,9 @@ def template_detail(request, template): 'site': site_obj, 'order': list(settings_mod.TEMPLATE_DIRS).index(dir), }) + root_path = re.sub(re.escape('doc/templates/%s/' % template) + '$', '', request.path) return render_to_response('admin_doc/template_detail.html', { + 'root_path': root_path, 'name': template, 'templates': templates, }, context_instance=RequestContext(request)) diff --git a/django/contrib/auth/admin.py b/django/contrib/auth/admin.py new file mode 100644 index 0000000000..998692a6cb --- /dev/null +++ b/django/contrib/auth/admin.py @@ -0,0 +1,66 @@ +from django.contrib.auth.models import User, Group +from django.core.exceptions import PermissionDenied +from django import oldforms, template +from django.shortcuts import render_to_response +from django.http import HttpResponseRedirect +from django.utils.translation import ugettext, ugettext_lazy as _ +from django.contrib import admin + +class GroupAdmin(admin.ModelAdmin): + search_fields = ('name',) + ordering = ('name',) + filter_horizontal = ('permissions',) + +class UserAdmin(admin.ModelAdmin): + fieldsets = ( + (None, {'fields': ('username', 'password')}), + (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), + (_('Permissions'), {'fields': ('is_staff', 'is_active', 'is_superuser', 'user_permissions')}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + (_('Groups'), {'fields': ('groups',)}), + ) + list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff') + list_filter = ('is_staff', 'is_superuser') + search_fields = ('username', 'first_name', 'last_name', 'email') + ordering = ('username',) + filter_horizontal = ('user_permissions',) + + def add_view(self, request): + # avoid a circular import. see #6718. + from django.contrib.auth.forms import UserCreationForm + if not self.has_change_permission(request): + raise PermissionDenied + if request.method == 'POST': + form = UserCreationForm(request.POST) + if form.is_valid(): + new_user = form.save() + msg = _('The %(name)s "%(obj)s" was added successfully.') % {'name': 'user', 'obj': new_user} + if "_addanother" in request.POST: + request.user.message_set.create(message=msg) + return HttpResponseRedirect(request.path) + else: + request.user.message_set.create(message=msg + ' ' + ugettext("You may edit it again below.")) + return HttpResponseRedirect('../%s/' % new_user.id) + else: + form = UserCreationForm() + return render_to_response('admin/auth/user/add_form.html', { + 'title': _('Add user'), + 'form': form, + 'is_popup': '_popup' in request.REQUEST, + 'add': True, + 'change': False, + 'has_add_permission': True, + 'has_delete_permission': False, + 'has_change_permission': True, + 'has_file_field': False, + 'has_absolute_url': False, + 'auto_populated_fields': (), + 'opts': User._meta, + 'save_as': False, + 'username_help_text': User._meta.get_field('username').help_text, + 'root_path': self.admin_site.root_path, + }, context_instance=template.RequestContext(request)) + +admin.site.register(Group, GroupAdmin) +admin.site.register(User, UserAdmin) + diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index 47a974cacd..f63dc7b854 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -3,88 +3,106 @@ from django.contrib.auth import authenticate from django.contrib.sites.models import Site from django.template import Context, loader from django.core import validators -from django import oldforms -from django.utils.translation import ugettext as _ +from django import newforms as forms +from django.utils.translation import ugettext_lazy as _ -class UserCreationForm(oldforms.Manipulator): - "A form that creates a user, with no privileges, from the given username and password." - def __init__(self): - self.fields = ( - oldforms.TextField(field_name='username', length=30, max_length=30, is_required=True, - validator_list=[validators.isAlphaNumeric, self.isValidUsername]), - oldforms.PasswordField(field_name='password1', length=30, max_length=60, is_required=True), - oldforms.PasswordField(field_name='password2', length=30, max_length=60, is_required=True, - validator_list=[validators.AlwaysMatchesOtherField('password1', _("The two password fields didn't match."))]), - ) - - def isValidUsername(self, field_data, all_data): +class UserCreationForm(forms.ModelForm): + """ + A form that creates a user, with no privileges, from the given username and password. + """ + username = forms.RegexField(label=_("Username"), max_length=30, regex=r'^\w+$', + help_text = _("Required. 30 characters or fewer. Alphanumeric characters only (letters, digits and underscores)."), + error_message = _("This value must contain only letters, numbers and underscores.")) + password1 = forms.CharField(label=_("Password"), max_length=60, widget=forms.PasswordInput) + password2 = forms.CharField(label=_("Password confirmation"), max_length=60, widget=forms.PasswordInput) + + class Meta: + model = User + fields = ("username",) + + def clean_username(self): + username = self.cleaned_data["username"] try: - User.objects.get(username=field_data) + User.objects.get(username=username) except User.DoesNotExist: - return - raise validators.ValidationError, _('A user with that username already exists.') - - def save(self, new_data): - "Creates the user." - return User.objects.create_user(new_data['username'], '', new_data['password1']) + return username + raise forms.ValidationError(_("A user with that username already exists.")) + + def clean_password2(self): + password1 = self.cleaned_data["password1"] + password2 = self.cleaned_data["password2"] + if password1 != password2: + raise forms.ValidationError(_("The two password fields didn't match.")) + return password2 + + def save(self, commit=True): + user = super(UserCreationForm, self).save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.save() + return user -class AuthenticationForm(oldforms.Manipulator): +class AuthenticationForm(forms.Form): """ Base class for authenticating users. Extend this to get a form that accepts username/password logins. """ - def __init__(self, request=None): + username = forms.CharField(label=_("Username"), max_length=30) + password = forms.CharField(label=_("Password"), max_length=30, widget=forms.PasswordInput) + + def __init__(self, request=None, *args, **kwargs): """ - If request is passed in, the manipulator will validate that cookies are + If request is passed in, the form will validate that cookies are enabled. Note that the request (a HttpRequest object) must have set a cookie with the key TEST_COOKIE_NAME and value TEST_COOKIE_VALUE before - running this validator. + running this validation. """ self.request = request - self.fields = [ - oldforms.TextField(field_name="username", length=15, max_length=30, is_required=True, - validator_list=[self.isValidUser, self.hasCookiesEnabled]), - oldforms.PasswordField(field_name="password", length=15, max_length=30, is_required=True), - ] self.user_cache = None - - def hasCookiesEnabled(self, field_data, all_data): - if self.request and not self.request.session.test_cookie_worked(): - raise validators.ValidationError, _("Your Web browser doesn't appear to have cookies enabled. Cookies are required for logging in.") - - def isValidUser(self, field_data, all_data): - username = field_data - password = all_data.get('password', None) - self.user_cache = authenticate(username=username, password=password) - if self.user_cache is None: - raise validators.ValidationError, _("Please enter a correct username and password. Note that both fields are case-sensitive.") - elif not self.user_cache.is_active: - raise validators.ValidationError, _("This account is inactive.") - + super(AuthenticationForm, self).__init__(*args, **kwargs) + + def clean(self): + username = self.cleaned_data.get('username') + password = self.cleaned_data.get('password') + + if username and password: + self.user_cache = authenticate(username=username, password=password) + if self.user_cache is None: + raise forms.ValidationError(_("Please enter a correct username and password. Note that both fields are case-sensitive.")) + elif not self.user_cache.is_active: + raise forms.ValidationError(_("This account is inactive.")) + + # TODO: determine whether this should move to its own method. + if self.request: + if not self.request.session.test_cookie_worked(): + raise forms.ValidationError(_("Your Web browser doesn't appear to have cookies enabled. Cookies are required for logging in.")) + + return self.cleaned_data + def get_user_id(self): if self.user_cache: return self.user_cache.id return None - + def get_user(self): return self.user_cache -class PasswordResetForm(oldforms.Manipulator): - "A form that lets a user request a password reset" - def __init__(self): - self.fields = ( - oldforms.EmailField(field_name="email", length=40, is_required=True, - validator_list=[self.isValidUserEmail]), - ) - - def isValidUserEmail(self, new_data, all_data): - "Validates that a user exists with the given e-mail address" - self.users_cache = list(User.objects.filter(email__iexact=new_data)) +class PasswordResetForm(forms.Form): + email = forms.EmailField(label=_("E-mail"), max_length=40) + + def clean_email(self): + """ + Validates that a user exists with the given e-mail address. + """ + email = self.cleaned_data["email"] + self.users_cache = User.objects.filter(email__iexact=email) if len(self.users_cache) == 0: - raise validators.ValidationError, _("That e-mail address doesn't have an associated user account. Are you sure you've registered?") - + raise forms.ValidationError(_("That e-mail address doesn't have an associated user account. Are you sure you've registered?")) + def save(self, domain_override=None, email_template_name='registration/password_reset_email.html'): - "Calculates a new password randomly and sends it to the user" + """ + Calculates a new password randomly and sends it to the user. + """ from django.core.mail import send_mail for user in self.users_cache: new_pass = User.objects.make_random_password() @@ -103,42 +121,69 @@ class PasswordResetForm(oldforms.Manipulator): 'domain': domain, 'site_name': site_name, 'user': user, - } - send_mail(_('Password reset on %s') % site_name, t.render(Context(c)), None, [user.email]) + } + send_mail(_("Password reset on %s") % site_name, + t.render(Context(c)), None, [user.email]) -class PasswordChangeForm(oldforms.Manipulator): - "A form that lets a user change his password." - def __init__(self, user): +class PasswordChangeForm(forms.Form): + """ + A form that lets a user change his/her password. + """ + old_password = forms.CharField(label=_("Old password"), max_length=30, widget=forms.PasswordInput) + new_password1 = forms.CharField(label=_("New password"), max_length=30, widget=forms.PasswordInput) + new_password2 = forms.CharField(label=_("New password confirmation"), max_length=30, widget=forms.PasswordInput) + + def __init__(self, user, *args, **kwargs): self.user = user - self.fields = ( - oldforms.PasswordField(field_name="old_password", length=30, max_length=30, is_required=True, - validator_list=[self.isValidOldPassword]), - oldforms.PasswordField(field_name="new_password1", length=30, max_length=30, is_required=True, - validator_list=[validators.AlwaysMatchesOtherField('new_password2', _("The two 'new password' fields didn't match."))]), - oldforms.PasswordField(field_name="new_password2", length=30, max_length=30, is_required=True), - ) - - def isValidOldPassword(self, new_data, all_data): - "Validates that the old_password field is correct." - if not self.user.check_password(new_data): - raise validators.ValidationError, _("Your old password was entered incorrectly. Please enter it again.") - - def save(self, new_data): - "Saves the new password." - self.user.set_password(new_data['new_password1']) - self.user.save() - -class AdminPasswordChangeForm(oldforms.Manipulator): - "A form used to change the password of a user in the admin interface." - def __init__(self, user): + super(PasswordChangeForm, self).__init__(*args, **kwargs) + + def clean_old_password(self): + """ + Validates that the old_password field is correct. + """ + old_password = self.cleaned_data["old_password"] + if not self.user.check_password(old_password): + raise forms.ValidationError(_("Your old password was entered incorrectly. Please enter it again.")) + return old_password + + def clean_new_password2(self): + password1 = self.cleaned_data.get('new_password1') + password2 = self.cleaned_data.get('new_password2') + if password1 and password2: + if password1 != password2: + raise forms.ValidationError(_("The two password fields didn't match.")) + return password2 + + def save(self, commit=True): + self.user.set_password(self.cleaned_data['new_password1']) + if commit: + self.user.save() + return self.user + +class AdminPasswordChangeForm(forms.Form): + """ + A form used to change the password of a user in the admin interface. + """ + password1 = forms.CharField(label=_("Password"), max_length=60, widget=forms.PasswordInput) + password2 = forms.CharField(label=_("Password (again)"), max_length=60, widget=forms.PasswordInput) + + def __init__(self, user, *args, **kwargs): self.user = user - self.fields = ( - oldforms.PasswordField(field_name='password1', length=30, max_length=60, is_required=True), - oldforms.PasswordField(field_name='password2', length=30, max_length=60, is_required=True, - validator_list=[validators.AlwaysMatchesOtherField('password1', _("The two password fields didn't match."))]), - ) - - def save(self, new_data): - "Saves the new password." - self.user.set_password(new_data['password1']) - self.user.save() + super(AdminPasswordChangeForm, self).__init__(*args, **kwargs) + + def clean_password2(self): + password1 = self.cleaned_data.get('password1') + password2 = self.cleaned_data.get('password2') + if password1 and password2: + if password1 != password2: + raise forms.ValidationError(_("The two password fields didn't match.")) + return password2 + + def save(self, commit=True): + """ + Saves the new password. + """ + self.user.set_password(self.cleaned_data["password1"]) + if commit: + self.user.save() + return self.user diff --git a/django/contrib/auth/models.py b/django/contrib/auth/models.py index 379a9f4c64..a0ed4f366f 100644 --- a/django/contrib/auth/models.py +++ b/django/contrib/auth/models.py @@ -91,16 +91,12 @@ class Group(models.Model): Beyond permissions, groups are a convenient way to categorize users to apply some label, or extended functionality, to them. For example, you could create a group 'Special users', and you could write code that would do special things to those users -- such as giving them access to a members-only portion of your site, or sending them members-only e-mail messages. """ name = models.CharField(_('name'), max_length=80, unique=True) - permissions = models.ManyToManyField(Permission, verbose_name=_('permissions'), blank=True, filter_interface=models.HORIZONTAL) + permissions = models.ManyToManyField(Permission, verbose_name=_('permissions'), blank=True) class Meta: verbose_name = _('group') verbose_name_plural = _('groups') - - class Admin: - search_fields = ('name',) - ordering = ('name',) - + def __unicode__(self): return self.name @@ -147,26 +143,13 @@ class User(models.Model): date_joined = models.DateTimeField(_('date joined'), default=datetime.datetime.now) groups = models.ManyToManyField(Group, verbose_name=_('groups'), blank=True, help_text=_("In addition to the permissions manually assigned, this user will also get all permissions granted to each group he/she is in.")) - user_permissions = models.ManyToManyField(Permission, verbose_name=_('user permissions'), blank=True, filter_interface=models.HORIZONTAL) + user_permissions = models.ManyToManyField(Permission, verbose_name=_('user permissions'), blank=True) objects = UserManager() class Meta: verbose_name = _('user') verbose_name_plural = _('users') - - class Admin: - fields = ( - (None, {'fields': ('username', 'password')}), - (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), - (_('Permissions'), {'fields': ('is_staff', 'is_active', 'is_superuser', 'user_permissions')}), - (_('Important dates'), {'fields': ('last_login', 'date_joined')}), - (_('Groups'), {'fields': ('groups',)}), - ) - list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff') - list_filter = ('is_staff', 'is_superuser') - search_fields = ('username', 'first_name', 'last_name', 'email') - ordering = ('username',) - + def __unicode__(self): return self.username diff --git a/django/contrib/auth/tests/__init__.py b/django/contrib/auth/tests/__init__.py new file mode 100644 index 0000000000..6242303f46 --- /dev/null +++ b/django/contrib/auth/tests/__init__.py @@ -0,0 +1,8 @@ +from django.contrib.auth.tests.basic import BASIC_TESTS, PasswordResetTest +from django.contrib.auth.tests.forms import FORM_TESTS + +__test__ = { + 'BASIC_TESTS': BASIC_TESTS, + 'PASSWORDRESET_TESTS': PasswordResetTest, + 'FORM_TESTS': FORM_TESTS, +} diff --git a/django/contrib/auth/tests.py b/django/contrib/auth/tests/basic.py index ea1ac26c21..76dbdc9cb9 100644 --- a/django/contrib/auth/tests.py +++ b/django/contrib/auth/tests/basic.py @@ -1,4 +1,5 @@ -""" + +BASIC_TESTS = """ >>> from django.contrib.auth.models import User, AnonymousUser >>> u = User.objects.create_user('testuser', 'test@example.com', 'testpw') >>> u.has_usable_password() @@ -60,14 +61,15 @@ from django.core import mail class PasswordResetTest(TestCase): fixtures = ['authtestdata.json'] urls = 'django.contrib.auth.urls' + def test_email_not_found(self): "Error is raised if the provided email address isn't currently registered" response = self.client.get('/password_reset/') self.assertEquals(response.status_code, 200) response = self.client.post('/password_reset/', {'email': 'not_a_real_email@email.com'}) - self.assertContains(response, "That e-mail address doesn't have an associated user account") + self.assertContains(response, "That e-mail address doesn't have an associated user account") self.assertEquals(len(mail.outbox), 0) - + def test_email_found(self): "Email is sent if a valid email address is provided for password reset" response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'}) diff --git a/django/contrib/auth/tests/forms.py b/django/contrib/auth/tests/forms.py new file mode 100644 index 0000000000..1e1e0a95d4 --- /dev/null +++ b/django/contrib/auth/tests/forms.py @@ -0,0 +1,135 @@ + +FORM_TESTS = """ +>>> from django.contrib.auth.models import User +>>> from django.contrib.auth.forms import UserCreationForm, AuthenticationForm +>>> from django.contrib.auth.forms import PasswordChangeForm + +The user already exists. + +>>> user = User.objects.create_user("jsmith", "jsmith@example.com", "test123") +>>> data = { +... 'username': 'jsmith', +... 'password1': 'test123', +... 'password2': 'test123', +... } +>>> form = UserCreationForm(data) +>>> form.is_valid() +False +>>> form["username"].errors +[u'A user with that username already exists.'] + +The username contains invalid data. + +>>> data = { +... 'username': 'jsmith@example.com', +... 'password1': 'test123', +... 'password2': 'test123', +... } +>>> form = UserCreationForm(data) +>>> form.is_valid() +False +>>> form["username"].errors +[u'This value must contain only letters, numbers and underscores.'] + +The verification password is incorrect. + +>>> data = { +... 'username': 'jsmith2', +... 'password1': 'test123', +... 'password2': 'test', +... } +>>> form = UserCreationForm(data) +>>> form.is_valid() +False +>>> form["password2"].errors +[u"The two password fields didn't match."] + +The success case. + +>>> data = { +... 'username': 'jsmith2', +... 'password1': 'test123', +... 'password2': 'test123', +... } +>>> form = UserCreationForm(data) +>>> form.is_valid() +True +>>> form.save() +<User: jsmith2> + +The user submits an invalid username. + +>>> data = { +... 'username': 'jsmith_does_not_exist', +... 'password': 'test123', +... } + +>>> form = AuthenticationForm(None, data) +>>> form.is_valid() +False +>>> form.non_field_errors() +[u'Please enter a correct username and password. Note that both fields are case-sensitive.'] + +The user is inactive. + +>>> data = { +... 'username': 'jsmith', +... 'password': 'test123', +... } +>>> user.is_active = False +>>> user.save() +>>> form = AuthenticationForm(None, data) +>>> form.is_valid() +False +>>> form.non_field_errors() +[u'This account is inactive.'] + +>>> user.is_active = True +>>> user.save() + +The success case + +>>> form = AuthenticationForm(None, data) +>>> form.is_valid() +True +>>> form.non_field_errors() +[] + +The old password is incorrect. + +>>> data = { +... 'old_password': 'test', +... 'new_password1': 'abc123', +... 'new_password2': 'abc123', +... } +>>> form = PasswordChangeForm(user, data) +>>> form.is_valid() +False +>>> form["old_password"].errors +[u'Your old password was entered incorrectly. Please enter it again.'] + +The two new passwords do not match. + +>>> data = { +... 'old_password': 'test123', +... 'new_password1': 'abc123', +... 'new_password2': 'abc', +... } +>>> form = PasswordChangeForm(user, data) +>>> form.is_valid() +False +>>> form["new_password2"].errors +[u"The two password fields didn't match."] + +The success case. + +>>> data = { +... 'old_password': 'test123', +... 'new_password1': 'abc123', +... 'new_password2': 'abc123', +... } +>>> form = PasswordChangeForm(user, data) +>>> form.is_valid() +True + +""" diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index 524710327a..0a52240631 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -1,42 +1,42 @@ -from django import oldforms from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import AuthenticationForm -from django.contrib.auth.forms import PasswordResetForm, PasswordChangeForm +from django.contrib.auth.forms import PasswordResetForm, PasswordChangeForm, AdminPasswordChangeForm +from django.core.exceptions import PermissionDenied +from django.shortcuts import render_to_response, get_object_or_404 from django.contrib.sites.models import Site, RequestSite from django.http import HttpResponseRedirect -from django.shortcuts import render_to_response from django.template import RequestContext from django.utils.http import urlquote +from django.utils.html import escape from django.utils.translation import ugettext as _ +from django.contrib.auth.models import User +import re def login(request, template_name='registration/login.html', redirect_field_name=REDIRECT_FIELD_NAME): "Displays the login form and handles the login action." - manipulator = AuthenticationForm() redirect_to = request.REQUEST.get(redirect_field_name, '') - if request.POST: - errors = manipulator.get_validation_errors(request.POST) - if not errors: + if request.method == "POST": + form = AuthenticationForm(data=request.POST) + if form.is_valid(): # Light security check -- make sure redirect_to isn't garbage. if not redirect_to or '//' in redirect_to or ' ' in redirect_to: from django.conf import settings redirect_to = settings.LOGIN_REDIRECT_URL from django.contrib.auth import login - login(request, manipulator.get_user()) + login(request, form.get_user()) if request.session.test_cookie_worked(): request.session.delete_test_cookie() return HttpResponseRedirect(redirect_to) else: - errors = {} + form = AuthenticationForm(request) request.session.set_test_cookie() - if Site._meta.installed: current_site = Site.objects.get_current() else: current_site = RequestSite(request) - return render_to_response(template_name, { - 'form': oldforms.FormWrapper(manipulator, request.POST, errors), + 'form': form, redirect_field_name: redirect_to, 'site_name': current_site.name, }, context_instance=RequestContext(request)) @@ -66,13 +66,11 @@ def redirect_to_login(next, login_url=None, redirect_field_name=REDIRECT_FIELD_N return HttpResponseRedirect('%s?%s=%s' % (login_url, urlquote(redirect_field_name), urlquote(next))) def password_reset(request, is_admin_site=False, template_name='registration/password_reset_form.html', - email_template_name='registration/password_reset_email.html'): - new_data, errors = {}, {} - form = PasswordResetForm() - if request.POST: - new_data = request.POST.copy() - errors = form.get_validation_errors(new_data) - if not errors: + email_template_name='registration/password_reset_email.html', + password_reset_form=PasswordResetForm): + if request.method == "POST": + form = password_reset_form(request.POST) + if form.is_valid(): if is_admin_site: form.save(domain_override=request.META['HTTP_HOST']) else: @@ -81,24 +79,57 @@ def password_reset(request, is_admin_site=False, template_name='registration/pas else: form.save(domain_override=RequestSite(request).domain, email_template_name=email_template_name) return HttpResponseRedirect('%sdone/' % request.path) - return render_to_response(template_name, {'form': oldforms.FormWrapper(form, new_data, errors)}, - context_instance=RequestContext(request)) + else: + form = password_reset_form() + return render_to_response(template_name, { + 'form': form, + }, context_instance=RequestContext(request)) def password_reset_done(request, template_name='registration/password_reset_done.html'): return render_to_response(template_name, context_instance=RequestContext(request)) def password_change(request, template_name='registration/password_change_form.html'): - new_data, errors = {}, {} - form = PasswordChangeForm(request.user) - if request.POST: - new_data = request.POST.copy() - errors = form.get_validation_errors(new_data) - if not errors: - form.save(new_data) + if request.method == "POST": + form = PasswordChangeForm(request.user, request.POST) + if form.is_valid(): + form.save() return HttpResponseRedirect('%sdone/' % request.path) - return render_to_response(template_name, {'form': oldforms.FormWrapper(form, new_data, errors)}, - context_instance=RequestContext(request)) + else: + form = PasswordChangeForm(request.user) + return render_to_response(template_name, { + 'form': form, + }, context_instance=RequestContext(request)) password_change = login_required(password_change) def password_change_done(request, template_name='registration/password_change_done.html'): return render_to_response(template_name, context_instance=RequestContext(request)) + +# TODO: move to admin.py in the ModelAdmin +def user_change_password(request, id): + if not request.user.has_perm('auth.change_user'): + raise PermissionDenied + user = get_object_or_404(User, pk=id) + if request.method == 'POST': + form = AdminPasswordChangeForm(user, request.POST) + if form.is_valid(): + new_user = form.save() + msg = _('Password changed successfully.') + request.user.message_set.create(message=msg) + return HttpResponseRedirect('..') + else: + form = AdminPasswordChangeForm(user) + return render_to_response('admin/auth/user/change_password.html', { + 'title': _('Change password: %s') % escape(user.username), + 'form': form, + 'is_popup': '_popup' in request.REQUEST, + 'add': True, + 'change': False, + 'has_delete_permission': False, + 'has_change_permission': True, + 'has_absolute_url': False, + 'opts': User._meta, + 'original': user, + 'save_as': False, + 'show_save': True, + 'root_path': re.sub('auth/user/(\d+)/password/$', '', request.path), + }, context_instance=RequestContext(request)) diff --git a/django/contrib/comments/admin.py b/django/contrib/comments/admin.py new file mode 100644 index 0000000000..81ecc699c7 --- /dev/null +++ b/django/contrib/comments/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin +from django.contrib.comments.models import Comment, FreeComment + + +class CommentAdmin(admin.ModelAdmin): + fieldsets = ( + (None, {'fields': ('content_type', 'object_id', 'site')}), + ('Content', {'fields': ('user', 'headline', 'comment')}), + ('Ratings', {'fields': ('rating1', 'rating2', 'rating3', 'rating4', 'rating5', 'rating6', 'rating7', 'rating8', 'valid_rating')}), + ('Meta', {'fields': ('is_public', 'is_removed', 'ip_address')}), + ) + list_display = ('user', 'submit_date', 'content_type', 'get_content_object') + list_filter = ('submit_date',) + date_hierarchy = 'submit_date' + search_fields = ('comment', 'user__username') + raw_id_fields = ('user',) + +class FreeCommentAdmin(admin.ModelAdmin): + fieldsets = ( + (None, {'fields': ('content_type', 'object_id', 'site')}), + ('Content', {'fields': ('person_name', 'comment')}), + ('Meta', {'fields': ('is_public', 'ip_address', 'approved')}), + ) + list_display = ('person_name', 'submit_date', 'content_type', 'get_content_object') + list_filter = ('submit_date',) + date_hierarchy = 'submit_date' + search_fields = ('comment', 'person_name') + +admin.site.register(Comment, CommentAdmin) +admin.site.register(FreeComment, FreeCommentAdmin)
\ No newline at end of file diff --git a/django/contrib/comments/models.py b/django/contrib/comments/models.py index d0c54b85cb..a13fec9e6e 100644 --- a/django/contrib/comments/models.py +++ b/django/contrib/comments/models.py @@ -66,7 +66,7 @@ class CommentManager(models.Manager): class Comment(models.Model): """A comment by a registered user.""" - user = models.ForeignKey(User, raw_id_admin=True) + user = models.ForeignKey(User) content_type = models.ForeignKey(ContentType) object_id = models.IntegerField(_('object ID')) headline = models.CharField(_('headline'), max_length=255, blank=True) @@ -96,18 +96,6 @@ class Comment(models.Model): verbose_name_plural = _('comments') ordering = ('-submit_date',) - class Admin: - fields = ( - (None, {'fields': ('content_type', 'object_id', 'site')}), - ('Content', {'fields': ('user', 'headline', 'comment')}), - ('Ratings', {'fields': ('rating1', 'rating2', 'rating3', 'rating4', 'rating5', 'rating6', 'rating7', 'rating8', 'valid_rating')}), - ('Meta', {'fields': ('is_public', 'is_removed', 'ip_address')}), - ) - list_display = ('user', 'submit_date', 'content_type', 'get_content_object') - list_filter = ('submit_date',) - date_hierarchy = 'submit_date' - search_fields = ('comment', 'user__username') - def __unicode__(self): return "%s: %s..." % (self.user.username, self.comment[:100]) @@ -188,17 +176,6 @@ class FreeComment(models.Model): verbose_name_plural = _('free comments') ordering = ('-submit_date',) - class Admin: - fields = ( - (None, {'fields': ('content_type', 'object_id', 'site')}), - ('Content', {'fields': ('person_name', 'comment')}), - ('Meta', {'fields': ('submit_date', 'is_public', 'ip_address', 'approved')}), - ) - list_display = ('person_name', 'submit_date', 'content_type', 'get_content_object') - list_filter = ('submit_date',) - date_hierarchy = 'submit_date' - search_fields = ('comment', 'person_name') - def __unicode__(self): return "%s: %s..." % (self.person_name, self.comment[:100]) @@ -306,3 +283,4 @@ class ModeratorDeletion(models.Model): def __unicode__(self): return _("Moderator deletion by %r") % self.user +
\ No newline at end of file diff --git a/django/contrib/comments/views/comments.py b/django/contrib/comments/views/comments.py index 67da5759ac..ba59cbafc9 100644 --- a/django/contrib/comments/views/comments.py +++ b/django/contrib/comments/views/comments.py @@ -1,3 +1,6 @@ +import base64 +import datetime + from django.core import validators from django import oldforms from django.core.mail import mail_admins, mail_managers @@ -7,16 +10,61 @@ from django.shortcuts import render_to_response from django.template import RequestContext from django.contrib.comments.models import Comment, FreeComment, RATINGS_REQUIRED, RATINGS_OPTIONAL, IS_PUBLIC from django.contrib.contenttypes.models import ContentType -from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth import authenticate from django.http import HttpResponseRedirect from django.utils.text import normalize_newlines from django.conf import settings from django.utils.translation import ungettext, ugettext as _ from django.utils.encoding import smart_unicode -import base64, datetime COMMENTS_PER_PAGE = 20 +# TODO: This is a copy of the manipulator-based form that used to live in +# contrib.auth.forms. It should be replaced with the newforms version that +# has now been added to contrib.auth.forms when the comments app gets updated +# for newforms. + +class AuthenticationForm(oldforms.Manipulator): + """ + Base class for authenticating users. Extend this to get a form that accepts + username/password logins. + """ + def __init__(self, request=None): + """ + If request is passed in, the manipulator will validate that cookies are + enabled. Note that the request (a HttpRequest object) must have set a + cookie with the key TEST_COOKIE_NAME and value TEST_COOKIE_VALUE before + running this validator. + """ + self.request = request + self.fields = [ + oldforms.TextField(field_name="username", length=15, max_length=30, is_required=True, + validator_list=[self.isValidUser, self.hasCookiesEnabled]), + oldforms.PasswordField(field_name="password", length=15, max_length=30, is_required=True), + ] + self.user_cache = None + + def hasCookiesEnabled(self, field_data, all_data): + if self.request and not self.request.session.test_cookie_worked(): + raise validators.ValidationError, _("Your Web browser doesn't appear to have cookies enabled. Cookies are required for logging in.") + + def isValidUser(self, field_data, all_data): + username = field_data + password = all_data.get('password', None) + self.user_cache = authenticate(username=username, password=password) + if self.user_cache is None: + raise validators.ValidationError, _("Please enter a correct username and password. Note that both fields are case-sensitive.") + elif not self.user_cache.is_active: + raise validators.ValidationError, _("This account is inactive.") + + def get_user_id(self): + if self.user_cache: + return self.user_cache.id + return None + + def get_user(self): + return self.user_cache + class PublicCommentManipulator(AuthenticationForm): "Manipulator that handles public registered comments" def __init__(self, user, ratings_required, ratings_range, num_rating_choices): diff --git a/django/contrib/flatpages/admin.py b/django/contrib/flatpages/admin.py new file mode 100644 index 0000000000..02bbaf6b1a --- /dev/null +++ b/django/contrib/flatpages/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from django.contrib.flatpages.models import FlatPage +from django.utils.translation import ugettext_lazy as _ + + +class FlatPageAdmin(admin.ModelAdmin): + fieldsets = ( + (None, {'fields': ('url', 'title', 'content', 'sites')}), + (_('Advanced options'), {'classes': ('collapse',), 'fields': ('enable_comments', 'registration_required', 'template_name')}), + ) + list_display = ('url', 'title') + list_filter = ('sites', 'enable_comments', 'registration_required') + search_fields = ('url', 'title') + +admin.site.register(FlatPage, FlatPageAdmin)
\ No newline at end of file diff --git a/django/contrib/flatpages/models.py b/django/contrib/flatpages/models.py index d61e9a3b1c..466425cb46 100644 --- a/django/contrib/flatpages/models.py +++ b/django/contrib/flatpages/models.py @@ -20,16 +20,7 @@ class FlatPage(models.Model): verbose_name = _('flat page') verbose_name_plural = _('flat pages') ordering = ('url',) - - class Admin: - fields = ( - (None, {'fields': ('url', 'title', 'content', 'sites')}), - (_('Advanced options'), {'classes': 'collapse', 'fields': ('enable_comments', 'registration_required', 'template_name')}), - ) - list_display = ('url', 'title') - list_filter = ('sites', 'enable_comments', 'registration_required') - search_fields = ('url', 'title') - + def __unicode__(self): return u"%s -- %s" % (self.url, self.title) diff --git a/django/contrib/redirects/models.py b/django/contrib/redirects/models.py index 1720f33466..991423268d 100644 --- a/django/contrib/redirects/models.py +++ b/django/contrib/redirects/models.py @@ -3,7 +3,7 @@ from django.contrib.sites.models import Site from django.utils.translation import ugettext_lazy as _ class Redirect(models.Model): - site = models.ForeignKey(Site, radio_admin=models.VERTICAL) + site = models.ForeignKey(Site) old_path = models.CharField(_('redirect from'), max_length=200, db_index=True, help_text=_("This should be an absolute path, excluding the domain name. Example: '/events/search/'.")) new_path = models.CharField(_('redirect to'), max_length=200, blank=True, @@ -15,11 +15,21 @@ class Redirect(models.Model): db_table = 'django_redirect' unique_together=(('site', 'old_path'),) ordering = ('old_path',) + + def __unicode__(self): + return "%s ---> %s" % (self.old_path, self.new_path) - class Admin: - list_display = ('old_path', 'new_path') - list_filter = ('site',) - search_fields = ('old_path', 'new_path') +# Register the admin options for these models. +# TODO: Maybe this should live in a separate module admin.py, but how would we +# ensure that module was loaded? + +from django.contrib import admin + +class RedirectAdmin(admin.ModelAdmin): + list_display = ('old_path', 'new_path') + list_filter = ('site',) + search_fields = ('old_path', 'new_path') + radio_fields = {'site': admin.VERTICAL} + +admin.site.register(Redirect, RedirectAdmin) - def __unicode__(self): - return u"%s ---> %s" % (self.old_path, self.new_path) diff --git a/django/contrib/sites/admin.py b/django/contrib/sites/admin.py new file mode 100644 index 0000000000..2442c24292 --- /dev/null +++ b/django/contrib/sites/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from django.contrib.sites.models import Site + + +class SiteAdmin(admin.ModelAdmin): + list_display = ('domain', 'name') + search_fields = ('domain', 'name') + +admin.site.register(Site, SiteAdmin)
\ No newline at end of file diff --git a/django/contrib/sites/models.py b/django/contrib/sites/models.py index c928a4e852..c44e5ce11f 100644 --- a/django/contrib/sites/models.py +++ b/django/contrib/sites/models.py @@ -32,18 +32,16 @@ class Site(models.Model): domain = models.CharField(_('domain name'), max_length=100) name = models.CharField(_('display name'), max_length=50) objects = SiteManager() + class Meta: db_table = 'django_site' verbose_name = _('site') verbose_name_plural = _('sites') ordering = ('domain',) - class Admin: - list_display = ('domain', 'name') - search_fields = ('domain', 'name') def __unicode__(self): return self.domain - + def delete(self): pk = self.pk super(Site, self).delete() @@ -51,7 +49,6 @@ class Site(models.Model): del(SITE_CACHE[pk]) except KeyError: pass - class RequestSite(object): """ diff --git a/django/core/management/validation.py b/django/core/management/validation.py index cd1f84f34b..e17409ae5d 100644 --- a/django/core/management/validation.py +++ b/django/core/management/validation.py @@ -51,8 +51,6 @@ def get_validation_errors(outfile, app=None): from PIL import Image except ImportError: e.add(opts, '"%s": To use ImageFields, you need to install the Python Imaging Library. Get it at http://www.pythonware.com/products/pil/ .' % f.name) - if f.prepopulate_from is not None and type(f.prepopulate_from) not in (list, tuple): - e.add(opts, '"%s": prepopulate_from should be a list or tuple.' % f.name) if f.choices: if isinstance(f.choices, basestring) or not is_iterable(f.choices): e.add(opts, '"%s": "choices" should be iterable (e.g., a tuple or list).' % f.name) @@ -145,54 +143,6 @@ def get_validation_errors(outfile, app=None): if r.get_accessor_name() == rel_query_name: e.add(opts, "Reverse query name for m2m field '%s' clashes with related field '%s.%s'. Add a related_name argument to the definition for '%s'." % (f.name, rel_opts.object_name, r.get_accessor_name(), f.name)) - # Check admin attribute. - if opts.admin is not None: - if not isinstance(opts.admin, models.AdminOptions): - e.add(opts, '"admin" attribute, if given, must be set to a models.AdminOptions() instance.') - else: - # list_display - if not isinstance(opts.admin.list_display, (list, tuple)): - e.add(opts, '"admin.list_display", if given, must be set to a list or tuple.') - else: - for fn in opts.admin.list_display: - try: - f = opts.get_field(fn) - except models.FieldDoesNotExist: - if not hasattr(cls, fn): - e.add(opts, '"admin.list_display" refers to %r, which isn\'t an attribute, method or property.' % fn) - else: - if isinstance(f, models.ManyToManyField): - e.add(opts, '"admin.list_display" doesn\'t support ManyToManyFields (%r).' % fn) - # list_display_links - if opts.admin.list_display_links and not opts.admin.list_display: - e.add(opts, '"admin.list_display" must be defined for "admin.list_display_links" to be used.') - if not isinstance(opts.admin.list_display_links, (list, tuple)): - e.add(opts, '"admin.list_display_links", if given, must be set to a list or tuple.') - else: - for fn in opts.admin.list_display_links: - try: - f = opts.get_field(fn) - except models.FieldDoesNotExist: - if not hasattr(cls, fn): - e.add(opts, '"admin.list_display_links" refers to %r, which isn\'t an attribute, method or property.' % fn) - if fn not in opts.admin.list_display: - e.add(opts, '"admin.list_display_links" refers to %r, which is not defined in "admin.list_display".' % fn) - # list_filter - if not isinstance(opts.admin.list_filter, (list, tuple)): - e.add(opts, '"admin.list_filter", if given, must be set to a list or tuple.') - else: - for fn in opts.admin.list_filter: - try: - f = opts.get_field(fn) - except models.FieldDoesNotExist: - e.add(opts, '"admin.list_filter" refers to %r, which isn\'t a field.' % fn) - # date_hierarchy - if opts.admin.date_hierarchy: - try: - f = opts.get_field(opts.admin.date_hierarchy) - except models.FieldDoesNotExist: - e.add(opts, '"admin.date_hierarchy" refers to %r, which isn\'t a field.' % opts.admin.date_hierarchy) - # Check ordering attribute. if opts.ordering: for field_name in opts.ordering: @@ -210,18 +160,6 @@ def get_validation_errors(outfile, app=None): except models.FieldDoesNotExist: e.add(opts, '"ordering" refers to "%s", a field that doesn\'t exist.' % field_name) - # Check core=True, if needed. - for related in opts.get_followed_related_objects(): - if not related.edit_inline: - continue - try: - for f in related.opts.fields: - if f.core: - raise StopIteration - e.add(related.opts, "At least one field in %s should have core=True, because it's being edited inline by %s.%s." % (related.opts.object_name, opts.module_name, opts.object_name)) - except StopIteration: - pass - # Check unique_together. for ut in opts.unique_together: for field_name in ut: diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index 86763d99f9..bd6cc3542d 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -5,7 +5,7 @@ from django.db import connection from django.db.models.loading import get_apps, get_app, get_models, get_model, register_models from django.db.models.query import Q from django.db.models.manager import Manager -from django.db.models.base import Model, AdminOptions +from django.db.models.base import Model from django.db.models.fields import * from django.db.models.fields.subclassing import SubfieldBase from django.db.models.fields.related import ForeignKey, OneToOneField, ManyToManyField, ManyToOneRel, ManyToManyRel, OneToOneRel, TABULAR, STACKED diff --git a/django/db/models/base.py b/django/db/models/base.py index 757f2378ce..e2ba49ee8c 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -15,7 +15,7 @@ from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, from django.db.models.fields import AutoField, ImageField, FieldDoesNotExist from django.db.models.fields.related import OneToOneRel, ManyToOneRel, OneToOneField from django.db.models.query import delete_objects, Q, CollectedObjects -from django.db.models.options import Options, AdminOptions +from django.db.models.options import Options from django.db import connection, transaction from django.db.models import signals from django.db.models.loading import register_models, get_model @@ -137,9 +137,6 @@ class ModelBase(type): return get_model(new_class._meta.app_label, name, False) def add_to_class(cls, name, value): - if name == 'Admin': - assert type(value) == types.ClassType, "%r attribute of %s model must be a class, not a %s object" % (name, cls.__name__, type(value)) - value = AdminOptions(**dict([(k, v) for k, v in value.__dict__.items() if not k.startswith('_')])) if hasattr(value, 'contribute_to_class'): value.contribute_to_class(cls, name) else: diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index 713343cc15..879807d2d2 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -28,16 +28,10 @@ from django.utils import datetime_safe class NOT_PROVIDED: pass -# Values for filter_interface. -HORIZONTAL, VERTICAL = 1, 2 - # The values to use for "blank" in SelectFields. Will be appended to the start of most "choices" lists. BLANK_CHOICE_DASH = [("", "---------")] BLANK_CHOICE_NONE = [("", "None")] -# returns the <ul> class for a given radio_admin value -get_ul_class = lambda x: 'radiolist%s' % ((x == HORIZONTAL) and ' inline' or '') - class FieldDoesNotExist(Exception): pass @@ -85,10 +79,10 @@ class Field(object): def __init__(self, verbose_name=None, name=None, primary_key=False, max_length=None, unique=False, blank=False, null=False, db_index=False, core=False, rel=None, default=NOT_PROVIDED, - editable=True, serialize=True, prepopulate_from=None, - unique_for_date=None, unique_for_month=None, unique_for_year=None, - validator_list=None, choices=None, radio_admin=None, help_text='', - db_column=None, db_tablespace=None, auto_created=False): + editable=True, serialize=True, unique_for_date=None, + unique_for_month=None, unique_for_year=None, validator_list=None, + choices=None, help_text='', db_column=None, db_tablespace=None, + auto_created=False): self.name = name self.verbose_name = verbose_name self.primary_key = primary_key @@ -102,11 +96,9 @@ class Field(object): self.editable = editable self.serialize = serialize self.validator_list = validator_list or [] - self.prepopulate_from = prepopulate_from self.unique_for_date, self.unique_for_month = unique_for_date, unique_for_month self.unique_for_year = unique_for_year self._choices = choices or [] - self.radio_admin = radio_admin self.help_text = help_text self.db_column = db_column self.db_tablespace = db_tablespace or settings.DEFAULT_INDEX_TABLESPACE @@ -294,11 +286,7 @@ class Field(object): params['max_length'] = self.max_length if self.choices: - if self.radio_admin: - field_objs = [oldforms.RadioSelectField] - params['ul_class'] = get_ul_class(self.radio_admin) - else: - field_objs = [oldforms.SelectField] + field_objs = [oldforms.SelectField] params['choices'] = self.get_choices_default() else: @@ -386,10 +374,7 @@ class Field(object): return first_choice + lst def get_choices_default(self): - if self.radio_admin: - return self.get_choices(include_blank=self.blank, blank_choice=BLANK_CHOICE_NONE) - else: - return self.get_choices() + return self.get_choices() def _get_val_from_obj(self, obj): if obj: @@ -1012,7 +997,11 @@ class NullBooleanField(Field): return [oldforms.NullBooleanField] def formfield(self, **kwargs): - defaults = {'form_class': forms.NullBooleanField} + defaults = { + 'form_class': forms.NullBooleanField, + 'required': not self.blank, + 'label': capfirst(self.verbose_name), + 'help_text': self.help_text} defaults.update(kwargs) return super(NullBooleanField, self).formfield(**defaults) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index a1977c07b4..594236b4c6 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -1,6 +1,6 @@ from django.db import connection, transaction from django.db.models import signals, get_model -from django.db.models.fields import AutoField, Field, IntegerField, PositiveIntegerField, PositiveSmallIntegerField, get_ul_class, FieldDoesNotExist +from django.db.models.fields import AutoField, Field, IntegerField, PositiveIntegerField, PositiveSmallIntegerField, FieldDoesNotExist from django.db.models.related import RelatedObject from django.db.models.query_utils import QueryWrapper from django.utils.text import capfirst @@ -541,7 +541,7 @@ class ManyToOneRel(object): def __init__(self, to, field_name, num_in_admin=3, min_num_in_admin=None, max_num_in_admin=None, num_extra_on_change=1, edit_inline=False, related_name=None, limit_choices_to=None, lookup_overrides=None, - raw_id_admin=False, parent_link=False): + parent_link=False): try: to._meta except AttributeError: # to._meta doesn't exist, so it must be RECURSIVE_RELATIONSHIP_CONSTANT @@ -554,7 +554,6 @@ class ManyToOneRel(object): limit_choices_to = {} self.limit_choices_to = limit_choices_to self.lookup_overrides = lookup_overrides or {} - self.raw_id_admin = raw_id_admin self.multiple = True self.parent_link = parent_link @@ -573,34 +572,29 @@ class OneToOneRel(ManyToOneRel): def __init__(self, to, field_name, num_in_admin=0, min_num_in_admin=None, max_num_in_admin=None, num_extra_on_change=None, edit_inline=False, related_name=None, limit_choices_to=None, lookup_overrides=None, - raw_id_admin=False, parent_link=False): + parent_link=False): # NOTE: *_num_in_admin and num_extra_on_change are intentionally # ignored here. We accept them as parameters only to match the calling # signature of ManyToOneRel.__init__(). super(OneToOneRel, self).__init__(to, field_name, num_in_admin, edit_inline=edit_inline, related_name=related_name, limit_choices_to=limit_choices_to, - lookup_overrides=lookup_overrides, raw_id_admin=raw_id_admin, - parent_link=parent_link) + lookup_overrides=lookup_overrides, parent_link=parent_link) self.multiple = False class ManyToManyRel(object): def __init__(self, to, num_in_admin=0, related_name=None, - filter_interface=None, limit_choices_to=None, raw_id_admin=False, symmetrical=True): + limit_choices_to=None, symmetrical=True): self.to = to self.num_in_admin = num_in_admin self.related_name = related_name - self.filter_interface = filter_interface if limit_choices_to is None: limit_choices_to = {} self.limit_choices_to = limit_choices_to self.edit_inline = False - self.raw_id_admin = raw_id_admin self.symmetrical = symmetrical self.multiple = True - assert not (self.raw_id_admin and self.filter_interface), "ManyToManyRels may not use both raw_id_admin and filter_interface" - class ForeignKey(RelatedField, Field): empty_strings_allowed = False def __init__(self, to, to_field=None, rel_class=ManyToOneRel, **kwargs): @@ -626,7 +620,6 @@ class ForeignKey(RelatedField, Field): related_name=kwargs.pop('related_name', None), limit_choices_to=kwargs.pop('limit_choices_to', None), lookup_overrides=kwargs.pop('lookup_overrides', None), - raw_id_admin=kwargs.pop('raw_id_admin', False), parent_link=kwargs.pop('parent_link', False)) Field.__init__(self, **kwargs) @@ -640,19 +633,11 @@ class ForeignKey(RelatedField, Field): def prepare_field_objs_and_params(self, manipulator, name_prefix): params = {'validator_list': self.validator_list[:], 'member_name': name_prefix + self.attname} - if self.rel.raw_id_admin: - field_objs = self.get_manipulator_field_objs() - params['validator_list'].append(curry(manipulator_valid_rel_key, self, manipulator)) + if self.null: + field_objs = [oldforms.NullSelectField] else: - if self.radio_admin: - field_objs = [oldforms.RadioSelectField] - params['ul_class'] = get_ul_class(self.radio_admin) - else: - if self.null: - field_objs = [oldforms.NullSelectField] - else: - field_objs = [oldforms.SelectField] - params['choices'] = self.get_choices_default() + field_objs = [oldforms.SelectField] + params['choices'] = self.get_choices_default() return field_objs, params def get_default(self): @@ -664,10 +649,7 @@ class ForeignKey(RelatedField, Field): def get_manipulator_field_objs(self): rel_field = self.rel.get_related_field() - if self.rel.raw_id_admin and not isinstance(rel_field, AutoField): - return rel_field.get_manipulator_field_objs() - else: - return [oldforms.IntegerField] + return [oldforms.IntegerField] def get_db_prep_save(self, value): if value == '' or value == None: @@ -679,15 +661,11 @@ class ForeignKey(RelatedField, Field): if not obj: # In required many-to-one fields with only one available choice, # select that one available choice. Note: For SelectFields - # (radio_admin=False), we have to check that the length of choices - # is *2*, not 1, because SelectFields always have an initial - # "blank" value. Otherwise (radio_admin=True), we check that the - # length is 1. - if not self.blank and (not self.rel.raw_id_admin or self.choices): + # we have to check that the length of choices is *2*, not 1, + # because SelectFields always have an initial "blank" value. + if not self.blank and self.choices: choice_list = self.get_choices_default() - if self.radio_admin and len(choice_list) == 1: - return {self.attname: choice_list[0][0]} - if not self.radio_admin and len(choice_list) == 2: + if len(choice_list) == 2: return {self.attname: choice_list[1][0]} return Field.flatten_data(self, follow, obj) @@ -704,7 +682,7 @@ class ForeignKey(RelatedField, Field): setattr(cls, related.get_accessor_name(), ForeignRelatedObjectsDescriptor(related)) def formfield(self, **kwargs): - defaults = {'form_class': forms.ModelChoiceField, 'queryset': self.rel.to._default_manager.all()} + defaults = {'form_class': forms.ModelChoiceField, 'queryset': self.rel.to._default_manager.complex_filter(self.rel.limit_choices_to)} defaults.update(kwargs) return super(ForeignKey, self).formfield(**defaults) @@ -743,27 +721,17 @@ class ManyToManyField(RelatedField, Field): kwargs['rel'] = ManyToManyRel(to, num_in_admin=kwargs.pop('num_in_admin', 0), related_name=kwargs.pop('related_name', None), - filter_interface=kwargs.pop('filter_interface', None), limit_choices_to=kwargs.pop('limit_choices_to', None), - raw_id_admin=kwargs.pop('raw_id_admin', False), symmetrical=kwargs.pop('symmetrical', True)) self.db_table = kwargs.pop('db_table', None) - if kwargs["rel"].raw_id_admin: - kwargs.setdefault("validator_list", []).append(self.isValidIDList) Field.__init__(self, **kwargs) - if self.rel.raw_id_admin: - msg = ugettext_lazy('Separate multiple IDs with commas.') - else: - msg = ugettext_lazy('Hold down "Control", or "Command" on a Mac, to select more than one.') + msg = ugettext_lazy('Hold down "Control", or "Command" on a Mac, to select more than one.') self.help_text = string_concat(self.help_text, ' ', msg) def get_manipulator_field_objs(self): - if self.rel.raw_id_admin: - return [oldforms.RawIdAdminField] - else: - choices = self.get_choices_default() - return [curry(oldforms.SelectMultipleField, size=min(max(len(choices), 5), 15), choices=choices)] + choices = self.get_choices_default() + return [curry(oldforms.SelectMultipleField, size=min(max(len(choices), 5), 15), choices=choices)] def get_choices_default(self): return Field.get_choices(self, include_blank=False) @@ -812,14 +780,11 @@ class ManyToManyField(RelatedField, Field): new_data = {} if obj: instance_ids = [instance._get_pk_val() for instance in getattr(obj, self.name).all()] - if self.rel.raw_id_admin: - new_data[self.name] = u",".join([smart_unicode(id) for id in instance_ids]) - else: - new_data[self.name] = instance_ids + new_data[self.name] = instance_ids else: # In required many-to-many fields with only one available choice, # select that one available choice. - if not self.blank and not self.rel.edit_inline and not self.rel.raw_id_admin: + if not self.blank and not self.rel.edit_inline: choices_list = self.get_choices_default() if len(choices_list) == 1: new_data[self.name] = [choices_list[0][0]] @@ -861,7 +826,7 @@ class ManyToManyField(RelatedField, Field): setattr(instance, self.attname, data) def formfield(self, **kwargs): - defaults = {'form_class': forms.ModelMultipleChoiceField, 'queryset': self.rel.to._default_manager.all()} + defaults = {'form_class': forms.ModelMultipleChoiceField, 'queryset': self.rel.to._default_manager.complex_filter(self.rel.limit_choices_to)} defaults.update(kwargs) # If initial is passed in, it's a list of related objects, but the # MultipleChoiceField takes a list of IDs. diff --git a/django/db/models/manipulators.py b/django/db/models/manipulators.py index 2d953260fb..4e6ddca26e 100644 --- a/django/db/models/manipulators.py +++ b/django/db/models/manipulators.py @@ -120,10 +120,7 @@ class AutomaticManipulator(oldforms.Manipulator): for f in self.opts.many_to_many: if self.follow.get(f.name, None): if not f.rel.edit_inline: - if f.rel.raw_id_admin: - new_vals = new_data.get(f.name, ()) - else: - new_vals = new_data.getlist(f.name) + new_vals = new_data.getlist(f.name) # First, clear the existing values. rel_manager = getattr(new_object, f.name) rel_manager.clear() @@ -220,8 +217,6 @@ class AutomaticManipulator(oldforms.Manipulator): for f in related.opts.many_to_many: if child_follow.get(f.name, None) and not f.rel.edit_inline: new_value = rel_new_data[f.attname] - if f.rel.raw_id_admin: - new_value = new_value[0] setattr(new_rel_obj, f.name, f.rel.to.objects.filter(pk__in=new_value)) if self.change: self.fields_changed.append('%s for %s "%s"' % (f.verbose_name, related.opts.verbose_name, new_rel_obj)) diff --git a/django/db/models/options.py b/django/db/models/options.py index a81a34d722..ffea6d5082 100644 --- a/django/db/models/options.py +++ b/django/db/models/options.py @@ -11,7 +11,6 @@ from django.db.models.fields.related import ManyToManyRel from django.db.models.fields import AutoField, FieldDoesNotExist from django.db.models.fields.proxy import OrderWrt from django.db.models.loading import get_models, app_cache_ready -from django.db.models import Manager from django.utils.translation import activate, deactivate_all, get_language, string_concat from django.utils.encoding import force_unicode, smart_str from django.utils.datastructures import SortedDict @@ -485,77 +484,3 @@ class Options(object): else: self._field_types[field_type] = False return self._field_types[field_type] - -class AdminOptions(object): - def __init__(self, fields=None, js=None, list_display=None, list_display_links=None, list_filter=None, - date_hierarchy=None, save_as=False, ordering=None, search_fields=None, - save_on_top=False, list_select_related=False, manager=None, list_per_page=100): - self.fields = fields - self.js = js or [] - self.list_display = list_display or ['__str__'] - self.list_display_links = list_display_links or [] - self.list_filter = list_filter or [] - self.date_hierarchy = date_hierarchy - self.save_as, self.ordering = save_as, ordering - self.search_fields = search_fields or [] - self.save_on_top = save_on_top - self.list_select_related = list_select_related - self.list_per_page = list_per_page - self.manager = manager or Manager() - - def get_field_sets(self, opts): - "Returns a list of AdminFieldSet objects for this AdminOptions object." - if self.fields is None: - field_struct = ((None, {'fields': [f.name for f in opts.fields + opts.many_to_many if f.editable and not isinstance(f, AutoField)]}),) - else: - field_struct = self.fields - new_fieldset_list = [] - for fieldset in field_struct: - fs_options = fieldset[1] - classes = fs_options.get('classes', ()) - description = fs_options.get('description', '') - new_fieldset_list.append(AdminFieldSet(fieldset[0], classes, - opts.get_field, fs_options['fields'], description)) - return new_fieldset_list - - def contribute_to_class(self, cls, name): - cls._meta.admin = self - # Make sure the admin manager has access to the model - self.manager.model = cls - -class AdminFieldSet(object): - def __init__(self, name, classes, field_locator_func, line_specs, description): - self.name = name - self.field_lines = [AdminFieldLine(field_locator_func, line_spec) for line_spec in line_specs] - self.classes = classes - self.description = description - - def __repr__(self): - return "FieldSet: (%s, %s)" % (self.name, self.field_lines) - - def bind(self, field_mapping, original, bound_field_set_class): - return bound_field_set_class(self, field_mapping, original) - - def __iter__(self): - for field_line in self.field_lines: - yield field_line - - def __len__(self): - return len(self.field_lines) - -class AdminFieldLine(object): - def __init__(self, field_locator_func, linespec): - if isinstance(linespec, basestring): - self.fields = [field_locator_func(linespec)] - else: - self.fields = [field_locator_func(field_name) for field_name in linespec] - - def bind(self, field_mapping, original, bound_field_line_class): - return bound_field_line_class(self, field_mapping, original) - - def __iter__(self): - for field in self.fields: - yield field - - def __len__(self): - return len(self.fields) diff --git a/django/newforms/__init__.py b/django/newforms/__init__.py index 0d9c68f9e0..99631e4e8f 100644 --- a/django/newforms/__init__.py +++ b/django/newforms/__init__.py @@ -15,3 +15,4 @@ from widgets import * from fields import * from forms import * from models import * +from formsets import *
\ No newline at end of file diff --git a/django/newforms/forms.py b/django/newforms/forms.py index fc203f36b5..753ee254bc 100644 --- a/django/newforms/forms.py +++ b/django/newforms/forms.py @@ -10,7 +10,7 @@ from django.utils.encoding import StrAndUnicode, smart_unicode, force_unicode from django.utils.safestring import mark_safe from fields import Field, FileField -from widgets import TextInput, Textarea +from widgets import Media, media_property, TextInput, Textarea from util import flatatt, ErrorDict, ErrorList, ValidationError __all__ = ('BaseForm', 'Form') @@ -31,6 +31,7 @@ def get_declared_fields(bases, attrs, with_base_fields=True): If 'with_base_fields' is True, all fields from the bases are used. Otherwise, only fields in the 'declared_fields' attribute on the bases are used. The distinction is useful in ModelForm subclassing. + Also integrates any additional media definitions """ fields = [(field_name, attrs.pop(field_name)) for field_name, obj in attrs.items() if isinstance(obj, Field)] fields.sort(lambda x, y: cmp(x[1].creation_counter, y[1].creation_counter)) @@ -56,8 +57,11 @@ class DeclarativeFieldsMetaclass(type): """ def __new__(cls, name, bases, attrs): attrs['base_fields'] = get_declared_fields(bases, attrs) - return super(DeclarativeFieldsMetaclass, + new_class = super(DeclarativeFieldsMetaclass, cls).__new__(cls, name, bases, attrs) + if 'media' not in attrs: + new_class.media = media_property(new_class) + return new_class class BaseForm(StrAndUnicode): # This is the main implementation of all the Form logic. Note that this @@ -65,7 +69,8 @@ class BaseForm(StrAndUnicode): # information. Any improvements to the form API should be made to *this* # class, not to the Form class. def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, - initial=None, error_class=ErrorList, label_suffix=':'): + initial=None, error_class=ErrorList, label_suffix=':', + empty_permitted=False): self.is_bound = data is not None or files is not None self.data = data or {} self.files = files or {} @@ -74,7 +79,9 @@ class BaseForm(StrAndUnicode): self.initial = initial or {} self.error_class = error_class self.label_suffix = label_suffix + self.empty_permitted = empty_permitted self._errors = None # Stores the errors after clean() has been called. + self._changed_data = None # The base_fields class attribute is the *class-wide* definition of # fields. Because a particular *instance* of the class might want to @@ -194,6 +201,10 @@ class BaseForm(StrAndUnicode): if not self.is_bound: # Stop further processing. return self.cleaned_data = {} + # If the form is permitted to be empty, and none of the form data has + # changed from the initial data, short circuit any validation. + if self.empty_permitted and not self.has_changed(): + return for name, field in self.fields.items(): # value_from_datadict() gets the data from the data dictionaries. # Each widget type knows how to retrieve its own data, because some @@ -229,6 +240,40 @@ class BaseForm(StrAndUnicode): """ return self.cleaned_data + def has_changed(self): + """ + Returns True if data differs from initial. + """ + return bool(self.changed_data) + + def _get_changed_data(self): + if self._changed_data is None: + self._changed_data = [] + # XXX: For now we're asking the individual widgets whether or not the + # data has changed. It would probably be more efficient to hash the + # initial data, store it in a hidden field, and compare a hash of the + # submitted data, but we'd need a way to easily get the string value + # for a given field. Right now, that logic is embedded in the render + # method of each widget. + for name, field in self.fields.items(): + prefixed_name = self.add_prefix(name) + data_value = field.widget.value_from_datadict(self.data, self.files, prefixed_name) + initial_value = self.initial.get(name, field.initial) + if field.widget._has_changed(initial_value, data_value): + self._changed_data.append(name) + return self._changed_data + changed_data = property(_get_changed_data) + + def _get_media(self): + """ + Provide a description of all media required to render the widgets on this form + """ + media = Media() + for field in self.fields.values(): + media = media + field.widget.media + return media + media = property(_get_media) + def is_multipart(self): """ Returns True if the form needs to be multipart-encrypted, i.e. it has diff --git a/django/newforms/formsets.py b/django/newforms/formsets.py new file mode 100644 index 0000000000..1ae27bf58c --- /dev/null +++ b/django/newforms/formsets.py @@ -0,0 +1,292 @@ +from forms import Form +from django.utils.encoding import StrAndUnicode +from django.utils.safestring import mark_safe +from fields import IntegerField, BooleanField +from widgets import Media, HiddenInput, TextInput +from util import ErrorList, ValidationError + +__all__ = ('BaseFormSet', 'all_valid') + +# special field names +TOTAL_FORM_COUNT = 'TOTAL_FORMS' +INITIAL_FORM_COUNT = 'INITIAL_FORMS' +MAX_FORM_COUNT = 'MAX_FORMS' +ORDERING_FIELD_NAME = 'ORDER' +DELETION_FIELD_NAME = 'DELETE' + +class ManagementForm(Form): + """ + ``ManagementForm`` is used to keep track of how many form instances + are displayed on the page. If adding new forms via javascript, you should + increment the count field of this form as well. + """ + def __init__(self, *args, **kwargs): + self.base_fields[TOTAL_FORM_COUNT] = IntegerField(widget=HiddenInput) + self.base_fields[INITIAL_FORM_COUNT] = IntegerField(widget=HiddenInput) + self.base_fields[MAX_FORM_COUNT] = IntegerField(widget=HiddenInput) + super(ManagementForm, self).__init__(*args, **kwargs) + +class BaseFormSet(StrAndUnicode): + """ + A collection of instances of the same Form class. + """ + def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, + initial=None, error_class=ErrorList): + self.is_bound = data is not None or files is not None + self.prefix = prefix or 'form' + self.auto_id = auto_id + self.data = data + self.files = files + self.initial = initial + self.error_class = error_class + self._errors = None + self._non_form_errors = None + # initialization is different depending on whether we recieved data, initial, or nothing + if data or files: + self.management_form = ManagementForm(data, auto_id=self.auto_id, prefix=self.prefix) + if self.management_form.is_valid(): + self._total_form_count = self.management_form.cleaned_data[TOTAL_FORM_COUNT] + self._initial_form_count = self.management_form.cleaned_data[INITIAL_FORM_COUNT] + self._max_form_count = self.management_form.cleaned_data[MAX_FORM_COUNT] + else: + raise ValidationError('ManagementForm data is missing or has been tampered with') + else: + if initial: + self._initial_form_count = len(initial) + if self._initial_form_count > self._max_form_count and self._max_form_count > 0: + self._initial_form_count = self._max_form_count + self._total_form_count = self._initial_form_count + self.extra + else: + self._initial_form_count = 0 + self._total_form_count = self.extra + if self._total_form_count > self._max_form_count and self._max_form_count > 0: + self._total_form_count = self._max_form_count + initial = {TOTAL_FORM_COUNT: self._total_form_count, + INITIAL_FORM_COUNT: self._initial_form_count, + MAX_FORM_COUNT: self._max_form_count} + self.management_form = ManagementForm(initial=initial, auto_id=self.auto_id, prefix=self.prefix) + + # construct the forms in the formset + self._construct_forms() + + def __unicode__(self): + return self.as_table() + + def _construct_forms(self): + # instantiate all the forms and put them in self.forms + self.forms = [] + for i in xrange(self._total_form_count): + self.forms.append(self._construct_form(i)) + + def _construct_form(self, i, **kwargs): + """ + Instantiates and returns the i-th form instance in a formset. + """ + defaults = {'auto_id': self.auto_id, 'prefix': self.add_prefix(i)} + if self.data or self.files: + defaults['data'] = self.data + defaults['files'] = self.files + if self.initial: + try: + defaults['initial'] = self.initial[i] + except IndexError: + pass + # Allow extra forms to be empty. + if i >= self._initial_form_count: + defaults['empty_permitted'] = True + defaults.update(kwargs) + form = self.form(**defaults) + self.add_fields(form, i) + return form + + def _get_initial_forms(self): + """Return a list of all the intial forms in this formset.""" + return self.forms[:self._initial_form_count] + initial_forms = property(_get_initial_forms) + + def _get_extra_forms(self): + """Return a list of all the extra forms in this formset.""" + return self.forms[self._initial_form_count:] + extra_forms = property(_get_extra_forms) + + # Maybe this should just go away? + def _get_cleaned_data(self): + """ + Returns a list of form.cleaned_data dicts for every form in self.forms. + """ + if not self.is_valid(): + raise AttributeError("'%s' object has no attribute 'cleaned_data'" % self.__class__.__name__) + return [form.cleaned_data for form in self.forms] + cleaned_data = property(_get_cleaned_data) + + def _get_deleted_forms(self): + """ + Returns a list of forms that have been marked for deletion. Raises an + AttributeError is deletion is not allowed. + """ + if not self.is_valid() or not self.can_delete: + raise AttributeError("'%s' object has no attribute 'deleted_forms'" % self.__class__.__name__) + # construct _deleted_form_indexes which is just a list of form indexes + # that have had their deletion widget set to True + if not hasattr(self, '_deleted_form_indexes'): + self._deleted_form_indexes = [] + for i in range(0, self._total_form_count): + form = self.forms[i] + # if this is an extra form and hasn't changed, don't consider it + if i >= self._initial_form_count and not form.has_changed(): + continue + if form.cleaned_data[DELETION_FIELD_NAME]: + self._deleted_form_indexes.append(i) + return [self.forms[i] for i in self._deleted_form_indexes] + deleted_forms = property(_get_deleted_forms) + + def _get_ordered_forms(self): + """ + Returns a list of form in the order specified by the incoming data. + Raises an AttributeError is deletion is not allowed. + """ + if not self.is_valid() or not self.can_order: + raise AttributeError("'%s' object has no attribute 'ordered_forms'" % self.__class__.__name__) + # Construct _ordering, which is a list of (form_index, order_field_value) + # tuples. After constructing this list, we'll sort it by order_field_value + # so we have a way to get to the form indexes in the order specified + # by the form data. + if not hasattr(self, '_ordering'): + self._ordering = [] + for i in range(0, self._total_form_count): + form = self.forms[i] + # if this is an extra form and hasn't changed, don't consider it + if i >= self._initial_form_count and not form.has_changed(): + continue + # don't add data marked for deletion to self.ordered_data + if self.can_delete and form.cleaned_data[DELETION_FIELD_NAME]: + continue + # A sort function to order things numerically ascending, but + # None should be sorted below anything else. Allowing None as + # a comparison value makes it so we can leave ordering fields + # blamk. + def compare_ordering_values(x, y): + if x[1] is None: + return 1 + if y[1] is None: + return -1 + return x[1] - y[1] + self._ordering.append((i, form.cleaned_data[ORDERING_FIELD_NAME])) + # After we're done populating self._ordering, sort it. + self._ordering.sort(compare_ordering_values) + # Return a list of form.cleaned_data dicts in the order spcified by + # the form data. + return [self.forms[i[0]] for i in self._ordering] + ordered_forms = property(_get_ordered_forms) + + def non_form_errors(self): + """ + Returns an ErrorList of errors that aren't associated with a particular + form -- i.e., from formset.clean(). Returns an empty ErrorList if there + are none. + """ + if self._non_form_errors is not None: + return self._non_form_errors + return self.error_class() + + def _get_errors(self): + """ + Returns a list of form.errors for every form in self.forms. + """ + if self._errors is None: + self.full_clean() + return self._errors + errors = property(_get_errors) + + def is_valid(self): + """ + Returns True if form.errors is empty for every form in self.forms. + """ + if not self.is_bound: + return False + # We loop over every form.errors here rather than short circuiting on the + # first failure to make sure validation gets triggered for every form. + forms_valid = True + for errors in self.errors: + if bool(errors): + forms_valid = False + return forms_valid and not bool(self.non_form_errors()) + + def full_clean(self): + """ + Cleans all of self.data and populates self._errors. + """ + self._errors = [] + if not self.is_bound: # Stop further processing. + return + for i in range(0, self._total_form_count): + form = self.forms[i] + self._errors.append(form.errors) + # Give self.clean() a chance to do cross-form validation. + try: + self.clean() + except ValidationError, e: + self._non_form_errors = e.messages + + def clean(self): + """ + Hook for doing any extra formset-wide cleaning after Form.clean() has + been called on every form. Any ValidationError raised by this method + will not be associated with a particular form; it will be accesible + via formset.non_form_errors() + """ + pass + + def add_fields(self, form, index): + """A hook for adding extra fields on to each form instance.""" + if self.can_order: + # Only pre-fill the ordering field for initial forms. + if index < self._initial_form_count: + form.fields[ORDERING_FIELD_NAME] = IntegerField(label='Order', initial=index+1, required=False) + else: + form.fields[ORDERING_FIELD_NAME] = IntegerField(label='Order', required=False) + if self.can_delete: + form.fields[DELETION_FIELD_NAME] = BooleanField(label='Delete', required=False) + + def add_prefix(self, index): + return '%s-%s' % (self.prefix, index) + + def is_multipart(self): + """ + Returns True if the formset needs to be multipart-encrypted, i.e. it + has FileInput. Otherwise, False. + """ + return self.forms[0].is_multipart() + + def _get_media(self): + # All the forms on a FormSet are the same, so you only need to + # interrogate the first form for media. + if self.forms: + return self.forms[0].media + else: + return Media() + media = property(_get_media) + + def as_table(self): + "Returns this formset rendered as HTML <tr>s -- excluding the <table></table>." + # XXX: there is no semantic division between forms here, there + # probably should be. It might make sense to render each form as a + # table row with each field as a td. + forms = u' '.join([form.as_table() for form in self.forms]) + return mark_safe(u'\n'.join([unicode(self.management_form), forms])) + +def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, + can_delete=False, max_num=0): + """Return a FormSet for the given form class.""" + attrs = {'form': form, 'extra': extra, + 'can_order': can_order, 'can_delete': can_delete, + '_max_form_count': max_num} + return type(form.__name__ + 'FormSet', (formset,), attrs) + +def all_valid(formsets): + """Returns true if every formset in formsets is valid.""" + valid = True + for formset in formsets: + if not formset.is_valid(): + valid = False + return valid diff --git a/django/newforms/models.py b/django/newforms/models.py index c3938d9ae7..43e2978ba8 100644 --- a/django/newforms/models.py +++ b/django/newforms/models.py @@ -12,13 +12,15 @@ from django.core.exceptions import ImproperlyConfigured from util import ValidationError, ErrorList from forms import BaseForm, get_declared_fields -from fields import Field, ChoiceField, EMPTY_VALUES -from widgets import Select, SelectMultiple, MultipleHiddenInput +from fields import Field, ChoiceField, IntegerField, EMPTY_VALUES +from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput +from widgets import media_property +from formsets import BaseFormSet, formset_factory, DELETION_FIELD_NAME __all__ = ( 'ModelForm', 'BaseModelForm', 'model_to_dict', 'fields_for_model', 'save_instance', 'form_for_model', 'form_for_instance', 'form_for_fields', - 'ModelChoiceField', 'ModelMultipleChoiceField' + 'ModelChoiceField', 'ModelMultipleChoiceField', ) def save_instance(form, instance, fields=None, fail_message='saved', @@ -30,7 +32,7 @@ def save_instance(form, instance, fields=None, fail_message='saved', database. Returns ``instance``. """ from django.db import models - opts = instance.__class__._meta + opts = instance._meta if form.errors: raise ValueError("The %s could not be %s because the data didn't" " validate." % (opts.object_name, fail_message)) @@ -44,7 +46,7 @@ def save_instance(form, instance, fields=None, fail_message='saved', f.save_form_data(instance, cleaned_data[f.name]) # Wrap up the saving of m2m data as a function. def save_m2m(): - opts = instance.__class__._meta + opts = instance._meta cleaned_data = form.cleaned_data for f in opts.many_to_many: if fields and f.name not in fields: @@ -226,6 +228,8 @@ class ModelFormMetaclass(type): if not parents: return new_class + if 'media' not in attrs: + new_class.media = media_property(new_class) declared_fields = get_declared_fields(bases, attrs, False) opts = new_class._meta = ModelFormOptions(getattr(new_class, 'Meta', None)) if opts.model: @@ -244,7 +248,7 @@ class ModelFormMetaclass(type): class BaseModelForm(BaseForm): def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, initial=None, error_class=ErrorList, label_suffix=':', - instance=None): + empty_permitted=False, instance=None): opts = self._meta if instance is None: # if we didn't get an instance, instantiate a new one @@ -256,7 +260,8 @@ class BaseModelForm(BaseForm): # if initial was provided, it should override the values from instance if initial is not None: object_data.update(initial) - BaseForm.__init__(self, data, files, auto_id, prefix, object_data, error_class, label_suffix) + BaseForm.__init__(self, data, files, auto_id, prefix, object_data, + error_class, label_suffix, empty_permitted) def save(self, commit=True): """ @@ -275,6 +280,209 @@ class BaseModelForm(BaseForm): class ModelForm(BaseModelForm): __metaclass__ = ModelFormMetaclass +def modelform_factory(model, form=ModelForm, fields=None, exclude=None, + formfield_callback=lambda f: f.formfield()): + # HACK: we should be able to construct a ModelForm without creating + # and passing in a temporary inner class + class Meta: + pass + setattr(Meta, 'model', model) + setattr(Meta, 'fields', fields) + setattr(Meta, 'exclude', exclude) + class_name = model.__name__ + 'Form' + return ModelFormMetaclass(class_name, (form,), {'Meta': Meta, + 'formfield_callback': formfield_callback}) + + +# ModelFormSets ############################################################## + +class BaseModelFormSet(BaseFormSet): + """ + A ``FormSet`` for editing a queryset and/or adding new objects to it. + """ + model = None + + def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, + queryset=None, **kwargs): + self.queryset = queryset + defaults = {'data': data, 'files': files, 'auto_id': auto_id, 'prefix': prefix} + if self._max_form_count > 0: + qs = self.get_queryset()[:self._max_form_count] + else: + qs = self.get_queryset() + defaults['initial'] = [model_to_dict(obj) for obj in qs] + defaults.update(kwargs) + super(BaseModelFormSet, self).__init__(**defaults) + + def get_queryset(self): + if self.queryset is not None: + return self.queryset + return self.model._default_manager.get_query_set() + + def save_new(self, form, commit=True): + """Saves and returns a new model instance for the given form.""" + return save_instance(form, self.model(), commit=commit) + + def save_existing(self, form, instance, commit=True): + """Saves and returns an existing model instance for the given form.""" + return save_instance(form, instance, commit=commit) + + def save(self, commit=True): + """Saves model instances for every form, adding and changing instances + as necessary, and returns the list of instances. + """ + if not commit: + self.saved_forms = [] + def save_m2m(): + for form in self.saved_forms: + form.save_m2m() + self.save_m2m = save_m2m + return self.save_existing_objects(commit) + self.save_new_objects(commit) + + def save_existing_objects(self, commit=True): + self.changed_objects = [] + self.deleted_objects = [] + if not self.get_queryset(): + return [] + + # Put the objects from self.get_queryset into a dict so they are easy to lookup by pk + existing_objects = {} + for obj in self.get_queryset(): + existing_objects[obj.pk] = obj + saved_instances = [] + for form in self.initial_forms: + obj = existing_objects[form.cleaned_data[self.model._meta.pk.attname]] + if self.can_delete and form.cleaned_data[DELETION_FIELD_NAME]: + self.deleted_objects.append(obj) + obj.delete() + else: + if form.changed_data: + self.changed_objects.append((obj, form.changed_data)) + saved_instances.append(self.save_existing(form, obj, commit=commit)) + if not commit: + self.saved_forms.append(form) + return saved_instances + + def save_new_objects(self, commit=True): + self.new_objects = [] + for form in self.extra_forms: + if not form.has_changed(): + continue + # If someone has marked an add form for deletion, don't save the + # object. + if self.can_delete and form.cleaned_data[DELETION_FIELD_NAME]: + continue + self.new_objects.append(self.save_new(form, commit=commit)) + if not commit: + self.saved_forms.append(form) + return self.new_objects + + def add_fields(self, form, index): + """Add a hidden field for the object's primary key.""" + self._pk_field_name = self.model._meta.pk.attname + form.fields[self._pk_field_name] = IntegerField(required=False, widget=HiddenInput) + super(BaseModelFormSet, self).add_fields(form, index) + +def modelformset_factory(model, form=ModelForm, formfield_callback=lambda f: f.formfield(), + formset=BaseModelFormSet, + extra=1, can_delete=False, can_order=False, + max_num=0, fields=None, exclude=None): + """ + Returns a FormSet class for the given Django model class. + """ + form = modelform_factory(model, form=form, fields=fields, exclude=exclude, + formfield_callback=formfield_callback) + FormSet = formset_factory(form, formset, extra=extra, max_num=max_num, + can_order=can_order, can_delete=can_delete) + FormSet.model = model + return FormSet + + +# InlineFormSets ############################################################# + +class BaseInlineFormset(BaseModelFormSet): + """A formset for child objects related to a parent.""" + def __init__(self, data=None, files=None, instance=None, save_as_new=False): + from django.db.models.fields.related import RelatedObject + self.instance = instance + self.save_as_new = save_as_new + # is there a better way to get the object descriptor? + self.rel_name = RelatedObject(self.fk.rel.to, self.model, self.fk).get_accessor_name() + super(BaseInlineFormset, self).__init__(data, files, prefix=self.rel_name) + + def _construct_forms(self): + if self.save_as_new: + self._total_form_count = self._initial_form_count + self._initial_form_count = 0 + super(BaseInlineFormset, self)._construct_forms() + + def get_queryset(self): + """ + Returns this FormSet's queryset, but restricted to children of + self.instance + """ + kwargs = {self.fk.name: self.instance} + return self.model._default_manager.filter(**kwargs) + + def save_new(self, form, commit=True): + kwargs = {self.fk.get_attname(): self.instance.pk} + new_obj = self.model(**kwargs) + return save_instance(form, new_obj, commit=commit) + +def _get_foreign_key(parent_model, model, fk_name=None): + """ + Finds and returns the ForeignKey from model to parent if there is one. + If fk_name is provided, assume it is the name of the ForeignKey field. + """ + # avoid circular import + from django.db.models import ForeignKey + opts = model._meta + if fk_name: + fks_to_parent = [f for f in opts.fields if f.name == fk_name] + if len(fks_to_parent) == 1: + fk = fks_to_parent[0] + if not isinstance(fk, ForeignKey) or fk.rel.to != parent_model: + raise Exception("fk_name '%s' is not a ForeignKey to %s" % (fk_name, parent_model)) + elif len(fks_to_parent) == 0: + raise Exception("%s has no field named '%s'" % (model, fk_name)) + else: + # Try to discover what the ForeignKey from model to parent_model is + fks_to_parent = [f for f in opts.fields if isinstance(f, ForeignKey) and f.rel.to == parent_model] + if len(fks_to_parent) == 1: + fk = fks_to_parent[0] + elif len(fks_to_parent) == 0: + raise Exception("%s has no ForeignKey to %s" % (model, parent_model)) + else: + raise Exception("%s has more than 1 ForeignKey to %s" % (model, parent_model)) + return fk + + +def inlineformset_factory(parent_model, model, form=ModelForm, + formset=BaseInlineFormset, fk_name=None, + fields=None, exclude=None, + extra=3, can_order=False, can_delete=True, max_num=0, + formfield_callback=lambda f: f.formfield()): + """ + Returns an ``InlineFormset`` for the given kwargs. + + You must provide ``fk_name`` if ``model`` has more than one ``ForeignKey`` + to ``parent_model``. + """ + fk = _get_foreign_key(parent_model, model, fk_name=fk_name) + # let the formset handle object deletion by default + + if exclude is not None: + exclude.append(fk.name) + else: + exclude = [fk.name] + FormSet = modelformset_factory(model, form=form, + formfield_callback=formfield_callback, + formset=formset, + extra=extra, can_delete=can_delete, can_order=can_order, + fields=fields, exclude=exclude, max_num=max_num) + FormSet.fk = fk + return FormSet + # Fields ##################################################################### diff --git a/django/newforms/widgets.py b/django/newforms/widgets.py index dc36530b93..2c9f3c2eba 100644 --- a/django/newforms/widgets.py +++ b/django/newforms/widgets.py @@ -9,7 +9,7 @@ except NameError: import copy from itertools import chain - +from django.conf import settings from django.utils.datastructures import MultiValueDict from django.utils.html import escape, conditional_escape from django.utils.translation import ugettext @@ -17,16 +17,118 @@ from django.utils.encoding import StrAndUnicode, force_unicode from django.utils.safestring import mark_safe from django.utils import datetime_safe from util import flatatt +from urlparse import urljoin __all__ = ( - 'Widget', 'TextInput', 'PasswordInput', + 'Media', 'MediaDefiningClass', 'Widget', 'TextInput', 'PasswordInput', 'HiddenInput', 'MultipleHiddenInput', 'FileInput', 'DateTimeInput', 'Textarea', 'CheckboxInput', 'Select', 'NullBooleanSelect', 'SelectMultiple', 'RadioSelect', 'CheckboxSelectMultiple', 'MultiWidget', 'SplitDateTimeWidget', ) +MEDIA_TYPES = ('css','js') + +class Media(StrAndUnicode): + def __init__(self, media=None, **kwargs): + if media: + media_attrs = media.__dict__ + else: + media_attrs = kwargs + + self._css = {} + self._js = [] + + for name in MEDIA_TYPES: + getattr(self, 'add_' + name)(media_attrs.get(name, None)) + + # Any leftover attributes must be invalid. + # if media_attrs != {}: + # raise TypeError, "'class Media' has invalid attribute(s): %s" % ','.join(media_attrs.keys()) + + def __unicode__(self): + return self.render() + + def render(self): + return u'\n'.join(chain(*[getattr(self, 'render_' + name)() for name in MEDIA_TYPES])) + + def render_js(self): + return [u'<script type="text/javascript" src="%s"></script>' % self.absolute_path(path) for path in self._js] + + def render_css(self): + # To keep rendering order consistent, we can't just iterate over items(). + # We need to sort the keys, and iterate over the sorted list. + media = self._css.keys() + media.sort() + return chain(*[ + [u'<link href="%s" type="text/css" media="%s" rel="stylesheet" />' % (self.absolute_path(path), medium) + for path in self._css[medium]] + for medium in media]) + + def absolute_path(self, path): + if path.startswith(u'http://') or path.startswith(u'https://') or path.startswith(u'/'): + return path + return urljoin(settings.MEDIA_URL,path) + + def __getitem__(self, name): + "Returns a Media object that only contains media of the given type" + if name in MEDIA_TYPES: + return Media(**{name: getattr(self, '_' + name)}) + raise KeyError('Unknown media type "%s"' % name) + + def add_js(self, data): + if data: + self._js.extend([path for path in data if path not in self._js]) + + def add_css(self, data): + if data: + for medium, paths in data.items(): + self._css.setdefault(medium, []).extend([path for path in paths if path not in self._css[medium]]) + + def __add__(self, other): + combined = Media() + for name in MEDIA_TYPES: + getattr(combined, 'add_' + name)(getattr(self, '_' + name, None)) + getattr(combined, 'add_' + name)(getattr(other, '_' + name, None)) + return combined + +def media_property(cls): + def _media(self): + # Get the media property of the superclass, if it exists + if hasattr(super(cls, self), 'media'): + base = super(cls, self).media + else: + base = Media() + + # Get the media definition for this class + definition = getattr(cls, 'Media', None) + if definition: + extend = getattr(definition, 'extend', True) + if extend: + if extend == True: + m = base + else: + m = Media() + for medium in extend: + m = m + base[medium] + return m + Media(definition) + else: + return Media(definition) + else: + return base + return property(_media) + +class MediaDefiningClass(type): + "Metaclass for classes that can have media definitions" + def __new__(cls, name, bases, attrs): + new_class = super(MediaDefiningClass, cls).__new__(cls, name, bases, + attrs) + if 'media' not in attrs: + new_class.media = media_property(new_class) + return new_class + class Widget(object): + __metaclass__ = MediaDefiningClass is_hidden = False # Determines whether this corresponds to an <input type="hidden">. needs_multipart_form = False # Determines does this widget need multipart-encrypted form @@ -65,6 +167,25 @@ class Widget(object): """ return data.get(name, None) + def _has_changed(self, initial, data): + """ + Return True if data differs from initial. + """ + # For purposes of seeing whether something has changed, None is + # the same as an empty string, if the data or inital value we get + # is None, replace it w/ u''. + if data is None: + data_value = u'' + else: + data_value = data + if initial is None: + initial_value = u'' + else: + initial_value = initial + if force_unicode(initial_value) != force_unicode(data_value): + return True + return False + def id_for_label(self, id_): """ Returns the HTML ID attribute of this Widget for use by a <label>, @@ -143,6 +264,11 @@ class FileInput(Input): def value_from_datadict(self, data, files, name): "File widgets take data from FILES, not POST" return files.get(name, None) + + def _has_changed(self, initial, data): + if data is None: + return False + return True class Textarea(Widget): def __init__(self, attrs=None): @@ -202,6 +328,11 @@ class CheckboxInput(Widget): return False return super(CheckboxInput, self).value_from_datadict(data, files, name) + def _has_changed(self, initial, data): + # Sometimes data or initial could be None or u'' which should be the + # same thing as False. + return bool(initial) != bool(data) + class Select(Widget): def __init__(self, attrs=None, choices=()): super(Select, self).__init__(attrs) @@ -244,6 +375,11 @@ class NullBooleanSelect(Select): value = data.get(name, None) return {u'2': True, u'3': False, True: True, False: False}.get(value, None) + def _has_changed(self, initial, data): + # Sometimes data or initial could be None or u'' which should be the + # same thing as False. + return bool(initial) != bool(data) + class SelectMultiple(Widget): def __init__(self, attrs=None, choices=()): super(SelectMultiple, self).__init__(attrs) @@ -268,6 +404,18 @@ class SelectMultiple(Widget): if isinstance(data, MultiValueDict): return data.getlist(name) return data.get(name, None) + + def _has_changed(self, initial, data): + if initial is None: + initial = [] + if data is None: + data = [] + if len(initial) != len(data): + return True + for value1, value2 in zip(initial, data): + if force_unicode(value1) != force_unicode(value2): + return True + return False class RadioInput(StrAndUnicode): """ @@ -447,6 +595,16 @@ class MultiWidget(Widget): def value_from_datadict(self, data, files, name): return [widget.value_from_datadict(data, files, name + '_%s' % i) for i, widget in enumerate(self.widgets)] + + def _has_changed(self, initial, data): + if initial is None: + initial = [u'' for x in range(0, len(data))] + else: + initial = self.decompress(initial) + for widget, initial, data in zip(self.widgets, initial, data): + if widget._has_changed(initial, data): + return True + return False def format_output(self, rendered_widgets): """ @@ -466,6 +624,14 @@ class MultiWidget(Widget): """ raise NotImplementedError('Subclasses must implement this method.') + def _get_media(self): + "Media for a multiwidget is the combination of all media of the subwidgets" + media = Media() + for w in self.widgets: + media = media + w.media + return media + media = property(_get_media) + class SplitDateTimeWidget(MultiWidget): """ A Widget that splits datetime input into two <input type="text"> boxes. diff --git a/docs/admin.txt b/docs/admin.txt new file mode 100644 index 0000000000..fbdd19bc90 --- /dev/null +++ b/docs/admin.txt @@ -0,0 +1,678 @@ +===================== +The Django admin site +===================== + +One of the most powerful parts of Django is the automatic admin interface. It +reads metadata in your model to provide a powerful and production-ready +interface that content producers can immediately use to start adding content to +the site. In this document, we discuss how to activate, use and customize +Django's admin interface. + +.. admonition:: Note + + The admin site has been refactored significantly since Django 0.96. This + document describes the newest version of the admin site, which allows for + much richer customization. If you follow the development of Django itself, + you may have heard this described as "newforms-admin." + +Overview +======== + +There are four steps in activating the Django admin site: + + 1. Determine which of your application's models should be editable in the + admin interface. + + 2. For each of those models, optionally create a ``ModelAdmin`` class that + encapsulates the customized admin functionality and options for that + particular model. + + 3. Instantiate an ``AdminSite`` and tell it about each of your models and + ``ModelAdmin`` classes. + + 4. Hook the ``AdminSite`` instance into your URLconf. + +``ModelAdmin`` objects +====================== + +The ``ModelAdmin`` class is the representation of a model in the admin +interface. These are stored in a file named ``admin.py`` in your application. +Let's take a look at a very simple example the ``ModelAdmin``:: + + from django.contrib import admin + from myproject.myapp.models import Author + + class AuthorAdmin(admin.ModelAdmin): + pass + admin.site.register(Author, AuthorAdmin) + +``ModelAdmin`` Options +---------------------- + +The ``ModelAdmin`` is very flexible. It has several options for dealing with +customizing the interface. All options are defined on the ``ModelAdmin`` +subclass:: + + class AuthorAdmin(admin.ModelAdmin): + date_hierarchy = 'pub_date' + +``date_hierarchy`` +~~~~~~~~~~~~~~~~~~ + +Set ``date_hierarchy`` to the name of a ``DateField`` or ``DateTimeField`` in +your model, and the change list page will include a date-based drilldown +navigation by that field. + +Example:: + + date_hierarchy = 'pub_date' + +``fieldsets`` +~~~~~~~~~~~~~ + +Set ``fieldsets`` to control the layout of admin "add" and "change" pages. + +``fieldsets`` is a list of two-tuples, in which each two-tuple represents a +``<fieldset>`` on the admin form page. (A ``<fieldset>`` is a "section" of the +form.) + +The two-tuples are in the format ``(name, field_options)``, where ``name`` is a +string representing the title of the fieldset and ``field_options`` is a +dictionary of information about the fieldset, including a list of fields to be +displayed in it. + +A full example, taken from the ``django.contrib.flatpages.FlatPage`` model:: + + class FlatPageAdmin(admin.ModelAdmin): + fieldsets = ( + (None, { + 'fields': ('url', 'title', 'content', 'sites') + }), + ('Advanced options', { + 'classes': ('collapse',), + 'fields': ('enable_comments', 'registration_required', 'template_name') + }), + ) + +This results in an admin page that looks like: + + .. image:: http://media.djangoproject.com/img/doc/flatfiles_admin.png + +If ``fieldsets`` isn't given, Django will default to displaying each field +that isn't an ``AutoField`` and has ``editable=True``, in a single fieldset, +in the same order as the fields are defined in the model. + +The ``field_options`` dictionary can have the following keys: + +``fields`` + A tuple of field names to display in this fieldset. This key is required. + + Example:: + + { + 'fields': ('first_name', 'last_name', 'address', 'city', 'state'), + } + + To display multiple fields on the same line, wrap those fields in their own + tuple. In this example, the ``first_name`` and ``last_name`` fields will + display on the same line:: + + { + 'fields': (('first_name', 'last_name'), 'address', 'city', 'state'), + } + +``classes`` + A string containing extra CSS classes to apply to the fieldset. + + Example:: + + { + 'classes': 'wide', + } + + Apply multiple classes by separating them with spaces. Example:: + + { + 'classes': 'wide extrapretty', + } + + Two useful classes defined by the default admin-site stylesheet are + ``collapse`` and ``wide``. Fieldsets with the ``collapse`` style will be + initially collapsed in the admin and replaced with a small "click to expand" + link. Fieldsets with the ``wide`` style will be given extra horizontal space. + +``description`` + A string of optional extra text to be displayed at the top of each fieldset, + under the heading of the fieldset. It's used verbatim, so you can use any HTML + and you must escape any special HTML characters (such as ampersands) yourself. + +``filter_horizontal`` +~~~~~~~~~~~~~~~~~~~~~ + +Use a nifty unobtrusive Javascript "filter" interface instead of the +usability-challenged ``<select multiple>`` in the admin form. The value is a +list of fields that should be displayed as a horizontal filter interface. See +``filter_vertical`` to use a vertical interface. + +``filter_vertical`` +~~~~~~~~~~~~~~~~~~~ + +Same as ``filter_horizontal``, but is a vertical display of the filter +interface. + +``list_display`` +~~~~~~~~~~~~~~~~ + +Set ``list_display`` to control which fields are displayed on the change list +page of the admin. + +Example:: + + list_display = ('first_name', 'last_name') + +If you don't set ``list_display``, the admin site will display a single column +that displays the ``__unicode__()`` representation of each object. + +A few special cases to note about ``list_display``: + + * If the field is a ``ForeignKey``, Django will display the + ``__unicode__()`` of the related object. + + * ``ManyToManyField`` fields aren't supported, because that would entail + executing a separate SQL statement for each row in the table. If you + want to do this nonetheless, give your model a custom method, and add + that method's name to ``list_display``. (See below for more on custom + methods in ``list_display``.) + + * If the field is a ``BooleanField`` or ``NullBooleanField``, Django will + display a pretty "on" or "off" icon instead of ``True`` or ``False``. + + * If the string given is a method of the model, Django will call it and + display the output. This method should have a ``short_description`` + function attribute, for use as the header for the field. + + Here's a full example model:: + + class Person(models.Model): + name = models.CharField(max_length=50) + birthday = models.DateField() + + def decade_born_in(self): + return self.birthday.strftime('%Y')[:3] + "0's" + decade_born_in.short_description = 'Birth decade' + + class PersonAdmin(admin.ModelAdmin): + list_display = ('name', 'decade_born_in') + + * If the string given is a method of the model, Django will HTML-escape the + output by default. If you'd rather not escape the output of the method, + give the method an ``allow_tags`` attribute whose value is ``True``. + + Here's a full example model:: + + class Person(models.Model): + first_name = models.CharField(max_length=50) + last_name = models.CharField(max_length=50) + color_code = models.CharField(max_length=6) + + def colored_name(self): + return '<span style="color: #%s;">%s %s</span>' % (self.color_code, self.first_name, self.last_name) + colored_name.allow_tags = True + + class PersonAdmin(admin.ModelAdmin): + list_display = ('first_name', 'last_name', 'colored_name') + + * If the string given is a method of the model that returns True or False + Django will display a pretty "on" or "off" icon if you give the method a + ``boolean`` attribute whose value is ``True``. + + Here's a full example model:: + + class Person(models.Model): + first_name = models.CharField(max_length=50) + birthday = models.DateField() + + def born_in_fifties(self): + return self.birthday.strftime('%Y')[:3] == 5 + born_in_fifties.boolean = True + + class PersonAdmin(admin.ModelAdmin): + list_display = ('name', 'born_in_fifties') + + + * The ``__str__()`` and ``__unicode__()`` methods are just as valid in + ``list_display`` as any other model method, so it's perfectly OK to do + this:: + + list_display = ('__unicode__', 'some_other_field') + + * Usually, elements of ``list_display`` that aren't actual database fields + can't be used in sorting (because Django does all the sorting at the + database level). + + However, if an element of ``list_display`` represents a certain database + field, you can indicate this fact by setting the ``admin_order_field`` + attribute of the item. + + For example:: + + class Person(models.Model): + first_name = models.CharField(max_length=50) + color_code = models.CharField(max_length=6) + + def colored_first_name(self): + return '<span style="color: #%s;">%s</span>' % (self.color_code, self.first_name) + colored_first_name.allow_tags = True + colored_first_name.admin_order_field = 'first_name' + + class PersonAdmin(admin.ModelAdmin): + list_display = ('first_name', 'colored_first_name') + + The above will tell Django to order by the ``first_name`` field when + trying to sort by ``colored_first_name`` in the admin. + +``list_display_links`` +~~~~~~~~~~~~~~~~~~~~~~ + +Set ``list_display_links`` to control which fields in ``list_display`` should +be linked to the "change" page for an object. + +By default, the change list page will link the first column -- the first field +specified in ``list_display`` -- to the change page for each item. But +``list_display_links`` lets you change which columns are linked. Set +``list_display_links`` to a list or tuple of field names (in the same format as +``list_display``) to link. + +``list_display_links`` can specify one or many field names. As long as the +field names appear in ``list_display``, Django doesn't care how many (or how +few) fields are linked. The only requirement is: If you want to use +``list_display_links``, you must define ``list_display``. + +In this example, the ``first_name`` and ``last_name`` fields will be linked on +the change list page:: + + class PersonAdmin(admin.ModelAdmin): + list_display = ('first_name', 'last_name', 'birthday') + list_display_links = ('first_name', 'last_name') + +Finally, note that in order to use ``list_display_links``, you must define +``list_display``, too. + +``list_filter`` +~~~~~~~~~~~~~~~ + +Set ``list_filter`` to activate filters in the right sidebar of the change list +page of the admin. This should be a list of field names, and each specified +field should be either a ``BooleanField``, ``CharField``, ``DateField``, +``DateTimeField``, ``IntegerField`` or ``ForeignKey``. + +This example, taken from the ``django.contrib.auth.models.User`` model, shows +how both ``list_display`` and ``list_filter`` work:: + + class UserAdmin(admin.ModelAdmin): + list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff') + list_filter = ('is_staff', 'is_superuser') + +The above code results in an admin change list page that looks like this: + + .. image:: http://media.djangoproject.com/img/doc/users_changelist.png + +(This example also has ``search_fields`` defined. See below.) + +``list_per_page`` +~~~~~~~~~~~~~~~~~ + +Set ``list_per_page`` to control how many items appear on each paginated admin +change list page. By default, this is set to ``100``. + +``list_select_related`` +~~~~~~~~~~~~~~~~~~~~~~~ + +Set ``list_select_related`` to tell Django to use ``select_related()`` in +retrieving the list of objects on the admin change list page. This can save you +a bunch of database queries. + +The value should be either ``True`` or ``False``. Default is ``False``. + +Note that Django will use ``select_related()``, regardless of this setting, +if one of the ``list_display`` fields is a ``ForeignKey``. + +For more on ``select_related()``, see `the select_related() docs`_. + +.. _the select_related() docs: ../db-api/#select-related + +``inlines`` +~~~~~~~~~~~ + +See ``InlineModelAdmin`` objects below. + +``ordering`` +~~~~~~~~~~~~ + +Set ``ordering`` to specify how objects on the admin change list page should be +ordered. This should be a list or tuple in the same format as a model's +``ordering`` parameter. + +If this isn't provided, the Django admin will use the model's default ordering. + +``prepopulated_fields`` +~~~~~~~~~~~~~~~~~~~~~~~ + +Set ``prepopulated_fields`` to a dictionary mapping field names to the fields +it should prepopulate from:: + + class ArticleAdmin(admin.ModelAdmin): + prepopulated_fields = {"slug": ("title",)} + +When set the given fields will use a bit of Javascript to populate from the +fields assigned. + +``prepopulated_fields`` doesn't accept DateTimeFields, ForeignKeys nor +ManyToManyFields. + +``radio_fields`` +~~~~~~~~~~~~~~~~ + +By default, Django's admin uses a select-box interface (<select>) for +fields that are ``ForeignKey`` or have ``choices`` set. If a field is present +in ``radio_fields``, Django will use a radio-button interface instead. +Assuming ``group`` is a ``ForeignKey`` on the ``Person`` model:: + + class PersonAdmin(admin.ModelAdmin): + radio_fields = {"group": admin.VERTICAL} + +You have the choice of using ``HORIZONTAL`` or ``VERTICAL`` from the +``django.contrib.admin`` module. + +Don't include a field in ``radio_fields`` unless it's a ``ForeignKey`` or has +``choices`` set. + +``raw_id_fields`` +~~~~~~~~~~~~~~~~~ + +By default, Django's admin uses a select-box interface (<select>) for +fields that are ``ForeignKey``. Sometimes you don't want to incur the +overhead of having to select all the related instances to display in the +drop-down. + +``raw_id_fields`` is a list of fields you would like to change +into a ``Input`` widget for the primary key. + +``save_as`` +~~~~~~~~~~~ + +Set ``save_as`` to enable a "save as" feature on admin change forms. + +Normally, objects have three save options: "Save", "Save and continue editing" +and "Save and add another". If ``save_as`` is ``True``, "Save and add another" +will be replaced by a "Save as" button. + +"Save as" means the object will be saved as a new object (with a new ID), +rather than the old object. + +By default, ``save_as`` is set to ``False``. + +``save_on_top`` +~~~~~~~~~~~~~~~ + +Set ``save_on_top`` to add save buttons across the top of your admin change +forms. + +Normally, the save buttons appear only at the bottom of the forms. If you set +``save_on_top``, the buttons will appear both on the top and the bottom. + +By default, ``save_on_top`` is set to ``False``. + +``search_fields`` +~~~~~~~~~~~~~~~~~ + +Set ``search_fields`` to enable a search box on the admin change list page. +This should be set to a list of field names that will be searched whenever +somebody submits a search query in that text box. + +These fields should be some kind of text field, such as ``CharField`` or +``TextField``. You can also perform a related lookup on a ``ForeignKey`` with +the lookup API "follow" notation:: + + search_fields = ['foreign_key__related_fieldname'] + +When somebody does a search in the admin search box, Django splits the search +query into words and returns all objects that contain each of the words, case +insensitive, where each word must be in at least one of ``search_fields``. For +example, if ``search_fields`` is set to ``['first_name', 'last_name']`` and a +user searches for ``john lennon``, Django will do the equivalent of this SQL +``WHERE`` clause:: + + WHERE (first_name ILIKE '%john%' OR last_name ILIKE '%john%') + AND (first_name ILIKE '%lennon%' OR last_name ILIKE '%lennon%') + +For faster and/or more restrictive searches, prefix the field name +with an operator: + +``^`` + Matches the beginning of the field. For example, if ``search_fields`` is + set to ``['^first_name', '^last_name']`` and a user searches for + ``john lennon``, Django will do the equivalent of this SQL ``WHERE`` + clause:: + + WHERE (first_name ILIKE 'john%' OR last_name ILIKE 'john%') + AND (first_name ILIKE 'lennon%' OR last_name ILIKE 'lennon%') + + This query is more efficient than the normal ``'%john%'`` query, because + the database only needs to check the beginning of a column's data, rather + than seeking through the entire column's data. Plus, if the column has an + index on it, some databases may be able to use the index for this query, + even though it's a ``LIKE`` query. + +``=`` + Matches exactly, case-insensitive. For example, if + ``search_fields`` is set to ``['=first_name', '=last_name']`` and + a user searches for ``john lennon``, Django will do the equivalent + of this SQL ``WHERE`` clause:: + + WHERE (first_name ILIKE 'john' OR last_name ILIKE 'john') + AND (first_name ILIKE 'lennon' OR last_name ILIKE 'lennon') + + Note that the query input is split by spaces, so, following this example, + it's currently not possible to search for all records in which + ``first_name`` is exactly ``'john winston'`` (containing a space). + +``@`` + Performs a full-text match. This is like the default search method but uses + an index. Currently this is only available for MySQL. + +``ModelAdmin`` media definitions +-------------------------------- + +There are times where you would like add a bit of CSS and/or Javascript to +the add/change views. This can be accomplished by using a Media inner class +on your ``ModelAdmin``:: + + class ArticleAdmin(admin.ModelAdmin): + class Media: + css = { + "all": ("my_styles.css",) + } + js = ("my_code.js",) + +Keep in mind that this will be prepended with ``MEDIA_URL``. The same rules +apply as `regular media definitions on forms`_. + +.. _regular media definitions on forms: ../newforms/#media + +``InlineModelAdmin`` objects +============================ + +The admin interface has the ability to edit models on the same page as a +parent model. These are called inlines. You can add them a model being +specifing them in a ``ModelAdmin.inlines`` attribute:: + + class BookInline(admin.TabularInline): + model = Book + + class AuthorAdmin(admin.ModelAdmin): + inlines = [ + BookInline, + ] + +Django provides two subclasses of ``InlineModelAdmin`` and they are:: + + * ``TabularInline`` + * ``StackedInline`` + +The difference between these two is merely the template used to render them. + +``InlineModelAdmin`` options +----------------------------- + +The ``InlineModelAdmin`` class is a subclass of ``ModelAdmin`` so it inherits +all the same functionality as well as some of its own: + +``model`` +~~~~~~~~~ + +The model in which the inline is using. This is required. + +``fk_name`` +~~~~~~~~~~~ + +The name of the foreign key on the model. In most cases this will be dealt +with automatically, but ``fk_name`` must be specified explicitly if there are +more than one foreign key to the same parent model. + +``formset`` +~~~~~~~~~~~ + +This defaults to ``BaseInlineFormset``. Using your own formset can give you +many possibilities of customization. Inlines are built around +`model formsets`_. + +.. _model formsets: ../modelforms/#model-formsets + +``form`` +~~~~~~~~ + +The value for ``form`` is inherited from ``ModelAdmin``. This is what is +passed through to ``formset_factory`` when creating the formset for this +inline. + +``extra`` +~~~~~~~~~ + +This controls the number of extra forms the formset will display in addition +to the initial forms. See the `formsets documentation`_ for more information. + +.. _formsets documentation: ../newforms/#formsets + +``max_num`` +~~~~~~~~~~~ + +This controls the maximum number of forms to show in the inline. This doesn't +directly corrolate to the number of objects, but can if the value is small +enough. See `max_num in formsets`_ for more information. + +.. _max_num in formsets: ../modelforms/#limiting-the-number-of-objects-editable + +``template`` +~~~~~~~~~~~~ + +The template used to render the inline on the page. + +``verbose_name`` +~~~~~~~~~~~~~~~~ + +An override to the ``verbose_name`` found in the model's inner ``Meta`` class. + +``verbose_name_plural`` +~~~~~~~~~~~~~~~~~~~~~~~ + +An override to the ``verbose_name_plural`` found in the model's inner ``Meta`` +class. + +Working with a model with two or more foreign keys to the same parent model +--------------------------------------------------------------------------- + +It is sometimes possible to have more than one foreign key to the same model. +Take this model for instance:: + + class Friendship(models.Model): + to_person = models.ForeignKey(Person, related_name="friends") + from_person = models.ForeignKey(Person, related_name="from_friends") + +If you wanted to display an inline on the ``Person`` admin add/change pages +you need to explicitly define the foreign key since it is unable to do so +automatically:: + + class FriendshipInline(admin.TabularInline): + model = Friendship + fk_name = "to_person" + + class PersonAdmin(admin.ModelAdmin): + inlines = [ + FriendshipInline, + ] + +``AdminSite`` objects +===================== + +Hooking ``AdminSite`` instances into your URLconf +------------------------------------------------- + +The last step in setting up the Django admin is to hook your ``AdminSite`` +instance into your URLconf. Do this by pointing a given URL at the +``AdminSite.root`` method. + +In this example, we register the default ``AdminSite`` instance +``django.contrib.admin.site`` at the URL ``/admin/`` :: + + # urls.py + from django.conf.urls.defaults import * + from django.contrib import admin + + admin.autodiscover() + + urlpatterns = patterns('', + ('^admin/(.*)', admin.site.root), + ) + +Above we used ``admin.autodiscover()`` to automatically load the +``INSTALLED_APPS`` admin.py modules. + +In this example, we register the ``AdminSite`` instance +``myproject.admin.admin_site`` at the URL ``/myadmin/`` :: + + # urls.py + from django.conf.urls.defaults import * + from myproject.admin import admin_site + + urlpatterns = patterns('', + ('^myadmin/(.*)', admin_site.root), + ) + +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. + +Note that the regular expression in the URLpattern *must* group everything in +the URL that comes after the URL root -- hence the ``(.*)`` in these examples. + +Multiple admin sites in the same URLconf +---------------------------------------- + +It's easy to create multiple instances of the admin site on the same +Django-powered Web site. Just create multiple instances of ``AdminSite`` and +root each one at a different URL. + +In this example, the URLs ``/basic-admin/`` and ``/advanced-admin/`` feature +separate versions of the admin site -- using the ``AdminSite`` instances +``myproject.admin.basic_site`` and ``myproject.admin.advanced_site``, +respectively:: + + # urls.py + from django.conf.urls.defaults import * + from myproject.admin import basic_site, advanced_site + + urlpatterns = patterns('', + ('^basic-admin/(.*)', basic_site.root), + ('^advanced-admin/(.*)', advanced_site.root), + ) diff --git a/docs/authentication.txt b/docs/authentication.txt index 4ec367a8b5..cd76731bc4 100644 --- a/docs/authentication.txt +++ b/docs/authentication.txt @@ -516,8 +516,8 @@ It's your responsibility to provide the login form in a template called ``registration/login.html`` by default. This template gets passed three template context variables: - * ``form``: A ``FormWrapper`` object representing the login form. See the - `forms documentation`_ for more on ``FormWrapper`` objects. + * ``form``: A ``Form`` object representing the login form. See the + `newforms documentation`_ for more on ``Form`` objects. * ``next``: The URL to redirect to after successful login. This may contain a query string, too. * ``site_name``: The name of the current ``Site``, according to the @@ -541,14 +541,14 @@ block:: {% block content %} - {% if form.has_errors %} + {% if form.errors %} <p>Your username and password didn't match. Please try again.</p> {% endif %} <form method="post" action="."> <table> - <tr><td><label for="id_username">Username:</label></td><td>{{ form.username }}</td></tr> - <tr><td><label for="id_password">Password:</label></td><td>{{ form.password }}</td></tr> + <tr><td>{{ form.username.label_tag }}</td><td>{{ form.username }}</td></tr> + <tr><td>{{ form.password.label_tag }}</td><td>{{ form.password }}</td></tr> </table> <input type="submit" value="login" /> @@ -557,7 +557,7 @@ block:: {% endblock %} -.. _forms documentation: ../forms/ +.. _newforms documentation: ../newforms/ .. _site framework docs: ../sites/ Other built-in views @@ -677,29 +677,29 @@ successful login. * ``login_url``: The URL of the login page to redirect to. This will default to ``settings.LOGIN_URL`` if not supplied. -Built-in manipulators ---------------------- +Built-in forms +-------------- + +**New in Django development version.** If you don't want to use the built-in views, but want the convenience -of not having to write manipulators for this functionality, the -authentication system provides several built-in manipulators: +of not having to write forms for this functionality, the authentication +system provides several built-in forms: - * ``django.contrib.auth.forms.AdminPasswordChangeForm``: A - manipulator used in the admin interface to change a user's - password. + * ``django.contrib.auth.forms.AdminPasswordChangeForm``: A form used in + the admin interface to change a user's password. - * ``django.contrib.auth.forms.AuthenticationForm``: A manipulator - for logging a user in. + * ``django.contrib.auth.forms.AuthenticationForm``: A form for logging a + user in. - * ``django.contrib.auth.forms.PasswordChangeForm``: A manipulator - for allowing a user to change their password. + * ``django.contrib.auth.forms.PasswordChangeForm``: A form for allowing a + user to change their password. - * ``django.contrib.auth.forms.PasswordResetForm``: A manipulator - for resetting a user's password and emailing the new password to - them. + * ``django.contrib.auth.forms.PasswordResetForm``: A form for resetting a + user's password and emailing the new password to them. - * ``django.contrib.auth.forms.UserCreationForm``: A manipulator - for creating a new user. + * ``django.contrib.auth.forms.UserCreationForm``: A form for creating a + new user. Limiting access to logged-in users that pass a test --------------------------------------------------- diff --git a/docs/custom_model_fields.txt b/docs/custom_model_fields.txt index 2b344921ef..cbaac873e3 100644 --- a/docs/custom_model_fields.txt +++ b/docs/custom_model_fields.txt @@ -204,7 +204,6 @@ order: * ``unique_for_year`` * ``validator_list`` * ``choices`` - * ``radio_admin`` * ``help_text`` * ``db_column`` * ``db_tablespace``: Currently only used with the Oracle backend and only diff --git a/docs/localflavor.txt b/docs/localflavor.txt index b4bccfb138..5a2e5b8fda 100644 --- a/docs/localflavor.txt +++ b/docs/localflavor.txt @@ -20,10 +20,10 @@ For example, here's how you can create a form with a field representing a French telephone number:: from django import newforms as forms - from django.contrib.localflavor.fr.forms import FRPhoneNumberField + from django.contrib.localflavor import fr class MyForm(forms.Form): - my_french_phone_no = FRPhoneNumberField() + my_french_phone_no = fr.forms.FRPhoneNumberField() Supported countries =================== diff --git a/docs/model-api.txt b/docs/model-api.txt index 4e7b5c3096..4accad122a 100644 --- a/docs/model-api.txt +++ b/docs/model-api.txt @@ -12,8 +12,6 @@ The basics: * Each attribute of the model represents a database field. * Model metadata (non-field information) goes in an inner class named ``Meta``. - * Metadata used for Django's admin site goes into an inner class named - ``Admin``. * With all of this, Django gives you an automatically-generated database-access API, which is explained in the `Database API reference`_. @@ -425,18 +423,6 @@ not specified, Django will use a default length of 50. Implies ``db_index=True``. -Accepts an extra option, ``prepopulate_from``, which is a list of fields -from which to auto-populate the slug, via JavaScript, in the object's admin -form:: - - models.SlugField(prepopulate_from=("pre_name", "name")) - -``prepopulate_from`` doesn't accept DateTimeFields, ForeignKeys nor -ManyToManyFields. - -The admin represents ``SlugField`` as an ``<input type="text">`` (a -single-line input). - ``SmallIntegerField`` ~~~~~~~~~~~~~~~~~~~~~ @@ -665,16 +651,6 @@ unless you want to override the default primary-key behavior. ``primary_key=True`` implies ``null=False`` and ``unique=True``. Only one primary key is allowed on an object. -``radio_admin`` -~~~~~~~~~~~~~~~ - -By default, Django's admin uses a select-box interface (<select>) for -fields that are ``ForeignKey`` or have ``choices`` set. If ``radio_admin`` -is set to ``True``, Django will use a radio-button interface instead. - -Don't use this for a field unless it's a ``ForeignKey`` or has ``choices`` -set. - ``unique`` ~~~~~~~~~~ @@ -822,14 +798,6 @@ relationship should work. All are optional: ======================= ============================================================ Argument Description ======================= ============================================================ - ``edit_inline`` If ``True``, this related object is edited - "inline" on the related object's page. This means - that the object will not have its own admin - interface. Use either ``models.TABULAR`` or ``models.STACKED``, - which, respectively, designate whether the inline-editable - objects are displayed as a table or as a "stack" of - fieldsets. - ``limit_choices_to`` A dictionary of lookup arguments and values (see the `Database API reference`_) that limit the available admin choices for this object. Use this @@ -848,39 +816,6 @@ relationship should work. All are optional: Not compatible with ``edit_inline``. - ``max_num_in_admin`` For inline-edited objects, this is the maximum - number of related objects to display in the admin. - Thus, if a pizza could only have up to 10 - toppings, ``max_num_in_admin=10`` would ensure - that a user never enters more than 10 toppings. - - Note that this doesn't ensure more than 10 related - toppings ever get created. It simply controls the - admin interface; it doesn't enforce things at the - Python API level or database level. - - ``min_num_in_admin`` The minimum number of related objects displayed in - the admin. Normally, at the creation stage, - ``num_in_admin`` inline objects are shown, and at - the edit stage ``num_extra_on_change`` blank - objects are shown in addition to all pre-existing - related objects. However, no fewer than - ``min_num_in_admin`` related objects will ever be - displayed. - - ``num_extra_on_change`` The number of extra blank related-object fields to - show at the change stage. - - ``num_in_admin`` The default number of inline objects to display - on the object page at the add stage. - - ``raw_id_admin`` Only display a field for the integer to be entered - instead of a drop-down menu. This is useful when - related to an object type that will have too many - rows to make a select box practical. - - Not used with ``edit_inline``. - ``related_name`` The name to use for the relation from the related object back to this one. See the `related objects documentation`_ for a full @@ -957,13 +892,6 @@ the relationship should work. All are optional: ======================= ============================================================ ``related_name`` See the description under ``ForeignKey`` above. - ``filter_interface`` Use a nifty unobtrusive Javascript "filter" interface - instead of the usability-challenged ``<select multiple>`` - in the admin form for this object. The value should be - ``models.HORIZONTAL`` or ``models.VERTICAL`` (i.e. - should the interface be stacked horizontally or - vertically). - ``limit_choices_to`` See the description under ``ForeignKey`` above. ``symmetrical`` Only used in the definition of ManyToManyFields on self. @@ -1255,412 +1183,6 @@ attribute is the primary key field for the model. You can read and set this value, just as you would for any other attribute, and it will update the correct field in the model. -Admin options -============= - -If you want your model to be visible to Django's admin site, give your model an -inner ``"class Admin"``, like so:: - - class Person(models.Model): - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - - class Admin: - # Admin options go here - pass - -The ``Admin`` class tells Django how to display the model in the admin site. - -Here's a list of all possible ``Admin`` options. None of these options are -required. To use an admin interface without specifying any options, use -``pass``, like so:: - - class Admin: - pass - -Adding ``class Admin`` to a model is completely optional. - -``date_hierarchy`` ------------------- - -Set ``date_hierarchy`` to the name of a ``DateField`` or ``DateTimeField`` in -your model, and the change list page will include a date-based drilldown -navigation by that field. - -Example:: - - date_hierarchy = 'pub_date' - -``fields`` ----------- - -Set ``fields`` to control the layout of admin "add" and "change" pages. - -``fields`` is a list of two-tuples, in which each two-tuple represents a -``<fieldset>`` on the admin form page. (A ``<fieldset>`` is a "section" of the -form.) - -The two-tuples are in the format ``(name, field_options)``, where ``name`` is a -string representing the title of the fieldset and ``field_options`` is a -dictionary of information about the fieldset, including a list of fields to be -displayed in it. - -A full example, taken from the ``django.contrib.flatpages.FlatPage`` model:: - - class Admin: - fields = ( - (None, { - 'fields': ('url', 'title', 'content', 'sites') - }), - ('Advanced options', { - 'classes': 'collapse', - 'fields' : ('enable_comments', 'registration_required', 'template_name') - }), - ) - -This results in an admin page that looks like: - - .. image:: http://media.djangoproject.com/img/doc/flatfiles_admin.png - -If ``fields`` isn't given, Django will default to displaying each field that -isn't an ``AutoField`` and has ``editable=True``, in a single fieldset, in -the same order as the fields are defined in the model. - -The ``field_options`` dictionary can have the following keys: - -``fields`` -~~~~~~~~~~ - -A tuple of field names to display in this fieldset. This key is required. - -Example:: - - { - 'fields': ('first_name', 'last_name', 'address', 'city', 'state'), - } - -To display multiple fields on the same line, wrap those fields in their own -tuple. In this example, the ``first_name`` and ``last_name`` fields will -display on the same line:: - - { - 'fields': (('first_name', 'last_name'), 'address', 'city', 'state'), - } - -``classes`` -~~~~~~~~~~~ - -A string containing extra CSS classes to apply to the fieldset. - -Example:: - - { - 'classes': 'wide', - } - -Apply multiple classes by separating them with spaces. Example:: - - { - 'classes': 'wide extrapretty', - } - -Two useful classes defined by the default admin-site stylesheet are -``collapse`` and ``wide``. Fieldsets with the ``collapse`` style will be -initially collapsed in the admin and replaced with a small "click to expand" -link. Fieldsets with the ``wide`` style will be given extra horizontal space. - -``description`` -~~~~~~~~~~~~~~~ - -A string of optional extra text to be displayed at the top of each fieldset, -under the heading of the fieldset. It's used verbatim, so you can use any HTML -and you must escape any special HTML characters (such as ampersands) yourself. - -``js`` ------- - -A list of strings representing URLs of JavaScript files to link into the admin -screen via ``<script src="">`` tags. This can be used to tweak a given type of -admin page in JavaScript or to provide "quick links" to fill in default values -for certain fields. - -If you use relative URLs -- URLs that don't start with ``http://`` or ``/`` -- -then the admin site will automatically prefix these links with -``settings.ADMIN_MEDIA_PREFIX``. - -``list_display`` ----------------- - -Set ``list_display`` to control which fields are displayed on the change list -page of the admin. - -Example:: - - list_display = ('first_name', 'last_name') - -If you don't set ``list_display``, the admin site will display a single column -that displays the ``__str__()`` representation of each object. - -A few special cases to note about ``list_display``: - - * If the field is a ``ForeignKey``, Django will display the - ``__unicode__()`` of the related object. - - * ``ManyToManyField`` fields aren't supported, because that would entail - executing a separate SQL statement for each row in the table. If you - want to do this nonetheless, give your model a custom method, and add - that method's name to ``list_display``. (See below for more on custom - methods in ``list_display``.) - - * If the field is a ``BooleanField`` or ``NullBooleanField``, Django will - display a pretty "on" or "off" icon instead of ``True`` or ``False``. - - * If the string given is a method of the model, Django will call it and - display the output. This method should have a ``short_description`` - function attribute, for use as the header for the field. - - Here's a full example model:: - - class Person(models.Model): - name = models.CharField(max_length=50) - birthday = models.DateField() - - class Admin: - list_display = ('name', 'decade_born_in') - - def decade_born_in(self): - return self.birthday.strftime('%Y')[:3] + "0's" - decade_born_in.short_description = 'Birth decade' - - * If the string given is a method of the model, Django will HTML-escape the - output by default. If you'd rather not escape the output of the method, - give the method an ``allow_tags`` attribute whose value is ``True``. - - Here's a full example model:: - - class Person(models.Model): - first_name = models.CharField(max_length=50) - last_name = models.CharField(max_length=50) - color_code = models.CharField(max_length=6) - - class Admin: - list_display = ('first_name', 'last_name', 'colored_name') - - def colored_name(self): - return '<span style="color: #%s;">%s %s</span>' % (self.color_code, self.first_name, self.last_name) - colored_name.allow_tags = True - - * If the string given is a method of the model that returns True or False - Django will display a pretty "on" or "off" icon if you give the method a - ``boolean`` attribute whose value is ``True``. - - Here's a full example model:: - - class Person(models.Model): - first_name = models.CharField(max_length=50) - birthday = models.DateField() - - class Admin: - list_display = ('name', 'born_in_fifties') - - def born_in_fifties(self): - return self.birthday.strftime('%Y')[:3] == 5 - born_in_fifties.boolean = True - - - * The ``__str__()`` and ``__unicode__()`` methods are just as valid in - ``list_display`` as any other model method, so it's perfectly OK to do - this:: - - list_display = ('__unicode__', 'some_other_field') - - * Usually, elements of ``list_display`` that aren't actual database fields - can't be used in sorting (because Django does all the sorting at the - database level). - - However, if an element of ``list_display`` represents a certain database - field, you can indicate this fact by setting the ``admin_order_field`` - attribute of the item. - - For example:: - - class Person(models.Model): - first_name = models.CharField(max_length=50) - color_code = models.CharField(max_length=6) - - class Admin: - list_display = ('first_name', 'colored_first_name') - - def colored_first_name(self): - return '<span style="color: #%s;">%s</span>' % (self.color_code, self.first_name) - colored_first_name.allow_tags = True - colored_first_name.admin_order_field = 'first_name' - - The above will tell Django to order by the ``first_name`` field when - trying to sort by ``colored_first_name`` in the admin. - -``list_display_links`` ----------------------- - -Set ``list_display_links`` to control which fields in ``list_display`` should -be linked to the "change" page for an object. - -By default, the change list page will link the first column -- the first field -specified in ``list_display`` -- to the change page for each item. But -``list_display_links`` lets you change which columns are linked. Set -``list_display_links`` to a list or tuple of field names (in the same format as -``list_display``) to link. - -``list_display_links`` can specify one or many field names. As long as the -field names appear in ``list_display``, Django doesn't care how many (or how -few) fields are linked. The only requirement is: If you want to use -``list_display_links``, you must define ``list_display``. - -In this example, the ``first_name`` and ``last_name`` fields will be linked on -the change list page:: - - class Admin: - list_display = ('first_name', 'last_name', 'birthday') - list_display_links = ('first_name', 'last_name') - -Finally, note that in order to use ``list_display_links``, you must define -``list_display``, too. - -``list_filter`` ---------------- - -Set ``list_filter`` to activate filters in the right sidebar of the change list -page of the admin. This should be a list of field names, and each specified -field should be either a ``BooleanField``, ``CharField``, ``DateField``, -``DateTimeField``, ``IntegerField`` or ``ForeignKey``. - -This example, taken from the ``django.contrib.auth.models.User`` model, shows -how both ``list_display`` and ``list_filter`` work:: - - class Admin: - list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff') - list_filter = ('is_staff', 'is_superuser') - -The above code results in an admin change list page that looks like this: - - .. image:: http://media.djangoproject.com/img/doc/users_changelist.png - -(This example also has ``search_fields`` defined. See below.) - -``list_per_page`` ------------------ - -Set ``list_per_page`` to control how many items appear on each paginated admin -change list page. By default, this is set to ``100``. - -``list_select_related`` ------------------------ - -Set ``list_select_related`` to tell Django to use ``select_related()`` in -retrieving the list of objects on the admin change list page. This can save you -a bunch of database queries. - -The value should be either ``True`` or ``False``. Default is ``False``. - -Note that Django will use ``select_related()``, regardless of this setting, -if one of the ``list_display`` fields is a ``ForeignKey``. - -For more on ``select_related()``, see `the select_related() docs`_. - -.. _the select_related() docs: ../db-api/#select-related - -``ordering`` ------------- - -Set ``ordering`` to specify how objects on the admin change list page should be -ordered. This should be a list or tuple in the same format as a model's -``ordering`` parameter. - -If this isn't provided, the Django admin will use the model's default ordering. - -``save_as`` ------------ - -Set ``save_as`` to enable a "save as" feature on admin change forms. - -Normally, objects have three save options: "Save", "Save and continue editing" -and "Save and add another". If ``save_as`` is ``True``, "Save and add another" -will be replaced by a "Save as" button. - -"Save as" means the object will be saved as a new object (with a new ID), -rather than the old object. - -By default, ``save_as`` is set to ``False``. - -``save_on_top`` ---------------- - -Set ``save_on_top`` to add save buttons across the top of your admin change -forms. - -Normally, the save buttons appear only at the bottom of the forms. If you set -``save_on_top``, the buttons will appear both on the top and the bottom. - -By default, ``save_on_top`` is set to ``False``. - -``search_fields`` ------------------ - -Set ``search_fields`` to enable a search box on the admin change list page. -This should be set to a list of field names that will be searched whenever -somebody submits a search query in that text box. - -These fields should be some kind of text field, such as ``CharField`` or -``TextField``. You can also perform a related lookup on a ``ForeignKey`` with -the lookup API "follow" notation:: - - search_fields = ['foreign_key__related_fieldname'] - -When somebody does a search in the admin search box, Django splits the search -query into words and returns all objects that contain each of the words, case -insensitive, where each word must be in at least one of ``search_fields``. For -example, if ``search_fields`` is set to ``['first_name', 'last_name']`` and a -user searches for ``john lennon``, Django will do the equivalent of this SQL -``WHERE`` clause:: - - WHERE (first_name ILIKE '%john%' OR last_name ILIKE '%john%') - AND (first_name ILIKE '%lennon%' OR last_name ILIKE '%lennon%') - -For faster and/or more restrictive searches, prefix the field name -with an operator: - -``^`` - Matches the beginning of the field. For example, if ``search_fields`` is - set to ``['^first_name', '^last_name']`` and a user searches for - ``john lennon``, Django will do the equivalent of this SQL ``WHERE`` - clause:: - - WHERE (first_name ILIKE 'john%' OR last_name ILIKE 'john%') - AND (first_name ILIKE 'lennon%' OR last_name ILIKE 'lennon%') - - This query is more efficient than the normal ``'%john%'`` query, because - the database only needs to check the beginning of a column's data, rather - than seeking through the entire column's data. Plus, if the column has an - index on it, some databases may be able to use the index for this query, - even though it's a ``LIKE`` query. - -``=`` - Matches exactly, case-insensitive. For example, if - ``search_fields`` is set to ``['=first_name', '=last_name']`` and - a user searches for ``john lennon``, Django will do the equivalent - of this SQL ``WHERE`` clause:: - - WHERE (first_name ILIKE 'john' OR last_name ILIKE 'john') - AND (first_name ILIKE 'lennon' OR last_name ILIKE 'lennon') - - Note that the query input is split by spaces, so, following this example, - it's currently not possible to search for all records in which - ``first_name`` is exactly ``'john winston'`` (containing a space). - -``@`` - Performs a full-text match. This is like the default search method but uses - an index. Currently this is only available for MySQL. - Managers ======== diff --git a/docs/modelforms.txt b/docs/modelforms.txt index 73335a03a2..9c06bc409d 100644 --- a/docs/modelforms.txt +++ b/docs/modelforms.txt @@ -376,3 +376,125 @@ There are a couple of things to note, however. Chances are these notes won't affect you unless you're trying to do something tricky with subclassing. + +Model Formsets +============== + +Similar to regular formsets there are a couple enhanced formset classes that +provide all the right things to work with your models. Lets reuse the +``Author`` model from above:: + + >>> from django.newforms.models import modelformset_factory + >>> AuthorFormSet = modelformset_factory(Author) + +This will create a formset that is capable of working with the data associated +to the ``Author`` model. It works just like a regular formset:: + + >>> formset = AuthorFormSet() + >>> print formset + <input type="hidden" name="form-TOTAL_FORMS" value="1" id="id_form-TOTAL_FORMS" /><input type="hidden" name="form-INITIAL_FORMS" value="0" id="id_form-INITIAL_FORMS" /> + <tr><th><label for="id_form-0-name">Name:</label></th><td><input id="id_form-0-name" type="text" name="form-0-name" maxlength="100" /></td></tr> + <tr><th><label for="id_form-0-title">Title:</label></th><td><select name="form-0-title" id="id_form-0-title"> + <option value="" selected="selected">---------</option> + <option value="MR">Mr.</option> + <option value="MRS">Mrs.</option> + <option value="MS">Ms.</option> + </select></td></tr> + <tr><th><label for="id_form-0-birth_date">Birth date:</label></th><td><input type="text" name="form-0-birth_date" id="id_form-0-birth_date" /><input type="hidden" name="form-0-id" id="id_form-0-id" /></td></tr> + +.. note:: + One thing to note is that ``modelformset_factory`` uses ``formset_factory`` + and by default uses ``can_delete=True``. + +Changing the queryset +--------------------- + +By default when you create a formset from a model the queryset will be all +objects in the model. This is best shown as ``Author.objects.all()``. This is +configurable:: + + >>> formset = AuthorFormSet(queryset=Author.objects.filter(name__startswith='O')) + +Alternatively, you can use a subclassing based approach:: + + from django.newforms.models import BaseModelFormSet + + class BaseAuthorFormSet(BaseModelFormSet): + def get_queryset(self): + return super(BaseAuthorFormSet, self).get_queryset().filter(name__startswith='O') + +Then your ``BaseAuthorFormSet`` would be passed into the factory function to +be used as a base:: + + >>> AuthorFormSet = modelformset_factory(Author, formset=BaseAuthorFormSet) + +Saving objects in the formset +----------------------------- + +Similar to a ``ModelForm`` you can save the data into the model. This is done +with the ``save()`` method on the formset:: + + # create a formset instance with POST data. + >>> formset = AuthorFormSet(request.POST) + + # assuming all is valid, save the data + >>> instances = formset.save() + +The ``save()`` method will return the instances that have been saved to the +database. If an instance did not change in the bound data it will not be +saved to the database and not found in ``instances`` in the above example. + +You can optionally pass in ``commit=False`` to ``save()`` to only return the +model instances without any database interaction:: + + # don't save to the database + >>> instances = formset.save(commit=False) + >>> for instance in instances: + ... # do something with instance + ... instance.save() + +This gives you the ability to attach data to the instances before saving them +to the database. If your formset contains a ``ManyToManyField`` you will also +need to make a call to ``formset.save_m2m()`` to ensure the many-to-many +relationships are saved properly. + +Limiting the number of objects editable +--------------------------------------- + +Similar to regular formsets you can use the ``max_num`` parameter to +``modelformset_factory`` to limit the number of forms displayed. With +model formsets this will properly limit the query to only select the maximum +number of objects needed:: + + >>> Author.objects.order_by('name') + [<Author: Charles Baudelaire>, <Author: Paul Verlaine>, <Author: Walt Whitman>] + + >>> AuthorFormSet = modelformset_factory(Author, max_num=2, extra=1) + >>> formset = AuthorFormSet(queryset=Author.objects.order_by('name')) + >>> formset.initial + [{'id': 1, 'name': u'Charles Baudelaire'}, {'id': 3, 'name': u'Paul Verlaine'}] + +If the value of ``max_num`` is less than the total objects returned it will +fill the rest with extra forms:: + + >>> AuthorFormSet = modelformset_factory(Author, max_num=4, extra=1) + >>> formset = AuthorFormSet(queryset=Author.objects.order_by('name')) + >>> for form in formset.forms: + ... print form.as_table() + <tr><th><label for="id_form-0-name">Name:</label></th><td><input id="id_form-0-name" type="text" name="form-0-name" value="Charles Baudelaire" maxlength="100" /><input type="hidden" name="form-0-id" value="1" id="id_form-0-id" /></td></tr> + <tr><th><label for="id_form-1-name">Name:</label></th><td><input id="id_form-1-name" type="text" name="form-1-name" value="Paul Verlaine" maxlength="100" /><input type="hidden" name="form-1-id" value="3" id="id_form-1-id" /></td></tr> + <tr><th><label for="id_form-2-name">Name:</label></th><td><input id="id_form-2-name" type="text" name="form-2-name" value="Walt Whitman" maxlength="100" /><input type="hidden" name="form-2-id" value="2" id="id_form-2-id" /></td></tr> + <tr><th><label for="id_form-3-name">Name:</label></th><td><input id="id_form-3-name" type="text" name="form-3-name" maxlength="100" /><input type="hidden" name="form-3-id" id="id_form-3-id" /></td></tr> + +Using ``inlineformset_factory`` +------------------------------- + +The ``inlineformset_factory`` is a helper to a common usage pattern of working +with related objects through a foreign key. Suppose you have two models +``Author`` and ``Book``. You want to create a formset that works with the +books of a specific author. Here is how you could accomplish this:: + + >>> from django.newforms.models import inlineformset_factory + >>> BookFormSet = inlineformset_factory(Author, Book) + >>> author = Author.objects.get(name=u'Orson Scott Card') + >>> formset = BookFormSet(instance=author) diff --git a/docs/newforms.txt b/docs/newforms.txt index 4240fe9985..530c9ce828 100644 --- a/docs/newforms.txt +++ b/docs/newforms.txt @@ -76,6 +76,9 @@ The library deals with these concepts: * **Form** -- A collection of fields that knows how to validate itself and display itself as HTML. + * **Media** -- A definition of the CSS and JavaScript resources that are + required to render a form. + The library is decoupled from the other Django components, such as the database layer, views and templates. It relies only on Django settings, a couple of ``django.utils`` helper functions and Django's internationalization hooks (but @@ -1864,6 +1867,643 @@ They've been deprecated, but you can still `view the documentation`_. .. _ModelForms documentation: ../modelforms/ .. _view the documentation: ../form_for_model/ +Media +===== + +Rendering an attractive and easy-to-use web form requires more than just +HTML - it also requires CSS stylesheets, and if you want to use fancy +"Web2.0" widgets, you may also need to include some JavaScript on each +page. The exact combination of CSS and JavaScript that is required for +any given page will depend upon the widgets that are in use on that page. + +This is where Django media definitions come in. Django allows you to +associate different media files with the forms and widgets that require +that media. For example, if you want to use a calendar to render DateFields, +you can define a custom Calendar widget. This widget can then be associated +with the CSS and Javascript that is required to render the calendar. When +the Calendar widget is used on a form, Django is able to identify the CSS and +JavaScript files that are required, and provide the list of file names +in a form suitable for easy inclusion on your web page. + +.. admonition:: Media and Django Admin + + The Django Admin application defines a number of customized widgets + for calendars, filtered selections, and so on. These widgets define + media requirements, and the Django Admin uses the custom widgets + in place of the Django defaults. The Admin templates will only include + those media files that are required to render the widgets on any + given page. + + If you like the widgets that the Django Admin application uses, + feel free to use them in your own application! They're all stored + in ``django.contrib.admin.widgets``. + +.. admonition:: Which JavaScript toolkit? + + Many JavaScript toolkits exist, and many of them include widgets (such + as calendar widgets) that can be used to enhance your application. + Django has deliberately avoided blessing any one JavaScript toolkit. + Each toolkit has its own relative strengths and weaknesses - use + whichever toolkit suits your requirements. Django is able to integrate + with any JavaScript toolkit. + +Media as a static definition +---------------------------- + +The easiest way to define media is as a static definition. Using this method, +the media declaration is an inner class. The properties of the inner class +define the media requirements. + +Here's a simple example:: + + class CalendarWidget(forms.TextInput): + class Media: + css = { + 'all': ('pretty.css',) + } + js = ('animations.js', 'actions.js') + +This code defines a ``CalendarWidget``, which will be based on ``TextInput``. +Every time the CalendarWidget is used on a form, that form will be directed +to include the CSS file ``pretty.css``, and the JavaScript files +``animations.js`` and ``actions.js``. + +This static media definition is converted at runtime into a widget property +named ``media``. The media for a CalendarWidget instance can be retrieved +through this property:: + + >>> w = CalendarWidget() + >>> print w.media + <link href="http://media.example.com/pretty.css" type="text/css" media="all" rel="stylesheet" /> + <script type="text/javascript" src="http://media.example.com/animations.js"></script> + <script type="text/javascript" src="http://media.example.com/actions.js"></script> + +Here's a list of all possible ``Media`` options. There are no required options. + +``css`` +~~~~~~~ + +A dictionary describing the CSS files required for various forms of output +media. + +The values in the dictionary should be a tuple/list of file names. See +`the section on media paths`_ for details of how to specify paths to media +files. + +.. _the section on media paths: `Paths in media definitions`_ + +The keys in the dictionary are the output media types. These are the same +types accepted by CSS files in media declarations: 'all', 'aural', 'braille', +'embossed', 'handheld', 'print', 'projection', 'screen', 'tty' and 'tv'. If +you need to have different stylesheets for different media types, provide +a list of CSS files for each output medium. The following example would +provide two CSS options -- one for the screen, and one for print:: + + class Media: + css = { + 'screen': ('pretty.css',), + 'print': ('newspaper.css',) + } + +If a group of CSS files are appropriate for multiple output media types, +the dictionary key can be a comma separated list of output media types. +In the following example, TV's and projectors will have the same media +requirements:: + + class Media: + css = { + 'screen': ('pretty.css',), + 'tv,projector': ('lo_res.css',), + 'print': ('newspaper.css',) + } + +If this last CSS definition were to be rendered, it would become the following HTML:: + + <link href="http://media.example.com/pretty.css" type="text/css" media="screen" rel="stylesheet" /> + <link href="http://media.example.com/lo_res.css" type="text/css" media="tv,projector" rel="stylesheet" /> + <link href="http://media.example.com/newspaper.css" type="text/css" media="print" rel="stylesheet" /> + +``js`` +~~~~~~ + +A tuple describing the required javascript files. See +`the section on media paths`_ for details of how to specify paths to media +files. + +``extend`` +~~~~~~~~~~ + +A boolean defining inheritance behavior for media declarations. + +By default, any object using a static media definition will inherit all the +media associated with the parent widget. This occurs regardless of how the +parent defines its media requirements. For example, if we were to extend our +basic Calendar widget from the example above:: + + class FancyCalendarWidget(CalendarWidget): + class Media: + css = { + 'all': ('fancy.css',) + } + js = ('whizbang.js',) + + >>> w = FancyCalendarWidget() + >>> print w.media + <link href="http://media.example.com/pretty.css" type="text/css" media="all" rel="stylesheet" /> + <link href="http://media.example.com/fancy.css" type="text/css" media="all" rel="stylesheet" /> + <script type="text/javascript" src="http://media.example.com/animations.js"></script> + <script type="text/javascript" src="http://media.example.com/actions.js"></script> + <script type="text/javascript" src="http://media.example.com/whizbang.js"></script> + +The FancyCalendar widget inherits all the media from it's parent widget. If +you don't want media to be inherited in this way, add an ``extend=False`` +declaration to the media declaration:: + + class FancyCalendar(Calendar): + class Media: + extend = False + css = { + 'all': ('fancy.css',) + } + js = ('whizbang.js',) + + >>> w = FancyCalendarWidget() + >>> print w.media + <link href="http://media.example.com/fancy.css" type="text/css" media="all" rel="stylesheet" /> + <script type="text/javascript" src="http://media.example.com/whizbang.js"></script> + +If you require even more control over media inheritance, define your media +using a `dynamic property`_. Dynamic properties give you complete control over +which media files are inherited, and which are not. + +.. _dynamic property: `Media as a dynamic property`_ + +Media as a dynamic property +--------------------------- + +If you need to perform some more sophisticated manipulation of media +requirements, you can define the media property directly. This is done +by defining a model property that returns an instance of ``forms.Media``. +The constructor for ``forms.Media`` accepts ``css`` and ``js`` keyword +arguments in the same format as that used in a static media definition. + +For example, the static media definition for our Calendar Widget could +also be defined in a dynamic fashion:: + + class CalendarWidget(forms.TextInput): + def _media(self): + return forms.Media(css={'all': ('pretty.css',)}, + js=('animations.js', 'actions.js')) + media = property(_media) + +See the section on `Media objects`_ for more details on how to construct +return values for dynamic media properties. + +Paths in media definitions +-------------------------- + +Paths used to specify media can be either relative or absolute. If a path +starts with '/', 'http://' or 'https://', it will be interpreted as an absolute +path, and left as-is. All other paths will be prepended with the value of +``settings.MEDIA_URL``. For example, if the MEDIA_URL for your site was +``http://media.example.com/``:: + + class CalendarWidget(forms.TextInput): + class Media: + css = { + 'all': ('/css/pretty.css',), + } + js = ('animations.js', 'http://othersite.com/actions.js') + + >>> w = CalendarWidget() + >>> print w.media + <link href="/css/pretty.css" type="text/css" media="all" rel="stylesheet" /> + <script type="text/javascript" src="http://media.example.com/animations.js"></script> + <script type="text/javascript" src="http://othersite.com/actions.js"></script> + +Media objects +------------- + +When you interrogate the media attribute of a widget or form, the value that +is returned is a ``forms.Media`` object. As we have already seen, the string +representation of a Media object is the HTML required to include media +in the ``<head>`` block of your HTML page. + +However, Media objects have some other interesting properties. + +Media subsets +~~~~~~~~~~~~~ + +If you only want media of a particular type, you can use the subscript operator +to filter out a medium of interest. For example:: + + >>> w = CalendarWidget() + >>> print w.media + <link href="http://media.example.com/pretty.css" type="text/css" media="all" rel="stylesheet" /> + <script type="text/javascript" src="http://media.example.com/animations.js"></script> + <script type="text/javascript" src="http://media.example.com/actions.js"></script> + + >>> print w.media['css'] + <link href="http://media.example.com/pretty.css" type="text/css" media="all" rel="stylesheet" /> + +When you use the subscript operator, the value that is returned is a new +Media object -- but one that only contains the media of interest. + +Combining media objects +~~~~~~~~~~~~~~~~~~~~~~~ + +Media objects can also be added together. When two media objects are added, +the resulting Media object contains the union of the media from both files:: + + class CalendarWidget(forms.TextInput): + class Media: + css = { + 'all': ('pretty.css',) + } + js = ('animations.js', 'actions.js') + + class OtherWidget(forms.TextInput): + class Media: + js = ('whizbang.js',) + + >>> w1 = CalendarWidget() + >>> w2 = OtherWidget() + >>> print w1+w2 + <link href="http://media.example.com/pretty.css" type="text/css" media="all" rel="stylesheet" /> + <script type="text/javascript" src="http://media.example.com/animations.js"></script> + <script type="text/javascript" src="http://media.example.com/actions.js"></script> + <script type="text/javascript" src="http://media.example.com/whizbang.js"></script> + +Media on Forms +-------------- + +Widgets aren't the only objects that can have media definitions -- forms +can also define media. The rules for media definitions on forms are the +same as the rules for widgets: declarations can be static or dynamic; +path and inheritance rules for those declarations are exactly the same. + +Regardless of whether you define a media declaration, *all* Form objects +have a media property. The default value for this property is the result +of adding the media definitions for all widgets that are part of the form:: + + class ContactForm(forms.Form): + date = DateField(widget=CalendarWidget) + name = CharField(max_length=40, widget=OtherWidget) + + >>> f = ContactForm() + >>> f.media + <link href="http://media.example.com/pretty.css" type="text/css" media="all" rel="stylesheet" /> + <script type="text/javascript" src="http://media.example.com/animations.js"></script> + <script type="text/javascript" src="http://media.example.com/actions.js"></script> + <script type="text/javascript" src="http://media.example.com/whizbang.js"></script> + +If you want to associate additional media with a form -- for example, CSS for form +layout -- simply add a media declaration to the form:: + + class ContactForm(forms.Form): + date = DateField(widget=CalendarWidget) + name = CharField(max_length=40, widget=OtherWidget) + + class Media: + css = { + 'all': ('layout.css',) + } + + >>> f = ContactForm() + >>> f.media + <link href="http://media.example.com/pretty.css" type="text/css" media="all" rel="stylesheet" /> + <link href="http://media.example.com/layout.css" type="text/css" media="all" rel="stylesheet" /> + <script type="text/javascript" src="http://media.example.com/animations.js"></script> + <script type="text/javascript" src="http://media.example.com/actions.js"></script> + <script type="text/javascript" src="http://media.example.com/whizbang.js"></script> + +Formsets +======== + +A formset is a layer of abstraction to working with multiple forms on the same +page. It can be best compared to a data grid. Let's say you have the following +form:: + + >>> from django import newforms as forms + >>> class ArticleForm(forms.Form): + ... title = forms.CharField() + ... pub_date = forms.DateField() + +You might want to allow the user to create several articles at once. To create +a formset of ``ArticleForm``s you would do:: + + >>> from django.newforms.formsets import formset_factory + >>> ArticleFormSet = formset_factory(ArticleForm) + +You now have created a formset named ``ArticleFormSet``. The formset gives you +the ability to iterate over the forms in the formset and display them as you +would with a regular form:: + + >>> formset = ArticleFormSet() + >>> for form in formset.forms: + ... print form.as_table() + <tr><th><label for="id_form-0-title">Title:</label></th><td><input type="text" name="form-0-title" id="id_form-0-title" /></td></tr> + <tr><th><label for="id_form-0-pub_date">Pub date:</label></th><td><input type="text" name="form-0-pub_date" id="id_form-0-pub_date" /></td></tr> + +As you can see it only displayed one form. This is because by default the +``formset_factory`` defines one extra form. This can be controlled with the +``extra`` parameter:: + + >>> ArticleFormSet = formset_factory(ArticleForm, extra=2) + +Using initial data with a formset +--------------------------------- + +Initial data is what drives the main usability of a formset. As shown above +you can define the number of extra forms. What this means is that you are +telling the formset how many additional forms to show in addition to the +number of forms it generates from the initial data. Lets take a look at an +example:: + + >>> ArticleFormSet = formset_factory(ArticleForm, extra=2) + >>> formset = ArticleFormSet(initial=[ + ... {'title': u'Django is now open source', + ... 'pub_date': datetime.date.today()}, + ... ]) + + >>> for form in formset.forms: + ... print form.as_table() + <tr><th><label for="id_form-0-title">Title:</label></th><td><input type="text" name="form-0-title" value="Django is now open source" id="id_form-0-title" /></td></tr> + <tr><th><label for="id_form-0-pub_date">Pub date:</label></th><td><input type="text" name="form-0-pub_date" value="2008-05-12" id="id_form-0-pub_date" /></td></tr> + <tr><th><label for="id_form-1-title">Title:</label></th><td><input type="text" name="form-1-title" id="id_form-1-title" /></td></tr> + <tr><th><label for="id_form-1-pub_date">Pub date:</label></th><td><input type="text" name="form-1-pub_date" id="id_form-1-pub_date" /></td></tr> + <tr><th><label for="id_form-2-title">Title:</label></th><td><input type="text" name="form-2-title" id="id_form-2-title" /></td></tr> + <tr><th><label for="id_form-2-pub_date">Pub date:</label></th><td><input type="text" name="form-2-pub_date" id="id_form-2-pub_date" /></td></tr> + +There are now a total of three forms showing above. One for the initial data +that was passed in and two extra forms. Also note that we are passing in a +list of dictionaries as the initial data. + +Limiting the maximum number of forms +------------------------------------ + +The ``max_num`` parameter to ``formset_factory`` gives you the ability to +force the maximum number of forms the formset will display:: + + >>> ArticleFormSet = formset_factory(ArticleForm, extra=2, max_num=1) + >>> formset = ArticleFormset() + >>> for form in formset.forms: + ... print form.as_table() + <tr><th><label for="id_form-0-title">Title:</label></th><td><input type="text" name="form-0-title" id="id_form-0-title" /></td></tr> + <tr><th><label for="id_form-0-pub_date">Pub date:</label></th><td><input type="text" name="form-0-pub_date" id="id_form-0-pub_date" /></td></tr> + +The default value of ``max_num`` is ``0`` which is the same as saying put no +limit on the number forms displayed. + +Formset validation +------------------ + +Validation with a formset is about identical to a regular ``Form``. There is +an ``is_valid`` method on the formset to provide a convenient way to validate +each form in the formset:: + + >>> ArticleFormSet = formset_factory(ArticleForm) + >>> formset = ArticleFormSet({}) + >>> formset.is_valid() + True + +We passed in no data to the formset which is resulting in a valid form. The +formset is smart enough to ignore extra forms that were not changed. If we +attempt to provide an article, but fail to do so:: + + >>> data = { + ... 'form-TOTAL_FORMS': u'1', + ... 'form-INITIAL_FORMS': u'1', + ... 'form-0-title': u'Test', + ... 'form-0-pub_date': u'', + ... } + >>> formset = ArticleFormSet(data) + >>> formset.is_valid() + False + >>> formset.errors + [{'pub_date': [u'This field is required.']}] + +As we can see the formset properly performed validation and gave us the +expected errors. + +Understanding the ManagementForm +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You may have noticed the additional data that was required in the formset's +data above. This data is coming from the ``ManagementForm``. This form is +dealt with internally to the formset. If you don't use it, it will result in +an exception:: + + >>> data = { + ... 'form-0-title': u'Test', + ... 'form-0-pub_date': u'', + ... } + >>> formset = ArticleFormSet(data) + Traceback (most recent call last): + ... + django.newforms.util.ValidationError: [u'ManagementForm data is missing or has been tampered with'] + +It is used to keep track of how many form instances are being displayed. If +you are adding new forms via javascript, you should increment the count fields +in this form as well. + +Custom formset validation +~~~~~~~~~~~~~~~~~~~~~~~~~ + +A formset has a ``clean`` method similar to the one on a ``Form`` class. This +is where you define your own validation that deals at the formset level:: + + >>> from django.newforms.formsets import BaseFormSet + + >>> class BaseArticleFormSet(BaseFormSet): + ... def clean(self): + ... raise forms.ValidationError, u'An error occured.' + + >>> ArticleFormSet = formset_factory(ArticleForm, formset=BaseArticleFormSet) + >>> formset = ArticleFormSet({}) + >>> formset.is_valid() + False + >>> formset.non_form_errors() + [u'An error occured.'] + +The formset ``clean`` method is called after all the ``Form.clean`` methods +have been called. The errors will be found using the ``non_form_errors()`` +method on the formset. + +Dealing with ordering and deletion of forms +------------------------------------------- + +Common use cases with a formset is dealing with ordering and deletion of the +form instances. This has been dealt with for you. The ``formset_factory`` +provides two optional parameters ``can_order`` and ``can_delete`` that will do +the extra work of adding the extra fields and providing simpler ways of +getting to that data. + +``can_order`` +~~~~~~~~~~~~~ + +Default: ``False`` + +Lets create a formset with the ability to order:: + + >>> ArticleFormSet = formset_factory(ArticleForm, can_order=True) + >>> formset = ArticleFormSet(initial=[ + ... {'title': u'Article #1', 'pub_date': datetime.date(2008, 5, 10)}, + ... {'title': u'Article #2', 'pub_date': datetime.date(2008, 5, 11)}, + ... ]) + >>> for form in formset.forms: + ... print form.as_table() + <tr><th><label for="id_form-0-title">Title:</label></th><td><input type="text" name="form-0-title" value="Article #1" id="id_form-0-title" /></td></tr> + <tr><th><label for="id_form-0-pub_date">Pub date:</label></th><td><input type="text" name="form-0-pub_date" value="2008-05-10" id="id_form-0-pub_date" /></td></tr> + <tr><th><label for="id_form-0-ORDER">Order:</label></th><td><input type="text" name="form-0-ORDER" value="1" id="id_form-0-ORDER" /></td></tr> + <tr><th><label for="id_form-1-title">Title:</label></th><td><input type="text" name="form-1-title" value="Article #2" id="id_form-1-title" /></td></tr> + <tr><th><label for="id_form-1-pub_date">Pub date:</label></th><td><input type="text" name="form-1-pub_date" value="2008-05-11" id="id_form-1-pub_date" /></td></tr> + <tr><th><label for="id_form-1-ORDER">Order:</label></th><td><input type="text" name="form-1-ORDER" value="2" id="id_form-1-ORDER" /></td></tr> + <tr><th><label for="id_form-2-title">Title:</label></th><td><input type="text" name="form-2-title" id="id_form-2-title" /></td></tr> + <tr><th><label for="id_form-2-pub_date">Pub date:</label></th><td><input type="text" name="form-2-pub_date" id="id_form-2-pub_date" /></td></tr> + <tr><th><label for="id_form-2-ORDER">Order:</label></th><td><input type="text" name="form-2-ORDER" id="id_form-2-ORDER" /></td></tr> + +This adds an additional field to each form. This new field is named ``ORDER`` +and is an ``forms.IntegerField``. For the forms that came from the initial +data it automatically assigned them a numeric value. Lets look at what will +happen when the user changes these values:: + + >>> data = { + ... 'form-TOTAL_FORMS': u'3', + ... 'form-INITIAL_FORMS': u'2', + ... 'form-0-title': u'Article #1', + ... 'form-0-pub_date': u'2008-05-10', + ... 'form-0-ORDER': u'2', + ... 'form-1-title': u'Article #2', + ... 'form-1-pub_date': u'2008-05-11', + ... 'form-1-ORDER': u'1', + ... 'form-2-title': u'Article #3', + ... 'form-2-pub_date': u'2008-05-01', + ... 'form-2-ORDER': u'0', + ... } + + >>> formset = ArticleFormSet(data, initial=[ + ... {'title': u'Article #1', 'pub_date': datetime.date(2008, 5, 10)}, + ... {'title': u'Article #2', 'pub_date': datetime.date(2008, 5, 11)}, + ... ]) + >>> formset.is_valid() + True + >>> for form in formset.ordered_forms: + ... print form.cleaned_data + {'pub_date': datetime.date(2008, 5, 1), 'ORDER': 0, 'title': u'Article #3'} + {'pub_date': datetime.date(2008, 5, 11), 'ORDER': 1, 'title': u'Article #2'} + {'pub_date': datetime.date(2008, 5, 10), 'ORDER': 2, 'title': u'Article #1'} + +``can_delete`` +~~~~~~~~~~~~~~ + +Default: ``False`` + +Lets create a formset with the ability to delete:: + + >>> ArticleFormSet = formset_factory(ArticleForm, can_delete=True) + >>> formset = ArticleFormSet(initial=[ + ... {'title': u'Article #1', 'pub_date': datetime.date(2008, 5, 10)}, + ... {'title': u'Article #2', 'pub_date': datetime.date(2008, 5, 11)}, + ... ]) + >>> for form in formset.forms: + .... print form.as_table() + <input type="hidden" name="form-TOTAL_FORMS" value="3" id="id_form-TOTAL_FORMS" /><input type="hidden" name="form-INITIAL_FORMS" value="2" id="id_form-INITIAL_FORMS" /> + <tr><th><label for="id_form-0-title">Title:</label></th><td><input type="text" name="form-0-title" value="Article #1" id="id_form-0-title" /></td></tr> + <tr><th><label for="id_form-0-pub_date">Pub date:</label></th><td><input type="text" name="form-0-pub_date" value="2008-05-10" id="id_form-0-pub_date" /></td></tr> + <tr><th><label for="id_form-0-DELETE">Delete:</label></th><td><input type="checkbox" name="form-0-DELETE" id="id_form-0-DELETE" /></td></tr> + <tr><th><label for="id_form-1-title">Title:</label></th><td><input type="text" name="form-1-title" value="Article #2" id="id_form-1-title" /></td></tr> + <tr><th><label for="id_form-1-pub_date">Pub date:</label></th><td><input type="text" name="form-1-pub_date" value="2008-05-11" id="id_form-1-pub_date" /></td></tr> + <tr><th><label for="id_form-1-DELETE">Delete:</label></th><td><input type="checkbox" name="form-1-DELETE" id="id_form-1-DELETE" /></td></tr> + <tr><th><label for="id_form-2-title">Title:</label></th><td><input type="text" name="form-2-title" id="id_form-2-title" /></td></tr> + <tr><th><label for="id_form-2-pub_date">Pub date:</label></th><td><input type="text" name="form-2-pub_date" id="id_form-2-pub_date" /></td></tr> + <tr><th><label for="id_form-2-DELETE">Delete:</label></th><td><input type="checkbox" name="form-2-DELETE" id="id_form-2-DELETE" /></td></tr> + +Similar to ``can_order`` this adds a new field to each form named ``DELETE`` +and is a ``forms.BooleanField``. When data comes through marking any of the +delete fields you can access them with ``deleted_forms``:: + + >>> data = { + ... 'form-TOTAL_FORMS': u'3', + ... 'form-INITIAL_FORMS': u'2', + ... 'form-0-title': u'Article #1', + ... 'form-0-pub_date': u'2008-05-10', + ... 'form-0-DELETE': u'on', + ... 'form-1-title': u'Article #2', + ... 'form-1-pub_date': u'2008-05-11', + ... 'form-1-DELETE': u'', + ... 'form-2-title': u'', + ... 'form-2-pub_date': u'', + ... 'form-2-DELETE': u'', + ... } + + >>> formset = ArticleFormSet(data, initial=[ + ... {'title': u'Article #1', 'pub_date': datetime.date(2008, 5, 10)}, + ... {'title': u'Article #2', 'pub_date': datetime.date(2008, 5, 11)}, + ... ]) + >>> [form.cleaned_data for form in formset.deleted_forms] + [{'DELETE': True, 'pub_date': datetime.date(2008, 5, 10), 'title': u'Article #1'}] + +Adding additional fields to a formset +------------------------------------- + +If you need to add additional fields to the formset this can be easily +accomplished. The formset base class provides an ``add_fields`` method. You +can simply override this method to add your own fields or even redefine the +default fields/attributes of the order and deletion fields:: + + >>> class BaseArticleFormSet(BaseFormSet): + ... def add_fields(self, form, index): + ... super(BaseArticleFormSet, self).add_fields(form, index) + ... form.fields["my_field"] = forms.CharField() + + >>> ArticleFormSet = formset_factory(ArticleForm, formset=BaseArticleFormSet) + >>> formset = ArticleFormSet() + >>> for form in formset.forms: + ... print form.as_table() + <tr><th><label for="id_form-0-title">Title:</label></th><td><input type="text" name="form-0-title" id="id_form-0-title" /></td></tr> + <tr><th><label for="id_form-0-pub_date">Pub date:</label></th><td><input type="text" name="form-0-pub_date" id="id_form-0-pub_date" /></td></tr> + <tr><th><label for="id_form-0-my_field">My field:</label></th><td><input type="text" name="form-0-my_field" id="id_form-0-my_field" /></td></tr> + +Using a formsets in views and templates +--------------------------------------- + +Using a formset inside a view is as easy as using a regular ``Form`` class. +The only thing you will want to be aware of is making sure to use the +management form inside the template. Lets look at a sample view:: + + def manage_articles(request): + ArticleFormSet = formset_factory(ArticleForm) + if request.method == 'POST': + formset = ArticleFormSet(request.POST, request.FILES) + if formset.is_valid(): + # do something with the formset.cleaned_data + else: + formset = ArticleFormSet() + return render_to_response('manage_articles.html', {'formset': formset}) + +The ``manage_articles.html`` template might look like this:: + + <form method="POST" action=""> + {{ formset.management_form }} + <table> + {% for form in formset.forms %} + {{ form }} + {% endfor %} + </table> + </form> + +However the above can be slightly shortcutted and let the formset itself deal +with the management form:: + + <form method="POST" action=""> + <table> + {{ formset }} + </table> + </form> + +The above ends up calling the ``as_table`` method on the formset class. + More coming soon ================ diff --git a/docs/tutorial02.txt b/docs/tutorial02.txt index 42c9800591..c69fd1459f 100644 --- a/docs/tutorial02.txt +++ b/docs/tutorial02.txt @@ -31,10 +31,10 @@ activate the admin site for your installation, do these three things: * Add ``"django.contrib.admin"`` to your ``INSTALLED_APPS`` setting. * Run ``python manage.py syncdb``. Since you have added a new application to ``INSTALLED_APPS``, the database tables need to be updated. - * Edit your ``mysite/urls.py`` file and uncomment the line below - "Uncomment this for admin:". This file is a URLconf; we'll dig into - URLconfs in the next tutorial. For now, all you need to know is that it - maps URL roots to applications. + * Edit your ``mysite/urls.py`` file and uncomment the lines below the + "Uncomment this for admin:" comments. This file is a URLconf; we'll dig + into URLconfs in the next tutorial. For now, all you need to know is that + it maps URL roots to applications. Start the development server ============================ @@ -71,19 +71,13 @@ Make the poll app modifiable in the admin But where's our poll app? It's not displayed on the admin index page. -Just one thing to do: We need to specify in the ``Poll`` model that ``Poll`` +Just one thing to do: We need to tell the admin that ``Poll`` objects have an admin interface. Edit the ``mysite/polls/models.py`` file and -make the following change to add an inner ``Admin`` class:: +add the following to the bottom of the file:: - class Poll(models.Model): - # ... - class Admin: - pass - -The ``class Admin`` will contain all the settings that control how this model -appears in the Django admin. All the settings are optional, however, so -creating an empty class means "give this object an admin interface using -all the default options." + from django.contrib import admin + + admin.site.register(Poll) Now reload the Django admin page to see your changes. Note that you don't have to restart the development server -- the server will auto-reload your project, @@ -92,8 +86,8 @@ so any modifications code will be seen immediately in your browser. Explore the free admin functionality ==================================== -Now that ``Poll`` has the inner ``Admin`` class, Django knows that it should be -displayed on the admin index page: +Now that we've registered ``Poll``, Django knows that it should be displayed on +the admin index page: .. image:: http://media.djangoproject.com/img/doc/tutorial-trunk/admin03t.png :alt: Django admin index page, now with polls displayed @@ -145,17 +139,26 @@ with the timestamp and username of the person who made the change: Customize the admin form ======================== -Take a few minutes to marvel at all the code you didn't have to write. +Take a few minutes to marvel at all the code you didn't have to write. When you +call ``admin.site.register(Poll)``, Django just lets you edit the object and +"guess" at how to display it within the admin. Often you'll want to control how +the admin looks and works. You'll do this by telling Django about the options +you want when you register the object. -Let's customize this a bit. We can reorder the fields by explicitly adding a -``fields`` parameter to ``Admin``:: +Let's see how this works by reordering the fields on the edit form. Replace the +``admin.site.register(Poll)`` line with:: - class Admin: - fields = ( - (None, {'fields': ('pub_date', 'question')}), - ) + class PollAdmin(admin.ModelAdmin): + fields = ['pub_date', 'question'] + + admin.site.register(Poll, PollAdmin) -That made the "Publication date" show up first instead of second: +You'll follow this pattern -- create a model admin object, then pass it as the +second argument to ``admin.site.register()`` -- any time you need to change the +admin options for an object. + +This particular change above makes the "Publication date" come before the +"Question" field: .. image:: http://media.djangoproject.com/img/doc/tutorial-trunk/admin07.png :alt: Fields have been reordered @@ -166,13 +169,15 @@ of fields, choosing an intuitive order is an important usability detail. And speaking of forms with dozens of fields, you might want to split the form up into fieldsets:: - class Admin: - fields = ( - (None, {'fields': ('question',)}), - ('Date information', {'fields': ('pub_date',)}), - ) + class PollAdmin(admin.ModelAdmin): + fieldsets = [ + (None, {'fields': ['question']}), + ('Date information', {'fields': ['pub_date']}), + ] + + admin.site.register(Poll, PollAdmin) -The first element of each tuple in ``fields`` is the title of the fieldset. +The first element of each tuple in ``fieldsets`` is the title of the fieldset. Here's what our form looks like now: .. image:: http://media.djangoproject.com/img/doc/tutorial-trunk/admin08t.png @@ -184,11 +189,11 @@ You can assign arbitrary HTML classes to each fieldset. Django provides a This is useful when you have a long form that contains a number of fields that aren't commonly used:: - class Admin: - fields = ( - (None, {'fields': ('question',)}), - ('Date information', {'fields': ('pub_date',), 'classes': 'collapse'}), - ) + class PollAdmin(admin.ModelAdmin): + fieldsets = [ + (None, {'fields': ['question']}), + ('Date information', {'fields': ['pub_date'], 'classes': 'pub_date'}), + ] .. image:: http://media.djangoproject.com/img/doc/tutorial-trunk/admin09.png :alt: Fieldset is initially collapsed @@ -201,14 +206,10 @@ the admin page doesn't display choices. Yet. -There are two ways to solve this problem. The first is to give the ``Choice`` -model its own inner ``Admin`` class, just as we did with ``Poll``. Here's what -that would look like:: +There are two ways to solve this problem. The first register ``Choice`` with the +admin just as we did with ``Poll``. That's easy:: - class Choice(models.Model): - # ... - class Admin: - pass + admin.site.register(Choice) Now "Choices" is an available option in the Django admin. The "Add choice" form looks like this: @@ -220,33 +221,35 @@ In that form, the "Poll" field is a select box containing every poll in the database. Django knows that a ``ForeignKey`` should be represented in the admin as a ``<select>`` box. In our case, only one poll exists at this point. -Also note the "Add Another" link next to "Poll." Every object with a ForeignKey -relationship to another gets this for free. When you click "Add Another," you'll -get a popup window with the "Add poll" form. If you add a poll in that window -and click "Save," Django will save the poll to the database and dynamically add -it as the selected choice on the "Add choice" form you're looking at. +Also note the "Add Another" link next to "Poll." Every object with a +``ForeignKey`` relationship to another gets this for free. When you click "Add +Another," you'll get a popup window with the "Add poll" form. If you add a poll +in that window and click "Save," Django will save the poll to the database and +dynamically add it as the selected choice on the "Add choice" form you're +looking at. But, really, this is an inefficient way of adding Choice objects to the system. It'd be better if you could add a bunch of Choices directly when you create the Poll object. Let's make that happen. -Remove the ``Admin`` for the Choice model. Then, edit the ``ForeignKey(Poll)`` -field like so:: +Remove the ``register()`` call for the Choice model. Then, edit the ``Poll`` +registration code to read:: - poll = models.ForeignKey(Poll, edit_inline=models.STACKED, num_in_admin=3) - -This tells Django: "Choice objects are edited on the Poll admin page. By -default, provide enough fields for 3 Choices." + class ChoiceInline(admin.StackedInline): + model = Choice + extra = 3 + + class PollAdmin(admin.ModelAdmin): + fieldsets = [ + (None, {'fields': ['question']}), + ('Date information', {'fields': ['pub_date'], 'classes': 'pub_date'}), + ] + inlines = [ChoiceInline] -Then change the other fields in ``Choice`` to give them ``core=True``:: + admin.site.register(Poll, PollAdmin) - choice = models.CharField(max_length=200, core=True) - votes = models.IntegerField(core=True) - -This tells Django: "When you edit a Choice on the Poll admin page, the 'choice' -and 'votes' fields are required. The presence of at least one of them signifies -the addition of a new Choice object, and clearing both of them signifies the -deletion of that existing Choice object." +This tells Django: "Choice objects are edited on the Poll admin page. By +default, provide enough fields for 3 choices." Load the "Add poll" page to see how that looks: @@ -255,19 +258,18 @@ Load the "Add poll" page to see how that looks: :target: http://media.djangoproject.com/img/doc/tutorial-trunk/admin11.png It works like this: There are three slots for related Choices -- as specified -by ``num_in_admin`` -- but each time you come back to the "Change" page for an -already-created object, you get one extra slot. (This means there's no -hard-coded limit on how many related objects can be added.) If you wanted space -for three extra Choices each time you changed the poll, you'd use -``num_extra_on_change=3``. +by ``extra`` -- and each time you come back to the "Change" page for an +already-created object, you get another three extra slots. One small problem, though. It takes a lot of screen space to display all the fields for entering related Choice objects. For that reason, Django offers an -alternate way of displaying inline related objects:: +tabular way of displaying inline related objects; you just need to change +the ``ChoiceInline`` declaration to read:: - poll = models.ForeignKey(Poll, edit_inline=models.TABULAR, num_in_admin=3) + class ChoiceInline(admin.TabularInline): + #... -With that ``edit_inline=models.TABULAR`` (instead of ``models.STACKED``), the +With that ``TabularInline`` (instead of ``StackedInline``), the related objects are displayed in a more compact, table-based format: .. image:: http://media.djangoproject.com/img/doc/tutorial-trunk/admin12.png @@ -285,21 +287,21 @@ Here's what it looks like at this point: :alt: Polls change list page :target: http://media.djangoproject.com/img/doc/tutorial-trunk/admin04.png -By default, Django displays the ``str()`` of each object. But sometimes it'd -be more helpful if we could display individual fields. To do that, use the -``list_display`` option, which is a tuple of field names to display, as columns, -on the change list page for the object:: +By default, Django displays the ``str()`` of each object. But sometimes it'd be +more helpful if we could display individual fields. To do that, use the +``list_display`` admin option, which is a tuple of field names to display, as +columns, on the change list page for the object:: - class Poll(models.Model): + class PollAdmin(admin.ModelAdmin): # ... - class Admin: - # ... - list_display = ('question', 'pub_date') + list_display = ('question', 'pub_date') Just for good measure, let's also include the ``was_published_today`` custom method from Tutorial 1:: - list_display = ('question', 'pub_date', 'was_published_today') + class PollAdmin(admin.ModelAdmin): + # ... + list_display = ('question', 'pub_date', 'was_published_today') Now the poll change list page looks like this: @@ -318,9 +320,8 @@ method a ``short_description`` attribute:: return self.pub_date.date() == datetime.date.today() was_published_today.short_description = 'Published today?' - Let's add another improvement to the Poll change list page: Filters. Add the -following line to ``Poll.Admin``:: +following line to ``PollAdmin``:: list_filter = ['pub_date'] diff --git a/tests/modeltests/invalid_models/models.py b/tests/modeltests/invalid_models/models.py index 8a480a2381..48e574af48 100644 --- a/tests/modeltests/invalid_models/models.py +++ b/tests/modeltests/invalid_models/models.py @@ -5,12 +5,11 @@ This example exists purely to point out errors in models. """ from django.db import models - +model_errors = "" class FieldErrors(models.Model): charfield = models.CharField() decimalfield = models.DecimalField() filefield = models.FileField() - prepopulate = models.CharField(max_length=10, prepopulate_from='bad') choices = models.CharField(max_length=10, choices='bad') choices2 = models.CharField(max_length=10, choices=[(1,2,3),(1,2,3)]) index = models.CharField(max_length=10, db_index='bad') @@ -116,7 +115,6 @@ model_errors = """invalid_models.fielderrors: "charfield": CharFields require a invalid_models.fielderrors: "decimalfield": DecimalFields require a "decimal_places" attribute. invalid_models.fielderrors: "decimalfield": DecimalFields require a "max_digits" attribute. invalid_models.fielderrors: "filefield": FileFields require an "upload_to" attribute. -invalid_models.fielderrors: "prepopulate": prepopulate_from should be a list or tuple. invalid_models.fielderrors: "choices": "choices" should be iterable (e.g., a tuple or list). invalid_models.fielderrors: "choices2": "choices" should be a sequence of two-tuples. invalid_models.fielderrors: "choices2": "choices" should be a sequence of two-tuples. diff --git a/tests/modeltests/model_forms/models.py b/tests/modeltests/model_forms/models.py index e8598bd68f..6838f11d4e 100644 --- a/tests/modeltests/model_forms/models.py +++ b/tests/modeltests/model_forms/models.py @@ -992,4 +992,22 @@ True u'...test3.png' >>> instance.delete() +# Media on a ModelForm ######################################################## + +# Similar to a regular Form class you can define custom media to be used on +# the ModelForm. + +>>> class ModelFormWithMedia(ModelForm): +... class Media: +... js = ('/some/form/javascript',) +... css = { +... 'all': ('/some/form/css',) +... } +... class Meta: +... model = PhoneNumber +>>> f = ModelFormWithMedia() +>>> print f.media +<link href="/some/form/css" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/some/form/javascript"></script> + """} diff --git a/tests/modeltests/model_formsets/__init__.py b/tests/modeltests/model_formsets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/tests/modeltests/model_formsets/__init__.py diff --git a/tests/modeltests/model_formsets/models.py b/tests/modeltests/model_formsets/models.py new file mode 100644 index 0000000000..84265450fc --- /dev/null +++ b/tests/modeltests/model_formsets/models.py @@ -0,0 +1,324 @@ +from django.db import models + +class Author(models.Model): + name = models.CharField(max_length=100) + + def __unicode__(self): + return self.name + +class Book(models.Model): + author = models.ForeignKey(Author) + title = models.CharField(max_length=100) + + def __unicode__(self): + return self.title + +class AuthorMeeting(models.Model): + name = models.CharField(max_length=100) + authors = models.ManyToManyField(Author) + created = models.DateField(editable=False) + + def __unicode__(self): + return self.name + + +__test__ = {'API_TESTS': """ + +>>> from datetime import date + +>>> from django.newforms.models import modelformset_factory + +>>> qs = Author.objects.all() +>>> AuthorFormSet = modelformset_factory(Author, extra=3) + +>>> formset = AuthorFormSet(queryset=qs) +>>> for form in formset.forms: +... print form.as_p() +<p><label for="id_form-0-name">Name:</label> <input id="id_form-0-name" type="text" name="form-0-name" maxlength="100" /><input type="hidden" name="form-0-id" id="id_form-0-id" /></p> +<p><label for="id_form-1-name">Name:</label> <input id="id_form-1-name" type="text" name="form-1-name" maxlength="100" /><input type="hidden" name="form-1-id" id="id_form-1-id" /></p> +<p><label for="id_form-2-name">Name:</label> <input id="id_form-2-name" type="text" name="form-2-name" maxlength="100" /><input type="hidden" name="form-2-id" id="id_form-2-id" /></p> + +>>> data = { +... 'form-TOTAL_FORMS': '3', # the number of forms rendered +... 'form-INITIAL_FORMS': '0', # the number of forms with initial data +... 'form-MAX_FORMS': '0', # the max number of forms +... 'form-0-name': 'Charles Baudelaire', +... 'form-1-name': 'Arthur Rimbaud', +... 'form-2-name': '', +... } + +>>> formset = AuthorFormSet(data=data, queryset=qs) +>>> formset.is_valid() +True + +>>> formset.save() +[<Author: Charles Baudelaire>, <Author: Arthur Rimbaud>] + +>>> for author in Author.objects.order_by('name'): +... print author.name +Arthur Rimbaud +Charles Baudelaire + + +Gah! We forgot Paul Verlaine. Let's create a formset to edit the existing +authors with an extra form to add him. We *could* pass in a queryset to +restrict the Author objects we edit, but in this case we'll use it to display +them in alphabetical order by name. + +>>> qs = Author.objects.order_by('name') +>>> AuthorFormSet = modelformset_factory(Author, extra=1, can_delete=False) + +>>> formset = AuthorFormSet(queryset=qs) +>>> for form in formset.forms: +... print form.as_p() +<p><label for="id_form-0-name">Name:</label> <input id="id_form-0-name" type="text" name="form-0-name" value="Arthur Rimbaud" maxlength="100" /><input type="hidden" name="form-0-id" value="2" id="id_form-0-id" /></p> +<p><label for="id_form-1-name">Name:</label> <input id="id_form-1-name" type="text" name="form-1-name" value="Charles Baudelaire" maxlength="100" /><input type="hidden" name="form-1-id" value="1" id="id_form-1-id" /></p> +<p><label for="id_form-2-name">Name:</label> <input id="id_form-2-name" type="text" name="form-2-name" maxlength="100" /><input type="hidden" name="form-2-id" id="id_form-2-id" /></p> + + +>>> data = { +... 'form-TOTAL_FORMS': '3', # the number of forms rendered +... 'form-INITIAL_FORMS': '2', # the number of forms with initial data +... 'form-MAX_FORMS': '0', # the max number of forms +... 'form-0-id': '2', +... 'form-0-name': 'Arthur Rimbaud', +... 'form-1-id': '1', +... 'form-1-name': 'Charles Baudelaire', +... 'form-2-name': 'Paul Verlaine', +... } + +>>> formset = AuthorFormSet(data=data, queryset=qs) +>>> formset.is_valid() +True + +# Only changed or new objects are returned from formset.save() +>>> formset.save() +[<Author: Paul Verlaine>] + +>>> for author in Author.objects.order_by('name'): +... print author.name +Arthur Rimbaud +Charles Baudelaire +Paul Verlaine + + +This probably shouldn't happen, but it will. If an add form was marked for +deltetion, make sure we don't save that form. + +>>> qs = Author.objects.order_by('name') +>>> AuthorFormSet = modelformset_factory(Author, extra=1, can_delete=True) + +>>> formset = AuthorFormSet(queryset=qs) +>>> for form in formset.forms: +... print form.as_p() +<p><label for="id_form-0-name">Name:</label> <input id="id_form-0-name" type="text" name="form-0-name" value="Arthur Rimbaud" maxlength="100" /></p> +<p><label for="id_form-0-DELETE">Delete:</label> <input type="checkbox" name="form-0-DELETE" id="id_form-0-DELETE" /><input type="hidden" name="form-0-id" value="2" id="id_form-0-id" /></p> +<p><label for="id_form-1-name">Name:</label> <input id="id_form-1-name" type="text" name="form-1-name" value="Charles Baudelaire" maxlength="100" /></p> +<p><label for="id_form-1-DELETE">Delete:</label> <input type="checkbox" name="form-1-DELETE" id="id_form-1-DELETE" /><input type="hidden" name="form-1-id" value="1" id="id_form-1-id" /></p> +<p><label for="id_form-2-name">Name:</label> <input id="id_form-2-name" type="text" name="form-2-name" value="Paul Verlaine" maxlength="100" /></p> +<p><label for="id_form-2-DELETE">Delete:</label> <input type="checkbox" name="form-2-DELETE" id="id_form-2-DELETE" /><input type="hidden" name="form-2-id" value="3" id="id_form-2-id" /></p> +<p><label for="id_form-3-name">Name:</label> <input id="id_form-3-name" type="text" name="form-3-name" maxlength="100" /></p> +<p><label for="id_form-3-DELETE">Delete:</label> <input type="checkbox" name="form-3-DELETE" id="id_form-3-DELETE" /><input type="hidden" name="form-3-id" id="id_form-3-id" /></p> + +>>> data = { +... 'form-TOTAL_FORMS': '4', # the number of forms rendered +... 'form-INITIAL_FORMS': '3', # the number of forms with initial data +... 'form-MAX_FORMS': '0', # the max number of forms +... 'form-0-id': '2', +... 'form-0-name': 'Arthur Rimbaud', +... 'form-1-id': '1', +... 'form-1-name': 'Charles Baudelaire', +... 'form-2-id': '3', +... 'form-2-name': 'Paul Verlaine', +... 'form-3-name': 'Walt Whitman', +... 'form-3-DELETE': 'on', +... } + +>>> formset = AuthorFormSet(data=data, queryset=qs) +>>> formset.is_valid() +True + +# No objects were changed or saved so nothing will come back. +>>> formset.save() +[] + +>>> for author in Author.objects.order_by('name'): +... print author.name +Arthur Rimbaud +Charles Baudelaire +Paul Verlaine + +Let's edit a record to ensure save only returns that one record. + +>>> data = { +... 'form-TOTAL_FORMS': '4', # the number of forms rendered +... 'form-INITIAL_FORMS': '3', # the number of forms with initial data +... 'form-MAX_FORMS': '0', # the max number of forms +... 'form-0-id': '2', +... 'form-0-name': 'Walt Whitman', +... 'form-1-id': '1', +... 'form-1-name': 'Charles Baudelaire', +... 'form-2-id': '3', +... 'form-2-name': 'Paul Verlaine', +... 'form-3-name': '', +... 'form-3-DELETE': '', +... } + +>>> formset = AuthorFormSet(data=data, queryset=qs) +>>> formset.is_valid() +True + +# One record has changed. +>>> formset.save() +[<Author: Walt Whitman>] + +Test the behavior of commit=False and save_m2m + +>>> meeting = AuthorMeeting.objects.create(created=date.today()) +>>> meeting.authors = Author.objects.all() + +# create an Author instance to add to the meeting. +>>> new_author = Author.objects.create(name=u'John Steinbeck') + +>>> AuthorMeetingFormSet = modelformset_factory(AuthorMeeting, extra=1, can_delete=True) +>>> data = { +... 'form-TOTAL_FORMS': '2', # the number of forms rendered +... 'form-INITIAL_FORMS': '1', # the number of forms with initial data +... 'form-MAX_FORMS': '0', # the max number of forms +... 'form-0-id': '1', +... 'form-0-name': '2nd Tuesday of the Week Meeting', +... 'form-0-authors': [2, 1, 3, 4], +... 'form-1-name': '', +... 'form-1-authors': '', +... 'form-1-DELETE': '', +... } +>>> formset = AuthorMeetingFormSet(data=data, queryset=AuthorMeeting.objects.all()) +>>> formset.is_valid() +True +>>> instances = formset.save(commit=False) +>>> for instance in instances: +... instance.created = date.today() +... instance.save() +>>> formset.save_m2m() +>>> instances[0].authors.all() +[<Author: Charles Baudelaire>, <Author: Walt Whitman>, <Author: Paul Verlaine>, <Author: John Steinbeck>] + +# delete the author we created to allow later tests to continue working. +>>> new_author.delete() + +Test the behavior of max_num with model formsets. It should properly limit +the queryset to reduce the amount of objects being pulled in when not being +used. + +>>> qs = Author.objects.order_by('name') + +>>> AuthorFormSet = modelformset_factory(Author, max_num=2) +>>> formset = AuthorFormSet(queryset=qs) +>>> formset.initial +[{'id': 1, 'name': u'Charles Baudelaire'}, {'id': 3, 'name': u'Paul Verlaine'}] + +>>> AuthorFormSet = modelformset_factory(Author, max_num=3) +>>> formset = AuthorFormSet(queryset=qs) +>>> formset.initial +[{'id': 1, 'name': u'Charles Baudelaire'}, {'id': 3, 'name': u'Paul Verlaine'}, {'id': 2, 'name': u'Walt Whitman'}] + +# Inline Formsets ############################################################ + +We can also create a formset that is tied to a parent model. This is how the +admin system's edit inline functionality works. + +>>> from django.newforms.models import inlineformset_factory + +>>> AuthorBooksFormSet = inlineformset_factory(Author, Book, can_delete=False, extra=3) +>>> author = Author.objects.get(name='Charles Baudelaire') + +>>> formset = AuthorBooksFormSet(instance=author) +>>> for form in formset.forms: +... print form.as_p() +<p><label for="id_book_set-0-title">Title:</label> <input id="id_book_set-0-title" type="text" name="book_set-0-title" maxlength="100" /><input type="hidden" name="book_set-0-id" id="id_book_set-0-id" /></p> +<p><label for="id_book_set-1-title">Title:</label> <input id="id_book_set-1-title" type="text" name="book_set-1-title" maxlength="100" /><input type="hidden" name="book_set-1-id" id="id_book_set-1-id" /></p> +<p><label for="id_book_set-2-title">Title:</label> <input id="id_book_set-2-title" type="text" name="book_set-2-title" maxlength="100" /><input type="hidden" name="book_set-2-id" id="id_book_set-2-id" /></p> + +>>> data = { +... 'book_set-TOTAL_FORMS': '3', # the number of forms rendered +... 'book_set-INITIAL_FORMS': '0', # the number of forms with initial data +... 'book_set-MAX_FORMS': '0', # the max number of forms +... 'book_set-0-title': 'Les Fleurs du Mal', +... 'book_set-1-title': '', +... 'book_set-2-title': '', +... } + +>>> formset = AuthorBooksFormSet(data, instance=author) +>>> formset.is_valid() +True + +>>> formset.save() +[<Book: Les Fleurs du Mal>] + +>>> for book in author.book_set.all(): +... print book.title +Les Fleurs du Mal + + +Now that we've added a book to Charles Baudelaire, let's try adding another +one. This time though, an edit form will be available for every existing +book. + +>>> AuthorBooksFormSet = inlineformset_factory(Author, Book, can_delete=False, extra=2) +>>> author = Author.objects.get(name='Charles Baudelaire') + +>>> formset = AuthorBooksFormSet(instance=author) +>>> for form in formset.forms: +... print form.as_p() +<p><label for="id_book_set-0-title">Title:</label> <input id="id_book_set-0-title" type="text" name="book_set-0-title" value="Les Fleurs du Mal" maxlength="100" /><input type="hidden" name="book_set-0-id" value="1" id="id_book_set-0-id" /></p> +<p><label for="id_book_set-1-title">Title:</label> <input id="id_book_set-1-title" type="text" name="book_set-1-title" maxlength="100" /><input type="hidden" name="book_set-1-id" id="id_book_set-1-id" /></p> +<p><label for="id_book_set-2-title">Title:</label> <input id="id_book_set-2-title" type="text" name="book_set-2-title" maxlength="100" /><input type="hidden" name="book_set-2-id" id="id_book_set-2-id" /></p> + +>>> data = { +... 'book_set-TOTAL_FORMS': '3', # the number of forms rendered +... 'book_set-INITIAL_FORMS': '1', # the number of forms with initial data +... 'book_set-MAX_FORMS': '0', # the max number of forms +... 'book_set-0-id': '1', +... 'book_set-0-title': 'Les Fleurs du Mal', +... 'book_set-1-title': 'Le Spleen de Paris', +... 'book_set-2-title': '', +... } + +>>> formset = AuthorBooksFormSet(data, instance=author) +>>> formset.is_valid() +True + +>>> formset.save() +[<Book: Le Spleen de Paris>] + +As you can see, 'Le Spleen de Paris' is now a book belonging to Charles Baudelaire. + +>>> for book in author.book_set.order_by('title'): +... print book.title +Le Spleen de Paris +Les Fleurs du Mal + +The save_as_new parameter lets you re-associate the data to a new instance. +This is used in the admin for save_as functionality. + +>>> data = { +... 'book_set-TOTAL_FORMS': '3', # the number of forms rendered +... 'book_set-INITIAL_FORMS': '2', # the number of forms with initial data +... 'book_set-MAX_FORMS': '0', # the max number of forms +... 'book_set-0-id': '1', +... 'book_set-0-title': 'Les Fleurs du Mal', +... 'book_set-1-id': '2', +... 'book_set-1-title': 'Le Spleen de Paris', +... 'book_set-2-title': '', +... } + +>>> formset = AuthorBooksFormSet(data, instance=Author(), save_as_new=True) +>>> formset.is_valid() +True + +>>> new_author = Author.objects.create(name='Charles Baudelaire') +>>> formset.instance = new_author +>>> [book for book in formset.save() if book.author.pk == new_author.pk] +[<Book: Les Fleurs du Mal>, <Book: Le Spleen de Paris>] + +"""} diff --git a/tests/regressiontests/admin_ordering/__init__.py b/tests/regressiontests/admin_ordering/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/tests/regressiontests/admin_ordering/__init__.py diff --git a/tests/regressiontests/admin_ordering/models.py b/tests/regressiontests/admin_ordering/models.py new file mode 100644 index 0000000000..601f06bb0a --- /dev/null +++ b/tests/regressiontests/admin_ordering/models.py @@ -0,0 +1,46 @@ +# coding: utf-8 +from django.db import models + +class Band(models.Model): + name = models.CharField(max_length=100) + bio = models.TextField() + rank = models.IntegerField() + + class Meta: + ordering = ('name',) + +__test__ = {'API_TESTS': """ + +Let's make sure that ModelAdmin.queryset uses the ordering we define in +ModelAdmin rather that ordering defined in the model's inner Meta +class. + +>>> from django.contrib.admin.options import ModelAdmin + +>>> b1 = Band(name='Aerosmith', bio='', rank=3) +>>> b1.save() +>>> b2 = Band(name='Radiohead', bio='', rank=1) +>>> b2.save() +>>> b3 = Band(name='Van Halen', bio='', rank=2) +>>> b3.save() + +The default ordering should be by name, as specified in the inner Meta class. + +>>> ma = ModelAdmin(Band, None) +>>> [b.name for b in ma.queryset(None)] +[u'Aerosmith', u'Radiohead', u'Van Halen'] + + +Let's use a custom ModelAdmin that changes the ordering, and make sure it +actually changes. + +>>> class BandAdmin(ModelAdmin): +... ordering = ('rank',) # default ordering is ('name',) +... + +>>> ma = BandAdmin(Band, None) +>>> [b.name for b in ma.queryset(None)] +[u'Radiohead', u'Van Halen', u'Aerosmith'] + +""" +} diff --git a/tests/regressiontests/admin_views/__init__.py b/tests/regressiontests/admin_views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/tests/regressiontests/admin_views/__init__.py diff --git a/tests/regressiontests/admin_views/fixtures/admin-views-users.xml b/tests/regressiontests/admin_views/fixtures/admin-views-users.xml new file mode 100644 index 0000000000..8d6c62b58f --- /dev/null +++ b/tests/regressiontests/admin_views/fixtures/admin-views-users.xml @@ -0,0 +1,81 @@ +<?xml version="1.0" encoding="utf-8"?> +<django-objects version="1.0"> + <object pk="100" model="auth.user"> + <field type="CharField" name="username">super</field> + <field type="CharField" name="first_name">Super</field> + <field type="CharField" name="last_name">User</field> + <field type="CharField" name="email">super@example.com</field> + <field type="CharField" name="password">sha1$995a3$6011485ea3834267d719b4c801409b8b1ddd0158</field> + <field type="BooleanField" name="is_staff">True</field> + <field type="BooleanField" name="is_active">True</field> + <field type="BooleanField" name="is_superuser">True</field> + <field type="DateTimeField" name="last_login">2007-05-30 13:20:10</field> + <field type="DateTimeField" name="date_joined">2007-05-30 13:20:10</field> + <field to="auth.group" name="groups" rel="ManyToManyRel"></field> + <field to="auth.permission" name="user_permissions" rel="ManyToManyRel"></field> + </object> + <object pk="101" model="auth.user"> + <field type="CharField" name="username">adduser</field> + <field type="CharField" name="first_name">Add</field> + <field type="CharField" name="last_name">User</field> + <field type="CharField" name="email">auser@example.com</field> + <field type="CharField" name="password">sha1$995a3$6011485ea3834267d719b4c801409b8b1ddd0158</field> + <field type="BooleanField" name="is_staff">True</field> + <field type="BooleanField" name="is_active">True</field> + <field type="BooleanField" name="is_superuser">False</field> + <field type="DateTimeField" name="last_login">2007-05-30 13:20:10</field> + <field type="DateTimeField" name="date_joined">2007-05-30 13:20:10</field> + <field to="auth.group" name="groups" rel="ManyToManyRel"></field> + <field to="auth.permission" name="user_permissions" rel="ManyToManyRel"></field> + </object> + <object pk="102" model="auth.user"> + <field type="CharField" name="username">changeuser</field> + <field type="CharField" name="first_name">Change</field> + <field type="CharField" name="last_name">User</field> + <field type="CharField" name="email">cuser@example.com</field> + <field type="CharField" name="password">sha1$995a3$6011485ea3834267d719b4c801409b8b1ddd0158</field> + <field type="BooleanField" name="is_staff">True</field> + <field type="BooleanField" name="is_active">True</field> + <field type="BooleanField" name="is_superuser">False</field> + <field type="DateTimeField" name="last_login">2007-05-30 13:20:10</field> + <field type="DateTimeField" name="date_joined">2007-05-30 13:20:10</field> + <field to="auth.group" name="groups" rel="ManyToManyRel"></field> + <field to="auth.permission" name="user_permissions" rel="ManyToManyRel"></field> + </object> + <object pk="103" model="auth.user"> + <field type="CharField" name="username">deleteuser</field> + <field type="CharField" name="first_name">Delete</field> + <field type="CharField" name="last_name">User</field> + <field type="CharField" name="email">duser@example.com</field> + <field type="CharField" name="password">sha1$995a3$6011485ea3834267d719b4c801409b8b1ddd0158</field> + <field type="BooleanField" name="is_staff">True</field> + <field type="BooleanField" name="is_active">True</field> + <field type="BooleanField" name="is_superuser">False</field> + <field type="DateTimeField" name="last_login">2007-05-30 13:20:10</field> + <field type="DateTimeField" name="date_joined">2007-05-30 13:20:10</field> + <field to="auth.group" name="groups" rel="ManyToManyRel"></field> + <field to="auth.permission" name="user_permissions" rel="ManyToManyRel"></field> + </object> + <object pk="104" model="auth.user"> + <field type="CharField" name="username">joepublic</field> + <field type="CharField" name="first_name">Joe</field> + <field type="CharField" name="last_name">Public</field> + <field type="CharField" name="email">joepublic@example.com</field> + <field type="CharField" name="password">sha1$995a3$6011485ea3834267d719b4c801409b8b1ddd0158</field> + <field type="BooleanField" name="is_staff">False</field> + <field type="BooleanField" name="is_active">True</field> + <field type="BooleanField" name="is_superuser">False</field> + <field type="DateTimeField" name="last_login">2007-05-30 13:20:10</field> + <field type="DateTimeField" name="date_joined">2007-05-30 13:20:10</field> + <field to="auth.group" name="groups" rel="ManyToManyRel"></field> + <field to="auth.permission" name="user_permissions" rel="ManyToManyRel"></field> + </object> + <object pk="1" model="admin_views.section"> + <field type="CharField" name="name">Test section</field> + </object> + <object pk="1" model="admin_views.article"> + <field type="TextField" name="content"><p>test content</p></field> + <field type="DateTimeField" name="date">2008-03-18 11:54:58</field> + <field to="admin_views.section" name="section" rel="ManyToOneRel">1</field> + </object> +</django-objects>
\ No newline at end of file diff --git a/tests/regressiontests/admin_views/fixtures/string-primary-key.xml b/tests/regressiontests/admin_views/fixtures/string-primary-key.xml new file mode 100644 index 0000000000..8e1dbf047f --- /dev/null +++ b/tests/regressiontests/admin_views/fixtures/string-primary-key.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<django-objects version="1.0"> + <object pk="1" model="admin_views.modelwithstringprimarykey"> + <field type="CharField" name="id"><![CDATA[abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 -_.!~*'() ;/?:@&=+$, <>#%" {}|\^[]`]]></field> + </object> +</django-objects>
\ No newline at end of file diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py new file mode 100644 index 0000000000..2062107397 --- /dev/null +++ b/tests/regressiontests/admin_views/models.py @@ -0,0 +1,61 @@ +from django.db import models +from django.contrib import admin + +class Section(models.Model): + """ + A simple section that links to articles, to test linking to related items + in admin views. + """ + name = models.CharField(max_length=100) + +class Article(models.Model): + """ + A simple article to test admin views. Test backwards compatibility. + """ + content = models.TextField() + date = models.DateTimeField() + section = models.ForeignKey(Section) + +class ArticleAdmin(admin.ModelAdmin): + list_display = ('content', 'date') + list_filter = ('date',) + + def changelist_view(self, request): + "Test that extra_context works" + return super(ArticleAdmin, self).changelist_view( + request, extra_context={ + 'extra_var': 'Hello!' + } + ) + +class CustomArticle(models.Model): + content = models.TextField() + date = models.DateTimeField() + +class CustomArticleAdmin(admin.ModelAdmin): + """ + Tests various hooks for using custom templates and contexts. + """ + change_list_template = 'custom_admin/change_list.html' + change_form_template = 'custom_admin/change_form.html' + object_history_template = 'custom_admin/object_history.html' + delete_confirmation_template = 'custom_admin/delete_confirmation.html' + + def changelist_view(self, request): + "Test that extra_context works" + return super(CustomArticleAdmin, self).changelist_view( + request, extra_context={ + 'extra_var': 'Hello!' + } + ) + +class ModelWithStringPrimaryKey(models.Model): + id = models.CharField(max_length=255, primary_key=True) + + def __unicode__(self): + return self.id + +admin.site.register(Article, ArticleAdmin) +admin.site.register(CustomArticle, CustomArticleAdmin) +admin.site.register(Section) +admin.site.register(ModelWithStringPrimaryKey) diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py new file mode 100644 index 0000000000..ad50928aa9 --- /dev/null +++ b/tests/regressiontests/admin_views/tests.py @@ -0,0 +1,362 @@ + +from django.test import TestCase +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.contrib.admin.models import LogEntry +from django.contrib.admin.sites import LOGIN_FORM_KEY, _encode_post_data +from django.contrib.admin.util import quote +from django.utils.html import escape + +# local test models +from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey + +def get_perm(Model, perm): + """Return the permission object, for the Model""" + ct = ContentType.objects.get_for_model(Model) + return Permission.objects.get(content_type=ct,codename=perm) + +class AdminViewPermissionsTest(TestCase): + """Tests for Admin Views Permissions.""" + + fixtures = ['admin-views-users.xml'] + + def setUp(self): + """Test setup.""" + # Setup permissions, for our users who can add, change, and delete. + # We can't put this into the fixture, because the content type id + # and the permission id could be different on each run of the test. + + opts = Article._meta + + # User who can add Articles + add_user = User.objects.get(username='adduser') + add_user.user_permissions.add(get_perm(Article, + opts.get_add_permission())) + + # User who can change Articles + change_user = User.objects.get(username='changeuser') + change_user.user_permissions.add(get_perm(Article, + opts.get_change_permission())) + + # User who can delete Articles + delete_user = User.objects.get(username='deleteuser') + delete_user.user_permissions.add(get_perm(Article, + opts.get_delete_permission())) + + delete_user.user_permissions.add(get_perm(Section, + Section._meta.get_delete_permission())) + + # login POST dicts + self.super_login = {'post_data': _encode_post_data({}), + LOGIN_FORM_KEY: 1, + 'username': 'super', + 'password': 'secret'} + self.super_email_login = {'post_data': _encode_post_data({}), + LOGIN_FORM_KEY: 1, + 'username': 'super@example.com', + 'password': 'secret'} + self.super_email_bad_login = {'post_data': _encode_post_data({}), + LOGIN_FORM_KEY: 1, + 'username': 'super@example.com', + 'password': 'notsecret'} + self.adduser_login = {'post_data': _encode_post_data({}), + LOGIN_FORM_KEY: 1, + 'username': 'adduser', + 'password': 'secret'} + self.changeuser_login = {'post_data': _encode_post_data({}), + LOGIN_FORM_KEY: 1, + 'username': 'changeuser', + 'password': 'secret'} + self.deleteuser_login = {'post_data': _encode_post_data({}), + LOGIN_FORM_KEY: 1, + 'username': 'deleteuser', + 'password': 'secret'} + self.joepublic_login = {'post_data': _encode_post_data({}), + LOGIN_FORM_KEY: 1, + 'username': 'joepublic', + 'password': 'secret'} + + def testTrailingSlashRequired(self): + """ + If you leave off the trailing slash, app should redirect and add it. + """ + self.client.post('/test_admin/admin/', self.super_login) + + request = self.client.get( + '/test_admin/admin/admin_views/article/add' + ) + self.assertRedirects(request, + '/test_admin/admin/admin_views/article/add/' + ) + + def testLogin(self): + """ + Make sure only staff members can log in. + + Successful posts to the login page will redirect to the orignal url. + Unsuccessfull attempts will continue to render the login page with + a 200 status code. + """ + # Super User + request = self.client.get('/test_admin/admin/') + self.failUnlessEqual(request.status_code, 200) + login = self.client.post('/test_admin/admin/', self.super_login) + self.assertRedirects(login, '/test_admin/admin/') + self.assertFalse(login.context) + self.client.get('/test_admin/admin/logout/') + + # Test if user enters e-mail address + request = self.client.get('/test_admin/admin/') + self.failUnlessEqual(request.status_code, 200) + login = self.client.post('/test_admin/admin/', self.super_email_login) + self.assertContains(login, "Your e-mail address is not your username") + # only correct passwords get a username hint + login = self.client.post('/test_admin/admin/', self.super_email_bad_login) + self.assertContains(login, "Usernames cannot contain the '@' character") + new_user = User(username='jondoe', password='secret', email='super@example.com') + new_user.save() + # check to ensure if there are multiple e-mail addresses a user doesn't get a 500 + login = self.client.post('/test_admin/admin/', self.super_email_login) + self.assertContains(login, "Usernames cannot contain the '@' character") + + # Add User + request = self.client.get('/test_admin/admin/') + self.failUnlessEqual(request.status_code, 200) + login = self.client.post('/test_admin/admin/', self.adduser_login) + self.assertRedirects(login, '/test_admin/admin/') + self.assertFalse(login.context) + self.client.get('/test_admin/admin/logout/') + + # Change User + request = self.client.get('/test_admin/admin/') + self.failUnlessEqual(request.status_code, 200) + login = self.client.post('/test_admin/admin/', self.changeuser_login) + self.assertRedirects(login, '/test_admin/admin/') + self.assertFalse(login.context) + self.client.get('/test_admin/admin/logout/') + + # Delete User + request = self.client.get('/test_admin/admin/') + self.failUnlessEqual(request.status_code, 200) + login = self.client.post('/test_admin/admin/', self.deleteuser_login) + self.assertRedirects(login, '/test_admin/admin/') + self.assertFalse(login.context) + self.client.get('/test_admin/admin/logout/') + + # Regular User should not be able to login. + request = self.client.get('/test_admin/admin/') + self.failUnlessEqual(request.status_code, 200) + login = self.client.post('/test_admin/admin/', self.joepublic_login) + self.failUnlessEqual(login.status_code, 200) + # Login.context is a list of context dicts we just need to check the first one. + self.assert_(login.context[0].get('error_message')) + + def testAddView(self): + """Test add view restricts access and actually adds items.""" + + add_dict = {'content': '<p>great article</p>', + 'date_0': '2008-03-18', 'date_1': '10:54:39', + 'section': 1} + + # Change User should not have access to add articles + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.changeuser_login) + request = self.client.get('/test_admin/admin/admin_views/article/add/') + self.failUnlessEqual(request.status_code, 403) + # Try POST just to make sure + post = self.client.post('/test_admin/admin/admin_views/article/add/', add_dict) + self.failUnlessEqual(post.status_code, 403) + self.failUnlessEqual(Article.objects.all().count(), 1) + self.client.get('/test_admin/admin/logout/') + + # Add user may login and POST to add view, then redirect to admin root + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.adduser_login) + post = self.client.post('/test_admin/admin/admin_views/article/add/', add_dict) + self.assertRedirects(post, '/test_admin/admin/') + self.failUnlessEqual(Article.objects.all().count(), 2) + self.client.get('/test_admin/admin/logout/') + + # Super can add too, but is redirected to the change list view + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.super_login) + post = self.client.post('/test_admin/admin/admin_views/article/add/', add_dict) + self.assertRedirects(post, '/test_admin/admin/admin_views/article/') + self.failUnlessEqual(Article.objects.all().count(), 3) + self.client.get('/test_admin/admin/logout/') + + # Check and make sure that if user expires, data still persists + post = self.client.post('/test_admin/admin/admin_views/article/add/', add_dict) + self.assertContains(post, 'Please log in again, because your session has expired.') + self.super_login['post_data'] = _encode_post_data(add_dict) + post = self.client.post('/test_admin/admin/admin_views/article/add/', self.super_login) + self.assertRedirects(post, '/test_admin/admin/admin_views/article/') + self.failUnlessEqual(Article.objects.all().count(), 4) + self.client.get('/test_admin/admin/logout/') + + def testChangeView(self): + """Change view should restrict access and allow users to edit items.""" + + change_dict = {'content': '<p>edited article</p>', + 'date_0': '2008-03-18', 'date_1': '10:54:39', + 'section': 1} + + # add user shoud not be able to view the list of article or change any of them + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.adduser_login) + request = self.client.get('/test_admin/admin/admin_views/article/') + self.failUnlessEqual(request.status_code, 403) + request = self.client.get('/test_admin/admin/admin_views/article/1/') + self.failUnlessEqual(request.status_code, 403) + post = self.client.post('/test_admin/admin/admin_views/article/1/', change_dict) + self.failUnlessEqual(post.status_code, 403) + self.client.get('/test_admin/admin/logout/') + + # change user can view all items and edit them + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.changeuser_login) + request = self.client.get('/test_admin/admin/admin_views/article/') + self.failUnlessEqual(request.status_code, 200) + request = self.client.get('/test_admin/admin/admin_views/article/1/') + self.failUnlessEqual(request.status_code, 200) + post = self.client.post('/test_admin/admin/admin_views/article/1/', change_dict) + self.assertRedirects(post, '/test_admin/admin/admin_views/article/') + self.failUnlessEqual(Article.objects.get(pk=1).content, '<p>edited article</p>') + self.client.get('/test_admin/admin/logout/') + + def testCustomModelAdminTemplates(self): + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.super_login) + + # Test custom change list template with custom extra context + request = self.client.get('/test_admin/admin/admin_views/customarticle/') + self.failUnlessEqual(request.status_code, 200) + self.assert_("var hello = 'Hello!';" in request.content) + self.assertTemplateUsed(request, 'custom_admin/change_list.html') + + # Test custom change form template + request = self.client.get('/test_admin/admin/admin_views/customarticle/add/') + self.assertTemplateUsed(request, 'custom_admin/change_form.html') + + # Add an article so we can test delete and history views + post = self.client.post('/test_admin/admin/admin_views/customarticle/add/', { + 'content': '<p>great article</p>', + 'date_0': '2008-03-18', + 'date_1': '10:54:39' + }) + self.assertRedirects(post, '/test_admin/admin/admin_views/customarticle/') + self.failUnlessEqual(CustomArticle.objects.all().count(), 1) + + # Test custom delete and object history templates + request = self.client.get('/test_admin/admin/admin_views/customarticle/1/delete/') + self.assertTemplateUsed(request, 'custom_admin/delete_confirmation.html') + request = self.client.get('/test_admin/admin/admin_views/customarticle/1/history/') + self.assertTemplateUsed(request, 'custom_admin/object_history.html') + + self.client.get('/test_admin/admin/logout/') + + def testCustomAdminSiteTemplates(self): + from django.contrib import admin + self.assertEqual(admin.site.index_template, None) + self.assertEqual(admin.site.login_template, None) + + self.client.get('/test_admin/admin/logout/') + request = self.client.get('/test_admin/admin/') + self.assertTemplateUsed(request, 'admin/login.html') + self.client.post('/test_admin/admin/', self.changeuser_login) + request = self.client.get('/test_admin/admin/') + self.assertTemplateUsed(request, 'admin/index.html') + + self.client.get('/test_admin/admin/logout/') + admin.site.login_template = 'custom_admin/login.html' + admin.site.index_template = 'custom_admin/index.html' + request = self.client.get('/test_admin/admin/') + self.assertTemplateUsed(request, 'custom_admin/login.html') + self.assert_('Hello from a custom login template' in request.content) + self.client.post('/test_admin/admin/', self.changeuser_login) + request = self.client.get('/test_admin/admin/') + self.assertTemplateUsed(request, 'custom_admin/index.html') + self.assert_('Hello from a custom index template' in request.content) + + # Finally, using monkey patching check we can inject custom_context arguments in to index + original_index = admin.site.index + def index(*args, **kwargs): + kwargs['extra_context'] = {'foo': '*bar*'} + return original_index(*args, **kwargs) + admin.site.index = index + request = self.client.get('/test_admin/admin/') + self.assertTemplateUsed(request, 'custom_admin/index.html') + self.assert_('Hello from a custom index template *bar*' in request.content) + + self.client.get('/test_admin/admin/logout/') + del admin.site.index # Resets to using the original + admin.site.login_template = None + admin.site.index_template = None + + def testDeleteView(self): + """Delete view should restrict access and actually delete items.""" + + delete_dict = {'post': 'yes'} + + # add user shoud not be able to delete articles + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.adduser_login) + request = self.client.get('/test_admin/admin/admin_views/article/1/delete/') + self.failUnlessEqual(request.status_code, 403) + post = self.client.post('/test_admin/admin/admin_views/article/1/delete/', delete_dict) + self.failUnlessEqual(post.status_code, 403) + self.failUnlessEqual(Article.objects.all().count(), 1) + self.client.get('/test_admin/admin/logout/') + + # Delete user can delete + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.deleteuser_login) + response = self.client.get('/test_admin/admin/admin_views/section/1/delete/') + # test response contains link to related Article + self.assertContains(response, "admin_views/article/1/") + + response = self.client.get('/test_admin/admin/admin_views/article/1/delete/') + self.failUnlessEqual(response.status_code, 200) + post = self.client.post('/test_admin/admin/admin_views/article/1/delete/', delete_dict) + self.assertRedirects(post, '/test_admin/admin/') + self.failUnlessEqual(Article.objects.all().count(), 0) + self.client.get('/test_admin/admin/logout/') + +class AdminViewStringPrimaryKeyTest(TestCase): + fixtures = ['admin-views-users.xml', 'string-primary-key.xml'] + + def __init__(self, *args): + super(AdminViewStringPrimaryKeyTest, self).__init__(*args) + self.pk = """abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 -_.!~*'() ;/?:@&=+$, <>#%" {}|\^[]`""" + + def setUp(self): + self.client.login(username='super', password='secret') + content_type_pk = ContentType.objects.get_for_model(ModelWithStringPrimaryKey).pk + LogEntry.objects.log_action(100, content_type_pk, self.pk, self.pk, 2, change_message='') + + def tearDown(self): + self.client.logout() + + def test_get_change_view(self): + "Retrieving the object using urlencoded form of primary key should work" + response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/%s/' % quote(self.pk)) + self.assertContains(response, escape(self.pk)) + self.failUnlessEqual(response.status_code, 200) + + def test_changelist_to_changeform_link(self): + "The link from the changelist referring to the changeform of the object should be quoted" + response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/') + should_contain = """<tr class="row1"><th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk)) + self.assertContains(response, should_contain) + + def test_recentactions_link(self): + "The link from the recent actions list referring to the changeform of the object should be quoted" + response = self.client.get('/test_admin/admin/') + should_contain = """<a href="admin_views/modelwithstringprimarykey/%s/">%s</a>""" % (quote(self.pk), escape(self.pk)) + self.assertContains(response, should_contain) + + def test_deleteconfirmation_link(self): + "The link from the delete confirmation page referring back to the changeform of the object should be quoted" + response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/%s/delete/' % quote(self.pk)) + should_contain = """<a href="../../%s/">%s</a>""" % (quote(self.pk), escape(self.pk)) + self.assertContains(response, should_contain) diff --git a/tests/regressiontests/admin_views/urls.py b/tests/regressiontests/admin_views/urls.py new file mode 100644 index 0000000000..e556812a45 --- /dev/null +++ b/tests/regressiontests/admin_views/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls.defaults import * +from django.contrib import admin + +urlpatterns = patterns('', + (r'^admin/doc/', include('django.contrib.admindocs.urls')), + (r'^admin/(.*)', admin.site.root), +) diff --git a/tests/regressiontests/admin_widgets/__init__.py b/tests/regressiontests/admin_widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/tests/regressiontests/admin_widgets/__init__.py diff --git a/tests/regressiontests/admin_widgets/models.py b/tests/regressiontests/admin_widgets/models.py new file mode 100644 index 0000000000..584d973c83 --- /dev/null +++ b/tests/regressiontests/admin_widgets/models.py @@ -0,0 +1,85 @@ + +from django.conf import settings +from django.db import models + +class Member(models.Model): + name = models.CharField(max_length=100) + + def __unicode__(self): + return self.name + +class Band(models.Model): + name = models.CharField(max_length=100) + members = models.ManyToManyField(Member) + + def __unicode__(self): + return self.name + +class Album(models.Model): + band = models.ForeignKey(Band) + name = models.CharField(max_length=100) + + def __unicode__(self): + return self.name + +__test__ = {'WIDGETS_TESTS': """ +>>> from datetime import datetime +>>> from django.utils.html import escape, conditional_escape +>>> from django.contrib.admin.widgets import FilteredSelectMultiple, AdminSplitDateTime +>>> from django.contrib.admin.widgets import AdminFileWidget, ForeignKeyRawIdWidget, ManyToManyRawIdWidget +>>> from django.contrib.admin.widgets import RelatedFieldWidgetWrapper + +Calling conditional_escape on the output of widget.render will simulate what +happens in the template. This is easier than setting up a template and context +for each test. + +Make sure that the Admin widgets render properly, that is, without their extra +HTML escaped. + +>>> w = FilteredSelectMultiple('test', False) +>>> print conditional_escape(w.render('test', 'test')) +<select multiple="multiple" name="test"> +</select><script type="text/javascript">addEvent(window, "load", function(e) {SelectFilter.init("id_test", "test", 0, "%(ADMIN_MEDIA_PREFIX)s"); });</script> +<BLANKLINE> + +>>> w = AdminSplitDateTime() +>>> print conditional_escape(w.render('test', datetime(2007, 12, 1, 9, 30))) +<p class="datetime">Date: <input value="2007-12-01" type="text" class="vDateField" name="test_0" size="10" /><br />Time: <input value="09:30:00" type="text" class="vTimeField" name="test_1" size="8" /></p> + +>>> w = AdminFileWidget() +>>> print conditional_escape(w.render('test', 'test')) +Currently: <a target="_blank" href="%(MEDIA_URL)stest">test</a> <br />Change: <input type="file" name="test" /> + +>>> band = Band.objects.create(pk=1, name='Linkin Park') +>>> album = band.album_set.create(name='Hybrid Theory') + +>>> rel = Album._meta.get_field('band').rel +>>> w = ForeignKeyRawIdWidget(rel) +>>> print conditional_escape(w.render('test', band.pk, attrs={})) +<input type="text" name="test" value="1" class="vForeignKeyRawIdAdminField" /><a href="../../../admin_widgets/band/" class="related-lookup" id="lookup_id_test" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%(ADMIN_MEDIA_PREFIX)simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a> <strong>Linkin Park</strong> + +>>> m1 = Member.objects.create(pk=1, name='Chester') +>>> m2 = Member.objects.create(pk=2, name='Mike') +>>> band.members.add(m1, m2) + +>>> rel = Band._meta.get_field('members').rel +>>> w = ManyToManyRawIdWidget(rel) +>>> print conditional_escape(w.render('test', [m1.pk, m2.pk], attrs={})) +<input type="text" name="test" value="1,2" class="vManyToManyRawIdAdminField" /><a href="../../../admin_widgets/member/" class="related-lookup" id="lookup_id_test" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%(ADMIN_MEDIA_PREFIX)simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a> +>>> w._has_changed(None, None) +False +>>> w._has_changed([], None) +False +>>> w._has_changed(None, [u'1']) +True +>>> w._has_changed([1, 2], [u'1', u'2']) +False +>>> w._has_changed([1, 2], [u'1']) +True +>>> w._has_changed([1, 2], [u'1', u'3']) +True + +""" % { + 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX, + 'MEDIA_URL': settings.MEDIA_URL, +}} diff --git a/tests/regressiontests/forms/forms.py b/tests/regressiontests/forms/forms.py index 041fa4054c..9add15163a 100644 --- a/tests/regressiontests/forms/forms.py +++ b/tests/regressiontests/forms/forms.py @@ -1667,4 +1667,76 @@ the list of errors is empty). You can also use it in {% if %} statements. <p><label>Password (again): <input type="password" name="password2" value="bar" /></label></p> <input type="submit" /> </form> + + +# The empty_permitted attribute ############################################## + +Sometimes (pretty much in formsets) we want to allow a form to pass validation +if it is completely empty. We can accomplish this by using the empty_permitted +agrument to a form constructor. + +>>> class SongForm(Form): +... artist = CharField() +... name = CharField() + +First let's show what happens id empty_permitted=False (the default): + +>>> data = {'artist': '', 'song': ''} + +>>> form = SongForm(data, empty_permitted=False) +>>> form.is_valid() +False +>>> form.errors +{'name': [u'This field is required.'], 'artist': [u'This field is required.']} +>>> form.cleaned_data +Traceback (most recent call last): +... +AttributeError: 'SongForm' object has no attribute 'cleaned_data' + + +Now let's show what happens when empty_permitted=True and the form is empty. + +>>> form = SongForm(data, empty_permitted=True) +>>> form.is_valid() +True +>>> form.errors +{} +>>> form.cleaned_data +{} + +But if we fill in data for one of the fields, the form is no longer empty and +the whole thing must pass validation. + +>>> data = {'artist': 'The Doors', 'song': ''} +>>> form = SongForm(data, empty_permitted=False) +>>> form.is_valid() +False +>>> form.errors +{'name': [u'This field is required.']} +>>> form.cleaned_data +Traceback (most recent call last): +... +AttributeError: 'SongForm' object has no attribute 'cleaned_data' + +If a field is not given in the data then None is returned for its data. Lets +make sure that when checking for empty_permitted that None is treated +accordingly. + +>>> data = {'artist': None, 'song': ''} +>>> form = SongForm(data, empty_permitted=True) +>>> form.is_valid() +True + +However, we *really* need to be sure we are checking for None as any data in +initial that returns False on a boolean call needs to be treated literally. + +>>> class PriceForm(Form): +... amount = FloatField() +... qty = IntegerField() + +>>> data = {'amount': '0.0', 'qty': ''} +>>> form = PriceForm(data, initial={'amount': 0.0}, empty_permitted=True) +>>> form.is_valid() +True + """ diff --git a/tests/regressiontests/forms/formsets.py b/tests/regressiontests/forms/formsets.py new file mode 100644 index 0000000000..dedc0a8e52 --- /dev/null +++ b/tests/regressiontests/forms/formsets.py @@ -0,0 +1,575 @@ +# -*- coding: utf-8 -*- +tests = """ +# Basic FormSet creation and usage ############################################ + +FormSet allows us to use multiple instance of the same form on 1 page. For now, +the best way to create a FormSet is by using the formset_factory function. + +>>> from django.newforms import Form, CharField, IntegerField, ValidationError +>>> from django.newforms.formsets import formset_factory, BaseFormSet + +>>> class Choice(Form): +... choice = CharField() +... votes = IntegerField() + +>>> ChoiceFormSet = formset_factory(Choice) + +A FormSet constructor takes the same arguments as Form. Let's create a FormSet +for adding data. By default, it displays 1 blank form. It can display more, +but we'll look at how to do so later. + +>>> formset = ChoiceFormSet(auto_id=False, prefix='choices') +>>> print formset +<input type="hidden" name="choices-TOTAL_FORMS" value="1" /><input type="hidden" name="choices-INITIAL_FORMS" value="0" /><input type="hidden" name="choices-MAX_FORMS" value="0" /> +<tr><th>Choice:</th><td><input type="text" name="choices-0-choice" /></td></tr> +<tr><th>Votes:</th><td><input type="text" name="choices-0-votes" /></td></tr> + + +On thing to note is that there needs to be a special value in the data. This +value tells the FormSet how many forms were displayed so it can tell how +many forms it needs to clean and validate. You could use javascript to create +new forms on the client side, but they won't get validated unless you increment +the TOTAL_FORMS field appropriately. + +>>> data = { +... 'choices-TOTAL_FORMS': '1', # the number of forms rendered +... 'choices-INITIAL_FORMS': '0', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '100', +... } + +We treat FormSet pretty much like we would treat a normal Form. FormSet has an +is_valid method, and a cleaned_data or errors attribute depending on whether all +the forms passed validation. However, unlike a Form instance, cleaned_data and +errors will be a list of dicts rather than just a single dict. + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +True +>>> [form.cleaned_data for form in formset.forms] +[{'votes': 100, 'choice': u'Calexico'}] + +If a FormSet was not passed any data, its is_valid method should return False. +>>> formset = ChoiceFormSet() +>>> formset.is_valid() +False + +FormSet instances can also have an error attribute if validation failed for +any of the forms. + +>>> data = { +... 'choices-TOTAL_FORMS': '1', # the number of forms rendered +... 'choices-INITIAL_FORMS': '0', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +False +>>> formset.errors +[{'votes': [u'This field is required.']}] + + +We can also prefill a FormSet with existing data by providing an ``initial`` +argument to the constructor. ``initial`` should be a list of dicts. By default, +an extra blank form is included. + +>>> initial = [{'choice': u'Calexico', 'votes': 100}] +>>> formset = ChoiceFormSet(initial=initial, auto_id=False, prefix='choices') +>>> for form in formset.forms: +... print form.as_ul() +<li>Choice: <input type="text" name="choices-0-choice" value="Calexico" /></li> +<li>Votes: <input type="text" name="choices-0-votes" value="100" /></li> +<li>Choice: <input type="text" name="choices-1-choice" /></li> +<li>Votes: <input type="text" name="choices-1-votes" /></li> + + +Let's simulate what would happen if we submitted this form. + +>>> data = { +... 'choices-TOTAL_FORMS': '2', # the number of forms rendered +... 'choices-INITIAL_FORMS': '1', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '100', +... 'choices-1-choice': '', +... 'choices-1-votes': '', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +True +>>> [form.cleaned_data for form in formset.forms] +[{'votes': 100, 'choice': u'Calexico'}, {}] + +But the second form was blank! Shouldn't we get some errors? No. If we display +a form as blank, it's ok for it to be submitted as blank. If we fill out even +one of the fields of a blank form though, it will be validated. We may want to +required that at least x number of forms are completed, but we'll show how to +handle that later. + +>>> data = { +... 'choices-TOTAL_FORMS': '2', # the number of forms rendered +... 'choices-INITIAL_FORMS': '1', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '100', +... 'choices-1-choice': 'The Decemberists', +... 'choices-1-votes': '', # missing value +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +False +>>> formset.errors +[{}, {'votes': [u'This field is required.']}] + +If we delete data that was pre-filled, we should get an error. Simply removing +data from form fields isn't the proper way to delete it. We'll see how to +handle that case later. + +>>> data = { +... 'choices-TOTAL_FORMS': '2', # the number of forms rendered +... 'choices-INITIAL_FORMS': '1', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': '', # deleted value +... 'choices-0-votes': '', # deleted value +... 'choices-1-choice': '', +... 'choices-1-votes': '', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +False +>>> formset.errors +[{'votes': [u'This field is required.'], 'choice': [u'This field is required.']}, {}] + + +# Displaying more than 1 blank form ########################################### + +We can also display more than 1 empty form at a time. To do so, pass a +extra argument to formset_factory. + +>>> ChoiceFormSet = formset_factory(Choice, extra=3) + +>>> formset = ChoiceFormSet(auto_id=False, prefix='choices') +>>> for form in formset.forms: +... print form.as_ul() +<li>Choice: <input type="text" name="choices-0-choice" /></li> +<li>Votes: <input type="text" name="choices-0-votes" /></li> +<li>Choice: <input type="text" name="choices-1-choice" /></li> +<li>Votes: <input type="text" name="choices-1-votes" /></li> +<li>Choice: <input type="text" name="choices-2-choice" /></li> +<li>Votes: <input type="text" name="choices-2-votes" /></li> + +Since we displayed every form as blank, we will also accept them back as blank. +This may seem a little strange, but later we will show how to require a minimum +number of forms to be completed. + +>>> data = { +... 'choices-TOTAL_FORMS': '3', # the number of forms rendered +... 'choices-INITIAL_FORMS': '0', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': '', +... 'choices-0-votes': '', +... 'choices-1-choice': '', +... 'choices-1-votes': '', +... 'choices-2-choice': '', +... 'choices-2-votes': '', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +True +>>> [form.cleaned_data for form in formset.forms] +[{}, {}, {}] + + +We can just fill out one of the forms. + +>>> data = { +... 'choices-TOTAL_FORMS': '3', # the number of forms rendered +... 'choices-INITIAL_FORMS': '0', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '100', +... 'choices-1-choice': '', +... 'choices-1-votes': '', +... 'choices-2-choice': '', +... 'choices-2-votes': '', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +True +>>> [form.cleaned_data for form in formset.forms] +[{'votes': 100, 'choice': u'Calexico'}, {}, {}] + + +And once again, if we try to partially complete a form, validation will fail. + +>>> data = { +... 'choices-TOTAL_FORMS': '3', # the number of forms rendered +... 'choices-INITIAL_FORMS': '0', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '100', +... 'choices-1-choice': 'The Decemberists', +... 'choices-1-votes': '', # missing value +... 'choices-2-choice': '', +... 'choices-2-votes': '', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +False +>>> formset.errors +[{}, {'votes': [u'This field is required.']}, {}] + + +The extra argument also works when the formset is pre-filled with initial +data. + +>>> initial = [{'choice': u'Calexico', 'votes': 100}] +>>> formset = ChoiceFormSet(initial=initial, auto_id=False, prefix='choices') +>>> for form in formset.forms: +... print form.as_ul() +<li>Choice: <input type="text" name="choices-0-choice" value="Calexico" /></li> +<li>Votes: <input type="text" name="choices-0-votes" value="100" /></li> +<li>Choice: <input type="text" name="choices-1-choice" /></li> +<li>Votes: <input type="text" name="choices-1-votes" /></li> +<li>Choice: <input type="text" name="choices-2-choice" /></li> +<li>Votes: <input type="text" name="choices-2-votes" /></li> +<li>Choice: <input type="text" name="choices-3-choice" /></li> +<li>Votes: <input type="text" name="choices-3-votes" /></li> + + +# FormSets with deletion ###################################################### + +We can easily add deletion ability to a FormSet with an agrument to +formset_factory. This will add a boolean field to each form instance. When +that boolean field is True, the form will be in formset.deleted_forms + +>>> ChoiceFormSet = formset_factory(Choice, can_delete=True) + +>>> initial = [{'choice': u'Calexico', 'votes': 100}, {'choice': u'Fergie', 'votes': 900}] +>>> formset = ChoiceFormSet(initial=initial, auto_id=False, prefix='choices') +>>> for form in formset.forms: +... print form.as_ul() +<li>Choice: <input type="text" name="choices-0-choice" value="Calexico" /></li> +<li>Votes: <input type="text" name="choices-0-votes" value="100" /></li> +<li>Delete: <input type="checkbox" name="choices-0-DELETE" /></li> +<li>Choice: <input type="text" name="choices-1-choice" value="Fergie" /></li> +<li>Votes: <input type="text" name="choices-1-votes" value="900" /></li> +<li>Delete: <input type="checkbox" name="choices-1-DELETE" /></li> +<li>Choice: <input type="text" name="choices-2-choice" /></li> +<li>Votes: <input type="text" name="choices-2-votes" /></li> +<li>Delete: <input type="checkbox" name="choices-2-DELETE" /></li> + +To delete something, we just need to set that form's special delete field to +'on'. Let's go ahead and delete Fergie. + +>>> data = { +... 'choices-TOTAL_FORMS': '3', # the number of forms rendered +... 'choices-INITIAL_FORMS': '2', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '100', +... 'choices-0-DELETE': '', +... 'choices-1-choice': 'Fergie', +... 'choices-1-votes': '900', +... 'choices-1-DELETE': 'on', +... 'choices-2-choice': '', +... 'choices-2-votes': '', +... 'choices-2-DELETE': '', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +True +>>> [form.cleaned_data for form in formset.forms] +[{'votes': 100, 'DELETE': False, 'choice': u'Calexico'}, {'votes': 900, 'DELETE': True, 'choice': u'Fergie'}, {}] +>>> [form.cleaned_data for form in formset.deleted_forms] +[{'votes': 900, 'DELETE': True, 'choice': u'Fergie'}] + + +# FormSets with ordering ###################################################### + +We can also add ordering ability to a FormSet with an agrument to +formset_factory. This will add a integer field to each form instance. When +form validation succeeds, [form.cleaned_data for form in formset.forms] will have the data in the correct +order specified by the ordering fields. If a number is duplicated in the set +of ordering fields, for instance form 0 and form 3 are both marked as 1, then +the form index used as a secondary ordering criteria. In order to put +something at the front of the list, you'd need to set it's order to 0. + +>>> ChoiceFormSet = formset_factory(Choice, can_order=True) + +>>> initial = [{'choice': u'Calexico', 'votes': 100}, {'choice': u'Fergie', 'votes': 900}] +>>> formset = ChoiceFormSet(initial=initial, auto_id=False, prefix='choices') +>>> for form in formset.forms: +... print form.as_ul() +<li>Choice: <input type="text" name="choices-0-choice" value="Calexico" /></li> +<li>Votes: <input type="text" name="choices-0-votes" value="100" /></li> +<li>Order: <input type="text" name="choices-0-ORDER" value="1" /></li> +<li>Choice: <input type="text" name="choices-1-choice" value="Fergie" /></li> +<li>Votes: <input type="text" name="choices-1-votes" value="900" /></li> +<li>Order: <input type="text" name="choices-1-ORDER" value="2" /></li> +<li>Choice: <input type="text" name="choices-2-choice" /></li> +<li>Votes: <input type="text" name="choices-2-votes" /></li> +<li>Order: <input type="text" name="choices-2-ORDER" /></li> + +>>> data = { +... 'choices-TOTAL_FORMS': '3', # the number of forms rendered +... 'choices-INITIAL_FORMS': '2', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '100', +... 'choices-0-ORDER': '1', +... 'choices-1-choice': 'Fergie', +... 'choices-1-votes': '900', +... 'choices-1-ORDER': '2', +... 'choices-2-choice': 'The Decemberists', +... 'choices-2-votes': '500', +... 'choices-2-ORDER': '0', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +True +>>> for form in formset.ordered_forms: +... print form.cleaned_data +{'votes': 500, 'ORDER': 0, 'choice': u'The Decemberists'} +{'votes': 100, 'ORDER': 1, 'choice': u'Calexico'} +{'votes': 900, 'ORDER': 2, 'choice': u'Fergie'} + +Ordering fields are allowed to be left blank, and if they *are* left blank, +they will be sorted below everything else. + +>>> data = { +... 'choices-TOTAL_FORMS': '4', # the number of forms rendered +... 'choices-INITIAL_FORMS': '3', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '100', +... 'choices-0-ORDER': '1', +... 'choices-1-choice': 'Fergie', +... 'choices-1-votes': '900', +... 'choices-1-ORDER': '2', +... 'choices-2-choice': 'The Decemberists', +... 'choices-2-votes': '500', +... 'choices-2-ORDER': '', +... 'choices-3-choice': 'Basia Bulat', +... 'choices-3-votes': '50', +... 'choices-3-ORDER': '', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +True +>>> for form in formset.ordered_forms: +... print form.cleaned_data +{'votes': 100, 'ORDER': 1, 'choice': u'Calexico'} +{'votes': 900, 'ORDER': 2, 'choice': u'Fergie'} +{'votes': 500, 'ORDER': None, 'choice': u'The Decemberists'} +{'votes': 50, 'ORDER': None, 'choice': u'Basia Bulat'} + + +# FormSets with ordering + deletion ########################################### + +Let's try throwing ordering and deletion into the same form. + +>>> ChoiceFormSet = formset_factory(Choice, can_order=True, can_delete=True) + +>>> initial = [ +... {'choice': u'Calexico', 'votes': 100}, +... {'choice': u'Fergie', 'votes': 900}, +... {'choice': u'The Decemberists', 'votes': 500}, +... ] +>>> formset = ChoiceFormSet(initial=initial, auto_id=False, prefix='choices') +>>> for form in formset.forms: +... print form.as_ul() +<li>Choice: <input type="text" name="choices-0-choice" value="Calexico" /></li> +<li>Votes: <input type="text" name="choices-0-votes" value="100" /></li> +<li>Order: <input type="text" name="choices-0-ORDER" value="1" /></li> +<li>Delete: <input type="checkbox" name="choices-0-DELETE" /></li> +<li>Choice: <input type="text" name="choices-1-choice" value="Fergie" /></li> +<li>Votes: <input type="text" name="choices-1-votes" value="900" /></li> +<li>Order: <input type="text" name="choices-1-ORDER" value="2" /></li> +<li>Delete: <input type="checkbox" name="choices-1-DELETE" /></li> +<li>Choice: <input type="text" name="choices-2-choice" value="The Decemberists" /></li> +<li>Votes: <input type="text" name="choices-2-votes" value="500" /></li> +<li>Order: <input type="text" name="choices-2-ORDER" value="3" /></li> +<li>Delete: <input type="checkbox" name="choices-2-DELETE" /></li> +<li>Choice: <input type="text" name="choices-3-choice" /></li> +<li>Votes: <input type="text" name="choices-3-votes" /></li> +<li>Order: <input type="text" name="choices-3-ORDER" /></li> +<li>Delete: <input type="checkbox" name="choices-3-DELETE" /></li> + +Let's delete Fergie, and put The Decemberists ahead of Calexico. + +>>> data = { +... 'choices-TOTAL_FORMS': '4', # the number of forms rendered +... 'choices-INITIAL_FORMS': '3', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms +... 'choices-0-choice': 'Calexico', +... 'choices-0-votes': '100', +... 'choices-0-ORDER': '1', +... 'choices-0-DELETE': '', +... 'choices-1-choice': 'Fergie', +... 'choices-1-votes': '900', +... 'choices-1-ORDER': '2', +... 'choices-1-DELETE': 'on', +... 'choices-2-choice': 'The Decemberists', +... 'choices-2-votes': '500', +... 'choices-2-ORDER': '0', +... 'choices-2-DELETE': '', +... 'choices-3-choice': '', +... 'choices-3-votes': '', +... 'choices-3-ORDER': '', +... 'choices-3-DELETE': '', +... } + +>>> formset = ChoiceFormSet(data, auto_id=False, prefix='choices') +>>> formset.is_valid() +True +>>> for form in formset.ordered_forms: +... print form.cleaned_data +{'votes': 500, 'DELETE': False, 'ORDER': 0, 'choice': u'The Decemberists'} +{'votes': 100, 'DELETE': False, 'ORDER': 1, 'choice': u'Calexico'} +>>> [form.cleaned_data for form in formset.deleted_forms] +[{'votes': 900, 'DELETE': True, 'ORDER': 2, 'choice': u'Fergie'}] + + +# FormSet clean hook ########################################################## + +FormSets have a hook for doing extra validation that shouldn't be tied to any +particular form. It follows the same pattern as the clean hook on Forms. + +Let's define a FormSet that takes a list of favorite drinks, but raises am +error if there are any duplicates. + +>>> class FavoriteDrinkForm(Form): +... name = CharField() +... + +>>> class BaseFavoriteDrinksFormSet(BaseFormSet): +... def clean(self): +... seen_drinks = [] +... for drink in self.cleaned_data: +... if drink['name'] in seen_drinks: +... raise ValidationError('You may only specify a drink once.') +... seen_drinks.append(drink['name']) +... + +>>> FavoriteDrinksFormSet = formset_factory(FavoriteDrinkForm, +... formset=BaseFavoriteDrinksFormSet, extra=3) + +We start out with a some duplicate data. + +>>> data = { +... 'drinks-TOTAL_FORMS': '2', # the number of forms rendered +... 'drinks-INITIAL_FORMS': '0', # the number of forms with initial data +... 'drinks-MAX_FORMS': '0', # the max number of forms +... 'drinks-0-name': 'Gin and Tonic', +... 'drinks-1-name': 'Gin and Tonic', +... } + +>>> formset = FavoriteDrinksFormSet(data, prefix='drinks') +>>> formset.is_valid() +False + +Any errors raised by formset.clean() are available via the +formset.non_form_errors() method. + +>>> for error in formset.non_form_errors(): +... print error +You may only specify a drink once. + + +Make sure we didn't break the valid case. + +>>> data = { +... 'drinks-TOTAL_FORMS': '2', # the number of forms rendered +... 'drinks-INITIAL_FORMS': '0', # the number of forms with initial data +... 'drinks-MAX_FORMS': '0', # the max number of forms +... 'drinks-0-name': 'Gin and Tonic', +... 'drinks-1-name': 'Bloody Mary', +... } + +>>> formset = FavoriteDrinksFormSet(data, prefix='drinks') +>>> formset.is_valid() +True +>>> for error in formset.non_form_errors(): +... print error + +# Limiting the maximum number of forms ######################################## + +# Base case for max_num. + +>>> LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=5, max_num=2) +>>> formset = LimitedFavoriteDrinkFormSet() +>>> for form in formset.forms: +... print form +<tr><th><label for="id_form-0-name">Name:</label></th><td><input type="text" name="form-0-name" id="id_form-0-name" /></td></tr> +<tr><th><label for="id_form-1-name">Name:</label></th><td><input type="text" name="form-1-name" id="id_form-1-name" /></td></tr> + +# Ensure the that max_num has no affect when extra is less than max_forms. + +>>> LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=1, max_num=2) +>>> formset = LimitedFavoriteDrinkFormSet() +>>> for form in formset.forms: +... print form +<tr><th><label for="id_form-0-name">Name:</label></th><td><input type="text" name="form-0-name" id="id_form-0-name" /></td></tr> + +# max_num with initial data + +# More initial forms than max_num will result in only the first max_num of +# them to be displayed with no extra forms. + +>>> initial = [ +... {'name': 'Gin Tonic'}, +... {'name': 'Bloody Mary'}, +... {'name': 'Jack and Coke'}, +... ] +>>> LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=1, max_num=2) +>>> formset = LimitedFavoriteDrinkFormSet(initial=initial) +>>> for form in formset.forms: +... print form +<tr><th><label for="id_form-0-name">Name:</label></th><td><input type="text" name="form-0-name" value="Gin Tonic" id="id_form-0-name" /></td></tr> +<tr><th><label for="id_form-1-name">Name:</label></th><td><input type="text" name="form-1-name" value="Bloody Mary" id="id_form-1-name" /></td></tr> + +# One form from initial and extra=3 with max_num=2 should result in the one +# initial form and one extra. + +>>> initial = [ +... {'name': 'Gin Tonic'}, +... ] +>>> LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=3, max_num=2) +>>> formset = LimitedFavoriteDrinkFormSet(initial=initial) +>>> for form in formset.forms: +... print form +<tr><th><label for="id_form-0-name">Name:</label></th><td><input type="text" name="form-0-name" value="Gin Tonic" id="id_form-0-name" /></td></tr> +<tr><th><label for="id_form-1-name">Name:</label></th><td><input type="text" name="form-1-name" id="id_form-1-name" /></td></tr> + + +# Regression test for #6926 ################################################## + +Make sure the management form has the correct prefix. + +>>> formset = FavoriteDrinksFormSet() +>>> formset.management_form.prefix +'form' + +>>> formset = FavoriteDrinksFormSet(data={}) +>>> formset.management_form.prefix +'form' + +>>> formset = FavoriteDrinksFormSet(initial={}) +>>> formset.management_form.prefix +'form' + +""" diff --git a/tests/regressiontests/forms/media.py b/tests/regressiontests/forms/media.py new file mode 100644 index 0000000000..3ea48876f5 --- /dev/null +++ b/tests/regressiontests/forms/media.py @@ -0,0 +1,359 @@ +# -*- coding: utf-8 -*- +# Tests for the media handling on widgets and forms + +media_tests = r""" +>>> from django.newforms import TextInput, Media, TextInput, CharField, Form, MultiWidget +>>> from django.conf import settings +>>> ORIGINAL_MEDIA_URL = settings.MEDIA_URL +>>> settings.MEDIA_URL = 'http://media.example.com/media/' + +# Check construction of media objects +>>> m = Media(css={'all': ('path/to/css1','/path/to/css2')}, js=('/path/to/js1','http://media.other.com/path/to/js2','https://secure.other.com/path/to/js3')) +>>> print m +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> + +>>> class Foo: +... css = { +... 'all': ('path/to/css1','/path/to/css2') +... } +... js = ('/path/to/js1','http://media.other.com/path/to/js2','https://secure.other.com/path/to/js3') +>>> m3 = Media(Foo) +>>> print m3 +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> + +>>> m3 = Media(Foo) +>>> print m3 +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> + +# A widget can exist without a media definition +>>> class MyWidget(TextInput): +... pass + +>>> w = MyWidget() +>>> print w.media +<BLANKLINE> + +############################################################### +# DSL Class-based media definitions +############################################################### + +# A widget can define media if it needs to. +# Any absolute path will be preserved; relative paths are combined +# with the value of settings.MEDIA_URL +>>> class MyWidget1(TextInput): +... class Media: +... css = { +... 'all': ('path/to/css1','/path/to/css2') +... } +... js = ('/path/to/js1','http://media.other.com/path/to/js2','https://secure.other.com/path/to/js3') + +>>> w1 = MyWidget1() +>>> print w1.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> + +# Media objects can be interrogated by media type +>>> print w1.media['css'] +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> + +>>> print w1.media['js'] +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> + +# Media objects can be combined. Any given media resource will appear only +# once. Duplicated media definitions are ignored. +>>> class MyWidget2(TextInput): +... class Media: +... css = { +... 'all': ('/path/to/css2','/path/to/css3') +... } +... js = ('/path/to/js1','/path/to/js4') + +>>> class MyWidget3(TextInput): +... class Media: +... css = { +... 'all': ('/path/to/css3','path/to/css1') +... } +... js = ('/path/to/js1','/path/to/js4') + +>>> w2 = MyWidget2() +>>> w3 = MyWidget3() +>>> print w1.media + w2.media + w3.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> +<script type="text/javascript" src="/path/to/js4"></script> + +# Check that media addition hasn't affected the original objects +>>> print w1.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> + +############################################################### +# Property-based media definitions +############################################################### + +# Widget media can be defined as a property +>>> class MyWidget4(TextInput): +... def _media(self): +... return Media(css={'all': ('/some/path',)}, js = ('/some/js',)) +... media = property(_media) + +>>> w4 = MyWidget4() +>>> print w4.media +<link href="/some/path" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/some/js"></script> + +# Media properties can reference the media of their parents +>>> class MyWidget5(MyWidget4): +... def _media(self): +... return super(MyWidget5, self).media + Media(css={'all': ('/other/path',)}, js = ('/other/js',)) +... media = property(_media) + +>>> w5 = MyWidget5() +>>> print w5.media +<link href="/some/path" type="text/css" media="all" rel="stylesheet" /> +<link href="/other/path" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/some/js"></script> +<script type="text/javascript" src="/other/js"></script> + +# Media properties can reference the media of their parents, +# even if the parent media was defined using a class +>>> class MyWidget6(MyWidget1): +... def _media(self): +... return super(MyWidget6, self).media + Media(css={'all': ('/other/path',)}, js = ('/other/js',)) +... media = property(_media) + +>>> w6 = MyWidget6() +>>> print w6.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<link href="/other/path" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> +<script type="text/javascript" src="/other/js"></script> + +############################################################### +# Inheritance of media +############################################################### + +# If a widget extends another but provides no media definition, it inherits the parent widget's media +>>> class MyWidget7(MyWidget1): +... pass + +>>> w7 = MyWidget7() +>>> print w7.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> + +# If a widget extends another but defines media, it extends the parent widget's media by default +>>> class MyWidget8(MyWidget1): +... class Media: +... css = { +... 'all': ('/path/to/css3','path/to/css1') +... } +... js = ('/path/to/js1','/path/to/js4') + +>>> w8 = MyWidget8() +>>> print w8.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> +<script type="text/javascript" src="/path/to/js4"></script> + +# If a widget extends another but defines media, it extends the parents widget's media, +# even if the parent defined media using a property. +>>> class MyWidget9(MyWidget4): +... class Media: +... css = { +... 'all': ('/other/path',) +... } +... js = ('/other/js',) + +>>> w9 = MyWidget9() +>>> print w9.media +<link href="/some/path" type="text/css" media="all" rel="stylesheet" /> +<link href="/other/path" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/some/js"></script> +<script type="text/javascript" src="/other/js"></script> + +# A widget can disable media inheritance by specifying 'extend=False' +>>> class MyWidget10(MyWidget1): +... class Media: +... extend = False +... css = { +... 'all': ('/path/to/css3','path/to/css1') +... } +... js = ('/path/to/js1','/path/to/js4') + +>>> w10 = MyWidget10() +>>> print w10.media +<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" /> +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="/path/to/js4"></script> + +# A widget can explicitly enable full media inheritance by specifying 'extend=True' +>>> class MyWidget11(MyWidget1): +... class Media: +... extend = True +... css = { +... 'all': ('/path/to/css3','path/to/css1') +... } +... js = ('/path/to/js1','/path/to/js4') + +>>> w11 = MyWidget11() +>>> print w11.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> +<script type="text/javascript" src="/path/to/js4"></script> + +# A widget can enable inheritance of one media type by specifying extend as a tuple +>>> class MyWidget12(MyWidget1): +... class Media: +... extend = ('css',) +... css = { +... 'all': ('/path/to/css3','path/to/css1') +... } +... js = ('/path/to/js1','/path/to/js4') + +>>> w12 = MyWidget12() +>>> print w12.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="/path/to/js4"></script> + +############################################################### +# Multi-media handling for CSS +############################################################### + +# A widget can define CSS media for multiple output media types +>>> class MultimediaWidget(TextInput): +... class Media: +... css = { +... 'screen, print': ('/file1','/file2'), +... 'screen': ('/file3',), +... 'print': ('/file4',) +... } +... js = ('/path/to/js1','/path/to/js4') + +>>> multimedia = MultimediaWidget() +>>> print multimedia.media +<link href="/file4" type="text/css" media="print" rel="stylesheet" /> +<link href="/file3" type="text/css" media="screen" rel="stylesheet" /> +<link href="/file1" type="text/css" media="screen, print" rel="stylesheet" /> +<link href="/file2" type="text/css" media="screen, print" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="/path/to/js4"></script> + +############################################################### +# Multiwidget media handling +############################################################### + +# MultiWidgets have a default media definition that gets all the +# media from the component widgets +>>> class MyMultiWidget(MultiWidget): +... def __init__(self, attrs=None): +... widgets = [MyWidget1, MyWidget2, MyWidget3] +... super(MyMultiWidget, self).__init__(widgets, attrs) + +>>> mymulti = MyMultiWidget() +>>> print mymulti.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> +<script type="text/javascript" src="/path/to/js4"></script> + +############################################################### +# Media processing for forms +############################################################### + +# You can ask a form for the media required by its widgets. +>>> class MyForm(Form): +... field1 = CharField(max_length=20, widget=MyWidget1()) +... field2 = CharField(max_length=20, widget=MyWidget2()) +>>> f1 = MyForm() +>>> print f1.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> +<script type="text/javascript" src="/path/to/js4"></script> + +# Form media can be combined to produce a single media definition. +>>> class AnotherForm(Form): +... field3 = CharField(max_length=20, widget=MyWidget3()) +>>> f2 = AnotherForm() +>>> print f1.media + f2.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> +<script type="text/javascript" src="/path/to/js4"></script> + +# Forms can also define media, following the same rules as widgets. +>>> class FormWithMedia(Form): +... field1 = CharField(max_length=20, widget=MyWidget1()) +... field2 = CharField(max_length=20, widget=MyWidget2()) +... class Media: +... js = ('/some/form/javascript',) +... css = { +... 'all': ('/some/form/css',) +... } +>>> f3 = FormWithMedia() +>>> print f3.media +<link href="http://media.example.com/media/path/to/css1" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> +<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" /> +<link href="/some/form/css" type="text/css" media="all" rel="stylesheet" /> +<script type="text/javascript" src="/path/to/js1"></script> +<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> +<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> +<script type="text/javascript" src="/path/to/js4"></script> +<script type="text/javascript" src="/some/form/javascript"></script> + +>>> settings.MEDIA_URL = ORIGINAL_MEDIA_URL +"""
\ No newline at end of file diff --git a/tests/regressiontests/forms/tests.py b/tests/regressiontests/forms/tests.py index bb0e30b874..ff8213c8d9 100644 --- a/tests/regressiontests/forms/tests.py +++ b/tests/regressiontests/forms/tests.py @@ -26,6 +26,8 @@ from localflavor.za import tests as localflavor_za_tests from regressions import tests as regression_tests from util import tests as util_tests from widgets import tests as widgets_tests +from formsets import tests as formset_tests +from media import media_tests __test__ = { 'extra_tests': extra_tests, @@ -53,6 +55,8 @@ __test__ = { 'localflavor_us_tests': localflavor_us_tests, 'localflavor_za_tests': localflavor_za_tests, 'regression_tests': regression_tests, + 'formset_tests': formset_tests, + 'media_tests': media_tests, 'util_tests': util_tests, 'widgets_tests': widgets_tests, } diff --git a/tests/regressiontests/forms/widgets.py b/tests/regressiontests/forms/widgets.py index 2c6b51a8ec..e0837ab5b3 100644 --- a/tests/regressiontests/forms/widgets.py +++ b/tests/regressiontests/forms/widgets.py @@ -202,6 +202,30 @@ u'<input type="file" class="fun" name="email" />' >>> w.render('email', 'ŠĐĆŽćžšđ', attrs={'class': 'fun'}) u'<input type="file" class="fun" name="email" />' +Test for the behavior of _has_changed for FileInput. The value of data will +more than likely come from request.FILES. The value of initial data will +likely be a filename stored in the database. Since its value is of no use to +a FileInput it is ignored. + +>>> w = FileInput() + +# No file was uploaded and no initial data. +>>> w._has_changed(u'', None) +False + +# A file was uploaded and no initial data. +>>> w._has_changed(u'', {'filename': 'resume.txt', 'content': 'My resume'}) +True + +# A file was not uploaded, but there is initial data +>>> w._has_changed(u'resume.txt', None) +False + +# A file was uploaded and there is initial data (file identity is not dealt +# with here) +>>> w._has_changed('resume.txt', {'filename': 'resume.txt', 'content': 'My resume'}) +True + # Textarea Widget ############################################################# >>> w = Textarea() @@ -292,6 +316,21 @@ checkboxes). >>> w.value_from_datadict({}, {}, 'testing') False +>>> w._has_changed(None, None) +False +>>> w._has_changed(None, u'') +False +>>> w._has_changed(u'', None) +False +>>> w._has_changed(u'', u'') +False +>>> w._has_changed(False, u'on') +True +>>> w._has_changed(True, u'on') +False +>>> w._has_changed(True, u'') +True + # Select Widget ############################################################### >>> w = Select() @@ -573,6 +612,20 @@ If 'choices' is passed to both the constructor and render(), then they'll both b >>> w.render('nums', ['ŠĐĆŽćžšđ'], choices=[('ŠĐĆŽćžšđ', 'ŠĐabcĆŽćžšđ'), ('ćžšđ', 'abcćžšđ')]) u'<select multiple="multiple" name="nums">\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="\u0160\u0110\u0106\u017d\u0107\u017e\u0161\u0111" selected="selected">\u0160\u0110abc\u0106\u017d\u0107\u017e\u0161\u0111</option>\n<option value="\u0107\u017e\u0161\u0111">abc\u0107\u017e\u0161\u0111</option>\n</select>' +# Test the usage of _has_changed +>>> w._has_changed(None, None) +False +>>> w._has_changed([], None) +False +>>> w._has_changed(None, [u'1']) +True +>>> w._has_changed([1, 2], [u'1', u'2']) +False +>>> w._has_changed([1, 2], [u'1']) +True +>>> w._has_changed([1, 2], [u'1', u'3']) +True + # RadioSelect Widget ########################################################## >>> w = RadioSelect() @@ -871,6 +924,20 @@ If 'choices' is passed to both the constructor and render(), then they'll both b <li><label><input type="checkbox" name="escape" value="good" /> you > me</label></li> </ul> +# Test the usage of _has_changed +>>> w._has_changed(None, None) +False +>>> w._has_changed([], None) +False +>>> w._has_changed(None, [u'1']) +True +>>> w._has_changed([1, 2], [u'1', u'2']) +False +>>> w._has_changed([1, 2], [u'1']) +True +>>> w._has_changed([1, 2], [u'1', u'3']) +True + # Unicode choices are correctly rendered as HTML >>> w.render('nums', ['ŠĐĆŽćžšđ'], choices=[('ŠĐĆŽćžšđ', 'ŠĐabcĆŽćžšđ'), ('ćžšđ', 'abcćžšđ')]) u'<ul>\n<li><label><input type="checkbox" name="nums" value="1" /> 1</label></li>\n<li><label><input type="checkbox" name="nums" value="2" /> 2</label></li>\n<li><label><input type="checkbox" name="nums" value="3" /> 3</label></li>\n<li><label><input checked="checked" type="checkbox" name="nums" value="\u0160\u0110\u0106\u017d\u0107\u017e\u0161\u0111" /> \u0160\u0110abc\u0106\u017d\u0107\u017e\u0161\u0111</label></li>\n<li><label><input type="checkbox" name="nums" value="\u0107\u017e\u0161\u0111" /> abc\u0107\u017e\u0161\u0111</label></li>\n</ul>' @@ -895,6 +962,25 @@ u'<input id="foo_0" type="text" class="big" value="john" name="name_0" /><br />< >>> w.render('name', ['john', 'lennon']) u'<input id="bar_0" type="text" class="big" value="john" name="name_0" /><br /><input id="bar_1" type="text" class="small" value="lennon" name="name_1" />' +>>> w = MyMultiWidget(widgets=(TextInput(), TextInput())) + +# test with no initial data +>>> w._has_changed(None, [u'john', u'lennon']) +True + +# test when the data is the same as initial +>>> w._has_changed(u'john__lennon', [u'john', u'lennon']) +False + +# test when the first widget's data has changed +>>> w._has_changed(u'john__lennon', [u'alfred', u'lennon']) +True + +# test when the last widget's data has changed. this ensures that it is not +# short circuiting while testing the widgets. +>>> w._has_changed(u'john__lennon', [u'john', u'denver']) +True + # SplitDateTimeWidget ######################################################### >>> w = SplitDateTimeWidget() @@ -913,6 +999,11 @@ included on both widgets. >>> w.render('date', datetime.datetime(2006, 1, 10, 7, 30)) u'<input type="text" class="pretty" value="2006-01-10" name="date_0" /><input type="text" class="pretty" value="07:30:00" name="date_1" />' +>>> w._has_changed(datetime.datetime(2008, 5, 5, 12, 40, 00), [u'2008-05-05', u'12:40:00']) +False +>>> w._has_changed(datetime.datetime(2008, 5, 5, 12, 40, 00), [u'2008-05-05', u'12:41:00']) +True + # DateTimeInput ############################################################### >>> w = DateTimeInput() diff --git a/tests/regressiontests/inline_formsets/__init__.py b/tests/regressiontests/inline_formsets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/tests/regressiontests/inline_formsets/__init__.py diff --git a/tests/regressiontests/inline_formsets/models.py b/tests/regressiontests/inline_formsets/models.py new file mode 100644 index 0000000000..b22f5e297d --- /dev/null +++ b/tests/regressiontests/inline_formsets/models.py @@ -0,0 +1,55 @@ +# coding: utf-8 +from django.db import models + +class School(models.Model): + name = models.CharField(max_length=100) + +class Parent(models.Model): + name = models.CharField(max_length=100) + +class Child(models.Model): + mother = models.ForeignKey(Parent, related_name='mothers_children') + father = models.ForeignKey(Parent, related_name='fathers_children') + school = models.ForeignKey(School) + name = models.CharField(max_length=100) + +__test__ = {'API_TESTS': """ + +>>> from django.newforms.models import inlineformset_factory + + +Child has two ForeignKeys to Parent, so if we don't specify which one to use +for the inline formset, we should get an exception. + +>>> ifs = inlineformset_factory(Parent, Child) +Traceback (most recent call last): + ... +Exception: <class 'regressiontests.inline_formsets.models.Child'> has more than 1 ForeignKey to <class 'regressiontests.inline_formsets.models.Parent'> + + +These two should both work without a problem. + +>>> ifs = inlineformset_factory(Parent, Child, fk_name='mother') +>>> ifs = inlineformset_factory(Parent, Child, fk_name='father') + + +If we specify fk_name, but it isn't a ForeignKey from the child model to the +parent model, we should get an exception. + +>>> ifs = inlineformset_factory(Parent, Child, fk_name='school') +Traceback (most recent call last): + ... +Exception: fk_name 'school' is not a ForeignKey to <class 'regressiontests.inline_formsets.models.Parent'> + + +If the field specified in fk_name is not a ForeignKey, we should get an +exception. + +>>> ifs = inlineformset_factory(Parent, Child, fk_name='test') +Traceback (most recent call last): + ... +Exception: <class 'regressiontests.inline_formsets.models.Child'> has no field named 'test' + + +""" +} diff --git a/tests/regressiontests/invalid_admin_options/models.py b/tests/regressiontests/invalid_admin_options/models.py deleted file mode 100644 index 14db463735..0000000000 --- a/tests/regressiontests/invalid_admin_options/models.py +++ /dev/null @@ -1,337 +0,0 @@ -""" -Admin options - -Test invalid and valid admin options to make sure that -model validation is working properly. -""" - -from django.db import models -model_errors = "" - -# TODO: Invalid admin options should not cause a metaclass error -##This should fail gracefully but is causing a metaclass error -#class BadAdminOption(models.Model): -# "Test nonexistent admin option" -# name = models.CharField(max_length=30) -# -# class Admin: -# nonexistent = 'option' -# -#model_errors += """invalid_admin_options.badadminoption: "admin" attribute, if given, must be set to a models.AdminOptions() instance. -#""" - -class ListDisplayBadOne(models.Model): - "Test list_display, list_display must be a list or tuple" - first_name = models.CharField(max_length=30) - - class Admin: - list_display = 'first_name' - -model_errors += """invalid_admin_options.listdisplaybadone: "admin.list_display", if given, must be set to a list or tuple. -""" - -class ListDisplayBadTwo(models.Model): - "Test list_display, list_display items must be attributes, methods or properties." - first_name = models.CharField(max_length=30) - - class Admin: - list_display = ['first_name','nonexistent'] - -model_errors += """invalid_admin_options.listdisplaybadtwo: "admin.list_display" refers to 'nonexistent', which isn't an attribute, method or property. -""" -class ListDisplayBadThree(models.Model): - "Test list_display, list_display items can not be a ManyToManyField." - first_name = models.CharField(max_length=30) - nick_names = models.ManyToManyField('ListDisplayGood') - - class Admin: - list_display = ['first_name','nick_names'] - -model_errors += """invalid_admin_options.listdisplaybadthree: "admin.list_display" doesn't support ManyToManyFields ('nick_names'). -""" - -class ListDisplayGood(models.Model): - "Test list_display, Admin list_display can be a attribute, method or property." - first_name = models.CharField(max_length=30) - - def _last_name(self): - return self.first_name - last_name = property(_last_name) - - def full_name(self): - return "%s %s" % (self.first_name, self.last_name) - - class Admin: - list_display = ['first_name','last_name','full_name'] - -class ListDisplayLinksBadOne(models.Model): - "Test list_display_links, item must be included in list_display." - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - - class Admin: - list_display = ['last_name'] - list_display_links = ['first_name'] - -model_errors += """invalid_admin_options.listdisplaylinksbadone: "admin.list_display_links" refers to 'first_name', which is not defined in "admin.list_display". -""" - -class ListDisplayLinksBadTwo(models.Model): - "Test list_display_links, must be a list or tuple." - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - - class Admin: - list_display = ['first_name','last_name'] - list_display_links = 'last_name' - -model_errors += """invalid_admin_options.listdisplaylinksbadtwo: "admin.list_display_links", if given, must be set to a list or tuple. -""" - -# TODO: Fix list_display_links validation or remove the check for list_display -## This is failing but the validation which should fail is not. -#class ListDisplayLinksBadThree(models.Model): -# "Test list_display_links, must define list_display to use list_display_links." -# first_name = models.CharField(max_length=30) -# last_name = models.CharField(max_length=30) -# -# class Admin: -# list_display_links = ('first_name',) -# -#model_errors += """invalid_admin_options.listdisplaylinksbadthree: "admin.list_display" must be defined for "admin.list_display_links" to be used. -#""" - -class ListDisplayLinksGood(models.Model): - "Test list_display_links, Admin list_display_list can be a attribute, method or property." - first_name = models.CharField(max_length=30) - - def _last_name(self): - return self.first_name - last_name = property(_last_name) - - def full_name(self): - return "%s %s" % (self.first_name, self.last_name) - - class Admin: - list_display = ['first_name','last_name','full_name'] - list_display_links = ['first_name','last_name','full_name'] - -class ListFilterBadOne(models.Model): - "Test list_filter, must be a list or tuple." - first_name = models.CharField(max_length=30) - - class Admin: - list_filter = 'first_name' - -model_errors += """invalid_admin_options.listfilterbadone: "admin.list_filter", if given, must be set to a list or tuple. -""" - -class ListFilterBadTwo(models.Model): - "Test list_filter, must be a field not a property or method." - first_name = models.CharField(max_length=30) - - def _last_name(self): - return self.first_name - last_name = property(_last_name) - - def full_name(self): - return "%s %s" % (self.first_name, self.last_name) - - class Admin: - list_filter = ['first_name','last_name','full_name'] - -model_errors += """invalid_admin_options.listfilterbadtwo: "admin.list_filter" refers to 'last_name', which isn't a field. -invalid_admin_options.listfilterbadtwo: "admin.list_filter" refers to 'full_name', which isn't a field. -""" - -class DateHierarchyBadOne(models.Model): - "Test date_hierarchy, must be a date or datetime field." - first_name = models.CharField(max_length=30) - birth_day = models.DateField() - - class Admin: - date_hierarchy = 'first_name' - -# TODO: Date Hierarchy needs to check if field is a date/datetime field. -#model_errors += """invalid_admin_options.datehierarchybadone: "admin.date_hierarchy" refers to 'first_name', which isn't a date field or datetime field. -#""" - -class DateHierarchyBadTwo(models.Model): - "Test date_hieracrhy, must be a field." - first_name = models.CharField(max_length=30) - birth_day = models.DateField() - - class Admin: - date_hierarchy = 'nonexistent' - -model_errors += """invalid_admin_options.datehierarchybadtwo: "admin.date_hierarchy" refers to 'nonexistent', which isn't a field. -""" - -class DateHierarchyGood(models.Model): - "Test date_hieracrhy, must be a field." - first_name = models.CharField(max_length=30) - birth_day = models.DateField() - - class Admin: - date_hierarchy = 'birth_day' - -class SearchFieldsBadOne(models.Model): - "Test search_fields, must be a list or tuple." - first_name = models.CharField(max_length=30) - - class Admin: - search_fields = ('nonexistent') - -# TODO: Add search_fields validation -#model_errors += """invalid_admin_options.seacrhfieldsbadone: "admin.search_fields", if given, must be set to a list or tuple. -#""" - -class SearchFieldsBadTwo(models.Model): - "Test search_fields, must be a field." - first_name = models.CharField(max_length=30) - - def _last_name(self): - return self.first_name - last_name = property(_last_name) - - class Admin: - search_fields = ['first_name','last_name'] - -# TODO: Add search_fields validation -#model_errors += """invalid_admin_options.seacrhfieldsbadone: "admin.search_fields" refers to 'last_name', which isn't a field. -#""" - -class SearchFieldsGood(models.Model): - "Test search_fields, must be a list or tuple." - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - - class Admin: - search_fields = ['first_name','last_name'] - - -class JsBadOne(models.Model): - "Test js, must be a list or tuple" - name = models.CharField(max_length=30) - - class Admin: - js = 'test.js' - -# TODO: Add a js validator -#model_errors += """invalid_admin_options.jsbadone: "admin.js", if given, must be set to a list or tuple. -#""" - -class SaveAsBad(models.Model): - "Test save_as, should be True or False" - name = models.CharField(max_length=30) - - class Admin: - save_as = 'not True or False' - -# TODO: Add a save_as validator. -#model_errors += """invalid_admin_options.saveasbad: "admin.save_as", if given, must be set to True or False. -#""" - -class SaveOnTopBad(models.Model): - "Test save_on_top, should be True or False" - name = models.CharField(max_length=30) - - class Admin: - save_on_top = 'not True or False' - -# TODO: Add a save_on_top validator. -#model_errors += """invalid_admin_options.saveontopbad: "admin.save_on_top", if given, must be set to True or False. -#""" - -class ListSelectRelatedBad(models.Model): - "Test list_select_related, should be True or False" - name = models.CharField(max_length=30) - - class Admin: - list_select_related = 'not True or False' - -# TODO: Add a list_select_related validator. -#model_errors += """invalid_admin_options.listselectrelatebad: "admin.list_select_related", if given, must be set to True or False. -#""" - -class ListPerPageBad(models.Model): - "Test list_per_page, should be a positive integer value." - name = models.CharField(max_length=30) - - class Admin: - list_per_page = 89.3 - -# TODO: Add a list_per_page validator. -#model_errors += """invalid_admin_options.listperpagebad: "admin.list_per_page", if given, must be a positive integer. -#""" - -class FieldsBadOne(models.Model): - "Test fields, should be a tuple" - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - - class Admin: - fields = 'not a tuple' - -# TODO: Add a fields validator. -#model_errors += """invalid_admin_options.fieldsbadone: "admin.fields", if given, must be a tuple. -#""" - -class FieldsBadTwo(models.Model): - """Test fields, 'fields' dict option is required.""" - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - - class Admin: - fields = ('Name', {'description': 'this fieldset needs fields'}) - -# TODO: Add a fields validator. -#model_errors += """invalid_admin_options.fieldsbadtwo: "admin.fields" each fieldset must include a 'fields' dict. -#""" - -class FieldsBadThree(models.Model): - """Test fields, 'classes' and 'description' are the only allowable extra dict options.""" - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - - class Admin: - fields = ('Name', {'fields': ('first_name','last_name'),'badoption': 'verybadoption'}) - -# TODO: Add a fields validator. -#model_errors += """invalid_admin_options.fieldsbadthree: "admin.fields" fieldset options must be either 'classes' or 'description'. -#""" - -class FieldsGood(models.Model): - "Test fields, working example" - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - birth_day = models.DateField() - - class Admin: - fields = ( - ('Name', {'fields': ('first_name','last_name'),'classes': 'collapse'}), - (None, {'fields': ('birth_day',),'description': 'enter your b-day'}) - ) - -class OrderingBad(models.Model): - "Test ordering, must be a field." - first_name = models.CharField(max_length=30) - last_name = models.CharField(max_length=30) - - class Admin: - ordering = 'nonexistent' - -# TODO: Add a ordering validator. -#model_errors += """invalid_admin_options.orderingbad: "admin.ordering" refers to 'nonexistent', which isn't a field. -#""" - -## TODO: Add a manager validator, this should fail gracefully. -#class ManagerBad(models.Model): -# "Test manager, must be a manager object." -# first_name = models.CharField(max_length=30) -# -# class Admin: -# manager = 'nonexistent' -# -#model_errors += """invalid_admin_options.managerbad: "admin.manager" refers to 'nonexistent', which isn't a Manager(). -#"""
\ No newline at end of file diff --git a/tests/regressiontests/modeladmin/__init__.py b/tests/regressiontests/modeladmin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/tests/regressiontests/modeladmin/__init__.py diff --git a/tests/regressiontests/modeladmin/models.py b/tests/regressiontests/modeladmin/models.py new file mode 100644 index 0000000000..17e3974e1c --- /dev/null +++ b/tests/regressiontests/modeladmin/models.py @@ -0,0 +1,876 @@ +# coding: utf-8 +from datetime import date + +from django.db import models +from django.contrib.auth.models import User + +class Band(models.Model): + name = models.CharField(max_length=100) + bio = models.TextField() + sign_date = models.DateField() + + def __unicode__(self): + return self.name + +class Concert(models.Model): + main_band = models.ForeignKey(Band, related_name='main_concerts') + opening_band = models.ForeignKey(Band, related_name='opening_concerts', + blank=True) + day = models.CharField(max_length=3, choices=((1, 'Fri'), (2, 'Sat'))) + transport = models.CharField(max_length=100, choices=( + (1, 'Plane'), + (2, 'Train'), + (3, 'Bus') + ), blank=True) + +class ValidationTestModel(models.Model): + name = models.CharField(max_length=100) + slug = models.SlugField() + users = models.ManyToManyField(User) + state = models.CharField(max_length=2, choices=(("CO", "Colorado"), ("WA", "Washington"))) + is_active = models.BooleanField() + pub_date = models.DateTimeField() + band = models.ForeignKey(Band) + +class ValidationTestInlineModel(models.Model): + parent = models.ForeignKey(ValidationTestModel) + +__test__ = {'API_TESTS': """ + +>>> from django.contrib.admin.options import ModelAdmin, HORIZONTAL, VERTICAL +>>> from django.contrib.admin.sites import AdminSite + +None of the following tests really depend on the content of the request, so +we'll just pass in None. + +>>> request = None + +# the sign_date is not 100 percent accurate ;) +>>> band = Band(name='The Doors', bio='', sign_date=date(1965, 1, 1)) +>>> band.save() + +Under the covers, the admin system will initialize ModelAdmin with a Model +class and an AdminSite instance, so let's just go ahead and do that manually +for testing. + +>>> site = AdminSite() +>>> ma = ModelAdmin(Band, site) + +>>> ma.get_form(request).base_fields.keys() +['name', 'bio', 'sign_date'] + + +# form/fields/fieldsets interaction ########################################## + +fieldsets_add and fieldsets_change should return a special data structure that +is used in the templates. They should generate the "right thing" whether we +have specified a custom form, the fields arugment, or nothing at all. + +Here's the default case. There are no custom form_add/form_change methods, +no fields argument, and no fieldsets argument. + +>>> ma = ModelAdmin(Band, site) +>>> ma.get_fieldsets(request) +[(None, {'fields': ['name', 'bio', 'sign_date']})] +>>> ma.get_fieldsets(request, band) +[(None, {'fields': ['name', 'bio', 'sign_date']})] + + +If we specify the fields argument, fieldsets_add and fielsets_change should +just stick the fields into a formsets structure and return it. + +>>> class BandAdmin(ModelAdmin): +... fields = ['name'] + +>>> ma = BandAdmin(Band, site) +>>> ma.get_fieldsets(request) +[(None, {'fields': ['name']})] +>>> ma.get_fieldsets(request, band) +[(None, {'fields': ['name']})] + + + + +If we specify fields or fieldsets, it should exclude fields on the Form class +to the fields specified. This may cause errors to be raised in the db layer if +required model fields arent in fields/fieldsets, but that's preferable to +ghost errors where you have a field in your Form class that isn't being +displayed because you forgot to add it to fields/fielsets + +>>> class BandAdmin(ModelAdmin): +... fields = ['name'] + +>>> ma = BandAdmin(Band, site) +>>> ma.get_form(request).base_fields.keys() +['name'] +>>> ma.get_form(request, band).base_fields.keys() +['name'] + +>>> class BandAdmin(ModelAdmin): +... fieldsets = [(None, {'fields': ['name']})] + +>>> ma = BandAdmin(Band, site) +>>> ma.get_form(request).base_fields.keys() +['name'] +>>> ma.get_form(request, band).base_fields.keys() +['name'] + + +If we specify a form, it should use it allowing custom validation to work +properly. This won't, however, break any of the admin widgets or media. + +>>> from django import newforms as forms +>>> class AdminBandForm(forms.ModelForm): +... delete = forms.BooleanField() +... +... class Meta: +... model = Band + +>>> class BandAdmin(ModelAdmin): +... form = AdminBandForm + +>>> ma = BandAdmin(Band, site) +>>> ma.get_form(request).base_fields.keys() +['name', 'bio', 'sign_date', 'delete'] +>>> type(ma.get_form(request).base_fields['sign_date'].widget) +<class 'django.contrib.admin.widgets.AdminDateWidget'> + +If we need to override the queryset of a ModelChoiceField in our custom form +make sure that RelatedFieldWidgetWrapper doesn't mess that up. + +>>> band2 = Band(name='The Beetles', bio='', sign_date=date(1962, 1, 1)) +>>> band2.save() + +>>> class AdminConcertForm(forms.ModelForm): +... class Meta: +... model = Concert +... +... def __init__(self, *args, **kwargs): +... super(AdminConcertForm, self).__init__(*args, **kwargs) +... self.fields["main_band"].queryset = Band.objects.filter(name='The Doors') + +>>> class ConcertAdmin(ModelAdmin): +... form = AdminConcertForm + +>>> ma = ConcertAdmin(Concert, site) +>>> form = ma.get_form(request)() +>>> print form["main_band"] +<select name="main_band" id="id_main_band"> +<option value="" selected="selected">---------</option> +<option value="1">The Doors</option> +</select> + +>>> band2.delete() + +# radio_fields behavior ################################################ + +First, without any radio_fields specified, the widgets for ForeignKey +and fields with choices specified ought to be a basic Select widget. +ForeignKey widgets in the admin are wrapped with RelatedFieldWidgetWrapper so +they need to be handled properly when type checking. For Select fields, all of +the choices lists have a first entry of dashes. + +>>> cma = ModelAdmin(Concert, site) +>>> cmafa = cma.get_form(request) + +>>> type(cmafa.base_fields['main_band'].widget.widget) +<class 'django.newforms.widgets.Select'> +>>> list(cmafa.base_fields['main_band'].widget.choices) +[(u'', u'---------'), (1, u'The Doors')] + +>>> type(cmafa.base_fields['opening_band'].widget.widget) +<class 'django.newforms.widgets.Select'> +>>> list(cmafa.base_fields['opening_band'].widget.choices) +[(u'', u'---------'), (1, u'The Doors')] + +>>> type(cmafa.base_fields['day'].widget) +<class 'django.newforms.widgets.Select'> +>>> list(cmafa.base_fields['day'].widget.choices) +[('', '---------'), (1, 'Fri'), (2, 'Sat')] + +>>> type(cmafa.base_fields['transport'].widget) +<class 'django.newforms.widgets.Select'> +>>> list(cmafa.base_fields['transport'].widget.choices) +[('', '---------'), (1, 'Plane'), (2, 'Train'), (3, 'Bus')] + +Now specify all the fields as radio_fields. Widgets should now be +RadioSelect, and the choices list should have a first entry of 'None' if +blank=True for the model field. Finally, the widget should have the +'radiolist' attr, and 'inline' as well if the field is specified HORIZONTAL. + +>>> class ConcertAdmin(ModelAdmin): +... radio_fields = { +... 'main_band': HORIZONTAL, +... 'opening_band': VERTICAL, +... 'day': VERTICAL, +... 'transport': HORIZONTAL, +... } + +>>> cma = ConcertAdmin(Concert, site) +>>> cmafa = cma.get_form(request) + +>>> type(cmafa.base_fields['main_band'].widget.widget) +<class 'django.contrib.admin.widgets.AdminRadioSelect'> +>>> cmafa.base_fields['main_band'].widget.attrs +{'class': 'radiolist inline'} +>>> list(cmafa.base_fields['main_band'].widget.choices) +[(1, u'The Doors')] + +>>> type(cmafa.base_fields['opening_band'].widget.widget) +<class 'django.contrib.admin.widgets.AdminRadioSelect'> +>>> cmafa.base_fields['opening_band'].widget.attrs +{'class': 'radiolist'} +>>> list(cmafa.base_fields['opening_band'].widget.choices) +[(u'', u'None'), (1, u'The Doors')] + +>>> type(cmafa.base_fields['day'].widget) +<class 'django.contrib.admin.widgets.AdminRadioSelect'> +>>> cmafa.base_fields['day'].widget.attrs +{'class': 'radiolist'} +>>> list(cmafa.base_fields['day'].widget.choices) +[(1, 'Fri'), (2, 'Sat')] + +>>> type(cmafa.base_fields['transport'].widget) +<class 'django.contrib.admin.widgets.AdminRadioSelect'> +>>> cmafa.base_fields['transport'].widget.attrs +{'class': 'radiolist inline'} +>>> list(cmafa.base_fields['transport'].widget.choices) +[('', u'None'), (1, 'Plane'), (2, 'Train'), (3, 'Bus')] + +>>> band.delete() + +# ModelAdmin Option Validation ################################################ + +>>> from django.contrib.admin.validation import validate +>>> from django.conf import settings + +# Ensure validation only runs when DEBUG = True + +>>> settings.DEBUG = True + +>>> class ValidationTestModelAdmin(ModelAdmin): +... raw_id_fields = 10 +>>> site = AdminSite() +>>> site.register(ValidationTestModel, ValidationTestModelAdmin) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.raw_id_fields` must be a list or tuple. + +>>> settings.DEBUG = False + +>>> class ValidationTestModelAdmin(ModelAdmin): +... raw_id_fields = 10 +>>> site = AdminSite() +>>> site.register(ValidationTestModel, ValidationTestModelAdmin) + +# raw_id_fields + +>>> class ValidationTestModelAdmin(ModelAdmin): +... raw_id_fields = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.raw_id_fields` must be a list or tuple. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... raw_id_fields = ('non_existent_field',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.raw_id_fields` refers to field `non_existent_field` that is missing from model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... raw_id_fields = ('name',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.raw_id_fields[0]`, `name` must be either a ForeignKey or ManyToManyField. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... raw_id_fields = ('users',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# fieldsets + +>>> class ValidationTestModelAdmin(ModelAdmin): +... fieldsets = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.fieldsets` must be a list or tuple. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... fieldsets = ({},) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.fieldsets[0]` must be a list or tuple. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... fieldsets = ((),) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.fieldsets[0]` does not have exactly two elements. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... fieldsets = (("General", ()),) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.fieldsets[0][1]` must be a dictionary. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... fieldsets = (("General", {}),) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `fields` key is required in ValidationTestModelAdmin.fieldsets[0][1] field options dict. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... fieldsets = (("General", {"fields": ("non_existent_field",)}),) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.fieldsets[0][1]['fields']` refers to field `non_existent_field` that is missing from the form. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... fieldsets = (("General", {"fields": ("name",)}),) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +>>> class ValidationTestModelAdmin(ModelAdmin): +... fieldsets = (("General", {"fields": ("name",)}),) +... fields = ["name",] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: Both fieldsets and fields are specified in ValidationTestModelAdmin. + +# form + +>>> class FakeForm(object): +... pass +>>> class ValidationTestModelAdmin(ModelAdmin): +... form = FakeForm +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: ValidationTestModelAdmin.form does not inherit from BaseModelForm. + +# fielsets with custom form + +>>> class BandAdmin(ModelAdmin): +... fieldsets = ( +... ('Band', { +... 'fields': ('non_existent_field',) +... }), +... ) +>>> validate(BandAdmin, Band) +Traceback (most recent call last): +... +ImproperlyConfigured: `BandAdmin.fieldsets[0][1]['fields']` refers to field `non_existent_field` that is missing from the form. + +>>> class BandAdmin(ModelAdmin): +... fieldsets = ( +... ('Band', { +... 'fields': ('name',) +... }), +... ) +>>> validate(BandAdmin, Band) + +>>> class AdminBandForm(forms.ModelForm): +... class Meta: +... model = Band +>>> class BandAdmin(ModelAdmin): +... form = AdminBandForm +... +... fieldsets = ( +... ('Band', { +... 'fields': ('non_existent_field',) +... }), +... ) +>>> validate(BandAdmin, Band) +Traceback (most recent call last): +... +ImproperlyConfigured: `BandAdmin.fieldsets[0][1]['fields']` refers to field `non_existent_field` that is missing from the form. + +>>> class AdminBandForm(forms.ModelForm): +... delete = forms.BooleanField() +... +... class Meta: +... model = Band +>>> class BandAdmin(ModelAdmin): +... form = AdminBandForm +... +... fieldsets = ( +... ('Band', { +... 'fields': ('name', 'bio', 'sign_date', 'delete') +... }), +... ) +>>> validate(BandAdmin, Band) + +# filter_vertical + +>>> class ValidationTestModelAdmin(ModelAdmin): +... filter_vertical = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.filter_vertical` must be a list or tuple. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... filter_vertical = ("non_existent_field",) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.filter_vertical` refers to field `non_existent_field` that is missing from model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... filter_vertical = ("name",) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.filter_vertical[0]` must be a ManyToManyField. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... filter_vertical = ("users",) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# filter_horizontal + +>>> class ValidationTestModelAdmin(ModelAdmin): +... filter_horizontal = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.filter_horizontal` must be a list or tuple. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... filter_horizontal = ("non_existent_field",) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.filter_horizontal` refers to field `non_existent_field` that is missing from model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... filter_horizontal = ("name",) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.filter_horizontal[0]` must be a ManyToManyField. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... filter_horizontal = ("users",) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# radio_fields + +>>> class ValidationTestModelAdmin(ModelAdmin): +... radio_fields = () +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.radio_fields` must be a dictionary. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... radio_fields = {"non_existent_field": None} +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.radio_fields` refers to field `non_existent_field` that is missing from model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... radio_fields = {"name": None} +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.radio_fields['name']` is neither an instance of ForeignKey nor does have choices set. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... radio_fields = {"state": None} +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.radio_fields['state']` is neither admin.HORIZONTAL nor admin.VERTICAL. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... radio_fields = {"state": VERTICAL} +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# prepopulated_fields + +>>> class ValidationTestModelAdmin(ModelAdmin): +... prepopulated_fields = () +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.prepopulated_fields` must be a dictionary. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... prepopulated_fields = {"non_existent_field": None} +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.prepopulated_fields` refers to field `non_existent_field` that is missing from model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... prepopulated_fields = {"slug": ("non_existent_field",)} +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.prepopulated_fields['non_existent_field'][0]` refers to field `non_existent_field` that is missing from model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... prepopulated_fields = {"users": ("name",)} +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.prepopulated_fields['users']` is either a DateTimeField, ForeignKey or ManyToManyField. This isn't allowed. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... prepopulated_fields = {"slug": ("name",)} +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# list_display + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_display = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_display` must be a list or tuple. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_display = ('non_existent_field',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_display[0]` refers to `non_existent_field` that is neither a field, method or property of model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_display = ('users',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_display[0]`, `users` is a ManyToManyField which is not supported. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_display = ('name',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# list_display_links + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_display_links = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_display_links` must be a list or tuple. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_display_links = ('non_existent_field',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_display_links[0]` refers to `non_existent_field` that is neither a field, method or property of model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_display_links = ('name',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_display_links[0]`refers to `name` which is not defined in `list_display`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_display = ('name',) +... list_display_links = ('name',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# list_filter + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_filter = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_filter` must be a list or tuple. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_filter = ('non_existent_field',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_filter[0]` refers to field `non_existent_field` that is missing from model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_filter = ('is_active',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# list_per_page + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_per_page = 'hello' +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_per_page` should be a integer. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_per_page = 100 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# search_fields + +>>> class ValidationTestModelAdmin(ModelAdmin): +... search_fields = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.search_fields` must be a list or tuple. + +# date_hierarchy + +>>> class ValidationTestModelAdmin(ModelAdmin): +... date_hierarchy = 'non_existent_field' +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.date_hierarchy` refers to field `non_existent_field` that is missing from model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... date_hierarchy = 'name' +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.date_hierarchy is neither an instance of DateField nor DateTimeField. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... date_hierarchy = 'pub_date' +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# ordering + +>>> class ValidationTestModelAdmin(ModelAdmin): +... ordering = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.ordering` must be a list or tuple. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... ordering = ('non_existent_field',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.ordering[0]` refers to field `non_existent_field` that is missing from model `ValidationTestModel`. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... ordering = ('?', 'name') +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.ordering` has the random ordering marker `?`, but contains other fields as well. Please either remove `?` or the other fields. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... ordering = ('?',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +>>> class ValidationTestModelAdmin(ModelAdmin): +... ordering = ('band__name',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +>>> class ValidationTestModelAdmin(ModelAdmin): +... ordering = ('name',) +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# list_select_related + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_select_related = 1 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.list_select_related` should be a boolean. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... list_select_related = False +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# save_as + +>>> class ValidationTestModelAdmin(ModelAdmin): +... save_as = 1 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.save_as` should be a boolean. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... save_as = True +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# save_on_top + +>>> class ValidationTestModelAdmin(ModelAdmin): +... save_on_top = 1 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.save_on_top` should be a boolean. + +>>> class ValidationTestModelAdmin(ModelAdmin): +... save_on_top = True +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# inlines + +>>> from django.contrib.admin.options import TabularInline + +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = 10 +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.inlines` must be a list or tuple. + +>>> class ValidationTestInline(object): +... pass +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.inlines[0]` does not inherit from BaseModelAdmin. + +>>> class ValidationTestInline(TabularInline): +... pass +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `model` is a required attribute of `ValidationTestModelAdmin.inlines[0]`. + +>>> class SomethingBad(object): +... pass +>>> class ValidationTestInline(TabularInline): +... model = SomethingBad +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestModelAdmin.inlines[0].model` does not inherit from models.Model. + +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# fields + +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... fields = 10 +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestInline.fields` must be a list or tuple. + +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... fields = ("non_existent_field",) +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestInline.fields` refers to field `non_existent_field` that is missing from the form. + +# fk_name + +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... fk_name = "non_existent_field" +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestInline.fk_name` refers to field `non_existent_field` that is missing from model `ValidationTestInlineModel`. + +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... fk_name = "parent" +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# extra + +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... extra = "hello" +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestInline.extra` should be a integer. + +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... extra = 2 +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# max_num + +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... max_num = "hello" +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestInline.max_num` should be a integer. + +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... max_num = 2 +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +# formset + +>>> from django.newforms.models import BaseModelFormSet + +>>> class FakeFormSet(object): +... pass +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... formset = FakeFormSet +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) +Traceback (most recent call last): +... +ImproperlyConfigured: `ValidationTestInline.formset` does not inherit from BaseModelFormSet. + +>>> class RealModelFormSet(BaseModelFormSet): +... pass +>>> class ValidationTestInline(TabularInline): +... model = ValidationTestInlineModel +... formset = RealModelFormSet +>>> class ValidationTestModelAdmin(ModelAdmin): +... inlines = [ValidationTestInline] +>>> validate(ValidationTestModelAdmin, ValidationTestModel) + +""" +} diff --git a/tests/templates/custom_admin/change_form.html b/tests/templates/custom_admin/change_form.html new file mode 100644 index 0000000000..f42ba4b649 --- /dev/null +++ b/tests/templates/custom_admin/change_form.html @@ -0,0 +1 @@ +{% extends "admin/change_form.html" %} diff --git a/tests/templates/custom_admin/change_list.html b/tests/templates/custom_admin/change_list.html new file mode 100644 index 0000000000..eebc9c7e30 --- /dev/null +++ b/tests/templates/custom_admin/change_list.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} + +{% block extrahead %} +<script type="text/javascript"> +var hello = '{{ extra_var }}'; +</script> +{% endblock %} diff --git a/tests/templates/custom_admin/delete_confirmation.html b/tests/templates/custom_admin/delete_confirmation.html new file mode 100644 index 0000000000..9353c5bfc8 --- /dev/null +++ b/tests/templates/custom_admin/delete_confirmation.html @@ -0,0 +1 @@ +{% extends "admin/delete_confirmation.html" %} diff --git a/tests/templates/custom_admin/index.html b/tests/templates/custom_admin/index.html new file mode 100644 index 0000000000..75b6ca3d18 --- /dev/null +++ b/tests/templates/custom_admin/index.html @@ -0,0 +1,6 @@ +{% extends "admin/index.html" %} + +{% block content %} +Hello from a custom index template {{ foo }} +{{ block.super }} +{% endblock %} diff --git a/tests/templates/custom_admin/login.html b/tests/templates/custom_admin/login.html new file mode 100644 index 0000000000..e10a26952f --- /dev/null +++ b/tests/templates/custom_admin/login.html @@ -0,0 +1,6 @@ +{% extends "admin/login.html" %} + +{% block content %} +Hello from a custom login template +{{ block.super }} +{% endblock %} diff --git a/tests/templates/custom_admin/object_history.html b/tests/templates/custom_admin/object_history.html new file mode 100644 index 0000000000..aee3b5bcba --- /dev/null +++ b/tests/templates/custom_admin/object_history.html @@ -0,0 +1 @@ +{% extends "admin/object_history.html" %} diff --git a/tests/urls.py b/tests/urls.py index cea453ef37..a8dc583aa1 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -20,6 +20,9 @@ urlpatterns = patterns('', # test urlconf for middleware tests (r'^middleware/', include('regressiontests.middleware.urls')), + + # admin view tests + (r'^test_admin/', include('regressiontests.admin_views.urls')), (r'^utils/', include('regressiontests.utils.urls')), |