From 649463dd348abd6d0cab890e2372e88fc452128e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 19 Jul 2008 07:53:02 +0000 Subject: [PATCH] Fixed #4412 -- Added support for optgroups, both in the model when defining choices, and in the form field and widgets when the optgroups are displayed. Thanks to Matt McClanahan , Tai Lee and SmileyChris for their contributions at various stages in the life of this ticket. git-svn-id: http://code.djangoproject.com/svn/django/trunk@7977 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/db/models/base.py | 2 +- django/db/models/fields/__init__.py | 12 ++++- django/forms/fields.py | 21 ++++++-- django/forms/widgets.py | 52 +++++++++++--------- docs/model-api.txt | 23 +++++++++ docs/newforms.txt | 10 +++- tests/regressiontests/forms/fields.py | 57 +++++++++++++++++++--- tests/regressiontests/forms/widgets.py | 67 ++++++++++++++++++++++++++ 8 files changed, 204 insertions(+), 40 deletions(-) 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'' % flatatt(final_attrs)] - # Normalize to string. - str_value = force_unicode(value) - for option_value, option_label in chain(self.choices, choices): - option_value = force_unicode(option_value) - selected_html = (option_value == str_value) and u' selected="selected"' or '' - output.append(u'' % ( - escape(option_value), selected_html, - conditional_escape(force_unicode(option_label)))) - output.append(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'' % escape(force_unicode(option_value))) + for option in option_label: + output.append(render_option(*option)) + 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()