Merge lp:~rvb/maas/multifield into lp:~maas-committers/maas/trunk

Proposed by Raphaël Badin
Status: Merged
Approved by: Julian Edwards
Approved revision: no longer in the source branch.
Merged at revision: 609
Proposed branch: lp:~rvb/maas/multifield
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 581 lines (+572/-0)
2 files modified
src/maasserver/config_forms.py (+332/-0)
src/maasserver/tests/test_config_forms.py (+240/-0)
To merge this branch: bzr merge lp:~rvb/maas/multifield
Reviewer Review Type Date Requested Status
Julian Edwards (community) Approve
Review via email: mp+108188@code.launchpad.net

Commit message

Add DictCharField, a form field used to store/display a dictionary.

Description of the change

This branch adds a new (form) field used to expose and store a dictionary. This new field (DictCharField) will use a list of widgets to display the data. Each key/value will be associated with a widget.

I'll use that to change src/maasserver/power_parameters.py in a follow-up branch. A third branch will then update the API and the form code to use that to populate Node.power_parameters from the API.

= Notes =

* Improvement over power-param-api4 *
This is a much better version of what I've done in https://code.launchpad.net/~rvb/maas/power-param-api4/+merge/107661. Instead of putting the logic in the API code (as it was done in the branch power-param-api4, this branch creates a plain Django field that can be used in the API or in the UI in a completely normal fashion.

* DictCharField and DictCharWidget *
One might find that this is a little bit hacky. I confess I had to perform open heart surgery on forms.MultiValueField and forms.widgets.MultiWidget. Given the quality of the Django code, I had to adapt a more bigger portion of the code that was strictly required but note that: a) I tried to document what I've done very thoroughly, a) the amount of code is not that great. c) More importantly this is fairly *well encapsulated* as this creates a completely normal Django form field that can be reused in a very simple way:

