diff --git a/.travis.yml b/.travis.yml index 22c45a8..db2a0eb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,25 +1,18 @@ language: python python: 2.7 env: -- TOX_ENV=py26-14 -- TOX_ENV=py26-16 -- TOX_ENV=py27-14 -- TOX_ENV=py27-16 -- TOX_ENV=py27-17 - TOX_ENV=py27-18 - TOX_ENV=py27-19 -- TOX_ENV=py33-16 -- TOX_ENV=py33-17 -- TOX_ENV=py34-16 -- TOX_ENV=py34-17 +- TOX_ENV=py27-110 - TOX_ENV=py34-18 +- TOX_ENV=py34-19 +- TOX_ENV=py34-110 - TOX_ENV=py35-18 - TOX_ENV=py35-19 -- TOX_ENV=pypy-14 -- TOX_ENV=pypy-16 -- TOX_ENV=pypy-17 +- TOX_ENV=py35-110 - TOX_ENV=pypy-18 - TOX_ENV=pypy-19 +- TOX_ENV=pypy-110 - TOX_ENV=docs - TOX_ENV=flake8 install: diff --git a/CHANGES.rst b/CHANGES.rst index 51c4d3e..3df0a6b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,9 +5,13 @@ Changelog ----- * Fix formset rendering in Django 1.9. `#17`_ * Add support for Django 1.9's ``get_bound_field``. `#18`_ +* Remove need for a custom metaclass. Formsets and forms now + show up in ``form.cleaned_data`` after validation in addition to the + ``SuperForm.forms`` and ``SuperForm.formsets`` attributes. `#19`_ .. _#17: https://github.com/gregmuellegger/django-superform/pull/17 .. _#18: https://github.com/gregmuellegger/django-superform/pull/18 +.. _#19: https://github.com/gregmuellegger/django-superform/pull/19 0.3.1 ----- diff --git a/django_superform/boundfield.py b/django_superform/boundfield.py index a9774b6..b1077df 100644 --- a/django_superform/boundfield.py +++ b/django_superform/boundfield.py @@ -24,7 +24,8 @@ def __iter__(self): for item in self.form.get_composite_field_value(self.name): yield item - # __len__, no changes required + def __len__(self): + return len(list(self.__iter__())) def __getitem__(self, item): """ diff --git a/django_superform/fields.py b/django_superform/fields.py index 8f5297e..8869b17 100644 --- a/django_superform/fields.py +++ b/django_superform/fields.py @@ -1,52 +1,11 @@ from django.forms.models import inlineformset_factory +from django.forms import Field from .boundfield import CompositeBoundField from .widgets import FormWidget, FormSetWidget -class BaseCompositeField(object): - """ - The ``BaseCompositeField`` takes care of keeping some kind of compatibility - with the ``django.forms.Field`` class. - """ - - widget = None - show_hidden_initial = False - - # Tracks each time a FormSetField instance is created. Used to retain - # order. - creation_counter = 0 - - def __init__(self, required=True, widget=None, label=None, help_text='', - localize=False, disabled=False): - self.required = required - self.label = label - self.help_text = help_text - self.disabled = disabled - - widget = widget or self.widget - if isinstance(widget, type): - widget = widget() - - # Trigger the localization machinery if needed. - self.localize = localize - if self.localize: - widget.is_localized = True - - # Let the widget know whether it should display as required. - widget.is_required = self.required - - # We do not call self.widget_attrs() here as the original field is - # doing it. - - self.widget = widget - - # Increase the creation counter, and save our local copy. - self.creation_counter = BaseCompositeField.creation_counter - BaseCompositeField.creation_counter += 1 - - -class CompositeField(BaseCompositeField): +class CompositeField(Field): """ Implements the base structure that is relevant for all composite fields. This field cannot be used directly, use a subclass of it. @@ -152,33 +111,28 @@ class RegistrationForm(SuperForm): widget = FormWidget def __init__(self, form_class, kwargs=None, **field_kwargs): + self.form_class = form_class super(FormField, self).__init__(**field_kwargs) - self.form_class = form_class if kwargs is None: kwargs = {} self.default_kwargs = kwargs - def get_form_class(self, form, name): - """ - Return the form class that will be used for instantiation in - ``get_form``. You can override this method in subclasses to change - the behaviour of the given form class. - """ - return self.form_class - def get_form(self, form, name): """ Get an instance of the form. """ kwargs = self.get_kwargs(form, name) - form_class = self.get_form_class(form, name) + form_class = self.form_class composite_form = form_class( data=form.data if form.is_bound else None, files=form.files if form.is_bound else None, **kwargs) return composite_form + def widget_attrs(self, widget): + return {'form_class': self.form_class} + class ModelFormField(FormField): """ @@ -295,7 +249,7 @@ def get_kwargs(self, form, name): kwargs['empty_permitted'] = True return kwargs - def get_field_name(self, form, name): + def get_field_name(self, name): return self.field_name or name def allow_blank(self, form, name): @@ -306,15 +260,11 @@ def allow_blank(self, form, name): if self.blank is not None: return self.blank model = form._meta.model - field = model._meta.get_field(self.get_field_name(form, name)) + field = model._meta.get_field(self.get_field_name(name)) return field.blank - def get_form_class(self, form, name): - form_class = self.form_class - return form_class - def get_instance(self, form, name): - field_name = self.get_field_name(form, name) + field_name = self.get_field_name(name) return getattr(form.instance, field_name) def save(self, form, name, composite_form, commit): @@ -326,7 +276,7 @@ def save(self, form, name, composite_form, commit): saved_obj = super(ForeignKeyFormField, self).save(form, name, composite_form, commit) - setattr(form.instance, self.get_field_name(form, name), saved_obj) + setattr(form.instance, self.get_field_name(name), saved_obj) if commit: form.instance.save() else: @@ -349,9 +299,9 @@ class FormSetField(CompositeField): widget = FormSetWidget def __init__(self, formset_class, kwargs=None, **field_kwargs): + self.formset_class = formset_class super(FormSetField, self).__init__(**field_kwargs) - self.formset_class = formset_class if kwargs is None: kwargs = {} self.default_kwargs = kwargs @@ -376,6 +326,9 @@ def get_formset(self, form, name): **kwargs) return formset + def widget_attrs(self, widget): + return {'formset_class': self.formset_class} + class ModelFormSetField(FormSetField): def shall_save(self, form, name, formset): diff --git a/django_superform/forms.py b/django_superform/forms.py index 13fe99e..2cbbe70 100644 --- a/django_superform/forms.py +++ b/django_superform/forms.py @@ -59,15 +59,15 @@ def post_form(request): Now to how you can access the instantiated formsets:: >>> form = PostForm() - >>> form.composite_fields['comments'] + >>> form.fields['comments'] Or in the template:: {{ form.as_p }} - {{ form.composite_fields.comments.management_form }} - {% for fieldset_form in form.composite_fields.comments %} + {{ form.fields.comments.management_form }} + {% for fieldset_form in form.fields.comments %} {{ fieldset_form.as_p }} {% endfor %} @@ -75,12 +75,9 @@ def post_form(request): """ -from functools import reduce +import django from django import forms -from django.forms.forms import DeclarativeFieldsMetaclass, ErrorDict, ErrorList -from django.forms.models import ModelFormMetaclass -from django.utils import six -import copy +from django.forms.forms import ErrorDict, ErrorList from .fields import CompositeField @@ -90,76 +87,16 @@ def post_form(request): from django.utils.datastructures import SortedDict as OrderedDict -class DeclerativeCompositeFieldsMetaclass(type): - """ - Metaclass that converts FormField and FormSetField attributes to a - dictionary called `composite_fields`. It will also include all composite - fields from parent classes. - """ - - def __new__(mcs, name, bases, attrs): - # Collect composite fields from current class. - current_fields = [] - for key, value in list(attrs.items()): - if isinstance(value, CompositeField): - current_fields.append((key, value)) - attrs.pop(key) - current_fields.sort(key=lambda x: x[1].creation_counter) - attrs['declared_composite_fields'] = OrderedDict(current_fields) - - new_class = super(DeclerativeCompositeFieldsMetaclass, mcs).__new__( - mcs, name, bases, attrs) - - # Walk through the MRO. - declared_fields = OrderedDict() - for base in reversed(new_class.__mro__): - # Collect fields from base class. - if hasattr(base, 'declared_composite_fields'): - declared_fields.update(base.declared_composite_fields) - - # Field shadowing. - for attr, value in base.__dict__.items(): - if value is None and attr in declared_fields: - declared_fields.pop(attr) - - new_class.base_composite_fields = declared_fields - new_class.declared_composite_fields = declared_fields - - return new_class - - -class SuperFormMetaclass( - DeclerativeCompositeFieldsMetaclass, - DeclarativeFieldsMetaclass): - """ - Metaclass for :class:`~django_superform.forms.SuperForm`. - """ - - -class SuperModelFormMetaclass( - DeclerativeCompositeFieldsMetaclass, - ModelFormMetaclass): - """ - Metaclass for :class:`~django_superform.forms.SuperModelForm`. - """ - - class SuperFormMixin(object): """ The base class for all super forms. It does not inherit from any other - classes, so you are free to mix it into any custom form class you have. You - need to use it together with ``SuperFormMetaclass``, like this: + classes, so you are free to mix it into any custom form class you have. .. code:: python from django_superform import SuperFormMixin - from django_superform import SuperFormMetaclass - import six - class MySuperForm(six.with_metaclass( - SuperFormMetaclass, - SuperFormMixin, - MyCustomForm)): + class MySuperForm(MyCustomForm): pass The goal of a superform is to behave just like a normal django form but is @@ -175,30 +112,39 @@ def __init__(self, *args, **kwargs): super(SuperFormMixin, self).__init__(*args, **kwargs) self._init_composite_fields() - def __getitem__(self, name): + if django.VERSION < (1, 9): + # This behavior is not needed after django 1.9 introduced + # get_bound_field. + + def __getitem__(self, name): + """ + Returns a ``django.forms.BoundField`` for the given field name. + It also returns + :class:`~django_superform.boundfield.CompositeBoundField` + instances for composite fields. + """ + field = self.fields[name] + if hasattr(field, 'get_bound_field'): + return field.get_bound_field(self, name) + return super(SuperFormMixin, self).__getitem__(name) + + def add_prefix(self, name): """ - Returns a ``django.forms.BoundField`` for the given field name. It also - returns :class:`~django_superform.boundfield.CompositeBoundField` - instances for composite fields. - """ - if name not in self.fields and name in self.composite_fields: - field = self.composite_fields[name] - return field.get_bound_field(self, name) - return super(SuperFormMixin, self).__getitem__(name) + Returns the field name with a prefix appended, if this Form has a + prefix set. - def add_composite_field(self, name, field): - """ - Add a dynamic composite field to the already existing ones and - initialize it appropriatly. + Subclasses may wish to override. """ - self.composite_fields[name] = field - self._init_composite_field(name, field) + field = self.fields.get(name) + if isinstance(field, CompositeField): + return field.get_prefix(self, name) + return super(SuperFormMixin, self).add_prefix(name) def get_composite_field_value(self, name): """ Return the form/formset instance for the given field name. """ - field = self.composite_fields[name] + field = self.fields[name] if hasattr(field, 'get_form'): return self.forms[name] if hasattr(field, 'get_formset'): @@ -207,26 +153,22 @@ def get_composite_field_value(self, name): def _init_composite_field(self, name, field): if hasattr(field, 'get_form'): form = field.get_form(self, name) + field.widget.form = form self.forms[name] = form if hasattr(field, 'get_formset'): formset = field.get_formset(self, name) + field.widget.formset = formset self.formsets[name] = formset def _init_composite_fields(self): """ Setup the forms and formsets. """ - # The base_composite_fields class attribute is the *class-wide* - # definition of fields. Because a particular *instance* of the class - # might want to alter self.composite_fields, we create - # self.composite_fields here by copying base_composite_fields. - # Instances should always modify self.composite_fields; they should not - # modify base_composite_fields. - self.composite_fields = copy.deepcopy(self.base_composite_fields) self.forms = OrderedDict() self.formsets = OrderedDict() - for name, field in self.composite_fields.items(): - self._init_composite_field(name, field) + for name, field in self.fields.items(): + if isinstance(field, CompositeField): + self._init_composite_field(name, field) def full_clean(self): """ @@ -247,14 +189,15 @@ def full_clean(self): @property def media(self): """ - Incooperate composite field's media. + Incorporate composite field's media. """ - media_list = [] - media_list.append(super(SuperFormMixin, self).media) - for composite_name in self.composite_fields.keys(): - form = self.get_composite_field_value(composite_name) - media_list.append(form.media) - return reduce(lambda a, b: a + b, media_list) + media = forms.Media() + for name, field in self.fields.items(): + if isinstance(field, CompositeField): + media = media + self.get_composite_field_value(name).media + else: + media = media + field.widget.media + return media class SuperModelFormMixin(SuperFormMixin): @@ -264,13 +207,8 @@ class SuperModelFormMixin(SuperFormMixin): .. code:: python from django_superform import SuperModelFormMixin - from django_superform import SuperModelFormMetaclass - import six - class MySuperForm(six.with_metaclass( - SuperModelFormMetaclass, - SuperModelFormMixin, - MyCustomModelForm)): + class MySuperForm(SuperModelFormMixin, MyCustomModelForm)): pass """ @@ -349,7 +287,7 @@ def save_form(self, commit=True): def save_forms(self, commit=True): saved_composites = [] for name, composite in self.forms.items(): - field = self.composite_fields[name] + field = self.fields[name] if hasattr(field, 'save'): field.save(self, name, composite, commit=commit) saved_composites.append(composite) @@ -364,7 +302,7 @@ def save_formsets(self, commit=True): """ saved_composites = [] for name, composite in self.formsets.items(): - field = self.composite_fields[name] + field = self.fields[name] if hasattr(field, 'save'): field.save(self, name, composite, commit=commit) saved_composites.append(composite) @@ -372,8 +310,7 @@ def save_formsets(self, commit=True): self._extend_save_m2m('save_formsets_m2m', saved_composites) -class SuperModelForm(six.with_metaclass(SuperModelFormMetaclass, - SuperModelFormMixin, forms.ModelForm)): +class SuperModelForm(SuperModelFormMixin, forms.ModelForm): """ The ``SuperModelForm`` works like a Django ``ModelForm`` but has the capabilities of nesting like :class:`~django_superform.forms.SuperForm`. @@ -382,8 +319,7 @@ class SuperModelForm(six.with_metaclass(SuperModelFormMetaclass, """ -class SuperForm(six.with_metaclass(SuperFormMetaclass, - SuperFormMixin, forms.Form)): +class SuperForm(SuperFormMixin, forms.Form): """ The base class for all super forms. The goal of a superform is to behave just like a normal django form but is able to take composite fields, like diff --git a/django_superform/widgets.py b/django_superform/widgets.py index f5173ce..4aa181c 100644 --- a/django_superform/widgets.py +++ b/django_superform/widgets.py @@ -1,3 +1,4 @@ +import django from django import forms from django.template import loader @@ -18,12 +19,12 @@ def __init__(self, *args, **kwargs): if template_name is not None: self.template_name = template_name super(TemplateWidget, self).__init__(*args, **kwargs) - self.context_instance = None def get_context_data(self): return {} - def get_context(self, name, value, attrs=None): + def get_context(self, name, value, attrs=None, **kwargs): + attrs = {} if attrs is None else attrs context = { 'name': name, 'hidden': self.is_hidden, @@ -39,6 +40,12 @@ def get_context(self, name, value, attrs=None): context.update(self.get_context_data()) context['attrs'] = self.build_attrs(attrs) + if django.VERSION >= (1, 11): + # Once support for older versions is dropped, this class + # should be replaced with template widgets now that django + # supports this. + # See https://docs.djangoproject.com/en/1.11/ref/forms/renderers/ + context['widget'] = context return context @@ -47,17 +54,20 @@ def render(self, name, value, attrs=None, **kwargs): if template_name is None: template_name = self.template_name context = self.get_context(name, value, attrs=attrs or {}, **kwargs) - return loader.render_to_string( - template_name, - dictionary=context, - context_instance=self.context_instance) + return loader.render_to_string(template_name, context=context) class FormWidget(TemplateWidget): template_name = 'superform/formfield.html' value_context_name = 'form' + def value_from_datadict(self, data, files, name): + return self.form + class FormSetWidget(TemplateWidget): template_name = 'superform/formsetfield.html' value_context_name = 'formset' + + def value_from_datadict(self, data, files, name): + return self.formset diff --git a/docs/fields.rst b/docs/fields.rst index d854431..388a212 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -28,7 +28,7 @@ a ``SuperForm``:: ------------- .. autoclass:: django_superform.fields.FormField - :members: get_form_class, get_form + :members: get_form ``ModelFormField`` ------------------ diff --git a/docs/howitworks.rst b/docs/howitworks.rst index 4a00b47..c78a112 100644 --- a/docs/howitworks.rst +++ b/docs/howitworks.rst @@ -12,28 +12,6 @@ you as expected. So here is a description of the life cycle of a super form and its composite fields. -The ``SuperFormMetaclass`` --------------------------- - -A super form is using a metaclass to discover all the fields used the form, -just like Django does it with normal form fields. That's what the -:class:`~django_superform.forms.SuperFormMetaclass` is for. - -That will discover all the used :class:`~django_superform.fields.FormField` and -:class:`~django_superform.fields.FormSetField` on your form and puts the -them in a attribute called ``base_composite_fields`` on the form class. - -So you can inspect all the composite fields with it: - -.. code:: python - - class PersonForm(SuperForm): - name = forms.CharField() - social_accounts = FormSet(SocialAccountsForm) - addresses = FormSetField(AddressForm) - - print(PersonForm.base_composite_fields) - On form instantiation --------------------- diff --git a/tests/models.py b/tests/models.py index 45f61db..bb207e0 100644 --- a/tests/models.py +++ b/tests/models.py @@ -15,11 +15,13 @@ class Post(models.Model): """ title = models.CharField(max_length=50) - series = models.ForeignKey('Series', null=True, blank=True) + series = models.ForeignKey('Series', null=True, blank=True, + on_delete=models.CASCADE) class Image(models.Model): - post = models.ForeignKey('Post', related_name='images') + post = models.ForeignKey('Post', related_name='images', + on_delete=models.CASCADE) name = models.CharField(max_length=50) position = models.PositiveIntegerField(default=0) diff --git a/tests/requirements.txt b/tests/requirements.txt index 498da52..4c555c1 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,7 +1,7 @@ coverage==3.7.1 tox==2.0.1 flake8==2.5.4 -pytest==2.8.7 -pytest-cov==2.2.1 -pytest-django==2.9.1 +pytest==3.0.5 +pytest-cov==2.4.0 +pytest-django==3.1.2 pytest-pythonpath==0.7 diff --git a/tests/settings.py b/tests/settings.py index f61e178..e16e03f 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -21,3 +21,22 @@ STATIC_URL = '/static/' SECRET_KEY = '0' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + 'loaders': [ + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ], + }, + }, +] diff --git a/tests/test_formfield.py b/tests/test_formfield.py index bf14171..225c1a9 100644 --- a/tests/test_formfield.py +++ b/tests/test_formfield.py @@ -33,9 +33,9 @@ class RegistrationForm(SuperForm): class FormFieldTests(TestCase): - def test_is_in_composite_fields(self): + def test_is_in_fields(self): superform = RegistrationForm() - self.assertTrue('address' in superform.composite_fields) + self.assertTrue('address' in superform.fields) def test_is_in_forms_attr(self): superform = RegistrationForm() diff --git a/tests/test_modelformfield.py b/tests/test_modelformfield.py index 4ee592a..28365c4 100644 --- a/tests/test_modelformfield.py +++ b/tests/test_modelformfield.py @@ -45,9 +45,9 @@ class Meta: class FormFieldTests(TestCase): - def test_is_in_composite_fields(self): + def test_is_in_fields(self): superform = PostForm() - self.assertTrue('series' in superform.composite_fields) + self.assertTrue('series' in superform.fields) def test_is_in_forms_attr(self): superform = PostForm() diff --git a/tests/test_superform.py b/tests/test_superform.py index 9deb282..5852f8e 100644 --- a/tests/test_superform.py +++ b/tests/test_superform.py @@ -30,27 +30,9 @@ class SubclassedAccountForm(AccountForm): class SuperFormTests(TestCase): - def test_base_composite_fields(self): - self.assertEqual(list(AccountForm.base_fields.keys()), ['username']) - - self.assertTrue(hasattr(AccountForm, 'base_composite_fields')) - self.assertEqual(list(AccountForm.base_composite_fields.keys()), ['emails', 'nested_form']) - self.assertTrue(hasattr(SubclassedAccountForm, 'base_composite_fields')) - self.assertEqual(list(SubclassedAccountForm.base_composite_fields.keys()), ['emails', 'nested_form', 'nested_form_2']) - - field = AccountForm.base_composite_fields['emails'] - self.assertIsInstance(field, FormSetField) - - field = AccountForm.base_composite_fields['nested_form'] - self.assertIsInstance(field, FormField) - - self.assertFalse(hasattr(AccountForm, 'forms')) - self.assertFalse(hasattr(AccountForm, 'formsets')) - def test_fields_in_instantiated_forms(self): form = AccountForm() - self.assertTrue(hasattr(form, 'composite_fields')) self.assertTrue(hasattr(form, 'forms')) self.assertTrue(hasattr(form, 'formsets')) diff --git a/tox.ini b/tox.ini index 00f7a58..f397b8f 100644 --- a/tox.ini +++ b/tox.ini @@ -3,22 +3,22 @@ minversion = 1.8 envlist = docs, flake8, - py26-{14,16}, - py27-{14,16,17,18,19}, - py33-{16,17,18}, - py34-{16,17,18,19}, - py35-{18,19}, - pypy-{14,16,17,18,19} + py27-{18,19,110,111}, + py33-{18}, + py34-{18,19,110,111,20}, + py35-{18,19,110,111,20}, + py36-{18,19,110,111,20}, + pypy-{18,19,110,111} [testenv] deps = - 14: Django >= 1.4, < 1.5 - 16: Django >= 1.6, < 1.7 - 17: Django >= 1.7, < 1.8 18: Django >= 1.8, < 1.9 19: Django >= 1.9, < 1.10 + 110: Django >= 1.10, < 1.11 + 111: Django >= 1.11, < 2 + 20: Django >= 2.0, < 2.1 -r{toxinidir}/tests/requirements.txt -commands = py.test --cov django_superform {posargs:tests} +commands = py.test -vv --cov django_superform {posargs:tests} [testenv:docs] changedir = docs