Skip to content

Commit

Permalink
Merge pull request #3225 from tomchristie/maxpeterson-grouped-choices…
Browse files Browse the repository at this point in the history
…-fix

Support grouped choices.
  • Loading branch information
tomchristie committed Aug 6, 2015
2 parents 9a77879 + 33d6d4a commit 37b4d42
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 29 deletions.
92 changes: 81 additions & 11 deletions rest_framework/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,53 @@ def set_value(dictionary, keys, value):
dictionary[keys[-1]] = value


def to_choices_dict(choices):
"""
Convert choices into key/value dicts.
pairwise_choices([1]) -> {1: 1}
pairwise_choices([(1, '1st'), (2, '2nd')]) -> {1: '1st', 2: '2nd'}
pairwise_choices([('Group', ((1, '1st'), 2))]) -> {'Group': {1: '1st', 2: '2nd'}}
"""
# Allow single, paired or grouped choices style:
# choices = [1, 2, 3]
# choices = [(1, 'First'), (2, 'Second'), (3, 'Third')]
# choices = [('Category', ((1, 'First'), (2, 'Second'))), (3, 'Third')]
ret = OrderedDict()
for choice in choices:
if (not isinstance(choice, (list, tuple))):
# single choice
ret[choice] = choice
else:
key, value = choice
if isinstance(value, (list, tuple)):
# grouped choices (category, sub choices)
ret[key] = to_choices_dict(value)
else:
# paired choice (key, display value)
ret[key] = value
return ret


def flatten_choices_dict(choices):
"""
Convert a group choices dict into a flat dict of choices.
flatten_choices({1: '1st', 2: '2nd'}) -> {1: '1st', 2: '2nd'}
flatten_choices({'Group': {1: '1st', 2: '2nd'}}) -> {1: '1st', 2: '2nd'}
"""
ret = OrderedDict()
for key, value in choices.items():
if isinstance(value, dict):
# grouped choices (category, sub choices)
for sub_key, sub_value in value.items():
ret[sub_key] = sub_value
else:
# choice (key, display value)
ret[key] = value
return ret


