diff --git a/django/db/models/base.py b/django/db/models/base.py
index e2ba49ee8c2..080e0af5888 100644
--- a/django/db/models/base.py
+++ b/django/db/models/base.py
@@ -426,7 +426,7 @@ class Model(object):
def _get_FIELD_display(self, field):
value = getattr(self, field.attname)
- return force_unicode(dict(field.choices).get(value, value), strings_only=True)
+ return force_unicode(dict(field.flatchoices).get(value, value), strings_only=True)
def _get_next_or_previous_by_FIELD(self, field, is_next, **kwargs):
op = is_next and 'gt' or 'lt'
diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py
index 52eaa435a28..78f75aea35c 100644
--- a/django/db/models/fields/__init__.py
+++ b/django/db/models/fields/__init__.py
@@ -288,7 +288,7 @@ class Field(object):
if self.choices:
field_objs = [oldforms.SelectField]
- params['choices'] = self.get_choices_default()
+ params['choices'] = self.flatchoices
else:
field_objs = self.get_manipulator_field_objs()
return (field_objs, params)
@@ -407,6 +407,16 @@ class Field(object):
return self._choices
choices = property(_get_choices)
+ def _get_flatchoices(self):
+ flat = []
+ for choice, value in self.get_choices_default():
+ if type(value) in (list, tuple):
+ flat.extend(value)
+ else:
+ flat.append((choice,value))
+ return flat
+ flatchoices = property(_get_flatchoices)
+
def save_form_data(self, instance, data):
setattr(instance, self.name, data)
diff --git a/django/forms/fields.py b/django/forms/fields.py
index 77bd8cbe942..9df89553927 100644
--- a/django/forms/fields.py
+++ b/django/forms/fields.py
@@ -585,7 +585,7 @@ class NullBooleanField(BooleanField):
class ChoiceField(Field):
widget = Select
default_error_messages = {
- 'invalid_choice': _(u'Select a valid choice. That choice is not one of the available choices.'),
+ 'invalid_choice': _(u'Select a valid choice. %(value)s is not one of the available choices.'),
}
def __init__(self, choices=(), required=True, widget=None, label=None,
@@ -615,11 +615,23 @@ class ChoiceField(Field):
value = smart_unicode(value)
if value == u'':
return value
- valid_values = set([smart_unicode(k) for k, v in self.choices])
- if value not in valid_values:
+ if not self.valid_value(value):
raise ValidationError(self.error_messages['invalid_choice'] % {'value': value})
return value
+ def valid_value(self, value):
+ "Check to see if the provided value is a valid choice"
+ for k, v in self.choices:
+ if type(v) in (tuple, list):
+ # This is an optgroup, so look inside the group for options
+ for k2, v2 in v:
+ if value == smart_unicode(k2):
+ return True
+ else:
+ if value == smart_unicode(k):
+ return True
+ return False
+
class MultipleChoiceField(ChoiceField):
hidden_widget = MultipleHiddenInput
widget = SelectMultiple
@@ -640,9 +652,8 @@ class MultipleChoiceField(ChoiceField):
raise ValidationError(self.error_messages['invalid_list'])
new_value = [smart_unicode(val) for val in value]
# Validate that each value in the value list is in self.choices.
- valid_values = set([smart_unicode(k) for k, v in self.choices])
for val in new_value:
- if val not in valid_values:
+ if not self.valid_value(val):
raise ValidationError(self.error_messages['invalid_choice'] % {'value': val})
return new_value
diff --git a/django/forms/widgets.py b/django/forms/widgets.py
index 2c9f3c2ebad..cc065d71d8d 100644
--- a/django/forms/widgets.py
+++ b/django/forms/widgets.py
@@ -345,17 +345,32 @@ class Select(Widget):
if value is None: value = ''
final_attrs = self.build_attrs(attrs, name=name)
output = [u'')
+ options = self.render_options(choices, [value])
+ if options:
+ output.append(options)
+ output.append('')
return mark_safe(u'\n'.join(output))
+ def render_options(self, choices, selected_choices):
+ def render_option(option_value, option_label):
+ option_value = force_unicode(option_value)
+ selected_html = (option_value in selected_choices) and u' selected="selected"' or ''
+ return u'' % (
+ escape(option_value), selected_html,
+ conditional_escape(force_unicode(option_label)))
+ # Normalize to strings.
+ selected_choices = set([force_unicode(v) for v in selected_choices])
+ output = []
+ for option_value, option_label in chain(self.choices, choices):
+ if isinstance(option_label, (list, tuple)):
+ output.append(u'')
+ else:
+ output.append(render_option(option_value, option_label))
+ return u'\n'.join(output)
+
class NullBooleanSelect(Select):
"""
A Select Widget intended to be used with NullBooleanField.
@@ -380,24 +395,15 @@ class NullBooleanSelect(Select):
# same thing as False.
return bool(initial) != bool(data)
-class SelectMultiple(Widget):
- def __init__(self, attrs=None, choices=()):
- super(SelectMultiple, self).__init__(attrs)
- # choices can be any iterable
- self.choices = choices
-
+class SelectMultiple(Select):
def render(self, name, value, attrs=None, choices=()):
if value is None: value = []
final_attrs = self.build_attrs(attrs, name=name)
output = [u'')
+ options = self.render_options(choices, value)
+ if options:
+ output.append(options)
+ output.append('')
return mark_safe(u'\n'.join(output))
def value_from_datadict(self, data, files, name):
diff --git a/docs/model-api.txt b/docs/model-api.txt
index 4accad122ad..4975953b976 100644
--- a/docs/model-api.txt
+++ b/docs/model-api.txt
@@ -554,6 +554,29 @@ or outside your model class altogether::
class Foo(models.Model):
gender = models.CharField(max_length=1, choices=GENDER_CHOICES)
+You can also collect your available choices into named groups that can
+be used for organizational purposes::
+
+ MEDIA_CHOICES = (
+ ('Audio', (
+ ('vinyl', 'Vinyl'),
+ ('cd', 'CD'),
+ )
+ ),
+ ('Video', (
+ ('vhs', 'VHS Tape'),
+ ('dvd', 'DVD'),
+ )
+ ),
+ ('unknown', 'Unknown'),
+ )
+
+The first element in each tuple is the name to apply to the group. The
+second element is an iterable of 2-tuples, with each 2-tuple containing
+a value and a human-readable name for an option. Grouped options may be
+combined with ungrouped options within a single list (such as the
+`unknown` option in this example).
+
For each model field that has ``choices`` set, Django will add a method to
retrieve the human-readable name for the field's current value. See
`get_FOO_display`_ in the database API documentation.
diff --git a/docs/newforms.txt b/docs/newforms.txt
index 530c9ce8289..c3f4a2c21c6 100644
--- a/docs/newforms.txt
+++ b/docs/newforms.txt
@@ -1236,7 +1236,11 @@ given length.
* Error message keys: ``required``, ``invalid_choice``
Takes one extra argument, ``choices``, which is an iterable (e.g., a list or
-tuple) of 2-tuples to use as choices for this field.
+tuple) of 2-tuples to use as choices for this field. This argument accepts
+the same formats as the ``choices`` argument to a model field. See the
+`model API documentation on choices`_ for more details.
+
+.. _model API documentation on choices: ../model-api#choices
``DateField``
~~~~~~~~~~~~~
@@ -1444,7 +1448,9 @@ These control the range of values permitted in the field.
* Error message keys: ``required``, ``invalid_choice``, ``invalid_list``
Takes one extra argument, ``choices``, which is an iterable (e.g., a list or
-tuple) of 2-tuples to use as choices for this field.
+tuple) of 2-tuples to use as choices for this field. This argument accepts
+the same formats as the ``choices`` argument to a model field. See the
+`model API documentation on choices`_ for more details.
``NullBooleanField``
~~~~~~~~~~~~~~~~~~~~
diff --git a/tests/regressiontests/forms/fields.py b/tests/regressiontests/forms/fields.py
index 5855e58bc99..c70ff2dff35 100644
--- a/tests/regressiontests/forms/fields.py
+++ b/tests/regressiontests/forms/fields.py
@@ -980,7 +980,7 @@ False
# ChoiceField #################################################################
->>> f = ChoiceField(choices=[('1', '1'), ('2', '2')])
+>>> f = ChoiceField(choices=[('1', 'One'), ('2', 'Two')])
>>> f.clean('')
Traceback (most recent call last):
...
@@ -996,9 +996,9 @@ u'1'
>>> f.clean('3')
Traceback (most recent call last):
...
-ValidationError: [u'Select a valid choice. That choice is not one of the available choices.']
+ValidationError: [u'Select a valid choice. 3 is not one of the available choices.']
->>> f = ChoiceField(choices=[('1', '1'), ('2', '2')], required=False)
+>>> f = ChoiceField(choices=[('1', 'One'), ('2', 'Two')], required=False)
>>> f.clean('')
u''
>>> f.clean(None)
@@ -1010,7 +1010,7 @@ u'1'
>>> f.clean('3')
Traceback (most recent call last):
...
-ValidationError: [u'Select a valid choice. That choice is not one of the available choices.']
+ValidationError: [u'Select a valid choice. 3 is not one of the available choices.']
>>> f = ChoiceField(choices=[('J', 'John'), ('P', 'Paul')])
>>> f.clean('J')
@@ -1018,7 +1018,25 @@ u'J'
>>> f.clean('John')
Traceback (most recent call last):
...
-ValidationError: [u'Select a valid choice. That choice is not one of the available choices.']
+ValidationError: [u'Select a valid choice. John is not one of the available choices.']
+
+>>> f = ChoiceField(choices=[('Numbers', (('1', 'One'), ('2', 'Two'))), ('Letters', (('3','A'),('4','B'))), ('5','Other')])
+>>> f.clean(1)
+u'1'
+>>> f.clean('1')
+u'1'
+>>> f.clean(3)
+u'3'
+>>> f.clean('3')
+u'3'
+>>> f.clean(5)
+u'5'
+>>> f.clean('5')
+u'5'
+>>> f.clean('6')
+Traceback (most recent call last):
+...
+ValidationError: [u'Select a valid choice. 6 is not one of the available choices.']
# NullBooleanField ############################################################
@@ -1036,7 +1054,7 @@ False
# MultipleChoiceField #########################################################
->>> f = MultipleChoiceField(choices=[('1', '1'), ('2', '2')])
+>>> f = MultipleChoiceField(choices=[('1', 'One'), ('2', 'Two')])
>>> f.clean('')
Traceback (most recent call last):
...
@@ -1072,7 +1090,7 @@ Traceback (most recent call last):
...
ValidationError: [u'Select a valid choice. 3 is not one of the available choices.']
->>> f = MultipleChoiceField(choices=[('1', '1'), ('2', '2')], required=False)
+>>> f = MultipleChoiceField(choices=[('1', 'One'), ('2', 'Two')], required=False)
>>> f.clean('')
[]
>>> f.clean(None)
@@ -1100,6 +1118,29 @@ Traceback (most recent call last):
...
ValidationError: [u'Select a valid choice. 3 is not one of the available choices.']
+>>> f = MultipleChoiceField(choices=[('Numbers', (('1', 'One'), ('2', 'Two'))), ('Letters', (('3','A'),('4','B'))), ('5','Other')])
+>>> f.clean([1])
+[u'1']
+>>> f.clean(['1'])
+[u'1']
+>>> f.clean([1, 5])
+[u'1', u'5']
+>>> f.clean([1, '5'])
+[u'1', u'5']
+>>> f.clean(['1', 5])
+[u'1', u'5']
+>>> f.clean(['1', '5'])
+[u'1', u'5']
+>>> f.clean(['6'])
+Traceback (most recent call last):
+...
+ValidationError: [u'Select a valid choice. 6 is not one of the available choices.']
+>>> f.clean(['1','6'])
+Traceback (most recent call last):
+...
+ValidationError: [u'Select a valid choice. 6 is not one of the available choices.']
+
+
# ComboField ##################################################################
ComboField takes a list of fields that should be used to validate a value,
@@ -1165,7 +1206,7 @@ u''
>>> f.clean('fields.py')
Traceback (most recent call last):
...
-ValidationError: [u'Select a valid choice. That choice is not one of the available choices.']
+ValidationError: [u'Select a valid choice. fields.py is not one of the available choices.']
>>> fix_os_paths(f.clean(path + 'fields.py'))
u'.../django/forms/fields.py'
>>> f = forms.FilePathField(path=path, match='^.*?\.py$')
diff --git a/tests/regressiontests/forms/widgets.py b/tests/regressiontests/forms/widgets.py
index abb16cbcdff..40c4d017932 100644
--- a/tests/regressiontests/forms/widgets.py
+++ b/tests/regressiontests/forms/widgets.py
@@ -458,6 +458,35 @@ over multiple times without getting consumed:
+Choices can be nested one level in order to create HTML optgroups:
+>>> w.choices=(('outer1', 'Outer 1'), ('Group "1"', (('inner1', 'Inner 1'), ('inner2', 'Inner 2'))))
+>>> print w.render('nestchoice', None)
+
+
+>>> print w.render('nestchoice', 'outer1')
+
+
+>>> print w.render('nestchoice', 'inner1')
+
+
# NullBooleanSelect Widget ####################################################
>>> w = NullBooleanSelect()
@@ -626,6 +655,44 @@ True
>>> w._has_changed([1, 2], [u'1', u'3'])
True
+# Choices can be nested one level in order to create HTML optgroups:
+>>> w.choices = (('outer1', 'Outer 1'), ('Group "1"', (('inner1', 'Inner 1'), ('inner2', 'Inner 2'))))
+>>> print w.render('nestchoice', None)
+
+
+>>> print w.render('nestchoice', ['outer1'])
+
+
+>>> print w.render('nestchoice', ['inner1'])
+
+
+>>> print w.render('nestchoice', ['outer1', 'inner2'])
+
+
# RadioSelect Widget ##########################################################
>>> w = RadioSelect()