diff --git a/django/newforms/fields.py b/django/newforms/fields.py index 3ba4f77c22..e96cb4a2d1 100644 --- a/django/newforms/fields.py +++ b/django/newforms/fields.py @@ -3,7 +3,7 @@ Field classes """ from django.utils.translation import gettext -from util import ValidationError, smart_unicode +from util import ErrorList, ValidationError, smart_unicode from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, CheckboxInput, Select, SelectMultiple import datetime import re @@ -16,7 +16,8 @@ __all__ = ( 'DEFAULT_DATETIME_INPUT_FORMATS', 'DateTimeField', 'RegexField', 'EmailField', 'URLField', 'BooleanField', 'ChoiceField', 'MultipleChoiceField', - 'ComboField', + 'ComboField', 'MultiValueField', + 'SplitDateTimeField', ) # These values, if given to to_python(), will trigger the self.required check. @@ -376,6 +377,9 @@ class MultipleChoiceField(ChoiceField): return new_value class ComboField(Field): + """ + A Field whose clean() method calls multiple Field clean() methods. + """ def __init__(self, fields=(), required=True, widget=None, label=None, initial=None): super(ComboField, self).__init__(required, widget, label, initial) # Set 'required' to False on the individual fields, because the @@ -394,3 +398,84 @@ class ComboField(Field): for field in self.fields: value = field.clean(value) return value + +class MultiValueField(Field): + """ + A Field that is composed of multiple Fields. + + Its clean() method takes a "decompressed" list of values. Each value in + this list is cleaned by the corresponding field -- the first value is + cleaned by the first field, the second value is cleaned by the second + field, etc. Once all fields are cleaned, the list of clean values is + "compressed" into a single value. + + Subclasses should implement compress(), which specifies how a list of + valid values should be converted to a single value. Subclasses should not + have to implement clean(). + + You'll probably want to use this with MultiWidget. + """ + def __init__(self, fields=(), required=True, widget=None, label=None, initial=None): + super(MultiValueField, self).__init__(required, widget, label, initial) + # Set 'required' to False on the individual fields, because the + # required validation will be handled by MultiValueField, not by those + # individual fields. + for f in fields: + f.required = False + self.fields = fields + + def clean(self, value): + """ + Validates every value in the given list. A value is validated against + the corresponding Field in self.fields. + + For example, if this MultiValueField was instantiated with + fields=(DateField(), TimeField()), clean() would call + DateField.clean(value[0]) and TimeField.clean(value[1]). + """ + clean_data = [] + errors = ErrorList() + if self.required and not value: + raise ValidationError(gettext(u'This field is required.')) + elif not self.required and not value: + return self.compress([]) + if not isinstance(value, (list, tuple)): + raise ValidationError(gettext(u'Enter a list of values.')) + for i, field in enumerate(self.fields): + try: + field_value = value[i] + except KeyError: + field_value = None + if self.required and field_value in EMPTY_VALUES: + raise ValidationError(gettext(u'This field is required.')) + try: + clean_data.append(field.clean(field_value)) + except ValidationError, e: + # Collect all validation errors in a single list, which we'll + # raise at the end of clean(), rather than raising a single + # exception for the first error we encounter. + errors.extend(e.messages) + if errors: + raise ValidationError(errors) + return self.compress(clean_data) + + def compress(self, data_list): + """ + Returns a single value for the given list of values. The values can be + assumed to be valid. + + For example, if this MultiValueField was instantiated with + fields=(DateField(), TimeField()), this might return a datetime + object created by combining the date and time in data_list. + """ + raise NotImplementedError('Subclasses must implement this method.') + +class SplitDateTimeField(MultiValueField): + def __init__(self, required=True, widget=None, label=None, initial=None): + fields = (DateField(), TimeField()) + super(SplitDateTimeField, self).__init__(fields, required, widget, label, initial) + + def compress(self, data_list): + if data_list: + return datetime.datetime.combine(*data_list) + return None diff --git a/django/newforms/widgets.py b/django/newforms/widgets.py index e18026485e..2b709d8684 100644 --- a/django/newforms/widgets.py +++ b/django/newforms/widgets.py @@ -6,6 +6,7 @@ __all__ = ( 'Widget', 'TextInput', 'PasswordInput', 'HiddenInput', 'MultipleHiddenInput', 'FileInput', 'Textarea', 'CheckboxInput', 'Select', 'SelectMultiple', 'RadioSelect', 'CheckboxSelectMultiple', + 'MultiWidget', 'SplitDateTimeWidget', ) from util import flatatt, StrAndUnicode, smart_unicode @@ -252,3 +253,66 @@ class CheckboxSelectMultiple(SelectMultiple): id_ += '_0' return id_ id_for_label = classmethod(id_for_label) + +class MultiWidget(Widget): + """ + A widget that is composed of multiple widgets. + + Its render() method takes a "decompressed" list of values, not a single + value. Each value in this list is rendered in the corresponding widget -- + the first value is rendered in the first widget, the second value is + rendered in the second widget, etc. + + Subclasses should implement decompress(), which specifies how a single + value should be converted to a list of values. Subclasses should not + have to implement clean(). + + Subclasses may implement format_output(), which takes the list of rendered + widgets and returns HTML that formats them any way you'd like. + + You'll probably want to use this with MultiValueField. + """ + def __init__(self, widgets, attrs=None): + self.widgets = [isinstance(w, type) and w() or w for w in widgets] + super(MultiWidget, self).__init__(attrs) + + def render(self, name, value, attrs=None): + # value is a list of values, each corresponding to a widget + # in self.widgets. + if not isinstance(value, list): + value = self.decompress(value) + output = [] + for i, widget in enumerate(self.widgets): + try: + widget_value = value[i] + except KeyError: + widget_value = None + output.append(widget.render(name + '_%s' % i, widget_value, attrs)) + return self.format_output(output) + + def value_from_datadict(self, data, name): + return [data.get(name + '_%s' % i) for i in range(len(self.widgets))] + + def format_output(self, rendered_widgets): + return u''.join(rendered_widgets) + + def decompress(self, value): + """ + Returns a list of decompressed values for the given compressed value. + The given value can be assumed to be valid, but not necessarily + non-empty. + """ + raise NotImplementedError('Subclasses must implement this method.') + +class SplitDateTimeWidget(MultiWidget): + """ + A Widget that splits datetime input into two boxes. + """ + def __init__(self, attrs=None): + widgets = (TextInput(attrs=attrs), TextInput(attrs=attrs)) + super(SplitDateTimeWidget, self).__init__(widgets, attrs) + + def decompress(self, value): + if value: + return [value.date(), value.time()] + return [None, None] diff --git a/tests/regressiontests/forms/tests.py b/tests/regressiontests/forms/tests.py index 5f003711dc..389b076ddd 100644 --- a/tests/regressiontests/forms/tests.py +++ b/tests/regressiontests/forms/tests.py @@ -684,6 +684,39 @@ If 'choices' is passed to both the constructor and render(), then they'll both b >>> w.render('nums', ['ŠĐĆŽćžšđ'], choices=[('ŠĐĆŽćžšđ', 'ŠĐabcĆŽćžšđ'), ('ćžšđ', 'abcćžšđ')]) u'' +# MultiWidget ################################################################# + +>>> class MyMultiWidget(MultiWidget): +... def decompress(self, value): +... if value: +... return value.split('__') +... return ['', ''] +... def format_output(self, rendered_widgets): +... return u'
'.join(rendered_widgets) +>>> w = MyMultiWidget(widgets=(TextInput(attrs={'class': 'big'}), TextInput(attrs={'class': 'small'}))) +>>> w.render('name', ['john', 'lennon']) +u'
' +>>> w.render('name', 'john__lennon') +u'
' + +# SplitDateTimeWidget ######################################################### + +>>> w = SplitDateTimeWidget() +>>> w.render('date', '') +u'' +>>> w.render('date', None) +u'' +>>> w.render('date', datetime.datetime(2006, 1, 10, 7, 30)) +u'' +>>> w.render('date', [datetime.date(2006, 1, 10), datetime.time(7, 30)]) +u'' + +You can also pass 'attrs' to the constructor. In this case, the attrs will be +included on both widgets. +>>> w = SplitDateTimeWidget(attrs={'class': 'pretty'}) +>>> w.render('date', datetime.datetime(2006, 1, 10, 7, 30)) +u'' + ########## # Fields # ########## @@ -1536,6 +1569,58 @@ u'' >>> f.clean(None) u'' +# SplitDateTimeField ########################################################## + +>>> f = SplitDateTimeField() +>>> f.clean([datetime.date(2006, 1, 10), datetime.time(7, 30)]) +datetime.datetime(2006, 1, 10, 7, 30) +>>> f.clean(None) +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] +>>> f.clean('') +Traceback (most recent call last): +... +ValidationError: [u'This field is required.'] +>>> f.clean('hello') +Traceback (most recent call last): +... +ValidationError: [u'Enter a list of values.'] +>>> f.clean(['hello', 'there']) +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid date.', u'Enter a valid time.'] +>>> f.clean(['2006-01-10', 'there']) +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid time.'] +>>> f.clean(['hello', '07:30']) +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid date.'] + +>>> f = SplitDateTimeField(required=False) +>>> f.clean([datetime.date(2006, 1, 10), datetime.time(7, 30)]) +datetime.datetime(2006, 1, 10, 7, 30) +>>> f.clean(None) +>>> f.clean('') +>>> f.clean('hello') +Traceback (most recent call last): +... +ValidationError: [u'Enter a list of values.'] +>>> f.clean(['hello', 'there']) +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid date.', u'Enter a valid time.'] +>>> f.clean(['2006-01-10', 'there']) +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid time.'] +>>> f.clean(['hello', '07:30']) +Traceback (most recent call last): +... +ValidationError: [u'Enter a valid date.'] + ######### # Forms # #########