class CreateOnlyDefault(object):
"""
This class may be used to provide default values that are only used
Expand Down Expand Up @@ -1111,17 +1158,8 @@ class ChoiceField(Field):
}

def __init__(self, choices, **kwargs):
# Allow either single or paired choices style:
# choices = [1, 2, 3]
# choices = [(1, 'First'), (2, 'Second'), (3, 'Third')]
pairs = [
isinstance(item, (list, tuple)) and len(item) == 2
for item in choices
]
if all(pairs):
self.choices = OrderedDict([(key, display_value) for key, display_value in choices])
else:
self.choices = OrderedDict([(item, item) for item in choices])
self.grouped_choices = to_choices_dict(choices)
self.choices = flatten_choices_dict(self.grouped_choices)

# Map the string representation of choices to the underlying value.
# Allows us to deal with eg. integer choices while supporting either
Expand All @@ -1148,6 +1186,38 @@ def to_representation(self, value):
return value
return self.choice_strings_to_values.get(six.text_type(value), value)

def iter_options(self):
"""
Helper method for use with templates rendering select widgets.
"""
class StartOptionGroup(object):
start_option_group = True
end_option_group = False

def __init__(self, label):
self.label = label

class EndOptionGroup(object):
start_option_group = False
end_option_group = True

class Option(object):
start_option_group = False
end_option_group = False

def __init__(self, value, display_text):
self.value = value
self.display_text = display_text

for key, value in self.grouped_choices.items():
if isinstance(value, dict):
yield StartOptionGroup(label=key)
for sub_key, sub_value in value.items():
yield Option(value=sub_key, display_text=sub_value)
yield EndOptionGroup()
else:
yield Option(value=key, display_text=value)


class MultipleChoiceField(ChoiceField):
default_error_messages = {
Expand Down
10 changes: 8 additions & 2 deletions rest_framework/templates/rest_framework/horizontal/select.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@
{% if field.allow_null or field.allow_blank %}
<option value="" {% if not field.value %}selected{% endif %}>--------</option>
{% endif %}
{% for key, text in field.choices.items %}
<option value="{{ key }}" {% if key == field.value %}selected{% endif %}>{{ text }}</option>
{% for select in field.iter_options %}
{% if select.start_option_group %}
<optgroup label="{{ select.label }}">
{% elif select.end_option_group %}
</optgroup>
{% else %}
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
{% endif %}
{% endfor %}
</select>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@

<div class="col-sm-10">
<select multiple {{ field.choices|yesno:",disabled" }} class="form-control" name="{{ field.name }}">
{% for key, text in field.choices.items %}
<option value="{{ key }}" {% if key in field.value %}selected{% endif %}>{{ text }}</option>
{% for select in field.iter_options %}
{% if select.start_option_group %}
<optgroup label="{{ select.label }}">
{% elif select.end_option_group %}
</optgroup>
{% else %}
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
{% endif %}
{% empty %}
<option>{{ no_items }}</option>
{% endfor %}
Expand Down
11 changes: 8 additions & 3 deletions rest_framework/templates/rest_framework/inline/select.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@
{% if field.allow_null or field.allow_blank %}
<option value="" {% if not field.value %}selected{% endif %}>--------</option>
{% endif %}

{% for key, text in field.choices.items %}
<option value="{{ key }}" {% if key == field.value %}selected{% endif %}>{{ text }}</option>
{% for select in field.iter_options %}
{% if select.start_option_group %}
<optgroup label="{{ select.label }}">
{% elif select.end_option_group %}
</optgroup>
{% else %}
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
{% endif %}
{% endfor %}
</select>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@
{% endif %}

<select multiple {{ field.choices|yesno:",disabled" }} class="form-control" name="{{ field.name }}">
{% for key, text in field.choices.items %}
<option value="{{ key }}" {% if key in field.value %}selected{% endif %}>{{ text }}</option>
{% empty %}
{% for select in field.iter_options %}
{% if select.start_option_group %}
<optgroup label="{{ select.label }}">
{% elif select.end_option_group %}
</optgroup>
{% else %}
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
{% endif %}
{% empty %}
<option>{{ no_items }}</option>
{% endfor %}
</select>
Expand Down
11 changes: 8 additions & 3 deletions rest_framework/templates/rest_framework/vertical/select.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@
{% if field.allow_null or field.allow_blank %}
<option value="" {% if not field.value %}selected{% endif %}>--------</option>
{% endif %}

{% for key, text in field.choices.items %}
<option value="{{ key }}" {% if key == field.value %}selected{% endif %}>{{ text }}</option>
{% for select in field.iter_options %}
{% if select.start_option_group %}
<optgroup label="{{ select.label }}">
{% elif select.end_option_group %}
</optgroup>
{% else %}
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
{% endif %}
{% endfor %}
</select>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@
{% endif %}

<select multiple {{ field.choices|yesno:",disabled" }} class="form-control" name="{{ field.name }}">
{% for key, text in field.choices.items %}
<option value="{{ key }}" {% if key in field.value %}selected{% endif %}>{{ text }}</option>
{% for select in field.iter_options %}
{% if select.start_option_group %}
<optgroup label="{{ select.label }}">
{% elif select.end_option_group %}
</optgroup>
{% else %}
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
{% endif %}
{% empty %}
<option>{{ no_items }}</option>
{% endfor %}
Expand Down
4 changes: 2 additions & 2 deletions rest_framework/utils/field_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,10 @@ def get_field_kwargs(field_name, model_field):
isinstance(model_field, models.TextField)):
kwargs['allow_blank'] = True

if model_field.flatchoices:
if model_field.choices:
# If this model field contains choices, then return early.
# Further keyword arguments are not valid.
kwargs['choices'] = model_field.flatchoices
kwargs['choices'] = model_field.choices
return kwargs

# Ensure that max_length is passed explicitly as a keyword arg,
Expand Down
88 changes: 88 additions & 0 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,34 @@ def test_allow_null(self):
output = field.run_validation(None)
assert output is None

def test_iter_options(self):
"""
iter_options() should return a list of options and option groups.
"""
field = serializers.ChoiceField(
choices=[
('Numbers', ['integer', 'float']),
('Strings', ['text', 'email', 'url']),
'boolean'
]
)
items = list(field.iter_options())

assert items[0].start_option_group
assert items[0].label == 'Numbers'
assert items[1].value == 'integer'
assert items[2].value == 'float'
assert items[3].end_option_group

assert items[4].start_option_group
assert items[4].label == 'Strings'
assert items[5].value == 'text'
assert items[6].value == 'email'
assert items[7].value == 'url'
assert items[8].end_option_group

assert items[9].value == 'boolean'


class TestChoiceFieldWithType(FieldValues):
"""
Expand Down Expand Up @@ -1153,6 +1181,66 @@ class TestChoiceFieldWithListChoices(FieldValues):
field = serializers.ChoiceField(choices=('poor', 'medium', 'good'))


class TestChoiceFieldWithGroupedChoices(FieldValues):
"""
Valid and invalid values for a `Choice` field that uses a grouped list for the
choices, rather than a list of pairs of (`value`, `description`).
"""
valid_inputs = {
'poor': 'poor',
'medium': 'medium',
'good': 'good',
}
invalid_inputs = {
'awful': ['"awful" is not a valid choice.']
}
outputs = {
'good': 'good'
}
field = serializers.ChoiceField(
choices=[
(
'Category',
(
('poor', 'Poor quality'),
('medium', 'Medium quality'),
),
),
('good', 'Good quality'),
]
)


class TestChoiceFieldWithMixedChoices(FieldValues):
"""
Valid and invalid values for a `Choice` field that uses a single paired or
grouped.
"""
valid_inputs = {
'poor': 'poor',
'medium': 'medium',
'good': 'good',
}
invalid_inputs = {
'awful': ['"awful" is not a valid choice.']
}
outputs = {
'good': 'good'
}
field = serializers.ChoiceField(
choices=[
(
'Category',
(
('poor', 'Poor quality'),
),
),
'medium',
('good', 'Good quality'),
]
)


class TestMultipleChoiceField(FieldValues):
"""
Valid and invalid values for `MultipleChoiceField`.
Expand Down
2 changes: 1 addition & 1 deletion tests/test_model_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ class Meta:
null_field = IntegerField(allow_null=True, required=False)
default_field = IntegerField(required=False)
descriptive_field = IntegerField(help_text='Some help text', label='A label')
choices_field = ChoiceField(choices=[('red', 'Red'), ('blue', 'Blue'), ('green', 'Green')])
choices_field = ChoiceField(choices=(('red', 'Red'), ('blue', 'Blue'), ('green', 'Green')))
""")
if six.PY2:
# This particular case is too awkward to resolve fully across
Expand Down
Loading

0 comments on commit 37b4d42

Please sign in to comment.