diff --git a/django/newforms/forms.py b/django/newforms/forms.py index 2b3aa97428..9c4b2702f6 100644 --- a/django/newforms/forms.py +++ b/django/newforms/forms.py @@ -159,6 +159,24 @@ class BaseForm(StrAndUnicode): """ return self.errors.get(NON_FIELD_ERRORS, ErrorList()) + def is_empty(self, exceptions=None): + """ + Returns True if this form has been bound and all fields that aren't + listed in exceptions are empty. + """ + # TODO: This could probably use some optimization + exceptions = exceptions or [] + for name, field in self.fields.items(): + if name in exceptions: + continue + # value_from_datadict() gets the data from the dictionary. + # 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.add_prefix(name)) + if value not in (None, ''): + return False + return True + def full_clean(self): """ Cleans all of self.data and populates self.__errors and self.clean_data. diff --git a/django/newforms/formsets.py b/django/newforms/formsets.py new file mode 100644 index 0000000000..c2649640a0 --- /dev/null +++ b/django/newforms/formsets.py @@ -0,0 +1,154 @@ +from django import newforms as forms + +# special field names +FORM_COUNT_FIELD_NAME = 'COUNT' +ORDERING_FIELD_NAME = 'ORDER' +DELETION_FIELD_NAME = 'DELETE' + +class ManagementForm(forms.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[FORM_COUNT_FIELD_NAME] = forms.IntegerField(widget=forms.HiddenInput) + super(ManagementForm, self).__init__(*args, **kwargs) + +class FormSet(object): + """A collection of instances of the same Form class.""" + + def __init__(self, form_class, data=None, auto_id='id_%s', prefix=None, initial=None): + self.form_class = form_class + self.prefix = prefix or 'form' + self.auto_id = auto_id + # initialization is different depending on whether we recieved data, initial, or nothing + if data: + self.management_form = ManagementForm(data, auto_id=self.auto_id, prefix=self.prefix) + if self.management_form.is_valid(): + form_count = self.management_form.clean_data[FORM_COUNT_FIELD_NAME] + else: + # not sure that ValidationError is the best thing to raise here + raise forms.ValidationError('ManagementForm data is missing or has been tampered with') + self.form_list = self._forms_for_data(data, form_count=form_count) + elif initial: + form_count = len(initial) + self.management_form = ManagementForm(initial={FORM_COUNT_FIELD_NAME: form_count+1}, auto_id=self.auto_id, prefix=self.prefix) + self.form_list = self._forms_for_initial(initial, form_count=form_count) + else: + self.management_form = ManagementForm(initial={FORM_COUNT_FIELD_NAME: 1}, auto_id=self.auto_id, prefix=self.prefix) + self.form_list = self._empty_forms(form_count=1) + + # TODO: initialization needs some cleanup and some restructuring + # TODO: allow more than 1 extra blank form to be displayed + + def _forms_for_data(self, data, form_count): + form_list = [] + for i in range(0, form_count-1): + form_instance = self.form_class(data, auto_id=self.auto_id, prefix=self.add_prefix(i)) + self.add_fields(form_instance, i) + form_list.append(form_instance) + # hackish, but if the last form stayed empty, replace it with a + # blank one. no 'data' or 'initial' arguments + form_instance = self.form_class(data, auto_id=self.auto_id, prefix=self.add_prefix(form_count-1)) + if form_instance.is_empty(): + form_instance = self.form_class(auto_id=self.auto_id, prefix=self.add_prefix(form_count-1)) + self.add_fields(form_instance, form_count-1) + form_list.append(form_instance) + return form_list + + def _forms_for_initial(self, initial, form_count): + form_list = [] + # generate a form for each item in initial, plus one empty one + for i in range(0, form_count): + form_instance = self.form_class(initial=initial[i], auto_id=self.auto_id, prefix=self.add_prefix(i)) + self.add_fields(form_instance, i) + form_list.append(form_instance) + # add 1 empty form + form_instance = self.form_class(auto_id=self.auto_id, prefix=self.add_prefix(i+1)) + self.add_fields(form_instance, i+1) + form_list.append(form_instance) + return form_list + + def _empty_forms(self, form_count): + form_list = [] + # we only need one form, there's no inital data and no post data + form_instance = self.form_class(auto_id=self.auto_id, prefix=self.add_prefix(0)) + form_list.append(form_instance) + return form_list + + def get_forms(self): + return self.form_list + + def add_fields(self, form, index): + """A hook for adding extra fields on to each form instance.""" + pass + + def add_prefix(self, index): + return '%s-%s' % (self.prefix, index) + + def _get_clean_data(self): + return self.get_clean_data() + + def get_clean_data(self): + clean_data_list = [] + for form in self.get_non_empty_forms(): + clean_data_list.append(form.clean_data) + return clean_data_list + + clean_data = property(_get_clean_data) + + def is_valid(self): + for form in self.get_non_empty_forms(): + if not form.is_valid(): + return False + return True + + def get_non_empty_forms(self): + """Return all forms that aren't empty.""" + return [form for form in self.form_list if not form.is_empty()] + +class FormSetWithDeletion(FormSet): + """A ``FormSet`` that handles deletion of forms.""" + + def add_fields(self, form, index): + """Add a delete checkbox to each form.""" + form.fields[DELETION_FIELD_NAME] = forms.BooleanField(label='Delete', required=False) + + def get_clean_data(self): + self.deleted_data = [] + clean_data_list = [] + for form in self.get_non_empty_forms(): + if form.clean_data[DELETION_FIELD_NAME]: + # stick data marked for deletetion in self.deleted_data + self.deleted_data.append(form.clean_data) + else: + clean_data_list.append(form.clean_data) + return clean_data_list + +class FormSetWithOrdering(FormSet): + """A ``FormSet`` that handles re-ordering of forms.""" + + def get_non_empty_forms(self): + return [form for form in self.form_list if not form.is_empty(exceptions=[ORDERING_FIELD_NAME])] + + def add_fields(self, form, index): + """Add an ordering field to each form.""" + form.fields[ORDERING_FIELD_NAME] = forms.IntegerField(label='Order', initial=index+1) + + def get_clean_data(self): + clean_data_list = [] + for form in self.get_non_empty_forms(): + clean_data_list.append(form.clean_data) + # sort clean_data by the 'ORDER' field + clean_data_list.sort(lambda x,y: x[ORDERING_FIELD_NAME] - y[ORDERING_FIELD_NAME]) + return clean_data_list + + def is_valid(self): + for form in self.get_non_empty_forms(): + if not form.is_valid(): + return False + return True + +# TODO: handle deletion and ordering in the same FormSet +# TODO: model integration: form_for_instance and form_for_model type functions diff --git a/tests/regressiontests/forms/tests.py b/tests/regressiontests/forms/tests.py index f805a221aa..2e51a45050 100644 --- a/tests/regressiontests/forms/tests.py +++ b/tests/regressiontests/forms/tests.py @@ -2895,6 +2895,158 @@ True >>> p.clean_data {'first_name': u'John', 'last_name': u'Lennon', 'birthday': datetime.date(1940, 10, 9)} +# FormSets #################################################################### + +FormSets allow you to create a bunch of instances of the same form class and +get back clean data as a list of dicts. + +>>> from django.newforms import formsets + +>>> class ChoiceForm(Form): +... choice = CharField() +... votes = IntegerField() + + +Create an empty form set + +>>> form_set = formsets.FormSet(ChoiceForm, prefix='choices', auto_id=False) +>>> for form in form_set.get_forms(): +... print form.as_ul() +
  • Choice:
  • +
  • Votes:
  • + + +Forms pre-filled with initial data. + +>>> initial_data = [ +... {'votes': 50, 'choice': u'The Doors', 'id': u'0'}, +... {'votes': 51, 'choice': u'The Beatles', 'id': u'1'}, +... ] + +>>> form_set = formsets.FormSet(ChoiceForm, initial=initial_data, auto_id=False, prefix='choices') +>>> print form_set.management_form.as_ul() + + +>>> for form in form_set.get_forms(): # print pre-filled forms +... print form.as_ul() +
  • Choice:
  • +
  • Votes:
  • +
  • Choice:
  • +
  • Votes:
  • +
  • Choice:
  • +
  • Votes:
  • + + +Tests for dealing with POSTed data + +>>> data = { +... 'choices-COUNT': u'3', # the number of forms rendered +... 'choices-0-choice': u'The Doors', +... 'choices-0-votes': u'50', +... 'choices-1-choice': u'The Beatles', +... 'choices-1-votes': u'51', +... 'choices-2-choice': u'', +... 'choices-2-votes': u'', +... } + + +>>> form_set = formsets.FormSet(ChoiceForm, data, auto_id=False, prefix='choices') +>>> print form_set.is_valid() +True +>>> for data in form_set.clean_data: +... print data +{'votes': 50, 'choice': u'The Doors'} +{'votes': 51, 'choice': u'The Beatles'} + + +FormSet with deletion fields + +>>> form_set = formsets.FormSetWithDeletion(ChoiceForm, initial=initial_data, auto_id=False, prefix='choices') +>>> for form in form_set.get_forms(): # print pre-filled forms +... print form.as_ul() +
  • Choice:
  • +
  • Votes:
  • +
  • Delete:
  • +
  • Choice:
  • +
  • Votes:
  • +
  • Delete:
  • +
  • Choice:
  • +
  • Votes:
  • +
  • Delete:
  • + +>>> data = { +... 'choices-COUNT': u'3', # the number of forms rendered +... 'choices-0-choice': u'Fergie', +... 'choices-0-votes': u'1000', +... 'choices-0-DELETE': u'on', # Delete this choice. +... 'choices-1-choice': u'The Decemberists', +... 'choices-1-votes': u'150', +... 'choices-2-choice': u'Calexico', +... 'choices-2-votes': u'90', +... } + +>>> form_set = formsets.FormSetWithDeletion(ChoiceForm, data, auto_id=False, prefix='choices') +>>> print form_set.is_valid() +True + +When we access form_set.clean_data, items marked for deletion won't be there, +but they *will* be in form_set.deleted_data + +>>> for data in form_set.clean_data: +... print data +{'votes': 150, 'DELETE': False, 'choice': u'The Decemberists'} +{'votes': 90, 'DELETE': False, 'choice': u'Calexico'} + +>>> for data in form_set.deleted_data: +... print data +{'votes': 1000, 'DELETE': True, 'choice': u'Fergie'} + + +FormSet with Ordering + +>>> form_set = formsets.FormSetWithOrdering(ChoiceForm, initial=initial_data, auto_id=False, prefix='choices') +>>> for form in form_set.get_forms(): # print pre-filled forms +... print form.as_ul() +
  • Choice:
  • +
  • Votes:
  • +
  • Order:
  • +
  • Choice:
  • +
  • Votes:
  • +
  • Order:
  • +
  • Choice:
  • +
  • Votes:
  • +
  • Order:
  • + +>>> data = { +... 'choices-COUNT': u'4', # the number of forms rendered +... 'choices-0-choice': u'Fergie', +... 'choices-0-votes': u'1000', +... 'choices-0-ORDER': u'3', +... 'choices-1-choice': u'The Decemberists', +... 'choices-1-votes': u'150', +... 'choices-1-ORDER': u'1', +... 'choices-2-choice': u'Calexico', +... 'choices-2-votes': u'90', +... 'choices-2-ORDER': u'2', +... 'choices-3-choice': u'', +... 'choices-3-votes': u'', +... 'choices-3-ORDER': u'4', +... } + +>>> form_set = formsets.FormSetWithOrdering(ChoiceForm, data, auto_id=False, prefix='choices') +>>> print form_set.is_valid() +True + +The form_set.clean_data will be in the correct order as specified by the +ORDER field from each form. + +>>> for data in form_set.clean_data: +... print data +{'votes': 150, 'ORDER': 1, 'choice': u'The Decemberists'} +{'votes': 90, 'ORDER': 2, 'choice': u'Calexico'} +{'votes': 1000, 'ORDER': 3, 'choice': u'Fergie'} + + # Forms with NullBooleanFields ################################################ NullBooleanField is a bit of a special case because its presentation (widget)