diff options
Diffstat (limited to 'webapp/django/forms')
-rw-r--r-- | webapp/django/forms/__init__.py | 17 | ||||
-rw-r--r-- | webapp/django/forms/extras/__init__.py | 1 | ||||
-rw-r--r-- | webapp/django/forms/extras/widgets.py | 79 | ||||
-rw-r--r-- | webapp/django/forms/fields.py | 837 | ||||
-rw-r--r-- | webapp/django/forms/forms.py | 396 | ||||
-rw-r--r-- | webapp/django/forms/formsets.py | 288 | ||||
-rw-r--r-- | webapp/django/forms/models.py | 558 | ||||
-rw-r--r-- | webapp/django/forms/util.py | 68 | ||||
-rw-r--r-- | webapp/django/forms/widgets.py | 663 |
9 files changed, 2907 insertions, 0 deletions
diff --git a/webapp/django/forms/__init__.py b/webapp/django/forms/__init__.py new file mode 100644 index 0000000000..0d9c68f9e0 --- /dev/null +++ b/webapp/django/forms/__init__.py @@ -0,0 +1,17 @@ +""" +Django validation and HTML form handling. + +TODO: + Default value for field + Field labels + Nestable Forms + FatalValidationError -- short-circuits all other validators on a form + ValidationWarning + "This form field requires foo.js" and form.js_includes() +""" + +from util import ValidationError +from widgets import * +from fields import * +from forms import * +from models import * diff --git a/webapp/django/forms/extras/__init__.py b/webapp/django/forms/extras/__init__.py new file mode 100644 index 0000000000..a7f6a9b3f6 --- /dev/null +++ b/webapp/django/forms/extras/__init__.py @@ -0,0 +1 @@ +from widgets import * diff --git a/webapp/django/forms/extras/widgets.py b/webapp/django/forms/extras/widgets.py new file mode 100644 index 0000000000..ffa7ba2de2 --- /dev/null +++ b/webapp/django/forms/extras/widgets.py @@ -0,0 +1,79 @@ +""" +Extra HTML Widget classes +""" + +import datetime +import re + +from django.forms.widgets import Widget, Select +from django.utils.dates import MONTHS +from django.utils.safestring import mark_safe + +__all__ = ('SelectDateWidget',) + +RE_DATE = re.compile(r'(\d{4})-(\d\d?)-(\d\d?)$') + +class SelectDateWidget(Widget): + """ + A Widget that splits date input into three <select> boxes. + + This also serves as an example of a Widget that has more than one HTML + element and hence implements value_from_datadict. + """ + month_field = '%s_month' + day_field = '%s_day' + year_field = '%s_year' + + def __init__(self, attrs=None, years=None): + # years is an optional list/tuple of years to use in the "year" select box. + self.attrs = attrs or {} + if years: + self.years = years + else: + this_year = datetime.date.today().year + self.years = range(this_year, this_year+10) + + def render(self, name, value, attrs=None): + try: + year_val, month_val, day_val = value.year, value.month, value.day + except AttributeError: + year_val = month_val = day_val = None + if isinstance(value, basestring): + match = RE_DATE.match(value) + if match: + year_val, month_val, day_val = [int(v) for v in match.groups()] + + output = [] + + if 'id' in self.attrs: + id_ = self.attrs['id'] + else: + id_ = 'id_%s' % name + + month_choices = MONTHS.items() + month_choices.sort() + local_attrs = self.build_attrs(id=self.month_field % id_) + select_html = Select(choices=month_choices).render(self.month_field % name, month_val, local_attrs) + output.append(select_html) + + day_choices = [(i, i) for i in range(1, 32)] + local_attrs['id'] = self.day_field % id_ + select_html = Select(choices=day_choices).render(self.day_field % name, day_val, local_attrs) + output.append(select_html) + + year_choices = [(i, i) for i in self.years] + local_attrs['id'] = self.year_field % id_ + select_html = Select(choices=year_choices).render(self.year_field % name, year_val, local_attrs) + output.append(select_html) + + return mark_safe(u'\n'.join(output)) + + def id_for_label(self, id_): + return '%s_month' % id_ + id_for_label = classmethod(id_for_label) + + def value_from_datadict(self, data, files, name): + y, m, d = data.get(self.year_field % name), data.get(self.month_field % name), data.get(self.day_field % name) + if y and m and d: + return '%s-%s-%s' % (y, m, d) + return data.get(name, None) diff --git a/webapp/django/forms/fields.py b/webapp/django/forms/fields.py new file mode 100644 index 0000000000..ee9b8c62f1 --- /dev/null +++ b/webapp/django/forms/fields.py @@ -0,0 +1,837 @@ +""" +Field classes. +""" + +import copy +import datetime +import os +import re +import time +import urlparse +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +# Python 2.3 fallbacks +try: + from decimal import Decimal, DecimalException +except ImportError: + from django.utils._decimal import Decimal, DecimalException +try: + set +except NameError: + from sets import Set as set + +from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import smart_unicode, smart_str + +from util import ErrorList, ValidationError +from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, DateTimeInput, TimeInput +from django.core.files.uploadedfile import SimpleUploadedFile as UploadedFile + +__all__ = ( + 'Field', 'CharField', 'IntegerField', + 'DEFAULT_DATE_INPUT_FORMATS', 'DateField', + 'DEFAULT_TIME_INPUT_FORMATS', 'TimeField', + 'DEFAULT_DATETIME_INPUT_FORMATS', 'DateTimeField', 'TimeField', + 'RegexField', 'EmailField', 'FileField', 'ImageField', 'URLField', + 'BooleanField', 'NullBooleanField', 'ChoiceField', 'MultipleChoiceField', + 'ComboField', 'MultiValueField', 'FloatField', 'DecimalField', + 'SplitDateTimeField', 'IPAddressField', 'FilePathField', +) + +# These values, if given to to_python(), will trigger the self.required check. +EMPTY_VALUES = (None, '') + + +class Field(object): + widget = TextInput # Default widget to use when rendering this type of Field. + hidden_widget = HiddenInput # Default widget to use when rendering this as "hidden". + default_error_messages = { + 'required': _(u'This field is required.'), + 'invalid': _(u'Enter a valid value.'), + } + + # Tracks each time a Field instance is created. Used to retain order. + creation_counter = 0 + + def __init__(self, required=True, widget=None, label=None, initial=None, + help_text=None, error_messages=None): + # required -- Boolean that specifies whether the field is required. + # True by default. + # widget -- A Widget class, or instance of a Widget class, that should + # be used for this Field when displaying it. Each Field has a + # default Widget that it'll use if you don't specify this. In + # most cases, the default widget is TextInput. + # label -- A verbose name for this field, for use in displaying this + # field in a form. By default, Django will use a "pretty" + # version of the form field name, if the Field is part of a + # Form. + # initial -- A value to use in this Field's initial display. This value + # is *not* used as a fallback if data isn't given. + # help_text -- An optional string to use as "help text" for this Field. + if label is not None: + label = smart_unicode(label) + self.required, self.label, self.initial = required, label, initial + if help_text is None: + self.help_text = u'' + else: + self.help_text = smart_unicode(help_text) + widget = widget or self.widget + if isinstance(widget, type): + widget = widget() + + # Hook into self.widget_attrs() for any Field-specific HTML attributes. + extra_attrs = self.widget_attrs(widget) + if extra_attrs: + widget.attrs.update(extra_attrs) + + self.widget = widget + + # Increase the creation counter, and save our local copy. + self.creation_counter = Field.creation_counter + Field.creation_counter += 1 + + def set_class_error_messages(messages, klass): + for base_class in klass.__bases__: + set_class_error_messages(messages, base_class) + messages.update(getattr(klass, 'default_error_messages', {})) + + messages = {} + set_class_error_messages(messages, self.__class__) + messages.update(error_messages or {}) + self.error_messages = messages + + def clean(self, value): + """ + Validates the given value and returns its "cleaned" value as an + appropriate Python object. + + Raises ValidationError for any errors. + """ + if self.required and value in EMPTY_VALUES: + raise ValidationError(self.error_messages['required']) + return value + + def widget_attrs(self, widget): + """ + Given a Widget instance (*not* a Widget class), returns a dictionary of + any HTML attributes that should be added to the Widget, based on this + Field. + """ + return {} + + def __deepcopy__(self, memo): + result = copy.copy(self) + memo[id(self)] = result + result.widget = copy.deepcopy(self.widget, memo) + return result + +class CharField(Field): + default_error_messages = { + 'max_length': _(u'Ensure this value has at most %(max)d characters (it has %(length)d).'), + 'min_length': _(u'Ensure this value has at least %(min)d characters (it has %(length)d).'), + } + + def __init__(self, max_length=None, min_length=None, *args, **kwargs): + self.max_length, self.min_length = max_length, min_length + super(CharField, self).__init__(*args, **kwargs) + + def clean(self, value): + "Validates max_length and min_length. Returns a Unicode object." + super(CharField, self).clean(value) + if value in EMPTY_VALUES: + return u'' + value = smart_unicode(value) + value_length = len(value) + if self.max_length is not None and value_length > self.max_length: + raise ValidationError(self.error_messages['max_length'] % {'max': self.max_length, 'length': value_length}) + if self.min_length is not None and value_length < self.min_length: + raise ValidationError(self.error_messages['min_length'] % {'min': self.min_length, 'length': value_length}) + return value + + def widget_attrs(self, widget): + if self.max_length is not None and isinstance(widget, (TextInput, PasswordInput)): + # The HTML attribute is maxlength, not max_length. + return {'maxlength': str(self.max_length)} + +class IntegerField(Field): + default_error_messages = { + 'invalid': _(u'Enter a whole number.'), + 'max_value': _(u'Ensure this value is less than or equal to %s.'), + 'min_value': _(u'Ensure this value is greater than or equal to %s.'), + } + + def __init__(self, max_value=None, min_value=None, *args, **kwargs): + self.max_value, self.min_value = max_value, min_value + super(IntegerField, self).__init__(*args, **kwargs) + + def clean(self, value): + """ + Validates that int() can be called on the input. Returns the result + of int(). Returns None for empty values. + """ + super(IntegerField, self).clean(value) + if value in EMPTY_VALUES: + return None + try: + value = int(str(value)) + except (ValueError, TypeError): + raise ValidationError(self.error_messages['invalid']) + if self.max_value is not None and value > self.max_value: + raise ValidationError(self.error_messages['max_value'] % self.max_value) + if self.min_value is not None and value < self.min_value: + raise ValidationError(self.error_messages['min_value'] % self.min_value) + return value + +class FloatField(Field): + default_error_messages = { + 'invalid': _(u'Enter a number.'), + 'max_value': _(u'Ensure this value is less than or equal to %s.'), + 'min_value': _(u'Ensure this value is greater than or equal to %s.'), + } + + def __init__(self, max_value=None, min_value=None, *args, **kwargs): + self.max_value, self.min_value = max_value, min_value + Field.__init__(self, *args, **kwargs) + + def clean(self, value): + """ + Validates that float() can be called on the input. Returns a float. + Returns None for empty values. + """ + super(FloatField, self).clean(value) + if not self.required and value in EMPTY_VALUES: + return None + try: + value = float(value) + except (ValueError, TypeError): + raise ValidationError(self.error_messages['invalid']) + if self.max_value is not None and value > self.max_value: + raise ValidationError(self.error_messages['max_value'] % self.max_value) + if self.min_value is not None and value < self.min_value: + raise ValidationError(self.error_messages['min_value'] % self.min_value) + return value + +class DecimalField(Field): + default_error_messages = { + 'invalid': _(u'Enter a number.'), + 'max_value': _(u'Ensure this value is less than or equal to %s.'), + 'min_value': _(u'Ensure this value is greater than or equal to %s.'), + 'max_digits': _('Ensure that there are no more than %s digits in total.'), + 'max_decimal_places': _('Ensure that there are no more than %s decimal places.'), + 'max_whole_digits': _('Ensure that there are no more than %s digits before the decimal point.') + } + + def __init__(self, max_value=None, min_value=None, max_digits=None, decimal_places=None, *args, **kwargs): + self.max_value, self.min_value = max_value, min_value + self.max_digits, self.decimal_places = max_digits, decimal_places + Field.__init__(self, *args, **kwargs) + + def clean(self, value): + """ + Validates that the input is a decimal number. Returns a Decimal + instance. Returns None for empty values. Ensures that there are no more + than max_digits in the number, and no more than decimal_places digits + after the decimal point. + """ + super(DecimalField, self).clean(value) + if not self.required and value in EMPTY_VALUES: + return None + value = smart_str(value).strip() + try: + value = Decimal(value) + except DecimalException: + raise ValidationError(self.error_messages['invalid']) + + sign, digittuple, exponent = value.as_tuple() + decimals = abs(exponent) + # digittuple doesn't include any leading zeros. + digits = len(digittuple) + if decimals >= digits: + # We have leading zeros up to or past the decimal point. Count + # everything past the decimal point as a digit. We also add one + # for leading zeros before the decimal point (any number of leading + # whole zeros collapse to one digit). + digits = decimals + 1 + whole_digits = digits - decimals + + if self.max_value is not None and value > self.max_value: + raise ValidationError(self.error_messages['max_value'] % self.max_value) + if self.min_value is not None and value < self.min_value: + raise ValidationError(self.error_messages['min_value'] % self.min_value) + if self.max_digits is not None and digits > self.max_digits: + raise ValidationError(self.error_messages['max_digits'] % self.max_digits) + if self.decimal_places is not None and decimals > self.decimal_places: + raise ValidationError(self.error_messages['max_decimal_places'] % self.decimal_places) + if self.max_digits is not None and self.decimal_places is not None and whole_digits > (self.max_digits - self.decimal_places): + raise ValidationError(self.error_messages['max_whole_digits'] % (self.max_digits - self.decimal_places)) + return value + +DEFAULT_DATE_INPUT_FORMATS = ( + '%Y-%m-%d', '%m/%d/%Y', '%m/%d/%y', # '2006-10-25', '10/25/2006', '10/25/06' + '%b %d %Y', '%b %d, %Y', # 'Oct 25 2006', 'Oct 25, 2006' + '%d %b %Y', '%d %b, %Y', # '25 Oct 2006', '25 Oct, 2006' + '%B %d %Y', '%B %d, %Y', # 'October 25 2006', 'October 25, 2006' + '%d %B %Y', '%d %B, %Y', # '25 October 2006', '25 October, 2006' +) + +class DateField(Field): + default_error_messages = { + 'invalid': _(u'Enter a valid date.'), + } + + def __init__(self, input_formats=None, *args, **kwargs): + super(DateField, self).__init__(*args, **kwargs) + self.input_formats = input_formats or DEFAULT_DATE_INPUT_FORMATS + + def clean(self, value): + """ + Validates that the input can be converted to a date. Returns a Python + datetime.date object. + """ + super(DateField, self).clean(value) + if value in EMPTY_VALUES: + return None + if isinstance(value, datetime.datetime): + return value.date() + if isinstance(value, datetime.date): + return value + for format in self.input_formats: + try: + return datetime.date(*time.strptime(value, format)[:3]) + except ValueError: + continue + raise ValidationError(self.error_messages['invalid']) + +DEFAULT_TIME_INPUT_FORMATS = ( + '%H:%M:%S', # '14:30:59' + '%H:%M', # '14:30' +) + +class TimeField(Field): + widget = TimeInput + default_error_messages = { + 'invalid': _(u'Enter a valid time.') + } + + def __init__(self, input_formats=None, *args, **kwargs): + super(TimeField, self).__init__(*args, **kwargs) + self.input_formats = input_formats or DEFAULT_TIME_INPUT_FORMATS + + def clean(self, value): + """ + Validates that the input can be converted to a time. Returns a Python + datetime.time object. + """ + super(TimeField, self).clean(value) + if value in EMPTY_VALUES: + return None + if isinstance(value, datetime.time): + return value + for format in self.input_formats: + try: + return datetime.time(*time.strptime(value, format)[3:6]) + except ValueError: + continue + raise ValidationError(self.error_messages['invalid']) + +DEFAULT_DATETIME_INPUT_FORMATS = ( + '%Y-%m-%d %H:%M:%S', # '2006-10-25 14:30:59' + '%Y-%m-%d %H:%M', # '2006-10-25 14:30' + '%Y-%m-%d', # '2006-10-25' + '%m/%d/%Y %H:%M:%S', # '10/25/2006 14:30:59' + '%m/%d/%Y %H:%M', # '10/25/2006 14:30' + '%m/%d/%Y', # '10/25/2006' + '%m/%d/%y %H:%M:%S', # '10/25/06 14:30:59' + '%m/%d/%y %H:%M', # '10/25/06 14:30' + '%m/%d/%y', # '10/25/06' +) + +class DateTimeField(Field): + widget = DateTimeInput + default_error_messages = { + 'invalid': _(u'Enter a valid date/time.'), + } + + def __init__(self, input_formats=None, *args, **kwargs): + super(DateTimeField, self).__init__(*args, **kwargs) + self.input_formats = input_formats or DEFAULT_DATETIME_INPUT_FORMATS + + def clean(self, value): + """ + Validates that the input can be converted to a datetime. Returns a + Python datetime.datetime object. + """ + super(DateTimeField, self).clean(value) + if value in EMPTY_VALUES: + return None + if isinstance(value, datetime.datetime): + return value + if isinstance(value, datetime.date): + return datetime.datetime(value.year, value.month, value.day) + if isinstance(value, list): + # Input comes from a SplitDateTimeWidget, for example. So, it's two + # components: date and time. + if len(value) != 2: + raise ValidationError(self.error_messages['invalid']) + value = '%s %s' % tuple(value) + for format in self.input_formats: + try: + return datetime.datetime(*time.strptime(value, format)[:6]) + except ValueError: + continue + raise ValidationError(self.error_messages['invalid']) + +class RegexField(CharField): + def __init__(self, regex, max_length=None, min_length=None, error_message=None, *args, **kwargs): + """ + regex can be either a string or a compiled regular expression object. + error_message is an optional error message to use, if + 'Enter a valid value' is too generic for you. + """ + # error_message is just kept for backwards compatibility: + if error_message: + error_messages = kwargs.get('error_messages') or {} + error_messages['invalid'] = error_message + kwargs['error_messages'] = error_messages + super(RegexField, self).__init__(max_length, min_length, *args, **kwargs) + if isinstance(regex, basestring): + regex = re.compile(regex) + self.regex = regex + + def clean(self, value): + """ + Validates that the input matches the regular expression. Returns a + Unicode object. + """ + value = super(RegexField, self).clean(value) + if value == u'': + return value + if not self.regex.search(value): + raise ValidationError(self.error_messages['invalid']) + return value + +email_re = re.compile( + r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom + r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-011\013\014\016-\177])*"' # quoted-string + r')@(?:[A-Z0-9-]+\.)+[A-Z]{2,6}$', re.IGNORECASE) # domain + +class EmailField(RegexField): + default_error_messages = { + 'invalid': _(u'Enter a valid e-mail address.'), + } + + def __init__(self, max_length=None, min_length=None, *args, **kwargs): + RegexField.__init__(self, email_re, max_length, min_length, *args, + **kwargs) + +try: + from django.conf import settings + URL_VALIDATOR_USER_AGENT = settings.URL_VALIDATOR_USER_AGENT +except ImportError: + # It's OK if Django settings aren't configured. + URL_VALIDATOR_USER_AGENT = 'Django (http://www.djangoproject.com/)' + + +class FileField(Field): + widget = FileInput + default_error_messages = { + 'invalid': _(u"No file was submitted. Check the encoding type on the form."), + 'missing': _(u"No file was submitted."), + 'empty': _(u"The submitted file is empty."), + } + + def __init__(self, *args, **kwargs): + super(FileField, self).__init__(*args, **kwargs) + + def clean(self, data, initial=None): + super(FileField, self).clean(initial or data) + if not self.required and data in EMPTY_VALUES: + return None + elif not data and initial: + return initial + + # UploadedFile objects should have name and size attributes. + try: + file_name = data.name + file_size = data.size + except AttributeError: + raise ValidationError(self.error_messages['invalid']) + + if not file_name: + raise ValidationError(self.error_messages['invalid']) + if not file_size: + raise ValidationError(self.error_messages['empty']) + + return data + +class ImageField(FileField): + default_error_messages = { + 'invalid_image': _(u"Upload a valid image. The file you uploaded was either not an image or a corrupted image."), + } + + def clean(self, data, initial=None): + """ + Checks that the file-upload field data contains a valid image (GIF, JPG, + PNG, possibly others -- whatever the Python Imaging Library supports). + """ + f = super(ImageField, self).clean(data, initial) + if f is None: + return None + elif not data and initial: + return initial + from PIL import Image + + # We need to get a file object for PIL. We might have a path or we might + # have to read the data into memory. + if hasattr(data, 'temporary_file_path'): + file = data.temporary_file_path() + else: + if hasattr(data, 'read'): + file = StringIO(data.read()) + else: + file = StringIO(data['content']) + + try: + # load() is the only method that can spot a truncated JPEG, + # but it cannot be called sanely after verify() + trial_image = Image.open(file) + trial_image.load() + + # Since we're about to use the file again we have to reset the + # file object if possible. + if hasattr(file, 'reset'): + file.reset() + + # verify() is the only method that can spot a corrupt PNG, + # but it must be called immediately after the constructor + trial_image = Image.open(file) + trial_image.verify() + except ImportError: + # Under PyPy, it is possible to import PIL. However, the underlying + # _imaging C module isn't available, so an ImportError will be + # raised. Catch and re-raise. + raise + except Exception: # Python Imaging Library doesn't recognize it as an image + raise ValidationError(self.error_messages['invalid_image']) + if hasattr(f, 'seek') and callable(f.seek): + f.seek(0) + return f + +url_re = re.compile( + r'^https?://' # http:// or https:// + r'(?:(?:[A-Z0-9-]+\.)+[A-Z]{2,6}|' #domain... + r'localhost|' #localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|/\S+)$', re.IGNORECASE) + +class URLField(RegexField): + default_error_messages = { + 'invalid': _(u'Enter a valid URL.'), + 'invalid_link': _(u'This URL appears to be a broken link.'), + } + + def __init__(self, max_length=None, min_length=None, verify_exists=False, + validator_user_agent=URL_VALIDATOR_USER_AGENT, *args, **kwargs): + super(URLField, self).__init__(url_re, max_length, min_length, *args, + **kwargs) + self.verify_exists = verify_exists + self.user_agent = validator_user_agent + + def clean(self, value): + # If no URL scheme given, assume http:// + if value and '://' not in value: + value = u'http://%s' % value + # If no URL path given, assume / + if value and not urlparse.urlsplit(value)[2]: + value += '/' + value = super(URLField, self).clean(value) + if value == u'': + return value + if self.verify_exists: + import urllib2 + headers = { + "Accept": "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5", + "Accept-Language": "en-us,en;q=0.5", + "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.7", + "Connection": "close", + "User-Agent": self.user_agent, + } + try: + req = urllib2.Request(value, None, headers) + u = urllib2.urlopen(req) + except ValueError: + raise ValidationError(self.error_messages['invalid']) + except: # urllib2.URLError, httplib.InvalidURL, etc. + raise ValidationError(self.error_messages['invalid_link']) + return value + +class BooleanField(Field): + widget = CheckboxInput + + def clean(self, value): + """Returns a Python boolean object.""" + # Explicitly check for the string 'False', which is what a hidden field + # will submit for False. Because bool("True") == True, we don't need to + # handle that explicitly. + if value == 'False': + value = False + else: + value = bool(value) + super(BooleanField, self).clean(value) + if not value and self.required: + raise ValidationError(self.error_messages['required']) + return value + +class NullBooleanField(BooleanField): + """ + A field whose valid values are None, True and False. Invalid values are + cleaned to None. + """ + widget = NullBooleanSelect + + def clean(self, value): + return {True: True, False: False}.get(value, None) + +class ChoiceField(Field): + widget = Select + default_error_messages = { + '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, + initial=None, help_text=None, *args, **kwargs): + super(ChoiceField, self).__init__(required, widget, label, initial, + help_text, *args, **kwargs) + self.choices = choices + + def _get_choices(self): + return self._choices + + def _set_choices(self, value): + # Setting choices also sets the choices on the widget. + # choices can be any iterable, but we call list() on it because + # it will be consumed more than once. + self._choices = self.widget.choices = list(value) + + choices = property(_get_choices, _set_choices) + + def clean(self, value): + """ + Validates that the input is in self.choices. + """ + value = super(ChoiceField, self).clean(value) + if value in EMPTY_VALUES: + value = u'' + value = smart_unicode(value) + if value == u'': + return value + 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 + default_error_messages = { + 'invalid_choice': _(u'Select a valid choice. %(value)s is not one of the available choices.'), + 'invalid_list': _(u'Enter a list of values.'), + } + + def clean(self, value): + """ + Validates that the input is a list or tuple. + """ + if self.required and not value: + raise ValidationError(self.error_messages['required']) + elif not self.required and not value: + return [] + if not isinstance(value, (list, tuple)): + 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. + for val in new_value: + if not self.valid_value(val): + raise ValidationError(self.error_messages['invalid_choice'] % {'value': val}) + return new_value + +class ComboField(Field): + """ + A Field whose clean() method calls multiple Field clean() methods. + """ + def __init__(self, fields=(), *args, **kwargs): + super(ComboField, self).__init__(*args, **kwargs) + # Set 'required' to False on the individual fields, because the + # required validation will be handled by ComboField, not by those + # individual fields. + for f in fields: + f.required = False + self.fields = fields + + def clean(self, value): + """ + Validates the given value against all of self.fields, which is a + list of Field instances. + """ + super(ComboField, self).clean(value) + for field in self.fields: + value = field.clean(value) + return value + +class MultiValueField(Field): + """ + A Field that aggregates the logic of multiple Fields. + + Its clean() method takes a "decompressed" list of values, which are then + cleaned into a single value according to self.fields. 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 not have to implement clean(). Instead, they must + implement compress(), which takes a list of valid values and returns a + "compressed" version of those values -- a single value. + + You'll probably want to use this with MultiWidget. + """ + default_error_messages = { + 'invalid': _(u'Enter a list of values.'), + } + + def __init__(self, fields=(), *args, **kwargs): + super(MultiValueField, self).__init__(*args, **kwargs) + # 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 not value or isinstance(value, (list, tuple)): + if not value or not [v for v in value if v not in EMPTY_VALUES]: + if self.required: + raise ValidationError(self.error_messages['required']) + else: + return self.compress([]) + else: + raise ValidationError(self.error_messages['invalid']) + for i, field in enumerate(self.fields): + try: + field_value = value[i] + except IndexError: + field_value = None + if self.required and field_value in EMPTY_VALUES: + raise ValidationError(self.error_messages['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 FilePathField(ChoiceField): + def __init__(self, path, match=None, recursive=False, required=True, + widget=None, label=None, initial=None, help_text=None, + *args, **kwargs): + self.path, self.match, self.recursive = path, match, recursive + super(FilePathField, self).__init__(choices=(), required=required, + widget=widget, label=label, initial=initial, help_text=help_text, + *args, **kwargs) + self.choices = [] + if self.match is not None: + self.match_re = re.compile(self.match) + if recursive: + for root, dirs, files in os.walk(self.path): + for f in files: + if self.match is None or self.match_re.search(f): + f = os.path.join(root, f) + self.choices.append((f, f.replace(path, "", 1))) + else: + try: + for f in os.listdir(self.path): + full_file = os.path.join(self.path, f) + if os.path.isfile(full_file) and (self.match is None or self.match_re.search(f)): + self.choices.append((full_file, f)) + except OSError: + pass + self.widget.choices = self.choices + +class SplitDateTimeField(MultiValueField): + default_error_messages = { + 'invalid_date': _(u'Enter a valid date.'), + 'invalid_time': _(u'Enter a valid time.'), + } + + def __init__(self, *args, **kwargs): + errors = self.default_error_messages.copy() + if 'error_messages' in kwargs: + errors.update(kwargs['error_messages']) + fields = ( + DateField(error_messages={'invalid': errors['invalid_date']}), + TimeField(error_messages={'invalid': errors['invalid_time']}), + ) + super(SplitDateTimeField, self).__init__(fields, *args, **kwargs) + + def compress(self, data_list): + if data_list: + # Raise a validation error if time or date is empty + # (possible if SplitDateTimeField has required=False). + if data_list[0] in EMPTY_VALUES: + raise ValidationError(self.error_messages['invalid_date']) + if data_list[1] in EMPTY_VALUES: + raise ValidationError(self.error_messages['invalid_time']) + return datetime.datetime.combine(*data_list) + return None + +ipv4_re = re.compile(r'^(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}$') + +class IPAddressField(RegexField): + default_error_messages = { + 'invalid': _(u'Enter a valid IPv4 address.'), + } + + def __init__(self, *args, **kwargs): + super(IPAddressField, self).__init__(ipv4_re, *args, **kwargs) diff --git a/webapp/django/forms/forms.py b/webapp/django/forms/forms.py new file mode 100644 index 0000000000..753ee254bc --- /dev/null +++ b/webapp/django/forms/forms.py @@ -0,0 +1,396 @@ +""" +Form classes +""" + +from copy import deepcopy + +from django.utils.datastructures import SortedDict +from django.utils.html import escape +from django.utils.encoding import StrAndUnicode, smart_unicode, force_unicode +from django.utils.safestring import mark_safe + +from fields import Field, FileField +from widgets import Media, media_property, TextInput, Textarea +from util import flatatt, ErrorDict, ErrorList, ValidationError + +__all__ = ('BaseForm', 'Form') + +NON_FIELD_ERRORS = '__all__' + +def pretty_name(name): + "Converts 'first_name' to 'First name'" + name = name[0].upper() + name[1:] + return name.replace('_', ' ') + +def get_declared_fields(bases, attrs, with_base_fields=True): + """ + Create a list of form field instances from the passed in 'attrs', plus any + similar fields on the base classes (in 'bases'). This is used by both the + Form and ModelForm metclasses. + + If 'with_base_fields' is True, all fields from the bases are used. + Otherwise, only fields in the 'declared_fields' attribute on the bases are + used. The distinction is useful in ModelForm subclassing. + Also integrates any additional media definitions + """ + fields = [(field_name, attrs.pop(field_name)) for field_name, obj in attrs.items() if isinstance(obj, Field)] + fields.sort(lambda x, y: cmp(x[1].creation_counter, y[1].creation_counter)) + + # If this class is subclassing another Form, add that Form's fields. + # Note that we loop over the bases in *reverse*. This is necessary in + # order to preserve the correct order of fields. + if with_base_fields: + for base in bases[::-1]: + if hasattr(base, 'base_fields'): + fields = base.base_fields.items() + fields + else: + for base in bases[::-1]: + if hasattr(base, 'declared_fields'): + fields = base.declared_fields.items() + fields + + return SortedDict(fields) + +class DeclarativeFieldsMetaclass(type): + """ + Metaclass that converts Field attributes to a dictionary called + 'base_fields', taking into account parent class 'base_fields' as well. + """ + def __new__(cls, name, bases, attrs): + attrs['base_fields'] = get_declared_fields(bases, attrs) + new_class = super(DeclarativeFieldsMetaclass, + cls).__new__(cls, name, bases, attrs) + if 'media' not in attrs: + new_class.media = media_property(new_class) + return new_class + +class BaseForm(StrAndUnicode): + # This is the main implementation of all the Form logic. Note that this + # class is different than Form. See the comments by the Form class for more + # information. Any improvements to the form API should be made to *this* + # class, not to the Form class. + def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, + initial=None, error_class=ErrorList, label_suffix=':', + empty_permitted=False): + self.is_bound = data is not None or files is not None + self.data = data or {} + self.files = files or {} + self.auto_id = auto_id + self.prefix = prefix + self.initial = initial or {} + self.error_class = error_class + self.label_suffix = label_suffix + self.empty_permitted = empty_permitted + self._errors = None # Stores the errors after clean() has been called. + self._changed_data = None + + # The base_fields class attribute is the *class-wide* definition of + # fields. Because a particular *instance* of the class might want to + # alter self.fields, we create self.fields here by copying base_fields. + # Instances should always modify self.fields; they should not modify + # self.base_fields. + self.fields = deepcopy(self.base_fields) + + def __unicode__(self): + return self.as_table() + + def __iter__(self): + for name, field in self.fields.items(): + yield BoundField(self, field, name) + + def __getitem__(self, name): + "Returns a BoundField with the given name." + try: + field = self.fields[name] + except KeyError: + raise KeyError('Key %r not found in Form' % name) + return BoundField(self, field, name) + + def _get_errors(self): + "Returns an ErrorDict for the data provided for the form" + if self._errors is None: + self.full_clean() + return self._errors + errors = property(_get_errors) + + def is_valid(self): + """ + Returns True if the form has no errors. Otherwise, False. If errors are + being ignored, returns False. + """ + return self.is_bound and not bool(self.errors) + + def add_prefix(self, field_name): + """ + Returns the field name with a prefix appended, if this Form has a + prefix set. + + Subclasses may wish to override. + """ + return self.prefix and ('%s-%s' % (self.prefix, field_name)) or field_name + + def _html_output(self, normal_row, error_row, row_ender, help_text_html, errors_on_separate_row): + "Helper function for outputting HTML. Used by as_table(), as_ul(), as_p()." + top_errors = self.non_field_errors() # Errors that should be displayed above all fields. + output, hidden_fields = [], [] + for name, field in self.fields.items(): + bf = BoundField(self, field, name) + bf_errors = self.error_class([escape(error) for error in bf.errors]) # Escape and cache in local variable. + if bf.is_hidden: + if bf_errors: + top_errors.extend([u'(Hidden field %s) %s' % (name, force_unicode(e)) for e in bf_errors]) + hidden_fields.append(unicode(bf)) + else: + if errors_on_separate_row and bf_errors: + output.append(error_row % force_unicode(bf_errors)) + if bf.label: + label = escape(force_unicode(bf.label)) + # Only add the suffix if the label does not end in + # punctuation. + if self.label_suffix: + if label[-1] not in ':?.!': + label += self.label_suffix + label = bf.label_tag(label) or '' + else: + label = '' + if field.help_text: + help_text = help_text_html % force_unicode(field.help_text) + else: + help_text = u'' + output.append(normal_row % {'errors': force_unicode(bf_errors), 'label': force_unicode(label), 'field': unicode(bf), 'help_text': help_text}) + if top_errors: + output.insert(0, error_row % force_unicode(top_errors)) + if hidden_fields: # Insert any hidden fields in the last row. + str_hidden = u''.join(hidden_fields) + if output: + last_row = output[-1] + # Chop off the trailing row_ender (e.g. '</td></tr>') and + # insert the hidden fields. + output[-1] = last_row[:-len(row_ender)] + str_hidden + row_ender + else: + # If there aren't any rows in the output, just append the + # hidden fields. + output.append(str_hidden) + return mark_safe(u'\n'.join(output)) + + def as_table(self): + "Returns this form rendered as HTML <tr>s -- excluding the <table></table>." + return self._html_output(u'<tr><th>%(label)s</th><td>%(errors)s%(field)s%(help_text)s</td></tr>', u'<tr><td colspan="2">%s</td></tr>', '</td></tr>', u'<br />%s', False) + + def as_ul(self): + "Returns this form rendered as HTML <li>s -- excluding the <ul></ul>." + return self._html_output(u'<li>%(errors)s%(label)s %(field)s%(help_text)s</li>', u'<li>%s</li>', '</li>', u' %s', False) + + def as_p(self): + "Returns this form rendered as HTML <p>s." + return self._html_output(u'<p>%(label)s %(field)s%(help_text)s</p>', u'%s', '</p>', u' %s', True) + + def non_field_errors(self): + """ + Returns an ErrorList of errors that aren't associated with a particular + field -- i.e., from Form.clean(). Returns an empty ErrorList if there + are none. + """ + return self.errors.get(NON_FIELD_ERRORS, self.error_class()) + + def full_clean(self): + """ + Cleans all of self.data and populates self._errors and + self.cleaned_data. + """ + self._errors = ErrorDict() + if not self.is_bound: # Stop further processing. + return + self.cleaned_data = {} + # If the form is permitted to be empty, and none of the form data has + # changed from the initial data, short circuit any validation. + if self.empty_permitted and not self.has_changed(): + return + for name, field in self.fields.items(): + # value_from_datadict() gets the data from the data dictionaries. + # Each widget type knows how to retrieve its own data, because some + # widgets split data over several HTML fields. + value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name)) + try: + if isinstance(field, FileField): + initial = self.initial.get(name, field.initial) + value = field.clean(value, initial) + else: + value = field.clean(value) + self.cleaned_data[name] = value + if hasattr(self, 'clean_%s' % name): + value = getattr(self, 'clean_%s' % name)() + self.cleaned_data[name] = value + except ValidationError, e: + self._errors[name] = e.messages + if name in self.cleaned_data: + del self.cleaned_data[name] + try: + self.cleaned_data = self.clean() + except ValidationError, e: + self._errors[NON_FIELD_ERRORS] = e.messages + if self._errors: + delattr(self, 'cleaned_data') + + def clean(self): + """ + Hook for doing any extra form-wide cleaning after Field.clean() been + called on every field. Any ValidationError raised by this method will + not be associated with a particular field; it will have a special-case + association with the field named '__all__'. + """ + return self.cleaned_data + + def has_changed(self): + """ + Returns True if data differs from initial. + """ + return bool(self.changed_data) + + def _get_changed_data(self): + if self._changed_data is None: + self._changed_data = [] + # XXX: For now we're asking the individual widgets whether or not the + # data has changed. It would probably be more efficient to hash the + # initial data, store it in a hidden field, and compare a hash of the + # submitted data, but we'd need a way to easily get the string value + # for a given field. Right now, that logic is embedded in the render + # method of each widget. + for name, field in self.fields.items(): + prefixed_name = self.add_prefix(name) + data_value = field.widget.value_from_datadict(self.data, self.files, prefixed_name) + initial_value = self.initial.get(name, field.initial) + if field.widget._has_changed(initial_value, data_value): + self._changed_data.append(name) + return self._changed_data + changed_data = property(_get_changed_data) + + def _get_media(self): + """ + Provide a description of all media required to render the widgets on this form + """ + media = Media() + for field in self.fields.values(): + media = media + field.widget.media + return media + media = property(_get_media) + + def is_multipart(self): + """ + Returns True if the form needs to be multipart-encrypted, i.e. it has + FileInput. Otherwise, False. + """ + for field in self.fields.values(): + if field.widget.needs_multipart_form: + return True + return False + +class Form(BaseForm): + "A collection of Fields, plus their associated data." + # This is a separate class from BaseForm in order to abstract the way + # self.fields is specified. This class (Form) is the one that does the + # fancy metaclass stuff purely for the semantic sugar -- it allows one + # to define a form using declarative syntax. + # BaseForm itself has no way of designating self.fields. + __metaclass__ = DeclarativeFieldsMetaclass + +class BoundField(StrAndUnicode): + "A Field plus data" + def __init__(self, form, field, name): + self.form = form + self.field = field + self.name = name + self.html_name = form.add_prefix(name) + if self.field.label is None: + self.label = pretty_name(name) + else: + self.label = self.field.label + self.help_text = field.help_text or '' + + def __unicode__(self): + """Renders this field as an HTML widget.""" + return self.as_widget() + + def _errors(self): + """ + Returns an ErrorList for this field. Returns an empty ErrorList + if there are none. + """ + return self.form.errors.get(self.name, self.form.error_class()) + errors = property(_errors) + + def as_widget(self, widget=None, attrs=None): + """ + Renders the field by rendering the passed widget, adding any HTML + attributes passed as attrs. If no widget is specified, then the + field's default widget will be used. + """ + if not widget: + widget = self.field.widget + attrs = attrs or {} + auto_id = self.auto_id + if auto_id and 'id' not in attrs and 'id' not in widget.attrs: + attrs['id'] = auto_id + if not self.form.is_bound: + data = self.form.initial.get(self.name, self.field.initial) + if callable(data): + data = data() + else: + data = self.data + return widget.render(self.html_name, data, attrs=attrs) + + def as_text(self, attrs=None): + """ + Returns a string of HTML for representing this as an <input type="text">. + """ + return self.as_widget(TextInput(), attrs) + + def as_textarea(self, attrs=None): + "Returns a string of HTML for representing this as a <textarea>." + return self.as_widget(Textarea(), attrs) + + def as_hidden(self, attrs=None): + """ + Returns a string of HTML for representing this as an <input type="hidden">. + """ + return self.as_widget(self.field.hidden_widget(), attrs) + + def _data(self): + """ + Returns the data for this BoundField, or None if it wasn't given. + """ + return self.field.widget.value_from_datadict(self.form.data, self.form.files, self.html_name) + data = property(_data) + + def label_tag(self, contents=None, attrs=None): + """ + Wraps the given contents in a <label>, if the field has an ID attribute. + Does not HTML-escape the contents. If contents aren't given, uses the + field's HTML-escaped label. + + If attrs are given, they're used as HTML attributes on the <label> tag. + """ + contents = contents or escape(self.label) + widget = self.field.widget + id_ = widget.attrs.get('id') or self.auto_id + if id_: + attrs = attrs and flatatt(attrs) or '' + contents = '<label for="%s"%s>%s</label>' % (widget.id_for_label(id_), attrs, contents) + return mark_safe(contents) + + def _is_hidden(self): + "Returns True if this BoundField's widget is hidden." + return self.field.widget.is_hidden + is_hidden = property(_is_hidden) + + def _auto_id(self): + """ + Calculates and returns the ID attribute for this BoundField, if the + associated Form has specified auto_id. Returns an empty string otherwise. + """ + auto_id = self.form.auto_id + if auto_id and '%s' in smart_unicode(auto_id): + return smart_unicode(auto_id) % self.html_name + elif auto_id: + return self.html_name + return '' + auto_id = property(_auto_id) diff --git a/webapp/django/forms/formsets.py b/webapp/django/forms/formsets.py new file mode 100644 index 0000000000..2f13bf5fed --- /dev/null +++ b/webapp/django/forms/formsets.py @@ -0,0 +1,288 @@ +from forms import Form +from django.utils.encoding import StrAndUnicode +from django.utils.safestring import mark_safe +from fields import IntegerField, BooleanField +from widgets import Media, HiddenInput +from util import ErrorList, ValidationError + +__all__ = ('BaseFormSet', 'all_valid') + +# special field names +TOTAL_FORM_COUNT = 'TOTAL_FORMS' +INITIAL_FORM_COUNT = 'INITIAL_FORMS' +ORDERING_FIELD_NAME = 'ORDER' +DELETION_FIELD_NAME = 'DELETE' + +class ManagementForm(Form): + """ + ``ManagementForm`` is used to keep track of how many form instances + are displayed on the page. If adding new forms via javascript, you should + increment the count field of this form as well. + """ + def __init__(self, *args, **kwargs): + self.base_fields[TOTAL_FORM_COUNT] = IntegerField(widget=HiddenInput) + self.base_fields[INITIAL_FORM_COUNT] = IntegerField(widget=HiddenInput) + super(ManagementForm, self).__init__(*args, **kwargs) + +class BaseFormSet(StrAndUnicode): + """ + A collection of instances of the same Form class. + """ + def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, + initial=None, error_class=ErrorList): + self.is_bound = data is not None or files is not None + self.prefix = prefix or 'form' + self.auto_id = auto_id + self.data = data + self.files = files + self.initial = initial + self.error_class = error_class + self._errors = None + self._non_form_errors = None + # initialization is different depending on whether we recieved data, initial, or nothing + if data or files: + self.management_form = ManagementForm(data, auto_id=self.auto_id, prefix=self.prefix) + if self.management_form.is_valid(): + self._total_form_count = self.management_form.cleaned_data[TOTAL_FORM_COUNT] + self._initial_form_count = self.management_form.cleaned_data[INITIAL_FORM_COUNT] + else: + raise ValidationError('ManagementForm data is missing or has been tampered with') + else: + if initial: + self._initial_form_count = len(initial) + if self._initial_form_count > self.max_num and self.max_num > 0: + self._initial_form_count = self.max_num + self._total_form_count = self._initial_form_count + self.extra + else: + self._initial_form_count = 0 + self._total_form_count = self.extra + if self._total_form_count > self.max_num and self.max_num > 0: + self._total_form_count = self.max_num + initial = {TOTAL_FORM_COUNT: self._total_form_count, + INITIAL_FORM_COUNT: self._initial_form_count} + self.management_form = ManagementForm(initial=initial, auto_id=self.auto_id, prefix=self.prefix) + + # construct the forms in the formset + self._construct_forms() + + def __unicode__(self): + return self.as_table() + + def _construct_forms(self): + # instantiate all the forms and put them in self.forms + self.forms = [] + for i in xrange(self._total_form_count): + self.forms.append(self._construct_form(i)) + + def _construct_form(self, i, **kwargs): + """ + Instantiates and returns the i-th form instance in a formset. + """ + defaults = {'auto_id': self.auto_id, 'prefix': self.add_prefix(i)} + if self.data or self.files: + defaults['data'] = self.data + defaults['files'] = self.files + if self.initial: + try: + defaults['initial'] = self.initial[i] + except IndexError: + pass + # Allow extra forms to be empty. + if i >= self._initial_form_count: + defaults['empty_permitted'] = True + defaults.update(kwargs) + form = self.form(**defaults) + self.add_fields(form, i) + return form + + def _get_initial_forms(self): + """Return a list of all the intial forms in this formset.""" + return self.forms[:self._initial_form_count] + initial_forms = property(_get_initial_forms) + + def _get_extra_forms(self): + """Return a list of all the extra forms in this formset.""" + return self.forms[self._initial_form_count:] + extra_forms = property(_get_extra_forms) + + # Maybe this should just go away? + def _get_cleaned_data(self): + """ + Returns a list of form.cleaned_data dicts for every form in self.forms. + """ + if not self.is_valid(): + raise AttributeError("'%s' object has no attribute 'cleaned_data'" % self.__class__.__name__) + return [form.cleaned_data for form in self.forms] + cleaned_data = property(_get_cleaned_data) + + def _get_deleted_forms(self): + """ + Returns a list of forms that have been marked for deletion. Raises an + AttributeError is deletion is not allowed. + """ + if not self.is_valid() or not self.can_delete: + raise AttributeError("'%s' object has no attribute 'deleted_forms'" % self.__class__.__name__) + # construct _deleted_form_indexes which is just a list of form indexes + # that have had their deletion widget set to True + if not hasattr(self, '_deleted_form_indexes'): + self._deleted_form_indexes = [] + for i in range(0, self._total_form_count): + form = self.forms[i] + # if this is an extra form and hasn't changed, don't consider it + if i >= self._initial_form_count and not form.has_changed(): + continue + if form.cleaned_data[DELETION_FIELD_NAME]: + self._deleted_form_indexes.append(i) + return [self.forms[i] for i in self._deleted_form_indexes] + deleted_forms = property(_get_deleted_forms) + + def _get_ordered_forms(self): + """ + Returns a list of form in the order specified by the incoming data. + Raises an AttributeError is deletion is not allowed. + """ + if not self.is_valid() or not self.can_order: + raise AttributeError("'%s' object has no attribute 'ordered_forms'" % self.__class__.__name__) + # Construct _ordering, which is a list of (form_index, order_field_value) + # tuples. After constructing this list, we'll sort it by order_field_value + # so we have a way to get to the form indexes in the order specified + # by the form data. + if not hasattr(self, '_ordering'): + self._ordering = [] + for i in range(0, self._total_form_count): + form = self.forms[i] + # if this is an extra form and hasn't changed, don't consider it + if i >= self._initial_form_count and not form.has_changed(): + continue + # don't add data marked for deletion to self.ordered_data + if self.can_delete and form.cleaned_data[DELETION_FIELD_NAME]: + continue + # A sort function to order things numerically ascending, but + # None should be sorted below anything else. Allowing None as + # a comparison value makes it so we can leave ordering fields + # blamk. + def compare_ordering_values(x, y): + if x[1] is None: + return 1 + if y[1] is None: + return -1 + return x[1] - y[1] + self._ordering.append((i, form.cleaned_data[ORDERING_FIELD_NAME])) + # After we're done populating self._ordering, sort it. + self._ordering.sort(compare_ordering_values) + # Return a list of form.cleaned_data dicts in the order spcified by + # the form data. + return [self.forms[i[0]] for i in self._ordering] + ordered_forms = property(_get_ordered_forms) + + def non_form_errors(self): + """ + Returns an ErrorList of errors that aren't associated with a particular + form -- i.e., from formset.clean(). Returns an empty ErrorList if there + are none. + """ + if self._non_form_errors is not None: + return self._non_form_errors + return self.error_class() + + def _get_errors(self): + """ + Returns a list of form.errors for every form in self.forms. + """ + if self._errors is None: + self.full_clean() + return self._errors + errors = property(_get_errors) + + def is_valid(self): + """ + Returns True if form.errors is empty for every form in self.forms. + """ + if not self.is_bound: + return False + # We loop over every form.errors here rather than short circuiting on the + # first failure to make sure validation gets triggered for every form. + forms_valid = True + for errors in self.errors: + if bool(errors): + forms_valid = False + return forms_valid and not bool(self.non_form_errors()) + + def full_clean(self): + """ + Cleans all of self.data and populates self._errors. + """ + self._errors = [] + if not self.is_bound: # Stop further processing. + return + for i in range(0, self._total_form_count): + form = self.forms[i] + self._errors.append(form.errors) + # Give self.clean() a chance to do cross-form validation. + try: + self.clean() + except ValidationError, e: + self._non_form_errors = e.messages + + def clean(self): + """ + Hook for doing any extra formset-wide cleaning after Form.clean() has + been called on every form. Any ValidationError raised by this method + will not be associated with a particular form; it will be accesible + via formset.non_form_errors() + """ + pass + + def add_fields(self, form, index): + """A hook for adding extra fields on to each form instance.""" + if self.can_order: + # Only pre-fill the ordering field for initial forms. + if index < self._initial_form_count: + form.fields[ORDERING_FIELD_NAME] = IntegerField(label='Order', initial=index+1, required=False) + else: + form.fields[ORDERING_FIELD_NAME] = IntegerField(label='Order', required=False) + if self.can_delete: + form.fields[DELETION_FIELD_NAME] = BooleanField(label='Delete', required=False) + + def add_prefix(self, index): + return '%s-%s' % (self.prefix, index) + + def is_multipart(self): + """ + Returns True if the formset needs to be multipart-encrypted, i.e. it + has FileInput. Otherwise, False. + """ + return self.forms[0].is_multipart() + + def _get_media(self): + # All the forms on a FormSet are the same, so you only need to + # interrogate the first form for media. + if self.forms: + return self.forms[0].media + else: + return Media() + media = property(_get_media) + + def as_table(self): + "Returns this formset rendered as HTML <tr>s -- excluding the <table></table>." + # XXX: there is no semantic division between forms here, there + # probably should be. It might make sense to render each form as a + # table row with each field as a td. + forms = u' '.join([form.as_table() for form in self.forms]) + return mark_safe(u'\n'.join([unicode(self.management_form), forms])) + +def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, + can_delete=False, max_num=0): + """Return a FormSet for the given form class.""" + attrs = {'form': form, 'extra': extra, + 'can_order': can_order, 'can_delete': can_delete, + 'max_num': max_num} + return type(form.__name__ + 'FormSet', (formset,), attrs) + +def all_valid(formsets): + """Returns true if every formset in formsets is valid.""" + valid = True + for formset in formsets: + if not formset.is_valid(): + valid = False + return valid diff --git a/webapp/django/forms/models.py b/webapp/django/forms/models.py new file mode 100644 index 0000000000..677556d91b --- /dev/null +++ b/webapp/django/forms/models.py @@ -0,0 +1,558 @@ +""" +Helper functions for creating Form classes from Django models +and database field objects. +""" + +from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import smart_unicode +from django.utils.datastructures import SortedDict + +from util import ValidationError, ErrorList +from forms import BaseForm, get_declared_fields +from fields import Field, ChoiceField, IntegerField, EMPTY_VALUES +from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput +from widgets import media_property +from formsets import BaseFormSet, formset_factory, DELETION_FIELD_NAME + +__all__ = ( + 'ModelForm', 'BaseModelForm', 'model_to_dict', 'fields_for_model', + 'save_instance', 'form_for_fields', 'ModelChoiceField', + 'ModelMultipleChoiceField', +) + +def save_instance(form, instance, fields=None, fail_message='saved', + commit=True): + """ + Saves bound Form ``form``'s cleaned_data into model instance ``instance``. + + If commit=True, then the changes to ``instance`` will be saved to the + database. Returns ``instance``. + """ + from django.db import models + opts = instance._meta + if form.errors: + raise ValueError("The %s could not be %s because the data didn't" + " validate." % (opts.object_name, fail_message)) + cleaned_data = form.cleaned_data + for f in opts.fields: + if not f.editable or isinstance(f, models.AutoField) \ + or not f.name in cleaned_data: + continue + if fields and f.name not in fields: + continue + f.save_form_data(instance, cleaned_data[f.name]) + # Wrap up the saving of m2m data as a function. + def save_m2m(): + opts = instance._meta + cleaned_data = form.cleaned_data + for f in opts.many_to_many: + if fields and f.name not in fields: + continue + if f.name in cleaned_data: + f.save_form_data(instance, cleaned_data[f.name]) + if commit: + # If we are committing, save the instance and the m2m data immediately. + instance.save() + save_m2m() + else: + # We're not committing. Add a method to the form to allow deferred + # saving of m2m data. + form.save_m2m = save_m2m + return instance + +def make_model_save(model, fields, fail_message): + """Returns the save() method for a Form.""" + def save(self, commit=True): + return save_instance(self, model(), fields, fail_message, commit) + return save + +def make_instance_save(instance, fields, fail_message): + """Returns the save() method for a Form.""" + def save(self, commit=True): + return save_instance(self, instance, fields, fail_message, commit) + return save + +def form_for_fields(field_list): + """ + Returns a Form class for the given list of Django database field instances. + """ + fields = SortedDict([(f.name, f.formfield()) + for f in field_list if f.editable]) + return type('FormForFields', (BaseForm,), {'base_fields': fields}) + + +# ModelForms ################################################################# + +def model_to_dict(instance, fields=None, exclude=None): + """ + Returns a dict containing the data in ``instance`` suitable for passing as + a Form's ``initial`` keyword argument. + + ``fields`` is an optional list of field names. If provided, only the named + fields will be included in the returned dict. + + ``exclude`` is an optional list of field names. If provided, the named + fields will be excluded from the returned dict, even if they are listed in + the ``fields`` argument. + """ + # avoid a circular import + from django.db.models.fields.related import ManyToManyField, OneToOneField + opts = instance._meta + data = {} + for f in opts.fields + opts.many_to_many: + if not f.editable: + continue + if fields and not f.name in fields: + continue + if exclude and f.name in exclude: + continue + if isinstance(f, ManyToManyField): + # If the object doesn't have a primry key yet, just use an empty + # list for its m2m fields. Calling f.value_from_object will raise + # an exception. + if instance.pk is None: + data[f.name] = [] + else: + # MultipleChoiceWidget needs a list of pks, not object instances. + data[f.name] = [obj.pk for obj in f.value_from_object(instance)] + elif isinstance(f, OneToOneField): + data[f.attname] = f.value_from_object(instance) + else: + data[f.name] = f.value_from_object(instance) + return data + +def fields_for_model(model, fields=None, exclude=None, formfield_callback=lambda f: f.formfield()): + """ + Returns a ``SortedDict`` containing form fields for the given model. + + ``fields`` is an optional list of field names. If provided, only the named + fields will be included in the returned fields. + + ``exclude`` is an optional list of field names. If provided, the named + fields will be excluded from the returned fields, even if they are listed + in the ``fields`` argument. + """ + # TODO: if fields is provided, it would be nice to return fields in that order + field_list = [] + opts = model._meta + for f in opts.fields + opts.many_to_many: + if not f.editable: + continue + if fields and not f.name in fields: + continue + if exclude and f.name in exclude: + continue + formfield = formfield_callback(f) + if formfield: + field_list.append((f.name, formfield)) + return SortedDict(field_list) + +class ModelFormOptions(object): + def __init__(self, options=None): + self.model = getattr(options, 'model', None) + self.fields = getattr(options, 'fields', None) + self.exclude = getattr(options, 'exclude', None) + + +class ModelFormMetaclass(type): + def __new__(cls, name, bases, attrs): + formfield_callback = attrs.pop('formfield_callback', + lambda f: f.formfield()) + try: + parents = [b for b in bases if issubclass(b, ModelForm)] + except NameError: + # We are defining ModelForm itself. + parents = None + new_class = super(ModelFormMetaclass, cls).__new__(cls, name, bases, + attrs) + if not parents: + return new_class + + if 'media' not in attrs: + new_class.media = media_property(new_class) + declared_fields = get_declared_fields(bases, attrs, False) + opts = new_class._meta = ModelFormOptions(getattr(new_class, 'Meta', None)) + if opts.model: + # If a model is defined, extract form fields from it. + fields = fields_for_model(opts.model, opts.fields, + opts.exclude, formfield_callback) + # Override default model fields with any custom declared ones + # (plus, include all the other declared fields). + fields.update(declared_fields) + else: + fields = declared_fields + new_class.declared_fields = declared_fields + new_class.base_fields = fields + return new_class + +class BaseModelForm(BaseForm): + def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, + initial=None, error_class=ErrorList, label_suffix=':', + empty_permitted=False, instance=None): + opts = self._meta + if instance is None: + # if we didn't get an instance, instantiate a new one + self.instance = opts.model() + object_data = {} + else: + self.instance = instance + object_data = model_to_dict(instance, opts.fields, opts.exclude) + # if initial was provided, it should override the values from instance + if initial is not None: + object_data.update(initial) + super(BaseModelForm, self).__init__(data, files, auto_id, prefix, object_data, + error_class, label_suffix, empty_permitted) + + def save(self, commit=True): + """ + Saves this ``form``'s cleaned_data into model instance + ``self.instance``. + + If commit=True, then the changes to ``instance`` will be saved to the + database. Returns ``instance``. + """ + if self.instance.pk is None: + fail_message = 'created' + else: + fail_message = 'changed' + return save_instance(self, self.instance, self._meta.fields, fail_message, commit) + +class ModelForm(BaseModelForm): + __metaclass__ = ModelFormMetaclass + +def modelform_factory(model, form=ModelForm, fields=None, exclude=None, + formfield_callback=lambda f: f.formfield()): + # HACK: we should be able to construct a ModelForm without creating + # and passing in a temporary inner class + class Meta: + pass + setattr(Meta, 'model', model) + setattr(Meta, 'fields', fields) + setattr(Meta, 'exclude', exclude) + class_name = model.__name__ + 'Form' + return ModelFormMetaclass(class_name, (form,), {'Meta': Meta, + 'formfield_callback': formfield_callback}) + + +# ModelFormSets ############################################################## + +class BaseModelFormSet(BaseFormSet): + """ + A ``FormSet`` for editing a queryset and/or adding new objects to it. + """ + model = None + + def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, + queryset=None, **kwargs): + self.queryset = queryset + defaults = {'data': data, 'files': files, 'auto_id': auto_id, 'prefix': prefix} + if self.max_num > 0: + qs = self.get_queryset()[:self.max_num] + else: + qs = self.get_queryset() + defaults['initial'] = [model_to_dict(obj) for obj in qs] + defaults.update(kwargs) + super(BaseModelFormSet, self).__init__(**defaults) + + def get_queryset(self): + if self.queryset is not None: + return self.queryset + return self.model._default_manager.get_query_set() + + def save_new(self, form, commit=True): + """Saves and returns a new model instance for the given form.""" + return save_instance(form, self.model(), commit=commit) + + def save_existing(self, form, instance, commit=True): + """Saves and returns an existing model instance for the given form.""" + return save_instance(form, instance, commit=commit) + + def save(self, commit=True): + """Saves model instances for every form, adding and changing instances + as necessary, and returns the list of instances. + """ + if not commit: + self.saved_forms = [] + def save_m2m(): + for form in self.saved_forms: + form.save_m2m() + self.save_m2m = save_m2m + return self.save_existing_objects(commit) + self.save_new_objects(commit) + + def save_existing_objects(self, commit=True): + self.changed_objects = [] + self.deleted_objects = [] + if not self.get_queryset(): + return [] + + # Put the objects from self.get_queryset into a dict so they are easy to lookup by pk + existing_objects = {} + for obj in self.get_queryset(): + existing_objects[obj.pk] = obj + saved_instances = [] + for form in self.initial_forms: + obj = existing_objects[form.cleaned_data[self.model._meta.pk.attname]] + if self.can_delete and form.cleaned_data[DELETION_FIELD_NAME]: + self.deleted_objects.append(obj) + obj.delete() + else: + if form.changed_data: + self.changed_objects.append((obj, form.changed_data)) + saved_instances.append(self.save_existing(form, obj, commit=commit)) + if not commit: + self.saved_forms.append(form) + return saved_instances + + def save_new_objects(self, commit=True): + self.new_objects = [] + for form in self.extra_forms: + if not form.has_changed(): + continue + # If someone has marked an add form for deletion, don't save the + # object. + if self.can_delete and form.cleaned_data[DELETION_FIELD_NAME]: + continue + self.new_objects.append(self.save_new(form, commit=commit)) + if not commit: + self.saved_forms.append(form) + return self.new_objects + + def add_fields(self, form, index): + """Add a hidden field for the object's primary key.""" + if self.model._meta.pk.auto_created: + self._pk_field_name = self.model._meta.pk.attname + form.fields[self._pk_field_name] = IntegerField(required=False, widget=HiddenInput) + super(BaseModelFormSet, self).add_fields(form, index) + +def modelformset_factory(model, form=ModelForm, formfield_callback=lambda f: f.formfield(), + formset=BaseModelFormSet, + extra=1, can_delete=False, can_order=False, + max_num=0, fields=None, exclude=None): + """ + Returns a FormSet class for the given Django model class. + """ + form = modelform_factory(model, form=form, fields=fields, exclude=exclude, + formfield_callback=formfield_callback) + FormSet = formset_factory(form, formset, extra=extra, max_num=max_num, + can_order=can_order, can_delete=can_delete) + FormSet.model = model + return FormSet + + +# InlineFormSets ############################################################# + +class BaseInlineFormSet(BaseModelFormSet): + """A formset for child objects related to a parent.""" + def __init__(self, data=None, files=None, instance=None, + save_as_new=False, prefix=None): + from django.db.models.fields.related import RelatedObject + self.instance = instance + self.save_as_new = save_as_new + # is there a better way to get the object descriptor? + self.rel_name = RelatedObject(self.fk.rel.to, self.model, self.fk).get_accessor_name() + super(BaseInlineFormSet, self).__init__(data, files, prefix=prefix or self.rel_name) + + def _construct_forms(self): + if self.save_as_new: + self._total_form_count = self._initial_form_count + self._initial_form_count = 0 + super(BaseInlineFormSet, self)._construct_forms() + + def get_queryset(self): + """ + Returns this FormSet's queryset, but restricted to children of + self.instance + """ + kwargs = {self.fk.name: self.instance} + return self.model._default_manager.filter(**kwargs) + + def save_new(self, form, commit=True): + kwargs = {self.fk.get_attname(): self.instance.pk} + new_obj = self.model(**kwargs) + return save_instance(form, new_obj, commit=commit) + +def _get_foreign_key(parent_model, model, fk_name=None): + """ + Finds and returns the ForeignKey from model to parent if there is one. + If fk_name is provided, assume it is the name of the ForeignKey field. + """ + # avoid circular import + from django.db.models import ForeignKey + opts = model._meta + if fk_name: + fks_to_parent = [f for f in opts.fields if f.name == fk_name] + if len(fks_to_parent) == 1: + fk = fks_to_parent[0] + if not isinstance(fk, ForeignKey) or \ + (fk.rel.to != parent_model and + fk.rel.to not in parent_model._meta.parents.keys()): + raise Exception("fk_name '%s' is not a ForeignKey to %s" % (fk_name, parent_model)) + elif len(fks_to_parent) == 0: + raise Exception("%s has no field named '%s'" % (model, fk_name)) + else: + # Try to discover what the ForeignKey from model to parent_model is + fks_to_parent = [ + f for f in opts.fields + if isinstance(f, ForeignKey) + and (f.rel.to == parent_model + or f.rel.to in parent_model._meta.parents.keys()) + ] + if len(fks_to_parent) == 1: + fk = fks_to_parent[0] + elif len(fks_to_parent) == 0: + raise Exception("%s has no ForeignKey to %s" % (model, parent_model)) + else: + raise Exception("%s has more than 1 ForeignKey to %s" % (model, parent_model)) + return fk + + +def inlineformset_factory(parent_model, model, form=ModelForm, + formset=BaseInlineFormSet, fk_name=None, + fields=None, exclude=None, + extra=3, can_order=False, can_delete=True, max_num=0, + formfield_callback=lambda f: f.formfield()): + """ + Returns an ``InlineFormSet`` for the given kwargs. + + You must provide ``fk_name`` if ``model`` has more than one ``ForeignKey`` + to ``parent_model``. + """ + fk = _get_foreign_key(parent_model, model, fk_name=fk_name) + # let the formset handle object deletion by default + + if exclude is not None: + exclude.append(fk.name) + else: + exclude = [fk.name] + FormSet = modelformset_factory(model, form=form, + formfield_callback=formfield_callback, + formset=formset, + extra=extra, can_delete=can_delete, can_order=can_order, + fields=fields, exclude=exclude, max_num=max_num) + FormSet.fk = fk + return FormSet + + +# Fields ##################################################################### + +class ModelChoiceIterator(object): + def __init__(self, field): + self.field = field + self.queryset = field.queryset + + def __iter__(self): + if self.field.empty_label is not None: + yield (u"", self.field.empty_label) + if self.field.cache_choices: + if self.field.choice_cache is None: + self.field.choice_cache = [ + (obj.pk, self.field.label_from_instance(obj)) + for obj in self.queryset.all() + ] + for choice in self.field.choice_cache: + yield choice + else: + for obj in self.queryset.all(): + yield (obj.pk, self.field.label_from_instance(obj)) + +class ModelChoiceField(ChoiceField): + """A ChoiceField whose choices are a model QuerySet.""" + # This class is a subclass of ChoiceField for purity, but it doesn't + # actually use any of ChoiceField's implementation. + default_error_messages = { + 'invalid_choice': _(u'Select a valid choice. That choice is not one of' + u' the available choices.'), + } + + def __init__(self, queryset, empty_label=u"---------", cache_choices=False, + required=True, widget=None, label=None, initial=None, + help_text=None, *args, **kwargs): + self.empty_label = empty_label + self.cache_choices = cache_choices + + # Call Field instead of ChoiceField __init__() because we don't need + # ChoiceField.__init__(). + Field.__init__(self, required, widget, label, initial, help_text, + *args, **kwargs) + self.queryset = queryset + self.choice_cache = None + + def _get_queryset(self): + return self._queryset + + def _set_queryset(self, queryset): + self._queryset = queryset + self.widget.choices = self.choices + + queryset = property(_get_queryset, _set_queryset) + + # this method will be used to create object labels by the QuerySetIterator. + # Override it to customize the label. + def label_from_instance(self, obj): + """ + This method is used to convert objects into strings; it's used to + generate the labels for the choices presented by this object. Subclasses + can override this method to customize the display of the choices. + """ + return smart_unicode(obj) + + def _get_choices(self): + # If self._choices is set, then somebody must have manually set + # the property self.choices. In this case, just return self._choices. + if hasattr(self, '_choices'): + return self._choices + + # Otherwise, execute the QuerySet in self.queryset to determine the + # choices dynamically. Return a fresh QuerySetIterator that has not been + # consumed. Note that we're instantiating a new QuerySetIterator *each* + # time _get_choices() is called (and, thus, each time self.choices is + # accessed) so that we can ensure the QuerySet has not been consumed. This + # construct might look complicated but it allows for lazy evaluation of + # the queryset. + return ModelChoiceIterator(self) + + choices = property(_get_choices, ChoiceField._set_choices) + + def clean(self, value): + Field.clean(self, value) + if value in EMPTY_VALUES: + return None + try: + value = self.queryset.get(pk=value) + except self.queryset.model.DoesNotExist: + raise ValidationError(self.error_messages['invalid_choice']) + return value + +class ModelMultipleChoiceField(ModelChoiceField): + """A MultipleChoiceField whose choices are a model QuerySet.""" + widget = SelectMultiple + hidden_widget = MultipleHiddenInput + default_error_messages = { + 'list': _(u'Enter a list of values.'), + 'invalid_choice': _(u'Select a valid choice. %s is not one of the' + u' available choices.'), + } + + def __init__(self, queryset, cache_choices=False, required=True, + widget=None, label=None, initial=None, + help_text=None, *args, **kwargs): + super(ModelMultipleChoiceField, self).__init__(queryset, None, + cache_choices, required, widget, label, initial, help_text, + *args, **kwargs) + + def clean(self, value): + if self.required and not value: + raise ValidationError(self.error_messages['required']) + elif not self.required and not value: + return [] + if not isinstance(value, (list, tuple)): + raise ValidationError(self.error_messages['list']) + final_values = [] + for val in value: + try: + obj = self.queryset.get(pk=val) + except self.queryset.model.DoesNotExist: + raise ValidationError(self.error_messages['invalid_choice'] % val) + else: + final_values.append(obj) + return final_values diff --git a/webapp/django/forms/util.py b/webapp/django/forms/util.py new file mode 100644 index 0000000000..3d80ad219f --- /dev/null +++ b/webapp/django/forms/util.py @@ -0,0 +1,68 @@ +from django.utils.html import escape +from django.utils.encoding import smart_unicode, StrAndUnicode, force_unicode +from django.utils.safestring import mark_safe + +def flatatt(attrs): + """ + Convert a dictionary of attributes to a single string. + The returned string will contain a leading space followed by key="value", + XML-style pairs. It is assumed that the keys do not need to be XML-escaped. + If the passed dictionary is empty, then return an empty string. + """ + return u''.join([u' %s="%s"' % (k, escape(v)) for k, v in attrs.items()]) + +class ErrorDict(dict, StrAndUnicode): + """ + A collection of errors that knows how to display itself in various formats. + + The dictionary keys are the field names, and the values are the errors. + """ + def __unicode__(self): + return self.as_ul() + + def as_ul(self): + if not self: return u'' + return mark_safe(u'<ul class="errorlist">%s</ul>' + % ''.join([u'<li>%s%s</li>' % (k, force_unicode(v)) + for k, v in self.items()])) + + def as_text(self): + return u'\n'.join([u'* %s\n%s' % (k, u'\n'.join([u' * %s' % force_unicode(i) for i in v])) for k, v in self.items()]) + +class ErrorList(list, StrAndUnicode): + """ + A collection of errors that knows how to display itself in various formats. + """ + def __unicode__(self): + return self.as_ul() + + def as_ul(self): + if not self: return u'' + return mark_safe(u'<ul class="errorlist">%s</ul>' + % ''.join([u'<li>%s</li>' % force_unicode(e) for e in self])) + + def as_text(self): + if not self: return u'' + return u'\n'.join([u'* %s' % force_unicode(e) for e in self]) + + def __repr__(self): + return repr([force_unicode(e) for e in self]) + +class ValidationError(Exception): + def __init__(self, message): + """ + ValidationError can be passed any object that can be printed (usually + a string) or a list of objects. + """ + if isinstance(message, list): + self.messages = ErrorList([smart_unicode(msg) for msg in message]) + else: + message = smart_unicode(message) + self.messages = ErrorList([message]) + + def __str__(self): + # This is needed because, without a __str__(), printing an exception + # instance would result in this: + # AttributeError: ValidationError instance has no attribute 'args' + # See http://www.python.org/doc/current/tut/node10.html#handling + return repr(self.messages) diff --git a/webapp/django/forms/widgets.py b/webapp/django/forms/widgets.py new file mode 100644 index 0000000000..2e01634571 --- /dev/null +++ b/webapp/django/forms/widgets.py @@ -0,0 +1,663 @@ +""" +HTML Widget classes +""" + +try: + set +except NameError: + from sets import Set as set # Python 2.3 fallback + +import copy +from itertools import chain +from django.conf import settings +from django.utils.datastructures import MultiValueDict, MergeDict +from django.utils.html import escape, conditional_escape +from django.utils.translation import ugettext +from django.utils.encoding import StrAndUnicode, force_unicode +from django.utils.safestring import mark_safe +from django.utils import datetime_safe +from util import flatatt +from urlparse import urljoin + +__all__ = ( + 'Media', 'MediaDefiningClass', 'Widget', 'TextInput', 'PasswordInput', + 'HiddenInput', 'MultipleHiddenInput', + 'FileInput', 'DateTimeInput', 'TimeInput', 'Textarea', 'CheckboxInput', + 'Select', 'NullBooleanSelect', 'SelectMultiple', 'RadioSelect', + 'CheckboxSelectMultiple', 'MultiWidget', 'SplitDateTimeWidget', +) + +MEDIA_TYPES = ('css','js') + +class Media(StrAndUnicode): + def __init__(self, media=None, **kwargs): + if media: + media_attrs = media.__dict__ + else: + media_attrs = kwargs + + self._css = {} + self._js = [] + + for name in MEDIA_TYPES: + getattr(self, 'add_' + name)(media_attrs.get(name, None)) + + # Any leftover attributes must be invalid. + # if media_attrs != {}: + # raise TypeError, "'class Media' has invalid attribute(s): %s" % ','.join(media_attrs.keys()) + + def __unicode__(self): + return self.render() + + def render(self): + return mark_safe(u'\n'.join(chain(*[getattr(self, 'render_' + name)() for name in MEDIA_TYPES]))) + + def render_js(self): + return [u'<script type="text/javascript" src="%s"></script>' % self.absolute_path(path) for path in self._js] + + def render_css(self): + # To keep rendering order consistent, we can't just iterate over items(). + # We need to sort the keys, and iterate over the sorted list. + media = self._css.keys() + media.sort() + return chain(*[ + [u'<link href="%s" type="text/css" media="%s" rel="stylesheet" />' % (self.absolute_path(path), medium) + for path in self._css[medium]] + for medium in media]) + + def absolute_path(self, path): + if path.startswith(u'http://') or path.startswith(u'https://') or path.startswith(u'/'): + return path + return urljoin(settings.MEDIA_URL,path) + + def __getitem__(self, name): + "Returns a Media object that only contains media of the given type" + if name in MEDIA_TYPES: + return Media(**{name: getattr(self, '_' + name)}) + raise KeyError('Unknown media type "%s"' % name) + + def add_js(self, data): + if data: + self._js.extend([path for path in data if path not in self._js]) + + def add_css(self, data): + if data: + for medium, paths in data.items(): + self._css.setdefault(medium, []).extend([path for path in paths if path not in self._css[medium]]) + + def __add__(self, other): + combined = Media() + for name in MEDIA_TYPES: + getattr(combined, 'add_' + name)(getattr(self, '_' + name, None)) + getattr(combined, 'add_' + name)(getattr(other, '_' + name, None)) + return combined + +def media_property(cls): + def _media(self): + # Get the media property of the superclass, if it exists + if hasattr(super(cls, self), 'media'): + base = super(cls, self).media + else: + base = Media() + + # Get the media definition for this class + definition = getattr(cls, 'Media', None) + if definition: + extend = getattr(definition, 'extend', True) + if extend: + if extend == True: + m = base + else: + m = Media() + for medium in extend: + m = m + base[medium] + return m + Media(definition) + else: + return Media(definition) + else: + return base + return property(_media) + +class MediaDefiningClass(type): + "Metaclass for classes that can have media definitions" + def __new__(cls, name, bases, attrs): + new_class = super(MediaDefiningClass, cls).__new__(cls, name, bases, + attrs) + if 'media' not in attrs: + new_class.media = media_property(new_class) + return new_class + +class Widget(object): + __metaclass__ = MediaDefiningClass + is_hidden = False # Determines whether this corresponds to an <input type="hidden">. + needs_multipart_form = False # Determines does this widget need multipart-encrypted form + + def __init__(self, attrs=None): + if attrs is not None: + self.attrs = attrs.copy() + else: + self.attrs = {} + + def __deepcopy__(self, memo): + obj = copy.copy(self) + obj.attrs = self.attrs.copy() + memo[id(self)] = obj + return obj + + def render(self, name, value, attrs=None): + """ + Returns this Widget rendered as HTML, as a Unicode string. + + The 'value' given is not guaranteed to be valid input, so subclass + implementations should program defensively. + """ + raise NotImplementedError + + def build_attrs(self, extra_attrs=None, **kwargs): + "Helper function for building an attribute dictionary." + attrs = dict(self.attrs, **kwargs) + if extra_attrs: + attrs.update(extra_attrs) + return attrs + + def value_from_datadict(self, data, files, name): + """ + Given a dictionary of data and this widget's name, returns the value + of this widget. Returns None if it's not provided. + """ + return data.get(name, None) + + def _has_changed(self, initial, data): + """ + Return True if data differs from initial. + """ + # For purposes of seeing whether something has changed, None is + # the same as an empty string, if the data or inital value we get + # is None, replace it w/ u''. + if data is None: + data_value = u'' + else: + data_value = data + if initial is None: + initial_value = u'' + else: + initial_value = initial + if force_unicode(initial_value) != force_unicode(data_value): + return True + return False + + def id_for_label(self, id_): + """ + Returns the HTML ID attribute of this Widget for use by a <label>, + given the ID of the field. Returns None if no ID is available. + + This hook is necessary because some widgets have multiple HTML + elements and, thus, multiple IDs. In that case, this method should + return an ID value that corresponds to the first ID in the widget's + tags. + """ + return id_ + id_for_label = classmethod(id_for_label) + +class Input(Widget): + """ + Base class for all <input> widgets (except type='checkbox' and + type='radio', which are special). + """ + input_type = None # Subclasses must define this. + + def render(self, name, value, attrs=None): + if value is None: value = '' + final_attrs = self.build_attrs(attrs, type=self.input_type, name=name) + if value != '': + # Only add the 'value' attribute if a value is non-empty. + final_attrs['value'] = force_unicode(value) + return mark_safe(u'<input%s />' % flatatt(final_attrs)) + +class TextInput(Input): + input_type = 'text' + +class PasswordInput(Input): + input_type = 'password' + + def __init__(self, attrs=None, render_value=True): + super(PasswordInput, self).__init__(attrs) + self.render_value = render_value + + def render(self, name, value, attrs=None): + if not self.render_value: value=None + return super(PasswordInput, self).render(name, value, attrs) + +class HiddenInput(Input): + input_type = 'hidden' + is_hidden = True + +class MultipleHiddenInput(HiddenInput): + """ + A widget that handles <input type="hidden"> for fields that have a list + of values. + """ + def __init__(self, attrs=None, choices=()): + super(MultipleHiddenInput, self).__init__(attrs) + # choices can be any iterable + self.choices = choices + + def render(self, name, value, attrs=None, choices=()): + if value is None: value = [] + final_attrs = self.build_attrs(attrs, type=self.input_type, name=name) + return mark_safe(u'\n'.join([(u'<input%s />' % + flatatt(dict(value=force_unicode(v), **final_attrs))) + for v in value])) + + def value_from_datadict(self, data, files, name): + if isinstance(data, (MultiValueDict, MergeDict)): + return data.getlist(name) + return data.get(name, None) + +class FileInput(Input): + input_type = 'file' + needs_multipart_form = True + + def render(self, name, value, attrs=None): + return super(FileInput, self).render(name, None, attrs=attrs) + + def value_from_datadict(self, data, files, name): + "File widgets take data from FILES, not POST" + return files.get(name, None) + + def _has_changed(self, initial, data): + if data is None: + return False + return True + +class Textarea(Widget): + def __init__(self, attrs=None): + # The 'rows' and 'cols' attributes are required for HTML correctness. + self.attrs = {'cols': '40', 'rows': '10'} + if attrs: + self.attrs.update(attrs) + + def render(self, name, value, attrs=None): + if value is None: value = '' + value = force_unicode(value) + final_attrs = self.build_attrs(attrs, name=name) + return mark_safe(u'<textarea%s>%s</textarea>' % (flatatt(final_attrs), + conditional_escape(force_unicode(value)))) + +class DateTimeInput(Input): + input_type = 'text' + format = '%Y-%m-%d %H:%M:%S' # '2006-10-25 14:30:59' + + def __init__(self, attrs=None, format=None): + super(DateTimeInput, self).__init__(attrs) + if format: + self.format = format + + def render(self, name, value, attrs=None): + if value is None: + value = '' + elif hasattr(value, 'strftime'): + value = datetime_safe.new_datetime(value) + value = value.strftime(self.format) + return super(DateTimeInput, self).render(name, value, attrs) + +class TimeInput(Input): + input_type = 'text' + + def render(self, name, value, attrs=None): + if value is None: + value = '' + elif hasattr(value, 'replace'): + value = value.replace(microsecond=0) + return super(TimeInput, self).render(name, value, attrs) + +class CheckboxInput(Widget): + def __init__(self, attrs=None, check_test=bool): + super(CheckboxInput, self).__init__(attrs) + # check_test is a callable that takes a value and returns True + # if the checkbox should be checked for that value. + self.check_test = check_test + + def render(self, name, value, attrs=None): + final_attrs = self.build_attrs(attrs, type='checkbox', name=name) + try: + result = self.check_test(value) + except: # Silently catch exceptions + result = False + if result: + final_attrs['checked'] = 'checked' + if value not in ('', True, False, None): + # Only add the 'value' attribute if a value is non-empty. + final_attrs['value'] = force_unicode(value) + return mark_safe(u'<input%s />' % flatatt(final_attrs)) + + def value_from_datadict(self, data, files, name): + if name not in data: + # A missing value means False because HTML form submission does not + # send results for unselected checkboxes. + return False + return super(CheckboxInput, self).value_from_datadict(data, files, name) + + def _has_changed(self, initial, data): + # Sometimes data or initial could be None or u'' which should be the + # same thing as False. + return bool(initial) != bool(data) + +class Select(Widget): + def __init__(self, attrs=None, choices=()): + super(Select, self).__init__(attrs) + # choices can be any iterable, but we may need to render this widget + # multiple times. Thus, collapse it into a list so it can be consumed + # more than once. + self.choices = list(choices) + + def render(self, name, value, attrs=None, choices=()): + if value is None: value = '' + final_attrs = self.build_attrs(attrs, name=name) + output = [u'<select%s>' % flatatt(final_attrs)] + options = self.render_options(choices, [value]) + if options: + output.append(options) + output.append('</select>') + 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'<option value="%s"%s>%s</option>' % ( + 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'<optgroup label="%s">' % escape(force_unicode(option_value))) + for option in option_label: + output.append(render_option(*option)) + output.append(u'</optgroup>') + 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. + """ + def __init__(self, attrs=None): + choices = ((u'1', ugettext('Unknown')), (u'2', ugettext('Yes')), (u'3', ugettext('No'))) + super(NullBooleanSelect, self).__init__(attrs, choices) + + def render(self, name, value, attrs=None, choices=()): + try: + value = {True: u'2', False: u'3', u'2': u'2', u'3': u'3'}[value] + except KeyError: + value = u'1' + return super(NullBooleanSelect, self).render(name, value, attrs, choices) + + def value_from_datadict(self, data, files, name): + value = data.get(name, None) + return {u'2': True, u'3': False, True: True, False: False}.get(value, None) + + def _has_changed(self, initial, data): + # Sometimes data or initial could be None or u'' which should be the + # same thing as False. + return bool(initial) != bool(data) + +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'<select multiple="multiple"%s>' % flatatt(final_attrs)] + options = self.render_options(choices, value) + if options: + output.append(options) + output.append('</select>') + return mark_safe(u'\n'.join(output)) + + def value_from_datadict(self, data, files, name): + if isinstance(data, (MultiValueDict, MergeDict)): + return data.getlist(name) + return data.get(name, None) + + def _has_changed(self, initial, data): + if initial is None: + initial = [] + if data is None: + data = [] + if len(initial) != len(data): + return True + for value1, value2 in zip(initial, data): + if force_unicode(value1) != force_unicode(value2): + return True + return False + +class RadioInput(StrAndUnicode): + """ + An object used by RadioFieldRenderer that represents a single + <input type='radio'>. + """ + + def __init__(self, name, value, attrs, choice, index): + self.name, self.value = name, value + self.attrs = attrs + self.choice_value = force_unicode(choice[0]) + self.choice_label = force_unicode(choice[1]) + self.index = index + + def __unicode__(self): + if 'id' in self.attrs: + label_for = ' for="%s_%s"' % (self.attrs['id'], self.index) + else: + label_for = '' + choice_label = conditional_escape(force_unicode(self.choice_label)) + return mark_safe(u'<label%s>%s %s</label>' % (label_for, self.tag(), choice_label)) + + def is_checked(self): + return self.value == self.choice_value + + def tag(self): + if 'id' in self.attrs: + self.attrs['id'] = '%s_%s' % (self.attrs['id'], self.index) + final_attrs = dict(self.attrs, type='radio', name=self.name, value=self.choice_value) + if self.is_checked(): + final_attrs['checked'] = 'checked' + return mark_safe(u'<input%s />' % flatatt(final_attrs)) + +class RadioFieldRenderer(StrAndUnicode): + """ + An object used by RadioSelect to enable customization of radio widgets. + """ + + def __init__(self, name, value, attrs, choices): + self.name, self.value, self.attrs = name, value, attrs + self.choices = choices + + def __iter__(self): + for i, choice in enumerate(self.choices): + yield RadioInput(self.name, self.value, self.attrs.copy(), choice, i) + + def __getitem__(self, idx): + choice = self.choices[idx] # Let the IndexError propogate + return RadioInput(self.name, self.value, self.attrs.copy(), choice, idx) + + def __unicode__(self): + return self.render() + + def render(self): + """Outputs a <ul> for this set of radio fields.""" + return mark_safe(u'<ul>\n%s\n</ul>' % u'\n'.join([u'<li>%s</li>' + % force_unicode(w) for w in self])) + +class RadioSelect(Select): + renderer = RadioFieldRenderer + + def __init__(self, *args, **kwargs): + # Override the default renderer if we were passed one. + renderer = kwargs.pop('renderer', None) + if renderer: + self.renderer = renderer + super(RadioSelect, self).__init__(*args, **kwargs) + + def get_renderer(self, name, value, attrs=None, choices=()): + """Returns an instance of the renderer.""" + if value is None: value = '' + str_value = force_unicode(value) # Normalize to string. + final_attrs = self.build_attrs(attrs) + choices = list(chain(self.choices, choices)) + return self.renderer(name, str_value, final_attrs, choices) + + def render(self, name, value, attrs=None, choices=()): + return self.get_renderer(name, value, attrs, choices).render() + + def id_for_label(self, id_): + # RadioSelect is represented by multiple <input type="radio"> fields, + # each of which has a distinct ID. The IDs are made distinct by a "_X" + # suffix, where X is the zero-based index of the radio field. Thus, + # the label for a RadioSelect should reference the first one ('_0'). + if id_: + id_ += '_0' + return id_ + id_for_label = classmethod(id_for_label) + +class CheckboxSelectMultiple(SelectMultiple): + def render(self, name, value, attrs=None, choices=()): + if value is None: value = [] + has_id = attrs and 'id' in attrs + final_attrs = self.build_attrs(attrs, name=name) + output = [u'<ul>'] + # Normalize to strings + str_values = set([force_unicode(v) for v in value]) + for i, (option_value, option_label) in enumerate(chain(self.choices, choices)): + # If an ID attribute was given, add a numeric index as a suffix, + # so that the checkboxes don't all have the same ID attribute. + if has_id: + final_attrs = dict(final_attrs, id='%s_%s' % (attrs['id'], i)) + label_for = u' for="%s"' % final_attrs['id'] + else: + label_for = '' + + cb = CheckboxInput(final_attrs, check_test=lambda value: value in str_values) + option_value = force_unicode(option_value) + rendered_cb = cb.render(name, option_value) + option_label = conditional_escape(force_unicode(option_label)) + output.append(u'<li><label%s>%s %s</label></li>' % (label_for, rendered_cb, option_label)) + output.append(u'</ul>') + return mark_safe(u'\n'.join(output)) + + def id_for_label(self, id_): + # See the comment for RadioSelect.id_for_label() + if id_: + 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 is different than other widgets', because it has to + figure out how to split a single value for display in multiple widgets. + The ``value`` argument can be one of two things: + + * A list. + * A normal value (e.g., a string) that has been "compressed" from + a list of values. + + In the second case -- i.e., if the value is NOT a list -- render() will + first "decompress" the value into a list before rendering it. It does so by + calling the decompress() method, which MultiWidget subclasses must + implement. This method takes a single "compressed" value and returns a + list. + + When render() does its HTML rendering, each value in the list is rendered + with the corresponding widget -- the first value is rendered in the first + widget, the second value is rendered in the second widget, etc. + + Subclasses may implement format_output(), which takes the list of rendered + widgets and returns a string of HTML that formats them any way you'd like. + + You'll probably want to use this class 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 = [] + final_attrs = self.build_attrs(attrs) + id_ = final_attrs.get('id', None) + for i, widget in enumerate(self.widgets): + try: + widget_value = value[i] + except IndexError: + widget_value = None + if id_: + final_attrs = dict(final_attrs, id='%s_%s' % (id_, i)) + output.append(widget.render(name + '_%s' % i, widget_value, final_attrs)) + return mark_safe(self.format_output(output)) + + def id_for_label(self, id_): + # See the comment for RadioSelect.id_for_label() + if id_: + id_ += '_0' + return id_ + id_for_label = classmethod(id_for_label) + + def value_from_datadict(self, data, files, name): + return [widget.value_from_datadict(data, files, name + '_%s' % i) for i, widget in enumerate(self.widgets)] + + def _has_changed(self, initial, data): + if initial is None: + initial = [u'' for x in range(0, len(data))] + else: + initial = self.decompress(initial) + for widget, initial, data in zip(self.widgets, initial, data): + if widget._has_changed(initial, data): + return True + return False + + def format_output(self, rendered_widgets): + """ + Given a list of rendered widgets (as strings), returns a Unicode string + representing the HTML for the whole lot. + + This hook allows you to format the HTML design of the widgets, if + needed. + """ + 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.') + + def _get_media(self): + "Media for a multiwidget is the combination of all media of the subwidgets" + media = Media() + for w in self.widgets: + media = media + w.media + return media + media = property(_get_media) + +class SplitDateTimeWidget(MultiWidget): + """ + A Widget that splits datetime input into two <input type="text"> 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().replace(microsecond=0)] + return [None, None] + |