MyForm(forms.Form)
    # A structured DictCharField which will create dicts like
    # {'sub_field1': 'value1', 'sub_field2': 'value2'}
    # or {'sub_field1': 'value1'} (Note that the second field is optional.
    constrained_multi_field = DictCharField(
        [
            ('sub_field1', forms.CharField(label="Sub Field 1")),
            ('sub_field2', forms.CharField(label="Sub Field 2"), required=False),
        ],
     # A completely un-constrained DictCharField.
     free_multi_field = DictCharField([], skip_check=True)

* Isolation *
Again, I want to state that this is fairly well isolated:
- this first branch only creates a completely independent module.
- the second branch simply updates the power_parameter module to use the newly created DictCharField.
https://code.launchpad.net/~rvb/maas/multifield-power_parameters/+merge/108191
- the third branch creates a new form used by the API, based on the one used in the UI to edit nodes and uses that form in the API. In this branch, the change to the API code is a one-liner and the change to forms.py is pretty minimal and fairly straightforward.
https://code.launchpad.net/~rvb/maas/multifield-api/+merge/108195

To post a comment you must log in.
Revision history for this message
Julian Edwards (julian-edwards) wrote :
Download full text (5.5 KiB)

Thanks Raphers. This is needs-fixing because there's a few coding style violations and a test antipattern.

35 +class DictCharField(forms.MultiValueField):
36 + """A field to edit a dictionary of strings. Each entry in the
37 + dictionary correspond to a sub-field.

s/correspond/corresponds/

41 + 'skip_check' allows to store an arbitrary dictionary in the field,

s/to store/the storing of/

44 + For instance a DictCharField created with the following list:
45 + [
46 + ('field1', forms.CharField(label="Field 1"),
47 + ('field2', forms.CharField(label="Field 2"),
48 + ]
49 + Will produce dictionaries of the form:
50 + {'field1': 'subvalue1', 'field2': 'subvalue2'}

I think this example needs expanding to show how assignment works. It wasn't clear to me how you get from the first bit to the second bit, which suddenly has values.

55 + self.field_dict = OrderedDict(field_items)

Why ordered? Shouldn't that be an option?

60 + if skip_check:
61 + self.field_dict[SKIP_CHECK_NAME] = forms.BooleanField(
62 + required=False)

I think SKIP_CHECK_NAME needs to be more obscure; it's quite conceivable that someone will have a field named skip_check. Perhaps '__skip_check' ?

76 + def compress(self, data):
77 + if data:

Docstring missing.

91 + - the method is splitted into clean_global and

s/splitted/split/

92 + clean_individual_fields;

The code has 'clean_sub_fields' ...

114 + value = value if not self.skip_check else value[:-1]

How about:

if self.skip_check:
    value = value[:-1]

which is easier to read. We tend to eschew this logic shortcut constraint, it's hideously ugly and error prone.

111 + def clean_global(self, value):
112 + # Remove the value corresponding to the SKIP_CHECK_NAME boolean field
113 + # if required.

Missing docstring.

115 + if not value or isinstance(value, (list, tuple)):
116 + is_empty = (
117 + not value or
118 + not [v for v in value if v not in validators.EMPTY_VALUES])

*blink*

Firstly, our coding standards say to check explicitly for None or False, and not use "if not value".

Second, this really needs splitting up into something that's more understandable. I don't have any good suggestions because I can't tell what you're trying to do here. The code appears to be doing more than the comment suggests.

127 + raise ValidationError(self.error_messages['invalid'])

Is this "invalid" text something that Django needs or can we write a better error text here?

129 + def clean_sub_fields(self, value):
130 + clean_data = []

Missing docstring.

134 + fields = self.fields if not self.skip_check else self.fields[:-1]

    fields = self.fields
    if self.skip_check:
        fields = self.fields[:-1]

135 + for i, field in enumerate(fields):

Our coding standards say to use useful variable names. Perhaps "index" here.

163 +def get_all_prefixed_values(data, name):
164 + result = {}

Missing docstring.

185 + - DictCharWidget has the (optional) ability to skip all the validation
186 + are instead fetch all the values prefixed by 'main_widget_' in the
187 + input data.

s/are instead/and instead/ ?

194 + - 'value_from_datadict' which fetches the value of the data to be
195 + processed by thi...

Read more...

review: Needs Fixing
Revision history for this message
Julian Edwards (julian-edwards) wrote :

Thank you LP for stripping all the formatting spaces on the code snippets >:(

Revision history for this message
Raphaël Badin (rvb) wrote :

Thanks a lot for the review Julian, I'll fix this when I'll be back on Tuesday. I had a quick glance at your comments and it seems that most of the weirdness that you pointed out to me are snippets of Django code that I had to embed in my own code because, like I said on the MP, the Django code is not sufficiently well written to allow me to override only the parts that I needed to override :(. eg:

** original Django code **
        if not value or isinstance(value, (list, tuple)):
            if not value or not [v for v in value if v not in validators.EMPTY_VALUES]:

** Semi-refactored Django code **
115 + if not value or isinstance(value, (list, tuple)):
116 + is_empty = (
117 + not value or
118 + not [v for v in value if v not in validators.EMPTY_VALUES])

But anyway, since this becomes our code, I'll fix this… thanks again for the thorough review.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Sunday 03 June 2012 10:12:18 you wrote:
> Thanks a lot for the review Julian, I'll fix this when I'll be back on
> Tuesday. I had a quick glance at your comments and it seems that most of
> the weirdness that you pointed out to me are snippets of Django code that I
> had to embed in my own code because, like I said on the MP, the Django code
> is not sufficiently well written to allow me to override only the parts
> that I needed to override :(. eg:
>
> ** original Django code **
> if not value or isinstance(value, (list, tuple)):
> if not value or not [v for v in value if v not in
> validators.EMPTY_VALUES]:
>
> ** Semi-refactored Django code **
> 115 + if not value or isinstance(value, (list, tuple)):
> 116 + is_empty = (
> 117 + not value or
> 118 + not [v for v in value if v not in validators.EMPTY_VALUES])
>
> But anyway, since this becomes our code, I'll fix this… thanks again for the
> thorough review.

Ok, thanks. I knew there had to be a reason you'd not be writing code that
badly on purpose!

Revision history for this message
Raphaël Badin (rvb) wrote :

> Ok, thanks. I knew there had to be a reason you'd not be writing code that
> badly on purpose!

Yeah, I confess I was tempted to leave it like it was written originally to better identify what was "stolen" from Django and what was not… If I refactor heavily the Django code, what is our code and what is not will become less obvious.

Revision history for this message
Raphaël Badin (rvb) wrote :
Download full text (6.1 KiB)

> Thanks Raphers. This is needs-fixing because there's a few coding style
> violations and a test antipattern.

Thanks for the review. This is a first batch of fixes. Now I'm working on improving the test file.
>
> s/correspond/corresponds/
> s/to store/the storing of/

Done.

>
> 44 + For instance a DictCharField created with the following list:
> 45 + [
[...]
> 50 + {'field1': 'subvalue1', 'field2': 'subvalue2'}
>
> I think this example needs expanding to show how assignment works. It wasn't
> clear to me how you get from the first bit to the second bit, which suddenly
> has values.

Dood point, done

> 55 + self.field_dict = OrderedDict(field_items)
>
> Why ordered? Shouldn't that be an option?

No, I need self.fields and self.names to be ordered in the same way. But note that this is an implementation detail.

> 60 + if skip_check:
> 61 + self.field_dict[SKIP_CHECK_NAME] = forms.BooleanField(
> 62 + required=False)
>
> I think SKIP_CHECK_NAME needs to be more obscure; it's quite conceivable that
> someone will have a field named skip_check. Perhaps '__skip_check' ?

You've got a point, but since SKIP_CHECK_NAME is exposed to the user, I think it should be readable. Instead I've added a check to make sure that no user-defined subfield is named 'skip_check'. I've added a test for that (test_DictCharField_does_not_allow_subfield_named_skip_check).

> 76 + def compress(self, data):
> 77 + if data:
>
> Docstring missing.

Done.

> s/splitted/split/

Fixed.

> The code has 'clean_sub_fields' ...

Right… fixed.

> 114 + value = value if not self.skip_check else value[:-1]
>
> How about:
>
> if self.skip_check:
> value = value[:-1]
>
> which is easier to read. We tend to eschew this logic shortcut constraint,
> it's hideously ugly and error prone.

Done. I've also added a comment to explain why this is for.

> 111 + def clean_global(self, value):
> 112 + # Remove the value corresponding to the SKIP_CHECK_NAME boolean
> field
> 113 + # if required.
>
> Missing docstring.

Fixed.

> 115 + if not value or isinstance(value, (list, tuple)):
> 116 + is_empty = (
> 117 + not value or
> 118 + not [v for v in value if v not in validators.EMPTY_VALUES])
>
> *blink*
>
> Firstly, our coding standards say to check explicitly for None or False, and
> not use "if not value".
>
> Second, this really needs splitting up into something that's more
> understandable. I don't have any good suggestions because I can't tell what
> you're trying to do here. The code appears to be doing more than the comment
> suggests.

Done, and I've added a comment.

> 127 + raise ValidationError(self.error_messages['invalid'])
>
> Is this "invalid" text something that Django needs or can we write a better
> error text here?

This is precisely done so that you can override all the default error messages on a per-field basis:
e.g.:
name = forms.CharField(error_messages={'invalid': "Custom invalid message here"})

>
> 129 + def clean_sub_fields(self, value):
> 130 + clean_data = []
>
> Missing docstring.

Fixed.

> 134 + fields = self.field...

Read more...

Revision history for this message
Raphaël Badin (rvb) wrote :

>
> 324 +class DictCharFieldTest(TestCase):
> 325 +
> 326 + def test_DictCharField_init(self):
> 327 + self.assertEqual(['field_a', 'field_b', 'field_c'],
> testField.names)
> 328 + self.assertEqual(
> 329 + ['field_a', 'field_b', 'field_c'], testField.widget.names)
> 330 + self.assertEqual(
> 331 + [field.widget for field in testField.field_dict.values()],
> 332 + testField.widget.widgets)
>
> This is a poor test because it's testing values that are set elsewhere in the
> test file, and is one of our testing antipatterns. Test values should be
> initialised in the same test. (i.e. the global "testField" is bad!)
>
> I'd fix this by having a factory for it so you can supply initialiser values
> in the test.

Fixed. I've not created a factory because I thought it wouldn't give us much considering that all the initialization code is in the creation of the list of the subfields. But now each test is isolated and much more readable.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

 status approve

On Tuesday 05 Jun 2012 09:06:22 Raphaël Badin wrote:
> > I think SKIP_CHECK_NAME needs to be more obscure; it's quite conceivable
> > that someone will have a field named skip_check. Perhaps '__skip_check'
> > ?
> You've got a point, but since SKIP_CHECK_NAME is exposed to the user, I
> think it should be readable. Instead I've added a check to make sure that
> no user-defined subfield is named 'skip_check'. I've added a test for that
> (test_DictCharField_does_not_allow_subfield_named_skip_check).

This is good that you've checked its presence - however I'd make the error
message convey more information like:

"skip_check is a reserved name"

so the user knows why it's disallowed.

+ """Extract the values for each widget from a data dict (QueryDict).
+ :param data: The data dict (usually request.data or request.GET where
+ request is a django.http.HttpRequest).
+ :param data: dict
+ :param files: The files dict (usually request.FILES where request is
a
+ django.http.HttpRequest).
+ :param files: dict
+ :param name: The name of the widget.
+ :param name: basestring

Some of these s/param/type/

Everything else is much better! I particularly like the doctest-like nature
of some of the docstrings, thank you.

Approved with the change above!

review: Approve
Revision history for this message
Raphaël Badin (rvb) wrote :

> This is good that you've checked its presence - however I'd make the error
> message convey more information like:
> "skip_check is a reserved name"
> so the user knows why it's disallowed.

Done.

> Some of these s/param/type/

Done.

Thanks!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'src/maasserver/config_forms.py'
2--- src/maasserver/config_forms.py 1970-01-01 00:00:00 +0000
3+++ src/maasserver/config_forms.py 2012-06-05 10:17:20 +0000
4@@ -0,0 +1,332 @@
5+# Copyright 2012 Canonical Ltd. This software is licensed under the
6+# GNU Affero General Public License version 3 (see the file LICENSE).
7+
8+"""Config forms utilities."""
9+
10+from __future__ import (
11+ absolute_import,
12+ print_function,
13+ unicode_literals,
14+ )
15+
16+__metaclass__ = type
17+__all__ = [
18+ 'DictCharField',
19+ 'DictCharWidget',
20+ ]
21+
22+from collections import OrderedDict
23+
24+from django import forms
25+from django.core import validators
26+from django.core.exceptions import ValidationError
27+from django.forms.fields import Field
28+from django.forms.util import ErrorList
29+from django.utils.safestring import mark_safe
30+
31+
32+SKIP_CHECK_NAME = 'skip_check'
33+
34+
35+class DictCharField(forms.MultiValueField):
36+ """A field to edit a dictionary of strings. Each entry in the
37+ dictionary corresponds to a sub-field.
38+
39+ The field is constructed with a list of tuples containing the name of the
40+ sub-fields and the sub-field themselves. An optional parameter
41+ 'skip_check' allows the storing of an arbitrary dictionary in the field,
42+ bypassing any validation made by the sub-fields.
43+
44+ For instance, if we create a form with a single DictCharField:
45+
46+ >>> from django import forms
47+ >>> class ExampleForm(forms.Form)
48+ example = DictCharField(
49+ [
50+ ('field1', forms.CharField(label="Field 1"),
51+ ('field2', forms.CharField(label="Field 2"),
52+ ])
53+ >>> data = QueryDict('example_field1=subvalue1&example_field2=subvalue2')
54+ >>> form = ExampleForm(data)
55+ >>> # The 'cleaned_data' of the 'example' field is populated with the
56+ >>> # values of the subfields.
57+ >>> form.cleaned_data['example']
58+ {'field1': 'subvalue1', 'field2': 'subvalue2'}
59+ """
60+
61+ def __init__(self, field_items, skip_check=False, *args,
62+ **kwargs):
63+ self.field_dict = OrderedDict(field_items)
64+ self.skip_check = skip_check
65+ # Make sure no subfield is named 'SKIP_CHECK_NAME'. If
66+ # skip_check is True this field will clash with the addtional
67+ # subfield added by the DictCharField constructor. We perform
68+ # this check even if skip_check=False because having a field named
69+ # 'skip_check' that isn't used to actually skip the checks would be
70+ # very confusing.
71+ if SKIP_CHECK_NAME in self.field_dict.keys():
72+ raise RuntimeError("No subfield can be named 'skip_check'.")
73+ # if skip_check: add a BooleanField to the list of fields, this will
74+ # be used to skip the validation of the fields and accept arbitrary
75+ # data.
76+ if skip_check:
77+ self.field_dict[SKIP_CHECK_NAME] = forms.BooleanField(
78+ required=False)
79+ self.names = [name for name in self.field_dict.keys()]
80+ # Create the DictCharWidget with init values from the list of fields.
81+ self.fields = self.field_dict.values()
82+ self.widget = DictCharWidget(
83+ [field.widget for field in self.fields],
84+ self.names,
85+ [field.label for field in self.fields],
86+ skip_check=skip_check,
87+ )
88+ # Upcall to Field and not MultiValueField to avoid setting all the
89+ # subfields' 'required' attributes to False.
90+ Field.__init__(self, *args, **kwargs)
91+
92+ def compress(self, data):
93+ """Returns a single value for the given list of values."""
94+ if data:
95+ if isinstance(data, dict):
96+ # If the data is a dict, this means that we're in the
97+ # situation where skip_check was true and we simply
98+ # return the dict.
99+ return data
100+ else:
101+ # Here data is the list of the values of the subfields,
102+ # return a dict with all the right keys:
103+ # For instance, for a DictCharField created with two
104+ # subfields 'field1' and 'field2', data will be
105+ # ['value1', 'value2'] and we will return:
106+ # {'field1': 'value1', 'field2': 'value2'}
107+ return dict(zip(self.names, data))
108+ return None
109+
110+ def clean(self, value):
111+ """Validates every value in the given list. A value is validated
112+ against the corresponding Field in self.fields.
113+
114+ This is an adapted version of Django's MultiValueField_ clean method.
115+
116+ The differences are:
117+ - the method is split into clean_global and
118+ clean_sub_fields;
119+ - the field and value corresponding to the SKIP_CHECK_NAME boolean
120+ field are removed;
121+ - each individual field 'required' attribute is used instead of the
122+ DictCharField's 'required' attribute. This allows a more
123+ fine-grained control of what's required and what's not required.
124+
125+ .. _MultiValueField: http://code.djangoproject.com/
126+ svn/django/tags/releases/1.3.1/django/forms/fields.py
127+ """
128+ if isinstance(value, dict):
129+ return value
130+ else:
131+ result = self.clean_global(value)
132+ if result is None:
133+ return None
134+ else:
135+ return self.clean_sub_fields(value)
136+
137+ def clean_global(self, value):
138+ """Make sure the value is not empty and is thus suitable to be
139+ feed to the sub fields' validators."""
140+ # Remove the value corresponding to the SKIP_CHECK_NAME boolean field
141+ # if required.
142+ if self.skip_check:
143+ # If self.skip_check: strip out the last value which
144+ # corresponds to the value of the 'skip_check' boolean field.
145+ value = value[:-1]
146+ if not value or isinstance(value, (list, tuple)):
147+ # value is considered empty if it is in
148+ # validators.EMPTY_VALUES, or if each of the subvalues is in
149+ # validators.EMPTY_VALUES.
150+ is_empty = (
151+ value in validators.EMPTY_VALUES or
152+ len(filter(
153+ lambda x: x not in validators.EMPTY_VALUES, value)) == 0)
154+ if is_empty:
155+ if self.required:
156+ raise ValidationError(self.error_messages['required'])
157+ else:
158+ return None
159+ else:
160+ return True
161+ else:
162+ raise ValidationError(self.error_messages['invalid'])
163+
164+ def clean_sub_fields(self, value):
165+ """'value' being the list of the values of the subfields, validate
166+ each subfield."""
167+ clean_data = []
168+ errors = ErrorList()
169+ # Remove the field corresponding to the SKIP_CHECK_NAME boolean field
170+ # if required.
171+ fields = self.fields if not self.skip_check else self.fields[:-1]
172+ for index, field in enumerate(fields):
173+ try:
174+ field_value = value[index]
175+ except IndexError:
176+ field_value = None
177+ # Check the field's 'required' field instead of the global
178+ # 'required' field to allow subfields to be required or not.
179+ if field.required and field_value in validators.EMPTY_VALUES:
180+ errors.append(
181+ '%s: %s' % (field.label, self.error_messages['required']))
182+ continue
183+ try:
184+ clean_data.append(field.clean(field_value))
185+ except ValidationError, e:
186+ # Collect all validation errors in a single list, which we'll
187+ # raise at the end of clean(), rather than raising a single
188+ # exception for the first error we encounter.
189+ errors.extend(
190+ ['%s: %s' % (field.label, message)
191+ for message in e.messages])
192+ if errors:
193+ raise ValidationError(errors)
194+
195+ out = self.compress(clean_data)
196+ self.validate(out)
197+ return out
198+
199+
200+def get_all_prefixed_values(data, name):
201+ """From a dictionary, extract a sub-dictionary of all the keys/values for
202+ which the key starts with a particular prefix. In the resulting
203+ dictionary, strip the prefix from the keys.
204+
205+ >>> get_all_prefixed_values(
206+ {'prefix_test': 'a', 'key': 'b'}, 'prefix_')
207+ {'test': 'a'}
208+ """
209+ result = {}
210+ for key, value in data.items():
211+ if key.startswith(name):
212+ new_key = key[len(name):]
213+ if new_key != SKIP_CHECK_NAME:
214+ result[new_key] = value
215+ return result
216+
217+
218+class DictCharWidget(forms.widgets.MultiWidget):
219+ """A widget to display the content of a dictionary. Each key will
220+ correspond to a subwidget. Although there is no harm in using this class
221+ directly, note that this is mostly destined to be used internally
222+ by DictCharField.
223+
224+ The customization compared to Django's MultiWidget_ are:
225+ - DictCharWidget displays all the subwidgets inside a fieldset tag;
226+ - DictCharWidget displays a label for each subwidget;
227+ - DictCharWidget names each subwidget 'main_widget_sub_widget_name'
228+ instead of 'main_widget_0';
229+ - DictCharWidget has the (optional) ability to skip all the validation
230+ and instead fetch all the values prefixed by 'main_widget_' in the
231+ input data.
232+
233+ To achieve that, we customize:
234+ - 'render' which returns the HTML code to display this widget;
235+ - 'id_for_label' which return the HTML ID attribute for this widget
236+ for use by a label. This widget is composed of multiple widgets so
237+ the id of the first widget is used;
238+ - 'value_from_datadict' which fetches the value of the data to be
239+ processed by this form to give a 'data' dictionary. We need to
240+ customize that because we've changed the way MultiWidget names
241+ sub-widgets;
242+ - 'decompress' which takes a single "compressed" value and returns a list
243+ of values to be used by the widgets.
244+
245+ .. _MultiWidget: http://code.djangoproject.com/
246+ svn/django/tags/releases/1.3.1/django/forms/widgets.py
247+ """
248+
249+ def __init__(self, widgets, names, labels, skip_check=False, attrs=None):
250+ self.names = names
251+ self.labels = labels
252+ self.skip_check = skip_check
253+ super(DictCharWidget, self).__init__(widgets, attrs)
254+
255+ def render(self, name, value, attrs=None):
256+ # value is a list of values, each corresponding to a widget
257+ # in self.widgets.
258+ if not isinstance(value, list):
259+ value = self.decompress(value)
260+ output = ['<fieldset>']
261+ final_attrs = self.build_attrs(attrs)
262+ id_ = final_attrs.get('id', None)
263+ for index, widget in enumerate(self.widgets):
264+ try:
265+ widget_value = value[index]
266+ except IndexError:
267+ widget_value = None
268+ if id_:
269+ final_attrs = dict(
270+ final_attrs, id='%s_%s' % (id_, self.names[index]))
271+ # Add label to each sub-field.
272+ if id_:
273+ label_for = ' for="%s"' % final_attrs['id']
274+ else:
275+ label_for = ''
276+ output.append(
277+ '<label%s>%s</label>' % (
278+ label_for, self.labels[index]))
279+ output.append(
280+ widget.render(
281+ '%s_%s' % (name, self.names[index]), widget_value,
282+ final_attrs))
283+ output.append('</fieldset>')
284+ return mark_safe(self.format_output(output))
285+
286+ def id_for_label(self, id_):
287+ """Returns the HTML ID attribute of this Widget. Since this is a
288+ widget with multiple HTML elements, this method returns an ID
289+ corresponding to the first ID in the widget's tags."""
290+ # See the comment for RadioSelect.id_for_label()
291+ if id_:
292+ id_ += '_%s' % self.names[0]
293+ return id_
294+
295+ def value_from_datadict(self, data, files, name):
296+ """Extract the values for each widget from a data dict (QueryDict).
297+ :param data: The data dict (usually request.data or request.GET where
298+ request is a django.http.HttpRequest).
299+ :param data: dict
300+ :param files: The files dict (usually request.FILES where request is a
301+ django.http.HttpRequest).
302+ :param files: dict
303+ :param name: The name of the widget.
304+ :param name: basestring
305+ :return: The extracted values. If skip_check has been activated, the
306+ extracted value will be a dictionary, if not, is will be a list.
307+ :rtype: dict or list
308+ """
309+ # self.widgets[-1] is the 'skip_check' boolean widget. If
310+ # self.skip_check is true and the value (in data) corresponding
311+ # to the 'skip_check' widget is true, extract all the values
312+ # from data which correspond to this widget and return a
313+ # dictionary of these keys/values.
314+ skip_check = (
315+ self.skip_check and
316+ self.widgets[-1].value_from_datadict(
317+ data, files, name + '_%s' % self.names[-1]))
318+ if skip_check:
319+ # If the skip_check option is on and the value of the boolean
320+ # field is true: simply return all the values from 'data' which
321+ # are prefixed by the name of this widget.
322+ return get_all_prefixed_values(data, name + '_')
323+ else:
324+ return [
325+ widget.value_from_datadict(
326+ data, files, name + '_%s' % self.names[i])
327+ for i, widget in enumerate(self.widgets)]
328+
329+ def decompress(self, value):
330+ """Returns a list of decompressed values for the given compressed
331+ value. The given value can be assumed to be valid, but not
332+ necessarily non-empty."""
333+ if value is not None:
334+ return [value.get(name, None) for name in self.names]
335+ else:
336+ return [None] * len(self.names)
337
338=== added file 'src/maasserver/tests/test_config_forms.py'
339--- src/maasserver/tests/test_config_forms.py 1970-01-01 00:00:00 +0000
340+++ src/maasserver/tests/test_config_forms.py 2012-06-05 10:17:20 +0000
341@@ -0,0 +1,240 @@
342+# Copyright 2012 Canonical Ltd. This software is licensed under the
343+# GNU Affero General Public License version 3 (see the file LICENSE).
344+
345+"""Test config forms utilities."""
346+
347+from __future__ import (
348+ absolute_import,
349+ print_function,
350+ unicode_literals,
351+ )
352+
353+__metaclass__ = type
354+__all__ = []
355+
356+from django import forms
357+from django.forms import widgets
358+from django.http import QueryDict
359+from maasserver.config_forms import (
360+ DictCharField,
361+ DictCharWidget,
362+ get_all_prefixed_values,
363+ )
364+from maasserver.testing.factory import factory
365+from maasserver.testing.testcase import TestCase
366+
367+
368+class TestDictCharField(TestCase):
369+
370+ def test_DictCharField_init(self):
371+ testField = DictCharField(
372+ [
373+ ('field_a', forms.CharField(label='Field a')),
374+ ('field_b', forms.CharField(label='Field b')),
375+ ('field_c', forms.CharField(label='Field c')),
376+ ])
377+ self.assertEqual(['field_a', 'field_b', 'field_c'], testField.names)
378+ self.assertEqual(
379+ ['field_a', 'field_b', 'field_c'], testField.widget.names)
380+ self.assertEqual(
381+ [field.widget for field in testField.field_dict.values()],
382+ testField.widget.widgets)
383+
384+ def test_DictCharField_does_not_allow_subfield_named_skip_check(self):
385+ # Creating a DictCharField with a subfield named 'skip_check' is not
386+ # allowed.
387+ self.assertRaises(
388+ RuntimeError, DictCharField,
389+ [('skip_check', forms.CharField(label='Skip Check'))])
390+
391+
392+class TestFormWithDictCharField(TestCase):
393+
394+ def test_DictCharField_processes_QueryDict_into_a_dict(self):
395+ class FakeForm(forms.Form):
396+ multi_field = DictCharField(
397+ [
398+ ('field_a', forms.CharField(label='Field a')),
399+ ('field_b', forms.CharField(
400+ label='Field b', required=False, max_length=3)),
401+ ('field_c', forms.CharField(
402+ label='Field c', required=False)),
403+ ])
404+
405+ fielda_value = factory.getRandomString()
406+ fieldc_value = factory.getRandomString()
407+ data = QueryDict(
408+ 'multi_field_field_a=%s&multi_field_field_c=%s' % (
409+ fielda_value, fieldc_value))
410+
411+ form = FakeForm(data)
412+
413+ self.assertTrue(form.is_valid())
414+ self.assertEqual(
415+ {
416+ 'field_a': fielda_value,
417+ 'field_b': '',
418+ 'field_c': fieldc_value,
419+ },
420+ form.cleaned_data['multi_field'])
421+
422+ def test_DictCharField_honors_field_constraint(self):
423+ class FakeForm(forms.Form):
424+ multi_field = DictCharField(
425+ [
426+ ('field_a', forms.CharField(label='Field a')),
427+ ('field_b', forms.CharField(
428+ label='Field b', required=False, max_length=3)),
429+ ])
430+
431+ # Create a value that will fail validation because it's too long.
432+ fielda_value = factory.getRandomString(10)
433+ data = QueryDict('multi_field_field_b=%s' % fielda_value)
434+ form = FakeForm(data)
435+
436+ self.assertFalse(form.is_valid())
437+ self.assertEqual(
438+ {'multi_field': [
439+ 'Field a: This field is required.',
440+ 'Field b: Ensure this value has at '
441+ 'most 3 characters (it has 10).']},
442+ form.errors)
443+
444+ def test_DictCharField_skip_check_true_skips_validation(self):
445+ # Create a value that will fail validation because it's too long.
446+ field_name = factory.getRandomString(10)
447+ field_value = factory.getRandomString(10)
448+ # multi_field_skip_check=true will make the form accept the value
449+ # even if it's not valid.
450+ data = QueryDict(
451+ 'multi_field_%s=%s&multi_field_skip_check=true' % (
452+ field_name, field_value))
453+
454+ class FakeFormSkip(forms.Form):
455+ multi_field = DictCharField(
456+ [(field_name, forms.CharField(label='Unused', max_length=3))],
457+ skip_check=True)
458+ form = FakeFormSkip(data)
459+
460+ self.assertTrue(form.is_valid())
461+ self.assertEqual(
462+ {field_name: field_value},
463+ form.cleaned_data['multi_field'])
464+
465+ def test_DictCharField_skip_check_false(self):
466+ # Create a value that will fail validation because it's too long.
467+ field_value = factory.getRandomString(10)
468+ field_name = factory.getRandomString(10)
469+ field_label = factory.getRandomString(10)
470+ # Force the check with multi_field_skip_check=false.
471+ data = QueryDict(
472+ 'multi_field_%s=%s&multi_field_skip_check=false' % (
473+ field_name, field_value))
474+
475+ class FakeFormSkip(forms.Form):
476+ multi_field = DictCharField(
477+ [(field_name, forms.CharField(
478+ label=field_label, max_length=3))],
479+ skip_check=True)
480+ form = FakeFormSkip(data)
481+
482+ self.assertFalse(form.is_valid())
483+ self.assertEqual(
484+ {
485+ 'multi_field': [
486+ "%s: Ensure this value has at most 3 characters "
487+ "(it has 10)." % field_label]
488+ },
489+ form.errors)
490+
491+ def test_DictCharField_accepts_required_false(self):
492+ # A form where the DictCharField instance is constructed with
493+ # required=False.
494+ class FakeFormRequiredFalse(forms.Form):
495+ multi_field = DictCharField(
496+ [('field_a', forms.CharField(label='Field a'))],
497+ required=False)
498+ char_field = forms.CharField(label='Field a')
499+
500+ char_value = factory.getRandomString(10)
501+ data = QueryDict('char_field=%s' % (char_value))
502+ form = FakeFormRequiredFalse(data)
503+ self.assertTrue(form.is_valid())
504+ self.assertEqual(
505+ {'char_field': char_value, 'multi_field': None},
506+ form.cleaned_data)
507+
508+
509+class TestUtilities(TestCase):
510+
511+ def test_get_all_prefixed_values_returns_sub_dict(self):
512+ inputs = [
513+ {'prefix_test': 'a', 'key': 'b', 'prefix_2': 'c'},
514+ {},
515+ {'b': factory.getRandomString()},
516+ ]
517+ prefix = 'prefix_'
518+ expected = [
519+ {'test': 'a', '2': 'c'},
520+ {},
521+ {},
522+ ]
523+ self.assertEqual(
524+ expected,
525+ map(lambda data: get_all_prefixed_values(data, prefix), inputs))
526+
527+
528+class TestDictCharWidget(TestCase):
529+
530+ def test_DictCharWidget_id_for_label_uses_first_fields_name(self):
531+ names = [factory.getRandomString()]
532+ labels = [factory.getRandomString()]
533+ widget = DictCharWidget(
534+ [widgets.TextInput, widgets.TextInput], names, labels)
535+ self.assertEqual(
536+ ' _%s' % names[0],
537+ widget.id_for_label(' '))
538+
539+ def test_DictCharWidget_renders_fieldset_with_label_and_field_names(self):
540+ names = [factory.getRandomString(), factory.getRandomString()]
541+ labels = [factory.getRandomString(), factory.getRandomString()]
542+ values = [factory.getRandomString(), factory.getRandomString()]
543+ widget = DictCharWidget(
544+ [widgets.TextInput, widgets.TextInput], names, labels)
545+ name = factory.getRandomString()
546+ self.assertEqual(
547+ '<fieldset>'
548+ '<label>%s</label>'
549+ '<input type="text" name="%s" value="%s" />'
550+ '<label>%s</label>'
551+ '<input type="text" name="%s" value="%s" />'
552+ '</fieldset>' %
553+ (
554+ labels[0],
555+ '%s_%s' % (name, names[0]), values[0],
556+ labels[1],
557+ '%s_%s' % (name, names[1]), values[1],
558+ ),
559+ widget.render(name, values))
560+
561+ def test_DictCharWidget_value_from_datadict_values_from_data(self):
562+ # 'value_from_datadict' extracts the value of the fields from a
563+ # QueryDict and returns them in the sub widgets' order.
564+ names = [factory.getRandomString(), factory.getRandomString()]
565+ labels = [factory.getRandomString(), factory.getRandomString()]
566+ name = factory.getRandomString()
567+ field_1_value = factory.getRandomString()
568+ field_2_value = factory.getRandomString()
569+ # Create a query string with the field2 before the field1 and another
570+ # (unknown) value.
571+ data = QueryDict(
572+ '%s_%s=%s&%s_%s=%s&%s=%s' % (
573+ name, names[1], field_2_value,
574+ name, names[0], field_1_value,
575+ factory.getRandomString(), factory.getRandomString())
576+ )
577+ widget = DictCharWidget(
578+ [widgets.TextInput, widgets.TextInput], names, labels)
579+ self.assertEqual(
580+ [field_1_value, field_2_value],
581+ widget.value_from_datadict(data, None, name))