diff options
Diffstat (limited to 'django/newforms')
| -rw-r--r-- | django/newforms/__init__.py | 1 | ||||
| -rw-r--r-- | django/newforms/forms.py | 51 | ||||
| -rw-r--r-- | django/newforms/formsets.py | 292 | ||||
| -rw-r--r-- | django/newforms/models.py | 222 | ||||
| -rw-r--r-- | django/newforms/widgets.py | 170 |
5 files changed, 724 insertions, 12 deletions
diff --git a/django/newforms/__init__.py b/django/newforms/__init__.py index 0d9c68f9e0..99631e4e8f 100644 --- a/django/newforms/__init__.py +++ b/django/newforms/__init__.py @@ -15,3 +15,4 @@ from widgets import * from fields import * from forms import * from models import * +from formsets import *
\ No newline at end of file diff --git a/django/newforms/forms.py b/django/newforms/forms.py index fc203f36b5..753ee254bc 100644 --- a/django/newforms/forms.py +++ b/django/newforms/forms.py @@ -10,7 +10,7 @@ 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 TextInput, Textarea +from widgets import Media, media_property, TextInput, Textarea from util import flatatt, ErrorDict, ErrorList, ValidationError __all__ = ('BaseForm', 'Form') @@ -31,6 +31,7 @@ def get_declared_fields(bases, attrs, with_base_fields=True): 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)) @@ -56,8 +57,11 @@ class DeclarativeFieldsMetaclass(type): """ def __new__(cls, name, bases, attrs): attrs['base_fields'] = get_declared_fields(bases, attrs) - return super(DeclarativeFieldsMetaclass, + 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 @@ -65,7 +69,8 @@ class BaseForm(StrAndUnicode): # 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=':'): + 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 {} @@ -74,7 +79,9 @@ class BaseForm(StrAndUnicode): 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 @@ -194,6 +201,10 @@ class BaseForm(StrAndUnicode): 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 @@ -229,6 +240,40 @@ class BaseForm(StrAndUnicode): """ 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 diff --git a/django/newforms/formsets.py b/django/newforms/formsets.py new file mode 100644 index 0000000000..1ae27bf58c --- /dev/null +++ b/django/newforms/formsets.py @@ -0,0 +1,292 @@ +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, TextInput +from util import ErrorList, ValidationError + +__all__ = ('BaseFormSet', 'all_valid') + +# special field names +TOTAL_FORM_COUNT = 'TOTAL_FORMS' +INITIAL_FORM_COUNT = 'INITIAL_FORMS' +MAX_FORM_COUNT = 'MAX_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) + self.base_fields[MAX_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] + self._max_form_count = self.management_form.cleaned_data[MAX_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_form_count and self._max_form_count > 0: + self._initial_form_count = self._max_form_count + 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_form_count and self._max_form_count > 0: + self._total_form_count = self._max_form_count + initial = {TOTAL_FORM_COUNT: self._total_form_count, + INITIAL_FORM_COUNT: self._initial_form_count, + MAX_FORM_COUNT: self._max_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_form_count': 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/django/newforms/models.py b/django/newforms/models.py index c3938d9ae7..43e2978ba8 100644 --- a/django/newforms/models.py +++ b/django/newforms/models.py @@ -12,13 +12,15 @@ from django.core.exceptions import ImproperlyConfigured from util import ValidationError, ErrorList from forms import BaseForm, get_declared_fields -from fields import Field, ChoiceField, EMPTY_VALUES -from widgets import Select, SelectMultiple, MultipleHiddenInput +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_model', 'form_for_instance', 'form_for_fields', - 'ModelChoiceField', 'ModelMultipleChoiceField' + 'ModelChoiceField', 'ModelMultipleChoiceField', ) def save_instance(form, instance, fields=None, fail_message='saved', @@ -30,7 +32,7 @@ def save_instance(form, instance, fields=None, fail_message='saved', database. Returns ``instance``. """ from django.db import models - opts = instance.__class__._meta + 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)) @@ -44,7 +46,7 @@ def save_instance(form, instance, fields=None, fail_message='saved', f.save_form_data(instance, cleaned_data[f.name]) # Wrap up the saving of m2m data as a function. def save_m2m(): - opts = instance.__class__._meta + opts = instance._meta cleaned_data = form.cleaned_data for f in opts.many_to_many: if fields and f.name not in fields: @@ -226,6 +228,8 @@ class ModelFormMetaclass(type): 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: @@ -244,7 +248,7 @@ class ModelFormMetaclass(type): class BaseModelForm(BaseForm): def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, initial=None, error_class=ErrorList, label_suffix=':', - instance=None): + empty_permitted=False, instance=None): opts = self._meta if instance is None: # if we didn't get an instance, instantiate a new one @@ -256,7 +260,8 @@ class BaseModelForm(BaseForm): # if initial was provided, it should override the values from instance if initial is not None: object_data.update(initial) - BaseForm.__init__(self, data, files, auto_id, prefix, object_data, error_class, label_suffix) + BaseForm.__init__(self, data, files, auto_id, prefix, object_data, + error_class, label_suffix, empty_permitted) def save(self, commit=True): """ @@ -275,6 +280,209 @@ class BaseModelForm(BaseForm): 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_form_count > 0: + qs = self.get_queryset()[:self._max_form_count] + 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.""" + 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): + 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=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: + 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] + 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 ##################################################################### diff --git a/django/newforms/widgets.py b/django/newforms/widgets.py index dc36530b93..2c9f3c2eba 100644 --- a/django/newforms/widgets.py +++ b/django/newforms/widgets.py @@ -9,7 +9,7 @@ except NameError: import copy from itertools import chain - +from django.conf import settings from django.utils.datastructures import MultiValueDict from django.utils.html import escape, conditional_escape from django.utils.translation import ugettext @@ -17,16 +17,118 @@ 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__ = ( - 'Widget', 'TextInput', 'PasswordInput', + 'Media', 'MediaDefiningClass', 'Widget', 'TextInput', 'PasswordInput', 'HiddenInput', 'MultipleHiddenInput', 'FileInput', 'DateTimeInput', '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 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 @@ -65,6 +167,25 @@ class Widget(object): """ 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>, @@ -143,6 +264,11 @@ class FileInput(Input): 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): @@ -202,6 +328,11 @@ class CheckboxInput(Widget): 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) @@ -244,6 +375,11 @@ class NullBooleanSelect(Select): 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(Widget): def __init__(self, attrs=None, choices=()): super(SelectMultiple, self).__init__(attrs) @@ -268,6 +404,18 @@ class SelectMultiple(Widget): if isinstance(data, MultiValueDict): 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): """ @@ -447,6 +595,16 @@ class MultiWidget(Widget): 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): """ @@ -466,6 +624,14 @@ class MultiWidget(Widget): """ 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. |
