├── tests ├── __init__.py ├── templates │ └── _print_name.html ├── requirements.txt ├── settings.py ├── test_global_exports.py ├── models.py ├── test_formsetfield.py ├── test_media.py ├── test_widgets.py ├── test_boundfield.py ├── test_formfield.py ├── test_superform.py └── test_modelformfield.py ├── docs ├── _static │ └── .gitignore ├── changelog.rst ├── quickstart.rst ├── boundfield.rst ├── install.rst ├── forms.rst ├── fields.rst ├── index.rst ├── howitworks.rst ├── Makefile ├── make.bat └── conf.py ├── django_superform ├── templates │ └── superform │ │ ├── formfield.html │ │ └── formsetfield.html ├── __init__.py ├── widgets.py ├── boundfield.py ├── forms.py └── fields.py ├── requirements.txt ├── .gitignore ├── .flake8 ├── pytest.ini ├── MANIFEST.in ├── CONTRIBUTING.rst ├── .pre-commit-config.yaml ├── .github └── workflows │ └── pipeline.yml ├── tox.ini ├── CHANGES.rst ├── LICENSE ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/_print_name.html: -------------------------------------------------------------------------------- 1 | {{ name }} 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGES.rst 2 | -------------------------------------------------------------------------------- /django_superform/templates/superform/formfield.html: -------------------------------------------------------------------------------- 1 | {{ form }} 2 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | TODO. 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.8,<1.9 2 | Sphinx==1.3.1 3 | pre-commit 4 | -r tests/requirements.txt 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /docs/_build/ 3 | /.tox/ 4 | /dist/ 5 | /django_superform.egg-info/ 6 | /.cache/ 7 | /.coverage* 8 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = .git, .venv, __pycache__, migrations, tests 4 | ignore = E203, E501, W605, W504, E265, W503 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --cov=django_superform --cov-report=term-missing 3 | python_paths = . 4 | DJANGO_SETTINGS_MODULE=tests.settings 5 | -------------------------------------------------------------------------------- /django_superform/templates/superform/formsetfield.html: -------------------------------------------------------------------------------- 1 | {{ formset.management_form }} 2 | {% for form in formset %} 3 | {{ form }} 4 | {% endfor %} 5 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | coverage==3.7.1 2 | tox==2.0.1 3 | flake8==2.5.4 4 | pytest==2.8.7 5 | pytest-cov==2.2.1 6 | pytest-django==2.9.1 7 | pytest-pythonpath==0.7 8 | -------------------------------------------------------------------------------- /docs/boundfield.rst: -------------------------------------------------------------------------------- 1 | Bound fields for nested forms 2 | ============================= 3 | 4 | .. autoclass:: django_superform.boundfield.CompositeBoundField 5 | :members: __iter__, __getitem__, as_text, as_textarea, as_hidden, data, value 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGES.rst 3 | include requirements.txt 4 | recursive-include django_superform/templates *.html 5 | recursive-include tests *.py *.html *.txt 6 | recursive-include docs *.rst *.png 7 | include docs/Makefile docs/make.bat docs/conf.py 8 | prune docs/_build 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://jazzband.co/static/img/jazzband.svg 2 | :target: https://jazzband.co/ 3 | :alt: Jazzband 4 | 5 | This is a `Jazzband `_ project. By contributing, you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: debug-statements 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/psf/black 9 | rev: 24.8.0 10 | hooks: 11 | - id: black 12 | - repo: https://github.com/PyCQA/flake8 13 | rev: 7.1.1 14 | hooks: 15 | - id: flake8 16 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | warnings.simplefilter("always") 4 | 5 | DATABASES = { 6 | "default": { 7 | "ENGINE": "django.db.backends.sqlite3", 8 | "NAME": ":memory:", 9 | }, 10 | } 11 | 12 | USE_I18N = True 13 | USE_L10N = True 14 | 15 | INSTALLED_APPS = [ 16 | "django_superform", 17 | "tests", 18 | ] 19 | 20 | MIDDLEWARE_CLASSES = () 21 | 22 | STATIC_URL = "/static/" 23 | 24 | SECRET_KEY = "0" 25 | -------------------------------------------------------------------------------- /tests/test_global_exports.py: -------------------------------------------------------------------------------- 1 | EXPECTED_EXPORTS = ( 2 | "ForeignKeyFormField", 3 | "FormField", 4 | "FormSetField", 5 | "FormSetWidget", 6 | "FormWidget", 7 | "InlineFormSetField", 8 | "ModelFormField", 9 | "ModelFormSetField", 10 | "SuperForm", 11 | "SuperModelForm", 12 | ) 13 | 14 | 15 | def test_all_exports_are_there(): 16 | import django_superform 17 | 18 | for expected_export in EXPECTED_EXPORTS: 19 | assert hasattr(django_superform, expected_export) 20 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Install ``django-superform`` 2 | ============================ 3 | 4 | Install the desired version with pip_:: 5 | 6 | pip install django-superform 7 | 8 | .. _pip: https://pip.pypa.io/en/stable/ 9 | 10 | Then add ``django-superform`` to ``INSTALLED_APPS`` in your project's 11 | settings file: 12 | 13 | .. code-block:: python 14 | 15 | INSTALLED_APPS = ( 16 | # ... 17 | 'django_superform', 18 | # ... 19 | ) 20 | 21 | Now you are ready to continue with the :doc:`quickstart guide `. 22 | -------------------------------------------------------------------------------- /docs/forms.rst: -------------------------------------------------------------------------------- 1 | Available Forms 2 | =============== 3 | 4 | ``SuperForm`` 5 | ------------- 6 | 7 | .. autoclass:: django_superform.forms.SuperForm 8 | :members: __getitem__ 9 | 10 | 11 | ``SuperFormMixin`` 12 | ------------------ 13 | 14 | .. autoclass:: django_superform.forms.SuperFormMixin 15 | 16 | 17 | ``SuperModelForm`` 18 | ------------------ 19 | 20 | .. autoclass:: django_superform.forms.SuperModelForm 21 | :members: save, save_form, save_forms, save_formsets 22 | 23 | 24 | ``SuperModelFormMixin`` 25 | ----------------------- 26 | 27 | .. autoclass:: django_superform.forms.SuperModelFormMixin 28 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ $default-branch ] 6 | pull_request: 7 | branches: '**' 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | # matrix: 16 | # python-version: ["3.3.7", "3.4.10", "3.5.10"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | # - name: Set up Python ${{ matrix.python-version }} 21 | # uses: actions/setup-python@v3 22 | # with: 23 | # python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install flake8 tox 28 | - name: Run tests and flake8 with tox 29 | run: | 30 | tox 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 1.8 3 | envlist = 4 | docs, 5 | flake8, 6 | py26-{14,16}, 7 | py27-{14,16,17,18,19}, 8 | py33-{16,17,18}, 9 | py34-{16,17,18,19}, 10 | py35-{18,19}, 11 | pypy-{14,16,17,18,19} 12 | 13 | [testenv] 14 | deps = 15 | 14: Django >= 1.4, < 1.5 16 | 16: Django >= 1.6, < 1.7 17 | 17: Django >= 1.7, < 1.8 18 | 18: Django >= 1.8, < 1.9 19 | 19: Django >= 1.9, < 1.10 20 | -r{toxinidir}/tests/requirements.txt 21 | commands = py.test --cov django_superform {posargs:tests} 22 | 23 | [testenv:docs] 24 | changedir = docs 25 | deps = 26 | -r{toxinidir}/requirements.txt 27 | commands = 28 | sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 29 | 30 | [testenv:flake8] 31 | deps = 32 | flake8==2.5.4 33 | commands = flake8 django_superform 34 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Series(models.Model): 5 | """ 6 | Multiple posts can be grouped into a series. 7 | """ 8 | 9 | title = models.CharField(max_length=50) 10 | 11 | 12 | class Post(models.Model): 13 | """ 14 | A blog post. I can be part of a series and can contain multiple images. 15 | """ 16 | 17 | title = models.CharField(max_length=50) 18 | series = models.ForeignKey("Series", null=True, blank=True) 19 | 20 | 21 | class Image(models.Model): 22 | post = models.ForeignKey("Post", related_name="images") 23 | 24 | name = models.CharField(max_length=50) 25 | position = models.PositiveIntegerField(default=0) 26 | 27 | # Is no ImageField to make testing easier. 28 | image_url = models.URLField() 29 | 30 | class Meta: 31 | ordering = ("position",) 32 | -------------------------------------------------------------------------------- /django_superform/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | ____ _ _____ _____ 4 | | \ |_|___ ___ ___ ___| __|_ _ ___ ___ ___| __|___ ___ _____ 5 | | | || | .'| | . | . |__ | | | . | -_| _| __| . | _| | 6 | |____/_| |__,|_|_|_ |___|_____|___| _|___|_| |__| |___|_| |_|_|_| 7 | |___| |___| |_| 8 | 9 | 10 | Author: Gregor Müllegger 11 | Project home: https://github.com/jazzband/django-superform 12 | See http://django-superform.readthedocs.org/en/latest/ for complete docs. 13 | """ 14 | from .fields import ( 15 | FormField, 16 | ModelFormField, 17 | ForeignKeyFormField, 18 | FormSetField, 19 | ModelFormSetField, 20 | InlineFormSetField, 21 | ) 22 | from .forms import SuperForm, SuperModelForm 23 | from .widgets import FormWidget, FormSetWidget 24 | 25 | 26 | __version__ = "0.4.0.dev1" 27 | 28 | 29 | __all__ = ( 30 | "FormField", 31 | "ModelFormField", 32 | "ForeignKeyFormField", 33 | "FormSetField", 34 | "ModelFormSetField", 35 | "InlineFormSetField", 36 | "SuperForm", 37 | "SuperModelForm", 38 | "FormWidget", 39 | "FormSetWidget", 40 | ) 41 | -------------------------------------------------------------------------------- /docs/fields.rst: -------------------------------------------------------------------------------- 1 | Fields 2 | ====== 3 | 4 | This is the class hierachy of all the available composite fields to be used in 5 | a ``SuperForm``:: 6 | 7 | + CompositeField 8 | | 9 | +-+ FormField 10 | | | 11 | | +-+ ModelFormField 12 | | | 13 | | +-- ForeignKeyFormField 14 | | 15 | +-+ FormSetField 16 | | 17 | +-+ ModelFormSetField 18 | | 19 | +-+ InlineFormSetField 20 | 21 | ``CompositeField`` 22 | ------------------ 23 | 24 | .. autoclass:: django_superform.fields.CompositeField 25 | :members: get_prefix, get_initial, get_kwargs 26 | 27 | ``FormField`` 28 | ------------- 29 | 30 | .. autoclass:: django_superform.fields.FormField 31 | :members: get_form_class, get_form 32 | 33 | ``ModelFormField`` 34 | ------------------ 35 | 36 | .. autoclass:: django_superform.fields.ModelFormField 37 | :members: get_instance, get_kwargs, shall_save, save 38 | 39 | ``ForeignKeyFormField`` 40 | ----------------------- 41 | 42 | .. autoclass:: django_superform.fields.ForeignKeyFormField 43 | 44 | ``FormSetField`` 45 | ---------------- 46 | 47 | .. autoclass:: django_superform.fields.FormSetField 48 | 49 | ``ModelFormSetField`` 50 | --------------------- 51 | 52 | .. autoclass:: django_superform.fields.ModelFormSetField 53 | 54 | ``InlineFormSetField`` 55 | ---------------------- 56 | 57 | .. autoclass:: django_superform.fields.InlineFormSetField 58 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-superform: nest all the forms! 2 | ===================================== 3 | 4 | A ``SuperForm`` lets you nest other forms and formsets inside a form. That way 5 | handling multiple forms on one page gets *super* easy and you can finally use 6 | forms for complex scenarios without driving insane. 7 | 8 | This is how a nested form looks: 9 | 10 | .. code-block:: python 11 | 12 | from django import forms 13 | from django.forms import formset_factory 14 | from django_superform import SuperForm, FormField, FormSetField 15 | 16 | class EmailForm(forms.Form): 17 | email = forms.EmailField() 18 | 19 | class AddressForm(forms.Form): 20 | street = forms.CharField() 21 | city = forms.CharField() 22 | 23 | class UserProfile(SuperForm): 24 | username = forms.CharField(max_length=50) 25 | address = FormField(AddressForm) 26 | emails = FormSetField(formset_factory(EmailForm)) 27 | 28 | ``django-superform`` also works well with model forms and inline model form sets. 29 | 30 | After you have :doc:`installed ` django-superform, continue with 31 | the :doc:`quickstart guide ` to see how to use it in your project. 32 | 33 | **Contents:** 34 | 35 | .. toctree:: 36 | :maxdepth: 2 37 | 38 | install 39 | quickstart 40 | forms 41 | fields 42 | boundfield 43 | howitworks 44 | changelog 45 | 46 | :ref:`genindex` | :ref:`search` 47 | 48 | .. :: 49 | 50 | :ref:`modindex` 51 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.4.0 5 | ----- 6 | * Fix formset rendering in Django 1.9. `#17`_ 7 | * Add support for Django 1.9's ``get_bound_field``. `#18`_ 8 | 9 | .. _#17: https://github.com/jazzband/django-superform/pull/17 10 | .. _#18: https://github.com/jazzband/django-superform/pull/18 11 | 12 | 0.3.1 13 | ----- 14 | 15 | * ``SuperForm.composite_fields`` dict is not longer shared between form 16 | instances. Every new form instances get's a deep copy. So changes to it 17 | won't leak into other instances of the same form class. 18 | 19 | 0.3.0 20 | ----- 21 | 22 | * `#11`_: Fix ``CompositeBoundField`` to allow direct access to nested form 23 | fields via ``form['nested_form']['field']``. 24 | * Support for Django's Media handling in nested forms. See `#3`_ and `#5`_. 25 | * Do not populate errorlist representations without any errors of nested 26 | formsets into the errors of the super form. See `#5`_ for details. 27 | 28 | .. _#3: https://github.com/jazzband/django-superform/issues/3 29 | .. _#5: https://github.com/jazzband/django-superform/pull/5 30 | .. _#11: https://github.com/jazzband/django-superform/issues/11 31 | 32 | 0.2.0 33 | ----- 34 | 35 | * Django 1.8 support. 36 | * Initial values given to the ``__init__`` method of the super-form will get 37 | passed through to the nested forms. 38 | * The ``empty_permitted`` argument for modelforms used in a ``ModelFormField`` 39 | is set depending on the ``required`` attribute given to the field. 40 | 41 | 0.1.0 42 | ----- 43 | 44 | * Initial release with proof of concept. 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Gregor Müllegger 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * Neither the name of django-superform nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 21 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 22 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 23 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /tests/test_formsetfield.py: -------------------------------------------------------------------------------- 1 | from django.forms.models import modelformset_factory 2 | from django.template import Context, Template 3 | from django.test import TestCase 4 | from django_superform import SuperModelForm, ModelFormSetField, InlineFormSetField 5 | 6 | from .models import Post, Image 7 | 8 | 9 | ImageFormSet = modelformset_factory(Image, fields=["name"]) 10 | 11 | 12 | class PostForm(SuperModelForm): 13 | class Meta: 14 | model = Post 15 | fields = ["title"] 16 | 17 | images_inlineformset = InlineFormSetField(Post, Image, fields=["name"]) 18 | images_modelformset = ModelFormSetField(ImageFormSet) 19 | 20 | def __init__(self, *args, **kwargs): 21 | super(PostForm, self).__init__(*args, **kwargs) 22 | self.formsets["images_modelformset"].queryset = self.instance.images.all() 23 | 24 | 25 | class TestFormSetField(TestCase): 26 | 27 | def test_inline_formset_field(self): 28 | post = Post.objects.create() 29 | _ = [post.images.create(name="image1"), post.images.create(name="image2")] 30 | form = PostForm(instance=post) 31 | t = Template("{{ form.images_inlineformset }}") 32 | c = Context({"form": form}) 33 | assert 'value="image1"' in t.render(c) 34 | assert 'value="image2"' in t.render(c) 35 | 36 | def test_model_formset_field(self): 37 | post = Post.objects.create() 38 | _ = [post.images.create(name="image1"), post.images.create(name="image2")] 39 | form = PostForm(instance=post) 40 | t = Template("{{ form.images_modelformset }}") 41 | c = Context({"form": form}) 42 | assert 'value="image1"' in t.render(c) 43 | assert 'value="image2"' in t.render(c) 44 | -------------------------------------------------------------------------------- /tests/test_media.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.forms.formsets import formset_factory 3 | from django.test import TestCase 4 | from django_superform import SuperForm, FormSetField 5 | 6 | 7 | class InputWithCSS(forms.TextInput): 8 | """A test widget with media directives.""" 9 | 10 | class Media(object): 11 | css = { 12 | "all": ["http://example.com/email_widget_style.css"], 13 | } 14 | 15 | 16 | class InputWithJS(forms.TextInput): 17 | """Another test widget with media directives.""" 18 | 19 | class Media(object): 20 | js = ["http://example.com/check_username_available.js"] 21 | 22 | 23 | class EmailForm(forms.Form): 24 | email = forms.EmailField(widget=InputWithCSS) 25 | 26 | 27 | EmailFormSet = formset_factory(EmailForm) 28 | 29 | 30 | class FormWithNestedMedia(SuperForm): 31 | username = forms.CharField(widget=InputWithJS) 32 | emails = FormSetField(EmailFormSet) 33 | 34 | class Media(object): 35 | css = { 36 | "all": ["/static/all.css"], 37 | "print": ["/static/print.css"], 38 | } 39 | js = ["/static/1.js"] 40 | 41 | 42 | class FormMediaTests(TestCase): 43 | def test_aggregate_media(self): 44 | """Media gets aggregated, including from composites.""" 45 | form = FormWithNestedMedia() 46 | 47 | expected_css = { 48 | "all": [ 49 | "http://example.com/email_widget_style.css", 50 | "/static/all.css", 51 | ], 52 | "print": ["/static/print.css"], 53 | } 54 | expected_js = [ 55 | "http://example.com/check_username_available.js", 56 | "/static/1.js", 57 | ] 58 | 59 | self.assertEqual(form.media._css, expected_css) 60 | self.assertEqual(form.media._js, expected_js) 61 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import codecs 3 | import re 4 | from os import path 5 | from distutils.core import setup 6 | from setuptools import find_packages 7 | 8 | 9 | def read(*parts): 10 | return codecs.open( 11 | path.join(path.dirname(__file__), *parts), encoding="utf-8" 12 | ).read() 13 | 14 | 15 | def find_version(*file_paths): 16 | version_file = read(*file_paths) 17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 18 | if version_match: 19 | return version_match.group(1) 20 | raise RuntimeError("Unable to find version string.") 21 | 22 | 23 | setup( 24 | name="django-superform", 25 | version=find_version("django_superform", "__init__.py"), 26 | author="Gregor Müllegger", 27 | author_email="gregor@muellegger.de", 28 | packages=find_packages(), 29 | include_package_data=True, 30 | url="https://github.com/jazzband/django-superform", 31 | license="BSD licence, see LICENSE file", 32 | description="So much easier handling of formsets.", 33 | long_description="\n\n".join((read("README.rst"), read("CHANGES.rst"))), 34 | classifiers=[ 35 | "Development Status :: 4 - Beta", 36 | "Environment :: Web Environment", 37 | "Framework :: Django", 38 | "Framework :: Django :: 1.4", 39 | "Framework :: Django :: 1.6", 40 | "Framework :: Django :: 1.7", 41 | "Framework :: Django :: 1.8", 42 | "Framework :: Django :: 1.9", 43 | "Intended Audience :: Developers", 44 | "License :: OSI Approved :: BSD License", 45 | "Natural Language :: English", 46 | "Programming Language :: Python", 47 | "Programming Language :: Python :: 2", 48 | "Programming Language :: Python :: 2.6", 49 | "Programming Language :: Python :: 2.7", 50 | "Programming Language :: Python :: 3", 51 | "Programming Language :: Python :: 3.3", 52 | "Programming Language :: Python :: 3.4", 53 | "Programming Language :: Python :: 3.5", 54 | ], 55 | zip_safe=False, 56 | ) 57 | -------------------------------------------------------------------------------- /django_superform/widgets.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.template import loader 3 | 4 | 5 | class TemplateWidget(forms.Widget): 6 | """ 7 | Template based widget. It renders the ``template_name`` set as attribute 8 | which can be overriden by the ``template_name`` argument to the 9 | ``__init__`` method. 10 | """ 11 | 12 | field = None 13 | template_name = None 14 | value_context_name = None 15 | 16 | def __init__(self, *args, **kwargs): 17 | template_name = kwargs.pop("template_name", None) 18 | if template_name is not None: 19 | self.template_name = template_name 20 | super(TemplateWidget, self).__init__(*args, **kwargs) 21 | self.context_instance = None 22 | 23 | def get_context_data(self): 24 | return {} 25 | 26 | def get_context(self, name, value, attrs=None): 27 | context = { 28 | "name": name, 29 | "hidden": self.is_hidden, 30 | "required": self.is_required, 31 | # In our case ``value`` is the form or formset instance. 32 | "value": value, 33 | } 34 | if self.value_context_name: 35 | context[self.value_context_name] = value 36 | 37 | if self.is_hidden: 38 | context["hidden"] = True 39 | 40 | context.update(self.get_context_data()) 41 | context["attrs"] = self.build_attrs(attrs) 42 | 43 | return context 44 | 45 | def render(self, name, value, attrs=None, **kwargs): 46 | template_name = kwargs.pop("template_name", None) 47 | if template_name is None: 48 | template_name = self.template_name 49 | context = self.get_context(name, value, attrs=attrs or {}, **kwargs) 50 | return loader.render_to_string( 51 | template_name, dictionary=context, context_instance=self.context_instance 52 | ) 53 | 54 | 55 | class FormWidget(TemplateWidget): 56 | template_name = "superform/formfield.html" 57 | value_context_name = "form" 58 | 59 | 60 | class FormSetWidget(TemplateWidget): 61 | template_name = "superform/formsetfield.html" 62 | value_context_name = "formset" 63 | -------------------------------------------------------------------------------- /tests/test_widgets.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django_superform.widgets import TemplateWidget 3 | 4 | 5 | class TemplateWidgetTests(TestCase): 6 | def test_it_takes_template_name_argument(self): 7 | widget = TemplateWidget(template_name="foo.html") 8 | self.assertEqual(widget.template_name, "foo.html") 9 | 10 | def test_it_puts_hidden_variable_in_context(self): 11 | widget = TemplateWidget() 12 | 13 | widget_context = widget.get_context("foo", None) 14 | self.assertEqual(widget_context["hidden"], False) 15 | 16 | class HiddenWidget(TemplateWidget): 17 | is_hidden = True 18 | 19 | hidden_widget = HiddenWidget() 20 | 21 | hidden_widget_context = hidden_widget.get_context("foo", None) 22 | self.assertEqual(hidden_widget_context["hidden"], True) 23 | 24 | def test_it_recognizes_value_context_name(self): 25 | class DifferentValueNameWidget(TemplateWidget): 26 | value_context_name = "strange_name" 27 | 28 | value = object() 29 | widget = DifferentValueNameWidget() 30 | context = widget.get_context("foo", value) 31 | 32 | self.assertTrue(context["strange_name"] is value) 33 | 34 | # The name 'value' is always available, regardless of the 35 | # value_context_name. 36 | self.assertTrue(context["value"] is value) 37 | 38 | def test_it_renders_template_from_attribute(self): 39 | class TemplateAttributeWidget(TemplateWidget): 40 | template_name = "_print_name.html" 41 | 42 | widget = TemplateAttributeWidget() 43 | with self.assertTemplateUsed("_print_name.html"): 44 | result = widget.render(name="A_NAME", value=None, attrs=None) 45 | self.assertEqual(result.strip(), "A_NAME") 46 | 47 | def test_it_render_takes_template_name_argument(self): 48 | class TemplateAttributeWidget(TemplateWidget): 49 | template_name = "_foo.html" 50 | 51 | widget = TemplateAttributeWidget() 52 | with self.assertTemplateUsed("_print_name.html"): 53 | with self.assertTemplateNotUsed("_foo.html"): 54 | result = widget.render( 55 | name="A_NAME", 56 | value=None, 57 | attrs=None, 58 | template_name="_print_name.html", 59 | ) 60 | self.assertEqual(result.strip(), "A_NAME") 61 | -------------------------------------------------------------------------------- /docs/howitworks.rst: -------------------------------------------------------------------------------- 1 | How it works 2 | ============ 3 | 4 | All super forms try to transparently handle the nested forms so that you can 5 | still expect the form to work in all generic usecases, like using the form with 6 | a class based view and similiar situations. 7 | 8 | It might still be useful to understand what is happening under the hood though. 9 | It will help to know what places to look into if something is not working for 10 | you as expected. 11 | 12 | So here is a description of the life cycle of a super form and its composite 13 | fields. 14 | 15 | The ``SuperFormMetaclass`` 16 | -------------------------- 17 | 18 | A super form is using a metaclass to discover all the fields used the form, 19 | just like Django does it with normal form fields. That's what the 20 | :class:`~django_superform.forms.SuperFormMetaclass` is for. 21 | 22 | That will discover all the used :class:`~django_superform.fields.FormField` and 23 | :class:`~django_superform.fields.FormSetField` on your form and puts the 24 | them in a attribute called ``base_composite_fields`` on the form class. 25 | 26 | So you can inspect all the composite fields with it: 27 | 28 | .. code:: python 29 | 30 | class PersonForm(SuperForm): 31 | name = forms.CharField() 32 | social_accounts = FormSet(SocialAccountsForm) 33 | addresses = FormSetField(AddressForm) 34 | 35 | print(PersonForm.base_composite_fields) 36 | 37 | On form instantiation 38 | --------------------- 39 | 40 | When instantiating the form, the composite fields get initialized. The 41 | initialization will instantiate the nested forms and formsets. 42 | 43 | So when creating a super form instance like ``PersonForm()``, it will loop over 44 | all composite fields and call their ``get_form()`` and ``get_formset`` methods. 45 | 46 | These methods will instantiated the nested forms. The nested forms and formsets 47 | will then be stored in the ``forms`` and ``formsets`` dictionaries on the super 48 | form. Using the ``PersonForm`` from above this will be inspectable like this in 49 | the code: 50 | 51 | .. code:: python 52 | 53 | form = PersonForm() 54 | form.forms['social_accounts'] # This is an instance of SocialAccountsForm. 55 | form.formsets['addresses'] # This is a formset instance containing 56 | # multiple AddressForms. 57 | 58 | Validating the form 59 | ------------------- 60 | 61 | TODO: Refine. 62 | 63 | Then when it gets to validation, the super form's ``full_clean()`` and 64 | ``is_valid()`` methods will clean and validate the nested forms/formsets as 65 | well. So ``is_valid()`` will return ``False`` when the super form's fields are 66 | valid but any of the nested forms/formsets is not. 67 | 68 | Errors will be attached to ``form.errors``. For forms it will be a error dict, 69 | for formsets it will be a list of the errors of the formset's forms. 70 | 71 | Saving model forms 72 | ------------------ 73 | 74 | The super form's ``save()`` method will first save the model that it takes 75 | care of. Then the nested forms and then the nested formsets. It will only 76 | return the saved model from the super form, but none of the objects from 77 | nested forms/formsets. This is to keep the API to the normal model forms the 78 | same. 79 | 80 | The ``commit`` argument is respected and passed down. So nothing is saved to 81 | the DB if you don't want it to. In that case, django forms will get a 82 | dynamically created ``save_m2m`` method that can be called later on to then 83 | save all the related stuff. The super form hooks in there to also save the 84 | nested forms and formsets then. And ofcourse it calls their ``save_m2m`` 85 | methods :) 86 | 87 | In the template 88 | --------------- 89 | 90 | TODO. How to use the composite bound fields. How to display errors. 91 | 92 | In template you can get a bound field (like with django's normal form fields) with 93 | {{ form.composite_field_name }}. Or you can get the real form instance with 94 | ``{{ form.forms.composite_field_name }}``, or the formset: ``{{ 95 | form.formsets.composite_field_name }}``. 96 | -------------------------------------------------------------------------------- /tests/test_boundfield.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.forms.forms import BoundField 3 | from django.forms.formsets import formset_factory 4 | from django.test import TestCase 5 | from django_superform import FormField 6 | from django_superform import FormSetField 7 | from django_superform import SuperForm 8 | from django_superform.boundfield import CompositeBoundField 9 | 10 | 11 | class EmailForm(forms.Form): 12 | email = forms.EmailField() 13 | 14 | 15 | EmailFormSet = formset_factory(EmailForm, extra=0) 16 | 17 | 18 | class NameForm(forms.Form): 19 | name = forms.CharField() 20 | 21 | 22 | NameFormSet = formset_factory(NameForm, extra=3) 23 | 24 | 25 | class AccountForm(SuperForm): 26 | username = forms.CharField() 27 | emails = FormSetField(EmailFormSet) 28 | multiple_names = FormSetField(NameFormSet) 29 | nested_form = FormField(NameForm) 30 | 31 | 32 | class CompositeBoundFieldTests(TestCase): 33 | def test_it_is_nonzero_for_empty_formsets(self): 34 | form = AccountForm() 35 | bf = form["emails"] 36 | self.assertTrue(isinstance(bf, CompositeBoundField)) 37 | self.assertEqual(len(bf), 0) 38 | self.assertEqual(bool(bf), True) 39 | 40 | def test_it_is_nonzero_for_filled_formsets(self): 41 | form = AccountForm(initial={"emails": [{"email": "admin@example.com"}]}) 42 | bf = form["emails"] 43 | self.assertTrue(isinstance(bf, CompositeBoundField)) 44 | self.assertEqual(len(bf), 1) 45 | self.assertEqual(bool(bf), True) 46 | 47 | def test_it_is_nonzero_for_forms(self): 48 | form = AccountForm() 49 | bf = form["nested_form"] 50 | self.assertTrue(isinstance(bf, CompositeBoundField)) 51 | self.assertEqual(bool(bf), True) 52 | 53 | def test_it_iterates_over_form_fields(self): 54 | form = AccountForm() 55 | composite_bf = form["nested_form"] 56 | for nested_bf in composite_bf: 57 | self.assertTrue(isinstance(nested_bf, BoundField)) 58 | self.assertEqual(nested_bf.name, "name") 59 | 60 | def test_it_iterates_over_formset_forms(self): 61 | form = AccountForm() 62 | composite_bf = form["multiple_names"] 63 | for nested_form in composite_bf: 64 | self.assertTrue(isinstance(nested_form, NameForm)) 65 | 66 | def test_it_allows_key_access_to_form_fields(self): 67 | form = AccountForm() 68 | composite_f = form["nested_form"] 69 | nested_bf = composite_f["name"] 70 | self.assertTrue(isinstance(nested_bf, BoundField)) 71 | self.assertEqual(nested_bf.name, "name") 72 | 73 | def test_it_raises_keyerror_for_nonexistent_form_field(self): 74 | form = AccountForm() 75 | composite_bf = form["nested_form"] 76 | with self.assertRaises(KeyError): 77 | composite_bf["nope"] 78 | # Also raises KeyError when using an integer lookup. 79 | with self.assertRaises(KeyError): 80 | composite_bf[0] 81 | 82 | def test_it_allows_index_access_to_formset_forms(self): 83 | form = AccountForm( 84 | initial={ 85 | "emails": [ 86 | {"email": "foo@example.com"}, 87 | {"email": "bar@example.com"}, 88 | ] 89 | } 90 | ) 91 | composite_bf = form["emails"] 92 | 93 | form1 = composite_bf[0] 94 | self.assertTrue(isinstance(form1, EmailForm)) 95 | self.assertEqual(form1.initial, {"email": "foo@example.com"}) 96 | 97 | form2 = composite_bf[1] 98 | self.assertTrue(isinstance(form2, EmailForm)) 99 | self.assertEqual(form2.initial, {"email": "bar@example.com"}) 100 | 101 | def test_it_raises_indexerror_for_nonexistent_formset_forms(self): 102 | form = AccountForm() 103 | composite_bf = form["emails"] 104 | with self.assertRaises(IndexError): 105 | composite_bf[0] 106 | # Raises TypeError when using a string lookup as an integer is a hard 107 | # requirement. 108 | with self.assertRaises(TypeError): 109 | composite_bf["name"] 110 | -------------------------------------------------------------------------------- /django_superform/boundfield.py: -------------------------------------------------------------------------------- 1 | from django.forms.forms import BoundField 2 | 3 | 4 | class CompositeBoundField(BoundField): 5 | """ 6 | This is an emulation of the BoundField that is returned if you call 7 | ``form['field_name']`` for an ordinary django field. A 8 | ``CompositeBoundField`` will be used if you access a nested form or formset 9 | with ``form['nested_form']``. 10 | 11 | The ``CompositeBoundField`` has the purpose of providing the same interface 12 | to other libraries that use a ``BoundField`` instance for rendering. 13 | """ 14 | 15 | # __init__, no changes required 16 | # __str__, no changes required 17 | 18 | def __iter__(self): 19 | """ 20 | Iterates over the composite field. For ``FormSetField`` this will 21 | return form instances. For ``FormField`` this will return bound form 22 | fields. 23 | """ 24 | for item in self.form.get_composite_field_value(self.name): 25 | yield item 26 | 27 | # __len__, no changes required 28 | 29 | def __getitem__(self, item): 30 | """ 31 | Allow named access to the form fields and forms contained in the 32 | composite fields. For ``FormField``s you get a bound field, for 33 | ``FormSetField`` you can use and integer index lookup for a specific 34 | form. 35 | 36 | Examples: 37 | 38 | .. code:: python 39 | 40 | # Gives ``street`` field of nested ``address`` form. 41 | form['address']['street'] 42 | 43 | .. code:: django 44 | 45 | {# That is useful in the template as well: #} 46 | {{ form.address.street }} 47 | """ 48 | composite_item = self.form.get_composite_field_value(self.name) 49 | return composite_item[item] 50 | 51 | def __bool__(self): 52 | """ 53 | Never evaluate the bound field as False. This is necessary since 54 | otherwise python will fallback to the __len__ implementation of the 55 | object. However this might return 0 for a ``CompositeBoundField`` since 56 | it might contain zero forms in the given formset. 57 | """ 58 | return True 59 | 60 | # Python 2's __bool__ is called __nonzero__. 61 | __nonzero__ = __bool__ 62 | 63 | @property 64 | def errors(self): 65 | """ 66 | Returns an ErrorList for this field, either the errors of the contained 67 | formset or of the inlined form. 68 | """ 69 | # TODO: We could make this work and return the forms/formsets errors. 70 | return self.form.error_class() 71 | 72 | # as_widget, no changes required 73 | 74 | def as_text(self, attrs=None, **kwargs): 75 | """ 76 | Not supported. This does not make sense for a CompositeBoundField. 77 | 78 | Will raise ``NotImplementedError``. 79 | """ 80 | raise NotImplementedError 81 | 82 | def as_textarea(self, attrs=None, **kwargs): 83 | """ 84 | Not supported. This does not make sense for a CompositeBoundField. 85 | 86 | Will raise ``NotImplementedError``. 87 | """ 88 | raise NotImplementedError 89 | 90 | def as_hidden(self, attrs=None, **kwargs): 91 | """ 92 | Raises ``NotImplementedError``. 93 | 94 | An implementation might be useful, which could return a string of HTML 95 | for representing the nested form or formsets as multiple . 97 | 98 | Pull request are welcome. 99 | """ 100 | # TODO: This might make sense. But we don't support it yet. 101 | raise NotImplementedError 102 | 103 | @property 104 | def data(self): 105 | """ 106 | Is always ``None``. This doesn't make much sense for a 107 | ``CompositeField``. 108 | """ 109 | return None 110 | 111 | def value(self): 112 | """ 113 | Returns the form/formset for this BoundField. This is passed into the 114 | call for ``self.widget.render`` in the ``as_widget`` method. 115 | """ 116 | return self.form.get_composite_field_value(self.name) 117 | 118 | # label_tag, no changes required 119 | # auto_id, no changes required 120 | # id_for_label, no changes required 121 | -------------------------------------------------------------------------------- /tests/test_formfield.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.template import Context, Template 3 | from django.test import TestCase 4 | from django_superform import SuperForm, FormField 5 | 6 | 7 | class AddressForm(forms.Form): 8 | street = forms.CharField() 9 | city = forms.CharField() 10 | 11 | def __init__(self, *args, **kwargs): 12 | self.custom_attribute = kwargs.pop("custom_kwarg", None) 13 | super(AddressForm, self).__init__(*args, **kwargs) 14 | 15 | 16 | class MultiAddressForm(SuperForm): 17 | address1 = FormField(AddressForm) 18 | address2 = FormField(AddressForm) 19 | 20 | 21 | class RegistrationForm(SuperForm): 22 | first_name = forms.CharField() 23 | last_name = forms.CharField() 24 | address = FormField(AddressForm) 25 | address_custom_kwarg = FormField(AddressForm, kwargs={"custom_kwarg": True}) 26 | address_initial = FormField( 27 | AddressForm, kwargs={"initial": {"street": "Homebase 42", "city": "Supertown"}} 28 | ) 29 | more_addresses = FormField(MultiAddressForm) 30 | 31 | 32 | class FormFieldTests(TestCase): 33 | def test_is_in_composite_fields(self): 34 | superform = RegistrationForm() 35 | self.assertTrue("address" in superform.composite_fields) 36 | 37 | def test_is_in_forms_attr(self): 38 | superform = RegistrationForm() 39 | self.assertTrue("address" in superform.forms) 40 | self.assertTrue(isinstance(superform.forms["address"], AddressForm)) 41 | 42 | def test_form_prefix(self): 43 | superform = RegistrationForm() 44 | form = superform.forms["address"] 45 | self.assertEqual(form.prefix, "form-address") 46 | 47 | def test_field_name(self): 48 | superform = RegistrationForm() 49 | boundfield = superform.forms["address"]["street"] 50 | self.assertEqual(boundfield.html_name, "form-address-street") 51 | 52 | superform = RegistrationForm(prefix="registration") 53 | boundfield = superform.forms["address"]["street"] 54 | self.assertEqual(boundfield.html_name, "registration-form-address-street") 55 | 56 | def test_form_kwargs(self): 57 | superform = RegistrationForm() 58 | form = superform.forms["address_custom_kwarg"] 59 | self.assertEqual(form.custom_attribute, True) 60 | 61 | def test_form_kwargs_initial(self): 62 | superform = RegistrationForm() 63 | form = superform.forms["address_initial"] 64 | self.assertEqual(form["street"].value(), "Homebase 42") 65 | self.assertEqual(form["city"].value(), "Supertown") 66 | 67 | def test_initial_pass_through(self): 68 | superform = RegistrationForm( 69 | initial={"first_name": "Patricia", "address": {"street": "Default Road"}} 70 | ) 71 | form = superform.forms["address"] 72 | self.assertEqual(superform["first_name"].value(), "Patricia") 73 | self.assertEqual(superform["last_name"].value(), None) 74 | self.assertEqual(form["street"].value(), "Default Road") 75 | self.assertEqual(form["city"].value(), None) 76 | 77 | def test_initial_pass_through_with_multiple_layers(self): 78 | superform = RegistrationForm( 79 | initial={ 80 | "more_addresses": { 81 | "address1": {"street": "Fooway", "city": "Testcity"}, 82 | "address2": { 83 | "street": "Barboulevard", 84 | }, 85 | } 86 | } 87 | ) 88 | address1 = superform.forms["more_addresses"].forms["address1"] 89 | address2 = superform.forms["more_addresses"].forms["address2"] 90 | self.assertEqual(address1["street"].value(), "Fooway") 91 | self.assertEqual(address1["city"].value(), "Testcity") 92 | self.assertEqual(address2["street"].value(), "Barboulevard") 93 | self.assertEqual(address2["city"].value(), None) 94 | 95 | def test_rendering_form_field(self): 96 | form = MultiAddressForm( 97 | initial={ 98 | "address1": { 99 | "street": "Fooway", 100 | }, 101 | "address2": { 102 | "street": "Barboulevard", 103 | }, 104 | } 105 | ) 106 | template = Template("{{ form.address1 }} {{ form.address2 }}") 107 | rendered = template.render(Context({"form": form})) 108 | assert 'value="Fooway"' in rendered 109 | assert 'value="Barboulevard"' in rendered 110 | -------------------------------------------------------------------------------- /tests/test_superform.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django import forms 3 | from django.forms.forms import ErrorDict, ErrorList 4 | from django.forms.formsets import formset_factory 5 | from django.test import TestCase 6 | from django_superform import FormField 7 | from django_superform import FormSetField 8 | from django_superform import SuperForm 9 | 10 | 11 | class EmailForm(forms.Form): 12 | email = forms.EmailField() 13 | 14 | 15 | EmailFormSet = formset_factory(EmailForm) 16 | 17 | 18 | class NameForm(forms.Form): 19 | name = forms.CharField() 20 | 21 | 22 | class AccountForm(SuperForm): 23 | username = forms.CharField() 24 | emails = FormSetField(EmailFormSet) 25 | nested_form = FormField(NameForm) 26 | 27 | 28 | class SubclassedAccountForm(AccountForm): 29 | nested_form_2 = FormField(NameForm) 30 | 31 | 32 | class SuperFormTests(TestCase): 33 | def test_base_composite_fields(self): 34 | self.assertEqual(list(AccountForm.base_fields.keys()), ["username"]) 35 | 36 | self.assertTrue(hasattr(AccountForm, "base_composite_fields")) 37 | self.assertEqual( 38 | list(AccountForm.base_composite_fields.keys()), ["emails", "nested_form"] 39 | ) 40 | self.assertTrue(hasattr(SubclassedAccountForm, "base_composite_fields")) 41 | self.assertEqual( 42 | list(SubclassedAccountForm.base_composite_fields.keys()), 43 | ["emails", "nested_form", "nested_form_2"], 44 | ) 45 | 46 | field = AccountForm.base_composite_fields["emails"] 47 | self.assertIsInstance(field, FormSetField) 48 | 49 | field = AccountForm.base_composite_fields["nested_form"] 50 | self.assertIsInstance(field, FormField) 51 | 52 | self.assertFalse(hasattr(AccountForm, "forms")) 53 | self.assertFalse(hasattr(AccountForm, "formsets")) 54 | 55 | def test_fields_in_instantiated_forms(self): 56 | form = AccountForm() 57 | 58 | self.assertTrue(hasattr(form, "composite_fields")) 59 | self.assertTrue(hasattr(form, "forms")) 60 | self.assertTrue(hasattr(form, "formsets")) 61 | 62 | self.assertEqual(list(form.forms.keys()), ["nested_form"]) 63 | nested_form = form.forms["nested_form"] 64 | self.assertIsInstance(nested_form, NameForm) 65 | 66 | self.assertEqual(list(form.formsets.keys()), ["emails"]) 67 | formset = form.formsets["emails"] 68 | self.assertIsInstance(formset, EmailFormSet) 69 | 70 | 71 | class FormSetsInSuperFormsTests(TestCase): 72 | def setUp(self): 73 | self.formset_data = { 74 | "formset-emails-INITIAL_FORMS": 0, 75 | "formset-emails-TOTAL_FORMS": 1, 76 | "formset-emails-MAX_NUM_FORMS": 3, 77 | } 78 | 79 | def test_prefix(self): 80 | form = AccountForm() 81 | formset = form.formsets["emails"] 82 | self.assertEqual(formset.prefix, "formset-emails") 83 | 84 | def test_validation(self): 85 | data = self.formset_data.copy() 86 | form = AccountForm(data) 87 | self.assertEqual(form.is_valid(), False) 88 | self.assertTrue(form.errors["username"]) 89 | self.assertFalse("emails" in form.errors) 90 | 91 | self.assertTrue(form.errors["nested_form"]) 92 | self.assertTrue(form.errors["nested_form"]["name"]) 93 | self.assertIsInstance(form.errors["nested_form"], ErrorDict) 94 | 95 | data = self.formset_data.copy() 96 | data["formset-emails-INITIAL_FORMS"] = 1 97 | form = AccountForm(data) 98 | self.assertEqual(form.is_valid(), False) 99 | self.assertTrue(form.errors["username"]) 100 | self.assertTrue(form.errors["emails"]) 101 | self.assertIsInstance(form.errors["emails"], ErrorList) 102 | 103 | def test_empty_form_has_no_errors(self): 104 | """Empty forms have no errors.""" 105 | form = AccountForm() 106 | 107 | self.assertFalse(form.is_valid()) 108 | self.assertFalse(form.errors) 109 | 110 | def test_formset_errors(self): 111 | """Formset errors get propagated properly.""" 112 | data = { 113 | "formset-emails-INITIAL_FORMS": 0, 114 | "formset-emails-TOTAL_FORMS": 1, 115 | "formset-emails-MAX_NUM_FORMS": 3, 116 | "formset-emails-0-email": "foobar", 117 | "username": "TestUser", 118 | "form-nested_form-name": "Some Name", 119 | } 120 | form = AccountForm(data) 121 | 122 | if django.VERSION >= (1, 5): 123 | expected_errors = ErrorList( 124 | [ErrorDict({"email": ErrorList(["Enter a valid email address."])})] 125 | ) 126 | else: 127 | # Fix for Django 1.4 128 | expected_errors = ErrorList( 129 | [ErrorDict({"email": ErrorList(["Enter a valid e-mail address."])})] 130 | ) 131 | 132 | self.assertFalse(form.is_valid()) 133 | self.assertEqual(form.errors["emails"], expected_errors) 134 | -------------------------------------------------------------------------------- /tests/test_modelformfield.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.template import Context, Template 3 | from django.test import TestCase 4 | from django_superform import SuperModelForm, ModelFormField 5 | 6 | from .models import Series, Post 7 | 8 | 9 | class UseFirstModelFormField(ModelFormField): 10 | def get_instance(self, form, name): 11 | try: 12 | return self.form_class._meta.model._default_manager.all()[0] 13 | except IndexError: 14 | return None 15 | 16 | 17 | class SeriesForm(forms.ModelForm): 18 | class Meta: 19 | model = Series 20 | fields = ("title",) 21 | 22 | 23 | class PostForm(SuperModelForm): 24 | series = ModelFormField(SeriesForm) 25 | 26 | class Meta: 27 | model = Post 28 | fields = ("title",) 29 | 30 | 31 | class UnrequiredSeriesPostForm(SuperModelForm): 32 | series = ModelFormField(SeriesForm, required=False) 33 | 34 | class Meta: 35 | model = Post 36 | fields = ("title",) 37 | 38 | 39 | class UseExistingSeriesPostForm(SuperModelForm): 40 | existing_series = UseFirstModelFormField(SeriesForm) 41 | 42 | class Meta: 43 | model = Post 44 | fields = ("title",) 45 | 46 | 47 | class FormFieldTests(TestCase): 48 | def test_is_in_composite_fields(self): 49 | superform = PostForm() 50 | self.assertTrue("series" in superform.composite_fields) 51 | 52 | def test_is_in_forms_attr(self): 53 | superform = PostForm() 54 | self.assertTrue("series" in superform.forms) 55 | self.assertTrue(isinstance(superform.forms["series"], SeriesForm)) 56 | 57 | def test_form_prefix(self): 58 | superform = PostForm() 59 | form = superform.forms["series"] 60 | self.assertEqual(form.prefix, "form-series") 61 | 62 | def test_form_save(self): 63 | superform = PostForm( 64 | { 65 | "title": "New blog post", 66 | "form-series-title": "Unrelated series", 67 | } 68 | ) 69 | superform.save() 70 | 71 | self.assertEqual(Series.objects.count(), 1) 72 | series = Series.objects.get() 73 | 74 | self.assertEqual(series.title, "Unrelated series") 75 | 76 | self.assertEqual(Post.objects.count(), 1) 77 | post = Post.objects.get() 78 | self.assertEqual(post.title, "New blog post") 79 | 80 | # The relation between post and series IS NOT done by the 81 | # ModelFormField. It only takes care of nesting. The relation can be 82 | # handled with the ModelFormField subclasses like ForeignKeyFormField. 83 | self.assertEqual(post.series, None) 84 | 85 | def test_override_get_instance(self): 86 | existing_series = Series.objects.create(title="Existing series") 87 | 88 | superform = UseExistingSeriesPostForm() 89 | self.assertEqual(superform.forms["existing_series"].instance, existing_series) 90 | self.assertEqual( 91 | superform.forms["existing_series"]["title"].value(), "Existing series" 92 | ) 93 | 94 | def test_save_existing_instance(self): 95 | existing_series = Series.objects.create(title="Existing series") 96 | 97 | superform = UseExistingSeriesPostForm( 98 | { 99 | "title": "Blog post", 100 | "form-existing_series-title": "Changed name", 101 | } 102 | ) 103 | superform.save() 104 | 105 | changed_series = Series.objects.get() 106 | self.assertEqual(changed_series.pk, existing_series.pk) 107 | self.assertEqual(changed_series.title, "Changed name") 108 | 109 | def test_required(self): 110 | form = PostForm( 111 | { 112 | "title": "New blog post", 113 | "form-series-title": "", 114 | } 115 | ) 116 | form.full_clean() 117 | 118 | self.assertFalse(form.is_valid()) 119 | self.assertTrue("required" in str(form.errors["series"]["title"])) 120 | 121 | # Test when key is not in data. 122 | form = PostForm( 123 | { 124 | "title": "New blog post", 125 | } 126 | ) 127 | form.full_clean() 128 | 129 | self.assertFalse(form.is_valid()) 130 | self.assertTrue("required" in str(form.errors["series"]["title"])) 131 | 132 | def test_not_required(self): 133 | form = UnrequiredSeriesPostForm( 134 | { 135 | "title": "New blog post", 136 | "form-series-title": "", 137 | } 138 | ) 139 | form.full_clean() 140 | 141 | self.assertTrue(form.is_valid()) 142 | 143 | form.save() 144 | self.assertEqual(Post.objects.count(), 1) 145 | self.assertEqual(Series.objects.count(), 0) 146 | 147 | def test_form_render(self): 148 | form = PostForm( 149 | initial={ 150 | "series": { 151 | "title": "my title", 152 | }, 153 | } 154 | ) 155 | rendered = Template("{{ form.series }}").render(Context({"form": form})) 156 | assert 'value="my title"' in rendered 157 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-superform 2 | ================ 3 | 4 | **Less sucking formsets.** 5 | 6 | |build| |package| |jazzband| 7 | 8 | Documentation_ | Changelog_ | Requirements_ | Installation_ 9 | 10 | A ``SuperForm`` is absolutely super if you want to nest a lot of forms in each 11 | other. Use formsets and nested forms inside the ``SuperForm``. The 12 | ``SuperForm`` will take care of its children! 13 | 14 | Imagine you want to have a view that shows and validates a form and a formset. 15 | Let's say you have a signup form where users can enter multiple email 16 | addresses. Django provides formsets_ for this usecase, but handling those in a 17 | view is usually quite troublesome. You need to validate both the form and the 18 | formset manually and you cannot use django's generic FormView_. So here comes 19 | **django-superform** into play. 20 | 21 | .. _formsets: https://docs.djangoproject.com/en/1.10/topics/forms/formsets/ 22 | .. _FormView: https://docs.djangoproject.com/en/1.10/ref/class-based-views/generic-editing/#formview 23 | 24 | Here we have an example for the usecase. Let's have a look at the 25 | ``forms.py``: 26 | 27 | .. code-block:: python 28 | 29 | from django import forms 30 | from django_superform import SuperModelForm, InlineFormSetField 31 | from myapp.models import Account, Email 32 | 33 | 34 | class EmailForm(forms.ModelForm): 35 | class Meta: 36 | model = Email 37 | fields = ('account', 'email',) 38 | 39 | 40 | EmailFormSet = modelformset_factory(EmailForm) 41 | 42 | 43 | class SignupForm(SuperModelForm): 44 | username = forms.CharField() 45 | # The model `Email` has a ForeignKey called `user` to `Account`. 46 | emails = InlineFormSetField(formset_class=EmailFormSet) 47 | 48 | class Meta: 49 | model = Account 50 | fields = ('username',) 51 | 52 | 53 | So we assign the ``EmailFormSet`` as a field directly to the ``SignupForm``. 54 | That's where it belongs! Ok and how do I handle this composite form in the 55 | view? Have a look: 56 | 57 | .. code-block:: python 58 | 59 | def post_form(request): 60 | if request.method == 'POST': 61 | form = PostForm(request.POST) 62 | if form.is_valid(): 63 | account = form.save() 64 | return HttpResponseRedirect('/success/') 65 | else: 66 | form = PostForm() 67 | return render_to_response('post_form.html', { 68 | 'form', 69 | }, context_instance=RequestContext(request)) 70 | 71 | 72 | No, we don't do anything different as we would do without having the 73 | ``FormSet`` on the ``SignupForm``. That way you are free to implement all the 74 | logic in the form it self where it belongs and use generic views like 75 | ``CreateView`` you would use them with simple forms. Want an example for this? 76 | 77 | .. code-block:: python 78 | 79 | from django.views.generic import CreateView 80 | from myapp.models import Account 81 | from myapp.forms import SignupForm 82 | 83 | 84 | class SignupView(CreateView): 85 | model = Account 86 | form_class = SignupForm 87 | 88 | 89 | urlpatterns = patterns('', 90 | url('^signup/$', SignupView.as_view()), 91 | ) 92 | 93 | And it just works. 94 | 95 | .. _Requirements: 96 | 97 | Requirements 98 | ------------ 99 | 100 | - Python 2.7 or Python 3.3+ or PyPy 101 | - Django 1.4+ 102 | 103 | .. _Installation: 104 | 105 | Installation 106 | ------------ 107 | 108 | Install the desired version with pip_:: 109 | 110 | pip install django-superform 111 | 112 | .. _pip: https://pip.pypa.io/en/stable/ 113 | 114 | Then add ``django-superform`` to ``INSTALLED_APPS`` in your settings file: 115 | 116 | .. code-block:: python 117 | 118 | INSTALLED_APPS = ( 119 | # ... 120 | 'django_superform', 121 | # ... 122 | ) 123 | 124 | Development 125 | ----------- 126 | 127 | - Clone django-superform:: 128 | 129 | git clone git@github.com:jazzband/django-superform.git 130 | 131 | - ``cd`` into the repository:: 132 | 133 | cd django-superform 134 | 135 | - Create a new virtualenv_. 136 | - Install the project requirements:: 137 | 138 | pip install -e . 139 | pip install -r requirements.txt 140 | 141 | - Run the test suite:: 142 | 143 | tox 144 | # Or if you want to iterate quickly and not test against all supported 145 | # Python and Django versions: 146 | py.test 147 | 148 | .. _virtualenv: https://virtualenv.pypa.io/en/latest/ 149 | 150 | Documentation 151 | ------------- 152 | 153 | Full documentation is available on Read the Docs: https://django-superform.readthedocs.org/ 154 | 155 | .. _Changelog: https://django-superform.readthedocs.org/en/latest/changelog.html 156 | .. _Documentation: https://django-superform.readthedocs.org/ 157 | 158 | .. |build| image:: https://github.com/jazzband/django-superform/actions/workflows/pipeline.yml/badge.svg 159 | :alt: Build Status 160 | :target: https://github.com/jazzband/django-superform/actions 161 | .. |package| image:: https://badge.fury.io/py/django-superform.svg 162 | :alt: Package Version 163 | :target: http://badge.fury.io/py/django-superform 164 | .. |jazzband| image:: https://jazzband.co/static/img/badge.svg 165 | :target: https://jazzband.co 166 | :alt: Jazzband 167 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-superform.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-superform.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-superform" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-superform" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-superform.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-superform.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-superform documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Nov 7 10:47:24 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import codecs 16 | from datetime import date 17 | import re 18 | import sys 19 | from os import path 20 | 21 | # If extensions (or modules to document with autodoc) are in another directory, 22 | # add these directories to sys.path here. If the directory is relative to the 23 | # documentation root, use os.path.abspath to make it absolute, like shown here. 24 | doc_path = path.dirname(path.abspath(__file__)) 25 | project_base_path = path.dirname(doc_path) 26 | 27 | sys.path.insert(0, project_base_path) 28 | 29 | # -- General configuration ------------------------------------------------ 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | "sphinx.ext.autodoc", 39 | "sphinx.ext.viewcode", 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ["_templates"] 44 | 45 | # The suffix of source filenames. 46 | source_suffix = ".rst" 47 | 48 | # The encoding of source files. 49 | # source_encoding = 'utf-8-sig' 50 | 51 | # The master toctree document. 52 | master_doc = "index" 53 | 54 | # General information about the project. 55 | project = "django-superform" 56 | copyright = str(date.today().year) + ", Gregor Müllegger" 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | 62 | 63 | def read(*parts): 64 | return codecs.open( 65 | path.join(path.dirname(__file__), *parts), encoding="utf-8" 66 | ).read() 67 | 68 | 69 | def find_version(*file_paths): 70 | version_file = read(*file_paths) 71 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 72 | if version_match: 73 | return version_match.group(1) 74 | raise RuntimeError("Unable to find version string.") 75 | 76 | 77 | version_tuple = find_version( 78 | path.join(project_base_path, "django_superform", "__init__.py") 79 | ).split(".") 80 | # 81 | # The short X.Y version. 82 | version = ".".join(version_tuple[:2]) 83 | # The full version, including alpha/beta/rc tags. 84 | release = ".".join(version_tuple) 85 | 86 | # The language for content autogenerated by Sphinx. Refer to documentation 87 | # for a list of supported languages. 88 | # language = None 89 | 90 | # There are two options for replacing |today|: either, you set today to some 91 | # non-false value, then it is used: 92 | # today = '' 93 | # Else, today_fmt is used as the format for a strftime call. 94 | # today_fmt = '%B %d, %Y' 95 | 96 | # List of patterns, relative to source directory, that match files and 97 | # directories to ignore when looking for source files. 98 | exclude_patterns = ["_build"] 99 | 100 | # The reST default role (used for this markup: `text`) to use for all 101 | # documents. 102 | # default_role = None 103 | 104 | # If true, '()' will be appended to :func: etc. cross-reference text. 105 | # add_function_parentheses = True 106 | 107 | # If true, the current module name will be prepended to all description 108 | # unit titles (such as .. function::). 109 | # add_module_names = True 110 | 111 | # If true, sectionauthor and moduleauthor directives will be shown in the 112 | # output. They are ignored by default. 113 | # show_authors = False 114 | 115 | # The name of the Pygments (syntax highlighting) style to use. 116 | pygments_style = "sphinx" 117 | 118 | # A list of ignored prefixes for module index sorting. 119 | # modindex_common_prefix = [] 120 | 121 | # If true, keep warnings as "system message" paragraphs in the built documents. 122 | # keep_warnings = False 123 | 124 | 125 | # -- Options for HTML output ---------------------------------------------- 126 | 127 | # The theme to use for HTML and HTML Help pages. See the documentation for 128 | # a list of builtin themes. 129 | # html_theme = 'alabaster' 130 | 131 | # Theme options are theme-specific and customize the look and feel of a theme 132 | # further. For a list of options available for each theme, see the 133 | # documentation. 134 | # html_theme_options = {} 135 | 136 | # Add any paths that contain custom themes here, relative to this directory. 137 | # html_theme_path = [] 138 | 139 | # The name for this set of Sphinx documents. If None, it defaults to 140 | # " v documentation". 141 | # html_title = None 142 | 143 | # A shorter title for the navigation bar. Default is the same as html_title. 144 | # html_short_title = None 145 | 146 | # The name of an image file (relative to this directory) to place at the top 147 | # of the sidebar. 148 | # html_logo = None 149 | 150 | # The name of an image file (within the static path) to use as favicon of the 151 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 152 | # pixels large. 153 | # html_favicon = None 154 | 155 | # Add any paths that contain custom static files (such as style sheets) here, 156 | # relative to this directory. They are copied after the builtin static files, 157 | # so a file named "default.css" will overwrite the builtin "default.css". 158 | html_static_path = ["_static"] 159 | 160 | # Add any extra paths that contain custom files (such as robots.txt or 161 | # .htaccess) here, relative to this directory. These files are copied 162 | # directly to the root of the documentation. 163 | # html_extra_path = [] 164 | 165 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 166 | # using the given strftime format. 167 | # html_last_updated_fmt = '%b %d, %Y' 168 | 169 | # If true, SmartyPants will be used to convert quotes and dashes to 170 | # typographically correct entities. 171 | # html_use_smartypants = True 172 | 173 | # Custom sidebar templates, maps document names to template names. 174 | # html_sidebars = {} 175 | 176 | # Additional templates that should be rendered to pages, maps page names to 177 | # template names. 178 | # html_additional_pages = {} 179 | 180 | # If false, no module index is generated. 181 | # html_domain_indices = True 182 | 183 | # If false, no index is generated. 184 | # html_use_index = True 185 | 186 | # If true, the index is split into individual pages for each letter. 187 | # html_split_index = False 188 | 189 | # If true, links to the reST sources are added to the pages. 190 | # html_show_sourcelink = True 191 | 192 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 193 | # html_show_sphinx = True 194 | 195 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 196 | # html_show_copyright = True 197 | 198 | # If true, an OpenSearch description file will be output, and all pages will 199 | # contain a tag referring to it. The value of this option must be the 200 | # base URL from which the finished HTML is served. 201 | # html_use_opensearch = '' 202 | 203 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 204 | # html_file_suffix = None 205 | 206 | # Output file base name for HTML help builder. 207 | htmlhelp_basename = "django-superformdoc" 208 | 209 | 210 | # -- Options for LaTeX output --------------------------------------------- 211 | 212 | latex_elements = { 213 | # The paper size ('letterpaper' or 'a4paper'). 214 | #'papersize': 'letterpaper', 215 | # The font size ('10pt', '11pt' or '12pt'). 216 | #'pointsize': '10pt', 217 | # Additional stuff for the LaTeX preamble. 218 | #'preamble': '', 219 | } 220 | 221 | # Grouping the document tree into LaTeX files. List of tuples 222 | # (source start file, target name, title, 223 | # author, documentclass [howto, manual, or own class]). 224 | latex_documents = [ 225 | ( 226 | "index", 227 | "django-superform.tex", 228 | "django-superform Documentation", 229 | "Gregor Müllegger", 230 | "manual", 231 | ), 232 | ] 233 | 234 | # The name of an image file (relative to this directory) to place at the top of 235 | # the title page. 236 | # latex_logo = None 237 | 238 | # For "manual" documents, if this is true, then toplevel headings are parts, 239 | # not chapters. 240 | # latex_use_parts = False 241 | 242 | # If true, show page references after internal links. 243 | # latex_show_pagerefs = False 244 | 245 | # If true, show URL addresses after external links. 246 | # latex_show_urls = False 247 | 248 | # Documents to append as an appendix to all manuals. 249 | # latex_appendices = [] 250 | 251 | # If false, no module index is generated. 252 | # latex_domain_indices = True 253 | 254 | 255 | # -- Options for manual page output --------------------------------------- 256 | 257 | # One entry per manual page. List of tuples 258 | # (source start file, name, description, authors, manual section). 259 | man_pages = [ 260 | ( 261 | "index", 262 | "django-superform", 263 | "django-superform Documentation", 264 | ["Gregor Müllegger"], 265 | 1, 266 | ) 267 | ] 268 | 269 | # If true, show URL addresses after external links. 270 | # man_show_urls = False 271 | 272 | 273 | # -- Options for Texinfo output ------------------------------------------- 274 | 275 | # Grouping the document tree into Texinfo files. List of tuples 276 | # (source start file, target name, title, author, 277 | # dir menu entry, description, category) 278 | texinfo_documents = [ 279 | ( 280 | "index", 281 | "django-superform", 282 | "django-superform Documentation", 283 | "Gregor Müllegger", 284 | "django-superform", 285 | "One line description of project.", 286 | "Miscellaneous", 287 | ), 288 | ] 289 | 290 | # Documents to append as an appendix to all manuals. 291 | # texinfo_appendices = [] 292 | 293 | # If false, no module index is generated. 294 | # texinfo_domain_indices = True 295 | 296 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 297 | # texinfo_show_urls = 'footnote' 298 | 299 | # If true, do not generate a @detailmenu in the "Top" node's menu. 300 | # texinfo_no_detailmenu = False 301 | -------------------------------------------------------------------------------- /django_superform/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is awesome. And needs more documentation. 3 | 4 | To bring some light in the big number of classes in this file: 5 | 6 | First there are: 7 | 8 | * ``SuperForm`` 9 | * ``SuperModelForm`` 10 | 11 | They are the forms that you probably want to use in your own code. They are 12 | direct base classes of ``django.forms.Form`` and ``django.forms.ModelForm`` 13 | and have the formset functionallity of this module backed in. They are ready 14 | to use. Subclass them and be happy. 15 | 16 | Then there are: 17 | 18 | * ``SuperFormMixin`` 19 | * ``SuperModelFormMixin`` 20 | 21 | These are the mixins you can use if you don't want to subclass from 22 | ``django.forms.Form`` for whatever reason. The ones with Base at the beginning 23 | don't have a metaclass attached. The ones without the Base in the name have 24 | the relevant metaclass in place that handles the search for 25 | ``FormSetField``s. 26 | 27 | 28 | Here is an example on how you can use this module:: 29 | 30 | from django import forms 31 | from django_superform import SuperModelForm, FormSetField 32 | from .forms import CommentFormSet 33 | 34 | 35 | class PostForm(SuperModelForm): 36 | title = forms.CharField() 37 | text = forms.CharField() 38 | comments = FormSetField(CommentFormSet) 39 | 40 | # Now you can use the form in the view: 41 | 42 | def post_form(request): 43 | if request.method == 'POST': 44 | form = PostForm(request.POST, request.FILES) 45 | if form.is_valid(): 46 | obj = form.save() 47 | return HttpResponseRedirect(obj.get_absolute_url()) 48 | else: 49 | form = PostForm() 50 | return render_to_response('post_form.html', { 51 | 'form', 52 | }, context_instance=RequestContext(request)) 53 | 54 | And yes, thanks for asking, the ``form.is_valid()`` and ``form.save()`` calls 55 | transparantly propagate to the defined comments formset and call their 56 | ``is_valid()`` and ``save()`` methods. So you don't have to do anything 57 | special in your view! 58 | 59 | Now to how you can access the instantiated formsets:: 60 | 61 | >>> form = PostForm() 62 | >>> form.composite_fields['comments'] 63 | 64 | 65 | Or in the template:: 66 | 67 | {{ form.as_p }} 68 | 69 | {{ form.composite_fields.comments.management_form }} 70 | {% for fieldset_form in form.composite_fields.comments %} 71 | {{ fieldset_form.as_p }} 72 | {% endfor %} 73 | 74 | You're welcome. 75 | 76 | """ 77 | 78 | from functools import reduce 79 | from django import forms 80 | from django.forms.forms import DeclarativeFieldsMetaclass, ErrorDict, ErrorList 81 | from django.forms.models import ModelFormMetaclass 82 | from django.utils import six 83 | import copy 84 | 85 | from .fields import CompositeField 86 | 87 | try: 88 | from collections import OrderedDict 89 | except ImportError: 90 | from django.utils.datastructures import SortedDict as OrderedDict 91 | 92 | 93 | class DeclerativeCompositeFieldsMetaclass(type): 94 | """ 95 | Metaclass that converts FormField and FormSetField attributes to a 96 | dictionary called `composite_fields`. It will also include all composite 97 | fields from parent classes. 98 | """ 99 | 100 | def __new__(mcs, name, bases, attrs): 101 | # Collect composite fields from current class. 102 | current_fields = [] 103 | for key, value in list(attrs.items()): 104 | if isinstance(value, CompositeField): 105 | current_fields.append((key, value)) 106 | attrs.pop(key) 107 | current_fields.sort(key=lambda x: x[1].creation_counter) 108 | attrs["declared_composite_fields"] = OrderedDict(current_fields) 109 | 110 | new_class = super(DeclerativeCompositeFieldsMetaclass, mcs).__new__( 111 | mcs, name, bases, attrs 112 | ) 113 | 114 | # Walk through the MRO. 115 | declared_fields = OrderedDict() 116 | for base in reversed(new_class.__mro__): 117 | # Collect fields from base class. 118 | if hasattr(base, "declared_composite_fields"): 119 | declared_fields.update(base.declared_composite_fields) 120 | 121 | # Field shadowing. 122 | for attr, value in base.__dict__.items(): 123 | if value is None and attr in declared_fields: 124 | declared_fields.pop(attr) 125 | 126 | new_class.base_composite_fields = declared_fields 127 | new_class.declared_composite_fields = declared_fields 128 | 129 | return new_class 130 | 131 | 132 | class SuperFormMetaclass( 133 | DeclerativeCompositeFieldsMetaclass, DeclarativeFieldsMetaclass 134 | ): 135 | """ 136 | Metaclass for :class:`~django_superform.forms.SuperForm`. 137 | """ 138 | 139 | 140 | class SuperModelFormMetaclass(DeclerativeCompositeFieldsMetaclass, ModelFormMetaclass): 141 | """ 142 | Metaclass for :class:`~django_superform.forms.SuperModelForm`. 143 | """ 144 | 145 | 146 | class SuperFormMixin(object): 147 | """ 148 | The base class for all super forms. It does not inherit from any other 149 | classes, so you are free to mix it into any custom form class you have. You 150 | need to use it together with ``SuperFormMetaclass``, like this: 151 | 152 | .. code:: python 153 | 154 | from django_superform import SuperFormMixin 155 | from django_superform import SuperFormMetaclass 156 | import six 157 | 158 | class MySuperForm(six.with_metaclass( 159 | SuperFormMetaclass, 160 | SuperFormMixin, 161 | MyCustomForm)): 162 | pass 163 | 164 | The goal of a superform is to behave just like a normal django form but is 165 | able to take composite fields, like 166 | :class:`~django_superform.fields.FormField` and 167 | :class:`~django_superform.fields.FormSetField`. 168 | 169 | Cleaning, validation, etc. should work totally transparent. See the 170 | :ref:`Quickstart Guide ` for how superforms are used. 171 | """ 172 | 173 | def __init__(self, *args, **kwargs): 174 | super(SuperFormMixin, self).__init__(*args, **kwargs) 175 | self._init_composite_fields() 176 | 177 | def __getitem__(self, name): 178 | """ 179 | Returns a ``django.forms.BoundField`` for the given field name. It also 180 | returns :class:`~django_superform.boundfield.CompositeBoundField` 181 | instances for composite fields. 182 | """ 183 | if name not in self.fields and name in self.composite_fields: 184 | field = self.composite_fields[name] 185 | return field.get_bound_field(self, name) 186 | return super(SuperFormMixin, self).__getitem__(name) 187 | 188 | def add_composite_field(self, name, field): 189 | """ 190 | Add a dynamic composite field to the already existing ones and 191 | initialize it appropriatly. 192 | """ 193 | self.composite_fields[name] = field 194 | self._init_composite_field(name, field) 195 | 196 | def get_composite_field_value(self, name): 197 | """ 198 | Return the form/formset instance for the given field name. 199 | """ 200 | field = self.composite_fields[name] 201 | if hasattr(field, "get_form"): 202 | return self.forms[name] 203 | if hasattr(field, "get_formset"): 204 | return self.formsets[name] 205 | 206 | def _init_composite_field(self, name, field): 207 | if hasattr(field, "get_form"): 208 | form = field.get_form(self, name) 209 | self.forms[name] = form 210 | if hasattr(field, "get_formset"): 211 | formset = field.get_formset(self, name) 212 | self.formsets[name] = formset 213 | 214 | def _init_composite_fields(self): 215 | """ 216 | Setup the forms and formsets. 217 | """ 218 | # The base_composite_fields class attribute is the *class-wide* 219 | # definition of fields. Because a particular *instance* of the class 220 | # might want to alter self.composite_fields, we create 221 | # self.composite_fields here by copying base_composite_fields. 222 | # Instances should always modify self.composite_fields; they should not 223 | # modify base_composite_fields. 224 | self.composite_fields = copy.deepcopy(self.base_composite_fields) 225 | self.forms = OrderedDict() 226 | self.formsets = OrderedDict() 227 | for name, field in self.composite_fields.items(): 228 | self._init_composite_field(name, field) 229 | 230 | def full_clean(self): 231 | """ 232 | Clean the form, including all formsets and add formset errors to the 233 | errors dict. Errors of nested forms and formsets are only included if 234 | they actually contain errors. 235 | """ 236 | super(SuperFormMixin, self).full_clean() 237 | for field_name, composite in self.forms.items(): 238 | composite.full_clean() 239 | if not composite.is_valid() and composite._errors: 240 | self._errors[field_name] = ErrorDict(composite._errors) 241 | for field_name, composite in self.formsets.items(): 242 | composite.full_clean() 243 | if not composite.is_valid() and composite._errors: 244 | self._errors[field_name] = ErrorList(composite._errors) 245 | 246 | @property 247 | def media(self): 248 | """ 249 | Incooperate composite field's media. 250 | """ 251 | media_list = [] 252 | media_list.append(super(SuperFormMixin, self).media) 253 | for composite_name in self.composite_fields.keys(): 254 | form = self.get_composite_field_value(composite_name) 255 | media_list.append(form.media) 256 | return reduce(lambda a, b: a + b, media_list) 257 | 258 | 259 | class SuperModelFormMixin(SuperFormMixin): 260 | """ 261 | Can be used in with your custom form subclasses like this: 262 | 263 | .. code:: python 264 | 265 | from django_superform import SuperModelFormMixin 266 | from django_superform import SuperModelFormMetaclass 267 | import six 268 | 269 | class MySuperForm(six.with_metaclass( 270 | SuperModelFormMetaclass, 271 | SuperModelFormMixin, 272 | MyCustomModelForm)): 273 | pass 274 | """ 275 | 276 | def save(self, commit=True): 277 | """ 278 | When saving a super model form, the nested forms and formsets will be 279 | saved as well. 280 | 281 | The implementation of ``.save()`` looks like this: 282 | 283 | .. code:: python 284 | 285 | saved_obj = self.save_form() 286 | self.save_forms() 287 | self.save_formsets() 288 | return saved_obj 289 | 290 | That makes it easy to override it in order to change the order in which 291 | things are saved. 292 | 293 | The ``.save()`` method will return only a single model instance even if 294 | nested forms are saved as well. That keeps the API similiar to what 295 | Django's model forms are offering. 296 | 297 | If ``commit=False`` django's modelform implementation will attach a 298 | ``save_m2m`` method to the form instance, so that you can call it 299 | manually later. When you call ``save_m2m``, the ``save_forms`` and 300 | ``save_formsets`` methods will be executed as well so again all nested 301 | forms are taken care of transparantly. 302 | """ 303 | saved_obj = self.save_form(commit=commit) 304 | self.save_forms(commit=commit) 305 | self.save_formsets(commit=commit) 306 | return saved_obj 307 | 308 | def _extend_save_m2m(self, name, composites): 309 | additional_save_m2m = [] 310 | for composite in composites: 311 | if hasattr(composite, "save_m2m"): 312 | additional_save_m2m.append(composite.save_m2m) 313 | 314 | if not additional_save_m2m: 315 | return 316 | 317 | def additional_saves(): 318 | for save_m2m in additional_save_m2m: 319 | save_m2m() 320 | 321 | # The save() method was called before save_forms()/save_formsets(), so 322 | # we will already have save_m2m() available. 323 | if hasattr(self, "save_m2m"): 324 | _original_save_m2m = self.save_m2m 325 | else: 326 | 327 | def _original_save_m2m(): 328 | return None 329 | 330 | def augmented_save_m2m(): 331 | _original_save_m2m() 332 | additional_saves() 333 | 334 | self.save_m2m = augmented_save_m2m 335 | setattr(self, name, additional_saves) 336 | 337 | def save_form(self, commit=True): 338 | """ 339 | This calls Django's ``ModelForm.save()``. It only takes care of 340 | saving this actual form, and leaves the nested forms and formsets 341 | alone. 342 | 343 | We separate this out of the 344 | :meth:`~django_superform.forms.SuperModelForm.save` method to make 345 | extensibility easier. 346 | """ 347 | return super(SuperModelFormMixin, self).save(commit=commit) 348 | 349 | def save_forms(self, commit=True): 350 | saved_composites = [] 351 | for name, composite in self.forms.items(): 352 | field = self.composite_fields[name] 353 | if hasattr(field, "save"): 354 | field.save(self, name, composite, commit=commit) 355 | saved_composites.append(composite) 356 | 357 | self._extend_save_m2m("save_forms_m2m", saved_composites) 358 | 359 | def save_formsets(self, commit=True): 360 | """ 361 | Save all formsets. If ``commit=False``, it will modify the form's 362 | ``save_m2m()`` so that it also calls the formsets' ``save_m2m()`` 363 | methods. 364 | """ 365 | saved_composites = [] 366 | for name, composite in self.formsets.items(): 367 | field = self.composite_fields[name] 368 | if hasattr(field, "save"): 369 | field.save(self, name, composite, commit=commit) 370 | saved_composites.append(composite) 371 | 372 | self._extend_save_m2m("save_formsets_m2m", saved_composites) 373 | 374 | 375 | class SuperModelForm( 376 | six.with_metaclass(SuperModelFormMetaclass, SuperModelFormMixin, forms.ModelForm) 377 | ): 378 | """ 379 | The ``SuperModelForm`` works like a Django ``ModelForm`` but has the 380 | capabilities of nesting like :class:`~django_superform.forms.SuperForm`. 381 | 382 | Saving a ``SuperModelForm`` will also save all nested model forms as well. 383 | """ 384 | 385 | 386 | class SuperForm(six.with_metaclass(SuperFormMetaclass, SuperFormMixin, forms.Form)): 387 | """ 388 | The base class for all super forms. The goal of a superform is to behave 389 | just like a normal django form but is able to take composite fields, like 390 | :class:`~django_superform.fields.FormField` and 391 | :class:`~django_superform.fields.FormSetField`. 392 | 393 | Cleaning, validation, etc. should work totally transparent. See the 394 | :ref:`Quickstart Guide ` for how superforms are used. 395 | """ 396 | -------------------------------------------------------------------------------- /django_superform/fields.py: -------------------------------------------------------------------------------- 1 | from django.forms.models import inlineformset_factory 2 | 3 | from .boundfield import CompositeBoundField 4 | from .widgets import FormWidget, FormSetWidget 5 | 6 | 7 | class BaseCompositeField(object): 8 | """ 9 | The ``BaseCompositeField`` takes care of keeping some kind of compatibility 10 | with the ``django.forms.Field`` class. 11 | """ 12 | 13 | widget = None 14 | show_hidden_initial = False 15 | 16 | # Tracks each time a FormSetField instance is created. Used to retain 17 | # order. 18 | creation_counter = 0 19 | 20 | def __init__( 21 | self, 22 | required=True, 23 | widget=None, 24 | label=None, 25 | help_text="", 26 | localize=False, 27 | disabled=False, 28 | ): 29 | self.required = required 30 | self.label = label 31 | self.help_text = help_text 32 | self.disabled = disabled 33 | 34 | widget = widget or self.widget 35 | if isinstance(widget, type): 36 | widget = widget() 37 | 38 | # Trigger the localization machinery if needed. 39 | self.localize = localize 40 | if self.localize: 41 | widget.is_localized = True 42 | 43 | # Let the widget know whether it should display as required. 44 | widget.is_required = self.required 45 | 46 | # We do not call self.widget_attrs() here as the original field is 47 | # doing it. 48 | 49 | self.widget = widget 50 | 51 | # Increase the creation counter, and save our local copy. 52 | self.creation_counter = BaseCompositeField.creation_counter 53 | BaseCompositeField.creation_counter += 1 54 | 55 | 56 | class CompositeField(BaseCompositeField): 57 | """ 58 | Implements the base structure that is relevant for all composite fields. 59 | This field cannot be used directly, use a subclass of it. 60 | """ 61 | 62 | prefix_name = "composite" 63 | 64 | def __init__(self, *args, **kwargs): 65 | super(CompositeField, self).__init__(*args, **kwargs) 66 | 67 | # Let the widget know about the field for easier complex renderings in 68 | # the template. 69 | self.widget.field = self 70 | 71 | def get_bound_field(self, form, field_name): 72 | return CompositeBoundField(form, self, field_name) 73 | 74 | def get_prefix(self, form, name): 75 | """ 76 | Return the prefix that is used for the formset. 77 | """ 78 | return "{form_prefix}{prefix_name}-{field_name}".format( 79 | form_prefix=form.prefix + "-" if form.prefix else "", 80 | prefix_name=self.prefix_name, 81 | field_name=name, 82 | ) 83 | 84 | def get_initial(self, form, name): 85 | """ 86 | Get the initial data that got passed into the superform for this 87 | composite field. It should return ``None`` if no initial values where 88 | given. 89 | """ 90 | 91 | if hasattr(form, "initial"): 92 | return form.initial.get(name, None) 93 | return None 94 | 95 | def get_kwargs(self, form, name): 96 | """ 97 | Return the keyword arguments that are used to instantiate the formset. 98 | """ 99 | kwargs = { 100 | "prefix": self.get_prefix(form, name), 101 | "initial": self.get_initial(form, name), 102 | } 103 | kwargs.update(self.default_kwargs) 104 | return kwargs 105 | 106 | 107 | class FormField(CompositeField): 108 | """ 109 | A field that can be used to nest a form inside another form:: 110 | 111 | from django import forms 112 | from django_superform import SuperForm 113 | 114 | class AddressForm(forms.Form): 115 | street = forms.CharField() 116 | city = forms.CharField() 117 | 118 | class RegistrationForm(SuperForm): 119 | first_name = forms.CharField() 120 | last_name = forms.CharField() 121 | address = FormField(AddressForm) 122 | 123 | You can then display the fields in the template with (given that 124 | ``registration_form`` is an instance of ``RegistrationForm``):: 125 | 126 | {{ registration_form.address.street }} 127 | {{ registration_form.address.street.errors }} 128 | {{ registration_form.address.city }} 129 | {{ registration_form.address.city.errors }} 130 | 131 | The fields will all have a prefix in their name so that the naming does not 132 | clash with other fields on the page. The name attribute of the input tag 133 | for the ``street`` field in this example will be: ``form-address-street``. 134 | The name will change if you set a prefix on the superform:: 135 | 136 | form = RegistrationForm(prefix='registration') 137 | 138 | Then the field name will be ``registration-form-address-street``. 139 | 140 | You can pass the ``kwargs`` argument to the ``__init__`` method in order to 141 | give keyword arguments that you want to pass through to the form when it is 142 | instaniated. So you could use this to pass in initial values:: 143 | 144 | class RegistrationForm(SuperForm): 145 | address = FormField(AddressForm, kwargs={ 146 | 'initial': {'street': 'Stairway to Heaven 1'} 147 | }) 148 | 149 | But you can also use nested initial values which you pass into the 150 | superform:: 151 | 152 | RegistrationForm(initial={ 153 | 'address': {'street': 'Highway to Hell 666'} 154 | }) 155 | 156 | The first method (using ``kwargs``) will take precedence. 157 | """ 158 | 159 | prefix_name = "form" 160 | widget = FormWidget 161 | 162 | def __init__(self, form_class, kwargs=None, **field_kwargs): 163 | super(FormField, self).__init__(**field_kwargs) 164 | 165 | self.form_class = form_class 166 | if kwargs is None: 167 | kwargs = {} 168 | self.default_kwargs = kwargs 169 | 170 | def get_form_class(self, form, name): 171 | """ 172 | Return the form class that will be used for instantiation in 173 | ``get_form``. You can override this method in subclasses to change 174 | the behaviour of the given form class. 175 | """ 176 | return self.form_class 177 | 178 | def get_form(self, form, name): 179 | """ 180 | Get an instance of the form. 181 | """ 182 | kwargs = self.get_kwargs(form, name) 183 | form_class = self.get_form_class(form, name) 184 | composite_form = form_class( 185 | data=form.data if form.is_bound else None, 186 | files=form.files if form.is_bound else None, 187 | **kwargs 188 | ) 189 | return composite_form 190 | 191 | 192 | class ModelFormField(FormField): 193 | """ 194 | This class is the to :class:`~django_superform.fields.FormField` what 195 | Django's :class:`ModelForm` is to :class:`Form`. It has the same behaviour 196 | as :class:`~django_superform.fields.FormField` but will also save the 197 | nested form if the super form is saved. Here is an example:: 198 | 199 | from django_superform import ModelFormField 200 | 201 | class EmailForm(forms.ModelForm): 202 | class Meta: 203 | model = EmailAddress 204 | fields = ('email',) 205 | 206 | class UserForm(SuperModelForm): 207 | email = ModelFormField(EmailForm) 208 | 209 | class Meta: 210 | model = User 211 | fields = ('username',) 212 | 213 | user_form = UserForm( 214 | {'username': 'john', 'form-email-email': 'john@example.com'}) 215 | if user_form.is_valid(): 216 | user_form.save() 217 | 218 | This will save the ``user_form`` and create a new instance of ``User`` 219 | model and it will also save the ``EmailForm`` and therefore create an 220 | instance of ``EmailAddress``! 221 | 222 | However you usually want to use one of the exsting subclasses, like 223 | :class:`~django_superform.fields.ForeignKeyFormField` or extend from 224 | ``ModelFormField`` class and override the 225 | :meth:`~django_superform.fields.ModelFormField.get_instance` method. 226 | 227 | .. note:: 228 | Usually the :class:`~django_superform.fields.ModelFormField` is used 229 | inside a :class:`~django_superform.forms.SuperModelForm`. You actually 230 | can use it within a :class:`~django_superform.forms.SuperForm`, but 231 | since this form type does not have a ``save()`` method, you will need 232 | to take care of saving the nested model form yourself. 233 | """ 234 | 235 | def get_instance(self, form, name): 236 | """ 237 | Provide an instance that shall be used when instantiating the 238 | modelform. The ``form`` argument is the super-form instance that this 239 | ``ModelFormField`` is used in. ``name`` is the name of this field on 240 | the super-form. 241 | 242 | This returns ``None`` by default. So you usually want to override this 243 | method in a subclass. 244 | """ 245 | return None 246 | 247 | def get_kwargs(self, form, name): 248 | """ 249 | Return the keyword arguments that are used to instantiate the formset. 250 | 251 | The ``instance`` kwarg will be set to the value returned by 252 | :meth:`~django_superform.fields.ModelFormField.get_instance`. The 253 | ``empty_permitted`` kwarg will be set to the inverse of the 254 | ``required`` argument passed into the constructor of this field. 255 | """ 256 | kwargs = super(ModelFormField, self).get_kwargs(form, name) 257 | instance = self.get_instance(form, name) 258 | kwargs.setdefault("instance", instance) 259 | kwargs.setdefault("empty_permitted", not self.required) 260 | return kwargs 261 | 262 | def shall_save(self, form, name, composite_form): 263 | """ 264 | Return ``True`` if the given ``composite_form`` (the nested form of 265 | this field) shall be saved. Return ``False`` if the form shall not be 266 | saved together with the super-form. 267 | 268 | By default it will return ``False`` if the form was not changed and the 269 | ``empty_permitted`` argument for the form was set to ``True``. That way 270 | you can allow empty forms. 271 | """ 272 | if composite_form.empty_permitted and not composite_form.has_changed(): 273 | return False 274 | return True 275 | 276 | def save(self, form, name, composite_form, commit): 277 | """ 278 | This method is called by 279 | :meth:`django_superform.forms.SuperModelForm.save` in order to save the 280 | modelform that this field takes care of and calls on the nested form's 281 | ``save()`` method. But only if 282 | :meth:`~django_superform.fields.ModelFormField.shall_save` returns 283 | ``True``. 284 | """ 285 | if self.shall_save(form, name, composite_form): 286 | return composite_form.save(commit=commit) 287 | return None 288 | 289 | 290 | class ForeignKeyFormField(ModelFormField): 291 | def __init__( 292 | self, form_class, kwargs=None, field_name=None, blank=None, **field_kwargs 293 | ): 294 | super(ForeignKeyFormField, self).__init__(form_class, kwargs, **field_kwargs) 295 | self.field_name = field_name 296 | self.blank = blank 297 | 298 | def get_kwargs(self, form, name): 299 | kwargs = super(ForeignKeyFormField, self).get_kwargs(form, name) 300 | if "instance" not in kwargs: 301 | kwargs.setdefault("instance", self.get_instance(form, name)) 302 | if "empty_permitted" not in kwargs: 303 | if self.allow_blank(form, name): 304 | kwargs["empty_permitted"] = True 305 | return kwargs 306 | 307 | def get_field_name(self, form, name): 308 | return self.field_name or name 309 | 310 | def allow_blank(self, form, name): 311 | """ 312 | Allow blank determines if the form might be completely empty. If it's 313 | empty it will result in a None as the saved value for the ForeignKey. 314 | """ 315 | if self.blank is not None: 316 | return self.blank 317 | model = form._meta.model 318 | field = model._meta.get_field(self.get_field_name(form, name)) 319 | return field.blank 320 | 321 | def get_form_class(self, form, name): 322 | form_class = self.form_class 323 | return form_class 324 | 325 | def get_instance(self, form, name): 326 | field_name = self.get_field_name(form, name) 327 | return getattr(form.instance, field_name) 328 | 329 | def save(self, form, name, composite_form, commit): 330 | # Support the ``empty_permitted`` attribute. This is set if the field 331 | # is ``blank=True`` . 332 | if composite_form.empty_permitted and not composite_form.has_changed(): 333 | saved_obj = composite_form.instance 334 | else: 335 | saved_obj = super(ForeignKeyFormField, self).save( 336 | form, name, composite_form, commit 337 | ) 338 | setattr(form.instance, self.get_field_name(form, name), saved_obj) 339 | if commit: 340 | form.instance.save() 341 | else: 342 | raise NotImplementedError( 343 | "ForeignKeyFormField cannot yet be used with non-commiting " 344 | "form saves." 345 | ) 346 | return saved_obj 347 | 348 | 349 | class FormSetField(CompositeField): 350 | """ 351 | First argument is a formset class that is instantiated by this 352 | FormSetField. 353 | 354 | You can pass the ``kwargs`` argument to specify kwargs values that 355 | are used when the ``formset_class`` is instantiated. 356 | """ 357 | 358 | prefix_name = "formset" 359 | widget = FormSetWidget 360 | 361 | def __init__(self, formset_class, kwargs=None, **field_kwargs): 362 | super(FormSetField, self).__init__(**field_kwargs) 363 | 364 | self.formset_class = formset_class 365 | if kwargs is None: 366 | kwargs = {} 367 | self.default_kwargs = kwargs 368 | 369 | def get_formset_class(self, form, name): 370 | """ 371 | Return the formset class that will be used for instantiation in 372 | ``get_formset``. You can override this method in subclasses to change 373 | the behaviour of the given formset class. 374 | """ 375 | return self.formset_class 376 | 377 | def get_formset(self, form, name): 378 | """ 379 | Get an instance of the formset. 380 | """ 381 | kwargs = self.get_kwargs(form, name) 382 | formset_class = self.get_formset_class(form, name) 383 | formset = formset_class( 384 | form.data if form.is_bound else None, 385 | form.files if form.is_bound else None, 386 | **kwargs 387 | ) 388 | return formset 389 | 390 | 391 | class ModelFormSetField(FormSetField): 392 | def shall_save(self, form, name, formset): 393 | return True 394 | 395 | def save(self, form, name, formset, commit): 396 | if self.shall_save(form, name, formset): 397 | return formset.save(commit=commit) 398 | return None 399 | 400 | 401 | class InlineFormSetField(ModelFormSetField): 402 | """ 403 | The ``InlineFormSetField`` helps when you want to use a inline formset. 404 | 405 | You can pass in either the keyword argument ``formset_class`` which is a 406 | ready to use formset that inherits from ``BaseInlineFormSet`` or was 407 | created by the ``inlineformset_factory``. 408 | 409 | The other option is to provide the arguments that you would usually pass 410 | into the ``inlineformset_factory``. The required arguments for that are: 411 | 412 | ``model`` 413 | The model class which should be represented by the forms in the 414 | formset. 415 | ``parent_model`` 416 | The parent model is the one that is referenced by the model in a 417 | foreignkey. 418 | ``form`` (optional) 419 | The model form that is used as a baseclass for the forms in the inline 420 | formset. 421 | 422 | You can use the ``kwargs`` keyword argument to pass extra arguments for the 423 | formset that are passed through when the formset is instantiated. 424 | 425 | All other not mentioned keyword arguments, like ``extra``, ``max_num`` etc. 426 | will be passed directly to the ``inlineformset_factory``. 427 | 428 | Example: 429 | 430 | class Gallery(models.Model): 431 | name = models.CharField(max_length=50) 432 | 433 | class Image(models.Model): 434 | gallery = models.ForeignKey(Gallery) 435 | image = models.ImageField(...) 436 | 437 | class GalleryForm(ModelFormWithFormSets): 438 | class Meta: 439 | model = Gallery 440 | fields = ('name',) 441 | 442 | images = InlineFormSetField( 443 | parent_model=Gallery, 444 | model=Image, 445 | extra=1) 446 | """ 447 | 448 | def __init__( 449 | self, 450 | parent_model=None, 451 | model=None, 452 | formset_class=None, 453 | kwargs=None, 454 | **factory_kwargs 455 | ): 456 | """ 457 | You need to either provide the ``formset_class`` or the ``model`` 458 | argument. 459 | 460 | If the ``formset_class`` argument is not given, the ``model`` argument 461 | is used to create the formset_class on the fly when needed by using the 462 | ``inlineformset_factory``. 463 | """ 464 | 465 | # Make sure that all standard arguments will get passed through to the 466 | # parent's __init__ method. 467 | field_kwargs = {} 468 | for arg in ["required", "widget", "label", "help_text", "localize"]: 469 | if arg in factory_kwargs: 470 | field_kwargs[arg] = factory_kwargs.pop(arg) 471 | 472 | self.parent_model = parent_model 473 | self.model = model 474 | self.formset_factory_kwargs = factory_kwargs 475 | super(InlineFormSetField, self).__init__( 476 | formset_class, kwargs=kwargs, **field_kwargs 477 | ) 478 | if ( 479 | self.formset_class is None 480 | and "form" not in self.formset_factory_kwargs 481 | and "fields" not in self.formset_factory_kwargs 482 | and "exclude" not in self.formset_factory_kwargs 483 | ): 484 | raise ValueError( 485 | "You need to either specify the `formset_class` argument or " 486 | "one of `form`/`fields`/`exclude` arguments " 487 | "when creating a {0}.".format(self.__class__.__name__) 488 | ) 489 | 490 | def get_model(self, form, name): 491 | return self.model 492 | 493 | def get_parent_model(self, form, name): 494 | if self.parent_model is not None: 495 | return self.parent_model 496 | return form._meta.model 497 | 498 | def get_formset_class(self, form, name): 499 | """ 500 | Either return the formset class that was provided as argument to the 501 | __init__ method, or build one based on the ``parent_model`` and 502 | ``model`` attributes. 503 | """ 504 | if self.formset_class is not None: 505 | return self.formset_class 506 | formset_class = inlineformset_factory( 507 | self.get_parent_model(form, name), 508 | self.get_model(form, name), 509 | **self.formset_factory_kwargs 510 | ) 511 | return formset_class 512 | 513 | def get_kwargs(self, form, name): 514 | kwargs = super(InlineFormSetField, self).get_kwargs(form, name) 515 | kwargs.setdefault("instance", form.instance) 516 | return kwargs 517 | --------------------------------------------------------------------------------