├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── NEWS.txt ├── README.rst ├── demo ├── blocks │ ├── __init__.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── blocks │ │ │ ├── block_form.html │ │ │ ├── block_list.html │ │ │ └── building_form.html │ ├── tests.py │ └── views.py ├── manage.py └── nested_formset_demo │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── setup.cfg ├── setup.py ├── src └── nested_formset │ ├── __init__.py │ ├── test_settings.py │ └── tests │ ├── __init__.py │ ├── models.py │ ├── test_factory.py │ ├── test_instantiation.py │ ├── test_properties.py │ ├── test_save.py │ ├── test_validation.py │ └── util.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /include/ 3 | /lib/ 4 | /lib64/ 5 | /*.egg 6 | *.pyc 7 | *.egg-info 8 | .Python 9 | /dist/ 10 | /demo/blocks.db 11 | /.tox/ 12 | /build/ 13 | /.eggs/ 14 | pip-selfcheck.json 15 | /.python-version 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | python: 4 | - 2.7 5 | - 3.6 6 | install: pip install tox-travis 7 | script: tox 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Nathan R. Yergler and Contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | Neither the name of original author nor the names of its contributors 16 | may be used to endorse or promote products derived from this software 17 | without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include NEWS.txt 3 | -------------------------------------------------------------------------------- /NEWS.txt: -------------------------------------------------------------------------------- 1 | News 2 | ==== 3 | 4 | Unreleased 5 | ---------- 6 | 7 | * Drop support for Django before 1.11 8 | * Update demo application for 1.11 9 | 10 | 0.1.4 11 | ----- 12 | 13 | *Release date: 7 March 2015* 14 | 15 | * Dropped Django 1.5.x support; it's no longer receiving security 16 | updates or maintenance. 17 | * Updated accepted factory `kwargs` for Django 1.7 and later 18 | * Fixed builds against Django tip. 19 | 20 | 0.1.3 21 | ----- 22 | 23 | *Release date: 28 September 2014* 24 | 25 | * Test against Django 1.7-final 26 | * `has_changed` now takes into account all nested forms. 27 | 28 | 0.1.2 29 | ----- 30 | 31 | *Release date: 20 May 2014* 32 | 33 | * Support for passing files to bound nested formsets 34 | * The media property now rolls up all nested media 35 | * Compatibility with Django 1.7. 36 | 37 | 0.1.1 38 | ----- 39 | 40 | *Release date: 6 March 2014* 41 | 42 | * Bump minor version number to get PyPI to index the development 43 | version link. 44 | 45 | 0.1 46 | --- 47 | 48 | *Release date: 6 March 2014* 49 | 50 | * Initial release on PyPI 51 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | Django Nested Formsets 3 | ====================== 4 | 5 | .. image:: https://travis-ci.org/nyergler/nested-formset.png?branch=master 6 | :target: https://travis-ci.org/nyergler/nested-formset 7 | 8 | Formsets_ are a Django abstraction that make it easier to manage 9 | multiple instances of a single Form_ on a page. In 2009 I wrote a 10 | `blog post`_ about using nesting formsets using Django 1.1. This is a 11 | generic implementation of the technique described there, targeting 12 | Django 1.11. A `follow-up blog post`_ provides additional 13 | context. 14 | 15 | Installing 16 | ========== 17 | 18 | You can install Django Nested Formsets using your favorite package 19 | management tool. For example:: 20 | 21 | $ pip install django-nested-formset 22 | 23 | You can also install the latest development version:: 24 | 25 | $ pip install git+https://github.com/nyergler/nested-formset#egg=django-nested-formset 26 | 27 | After installing the package, you can use the 28 | ``nestedformset_factory`` function to create your formset class. 29 | 30 | Developing 31 | ========== 32 | 33 | If you'd like to work on the source, I suggest cloning the repository 34 | and creating a virtualenv. 35 | 36 | :: 37 | 38 | $ cd nested-formset 39 | $ virtualenv . 40 | $ source bin/activate 41 | $ python setup.py develop 42 | 43 | The last line will install the installation and test dependencies. 44 | 45 | To run the unit test suite, run the following:: 46 | 47 | $ python setup.py test 48 | 49 | See Also 50 | ======== 51 | 52 | * `Django Formset documentation`_ 53 | * `jquery.django-formset`_ Dynamic creation of formsets from the empty 54 | formset. 55 | 56 | License 57 | ======= 58 | 59 | This package is released under a BSD style license. See LICENSE for details. 60 | 61 | .. _Formsets: https://docs.djangoproject.com/en/1.5/topics/forms/formsets/ 62 | .. _`Django Formset documentation`: Formsets_ 63 | .. _Form: https://docs.djangoproject.com/en/1.5/topics/forms/ 64 | .. _`blog post`: http://yergler.net/blog/2009/09/27/nested-formsets-with-django/ 65 | .. _`follow-up blog post`: http://yergler.net/blog/2013/09/03/nested-formsets-redux/ 66 | .. _`jquery.django-formset`: https://github.com/mbertheau/jquery.django-formset 67 | -------------------------------------------------------------------------------- /demo/blocks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyergler/nested-formset/2989e058a5b1c7dbb8bc124923a56ffcbb8720a5/demo/blocks/__init__.py -------------------------------------------------------------------------------- /demo/blocks/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-01-10 17:31 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Block', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('description', models.CharField(max_length=255)), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='Building', 26 | fields=[ 27 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('address', models.CharField(max_length=255)), 29 | ('block', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blocks.Block')), 30 | ], 31 | ), 32 | migrations.CreateModel( 33 | name='Tenant', 34 | fields=[ 35 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 36 | ('name', models.CharField(max_length=255)), 37 | ('unit', models.CharField(max_length=255)), 38 | ('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blocks.Building')), 39 | ], 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /demo/blocks/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyergler/nested-formset/2989e058a5b1c7dbb8bc124923a56ffcbb8720a5/demo/blocks/migrations/__init__.py -------------------------------------------------------------------------------- /demo/blocks/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Block(models.Model): 5 | description = models.CharField(max_length=255) 6 | 7 | 8 | class Building(models.Model): 9 | block = models.ForeignKey(Block) 10 | address = models.CharField(max_length=255) 11 | 12 | 13 | class Tenant(models.Model): 14 | building = models.ForeignKey(Building) 15 | name = models.CharField(max_length=255) 16 | unit = models.CharField( 17 | blank=False, 18 | max_length=255, 19 | ) 20 | -------------------------------------------------------------------------------- /demo/blocks/templates/blocks/block_form.html: -------------------------------------------------------------------------------- 1 |

New Block

2 | 3 |
4 | {% csrf_token %} 5 | {{ form }} 6 | 7 | 8 |
9 | -------------------------------------------------------------------------------- /demo/blocks/templates/blocks/block_list.html: -------------------------------------------------------------------------------- 1 |

Blocks

2 | 3 | 9 | 10 | new block 11 | -------------------------------------------------------------------------------- /demo/blocks/templates/blocks/building_form.html: -------------------------------------------------------------------------------- 1 |

Edit Buildings

2 | 3 |
4 | {% csrf_token %} 5 | {{ form.management_form }} 6 | {% for building in form %} 7 | {{ building.as_p }} 8 | 9 | {{ building.nested.management_form }} 10 | {% for tenant in building.nested %} 11 | {{ tenant.as_p }} 12 | {% endfor %} 13 | 14 | {% endfor %} 15 | 16 | 17 | 18 |
19 | -------------------------------------------------------------------------------- /demo/blocks/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates writing tests using the unittest module. These will pass 3 | when you run "manage.py test". 4 | 5 | Replace this with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | def test_basic_addition(self): 13 | """ 14 | Tests that 1 + 1 always equals 2. 15 | """ 16 | self.assertEqual(1 + 1, 2) 17 | -------------------------------------------------------------------------------- /demo/blocks/views.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse 2 | from django.forms.models import inlineformset_factory 3 | from django.views.generic import ( 4 | ListView, 5 | CreateView, 6 | UpdateView, 7 | ) 8 | 9 | from nested_formset import nestedformset_factory 10 | 11 | from blocks import models 12 | 13 | 14 | class ListBlocksView(ListView): 15 | 16 | model = models.Block 17 | fields = '__all__' 18 | 19 | 20 | class CreateBlockView(CreateView): 21 | 22 | model = models.Block 23 | fields = '__all__' 24 | 25 | def get_success_url(self): 26 | 27 | return reverse('blocks-list') 28 | 29 | 30 | class EditBuildingsView(UpdateView): 31 | 32 | model = models.Block 33 | fields = '__all__' 34 | 35 | def get_template_names(self): 36 | 37 | return ['blocks/building_form.html'] 38 | 39 | def get_form_class(self): 40 | 41 | return nestedformset_factory( 42 | models.Block, 43 | models.Building, 44 | nested_formset=inlineformset_factory( 45 | models.Building, 46 | models.Tenant, 47 | fields = '__all__' 48 | ) 49 | ) 50 | 51 | def get_success_url(self): 52 | 53 | return reverse('blocks-list') 54 | -------------------------------------------------------------------------------- /demo/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nested_formset_demo.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /demo/nested_formset_demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyergler/nested-formset/2989e058a5b1c7dbb8bc124923a56ffcbb8720a5/demo/nested_formset_demo/__init__.py -------------------------------------------------------------------------------- /demo/nested_formset_demo/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for nested_formset_demo project. 2 | import os 3 | 4 | ROOT_DIR = os.path.abspath( 5 | os.path.join(os.path.dirname(__file__), '..'), 6 | ) 7 | 8 | DEBUG = True 9 | 10 | ADMINS = ( 11 | # ('Your Name', 'your_email@example.com'), 12 | ) 13 | 14 | MANAGERS = ADMINS 15 | 16 | DATABASES = { 17 | 'default': { 18 | 'ENGINE': 'django.db.backends.sqlite3', 19 | 'NAME': 'blocks.db', # Or path to database file if using sqlite3. 20 | # The following settings are not used with sqlite3: 21 | 'USER': '', 22 | 'PASSWORD': '', 23 | 'HOST': '', # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. 24 | 'PORT': '', # Set to empty string for default. 25 | } 26 | } 27 | 28 | # Hosts/domain names that are valid for this site; required if DEBUG is False 29 | # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts 30 | ALLOWED_HOSTS = [] 31 | 32 | # Local time zone for this installation. Choices can be found here: 33 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 34 | # although not all choices may be available on all operating systems. 35 | # In a Windows environment this must be set to your system time zone. 36 | TIME_ZONE = 'America/Chicago' 37 | 38 | # Language code for this installation. All choices can be found here: 39 | # http://www.i18nguy.com/unicode/language-identifiers.html 40 | LANGUAGE_CODE = 'en-us' 41 | 42 | SITE_ID = 1 43 | 44 | # If you set this to False, Django will make some optimizations so as not 45 | # to load the internationalization machinery. 46 | USE_I18N = True 47 | 48 | # If you set this to False, Django will not format dates, numbers and 49 | # calendars according to the current locale. 50 | USE_L10N = True 51 | 52 | # If you set this to False, Django will not use timezone-aware datetimes. 53 | USE_TZ = True 54 | 55 | # Absolute filesystem path to the directory that will hold user-uploaded files. 56 | # Example: "/var/www/example.com/media/" 57 | MEDIA_ROOT = '' 58 | 59 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 60 | # trailing slash. 61 | # Examples: "http://example.com/media/", "http://media.example.com/" 62 | MEDIA_URL = '' 63 | 64 | # Absolute path to the directory static files should be collected to. 65 | # Don't put anything in this directory yourself; store your static files 66 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 67 | # Example: "/var/www/example.com/static/" 68 | STATIC_ROOT = '' 69 | 70 | # URL prefix for static files. 71 | # Example: "http://example.com/static/", "http://static.example.com/" 72 | STATIC_URL = '/static/' 73 | 74 | # Additional locations of static files 75 | STATICFILES_DIRS = ( 76 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 77 | # Always use forward slashes, even on Windows. 78 | # Don't forget to use absolute paths, not relative paths. 79 | ) 80 | 81 | # List of finder classes that know how to find static files in 82 | # various locations. 83 | STATICFILES_FINDERS = ( 84 | 'django.contrib.staticfiles.finders.FileSystemFinder', 85 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 86 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 87 | ) 88 | 89 | # Make this unique, and don't share it with anybody. 90 | SECRET_KEY = 'kcz#6^qb13_i_=_2!0=e6*$*o2e-p748#=&p-pccq2**-g5zjr' 91 | 92 | TEMPLATES = [ 93 | { 94 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 95 | 'APP_DIRS': True, 96 | }, 97 | ] 98 | 99 | MIDDLEWARE_CLASSES = ( 100 | 'django.middleware.common.CommonMiddleware', 101 | 'django.contrib.sessions.middleware.SessionMiddleware', 102 | 'django.middleware.csrf.CsrfViewMiddleware', 103 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 104 | 'django.contrib.messages.middleware.MessageMiddleware', 105 | # Uncomment the next line for simple clickjacking protection: 106 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 107 | ) 108 | 109 | ROOT_URLCONF = 'nested_formset_demo.urls' 110 | 111 | # Python dotted path to the WSGI application used by Django's runserver. 112 | WSGI_APPLICATION = 'nested_formset_demo.wsgi.application' 113 | 114 | INSTALLED_APPS = ( 115 | 'django.contrib.auth', 116 | 'django.contrib.contenttypes', 117 | 'django.contrib.sessions', 118 | 'django.contrib.sites', 119 | 'django.contrib.messages', 120 | 'django.contrib.staticfiles', 121 | # Uncomment the next line to enable the admin: 122 | # 'django.contrib.admin', 123 | # Uncomment the next line to enable admin documentation: 124 | # 'django.contrib.admindocs', 125 | 'blocks', 126 | ) 127 | 128 | # A sample logging configuration. The only tangible logging 129 | # performed by this configuration is to send an email to 130 | # the site admins on every HTTP 500 error when DEBUG=False. 131 | # See http://docs.djangoproject.com/en/dev/topics/logging for 132 | # more details on how to customize your logging configuration. 133 | LOGGING = { 134 | 'version': 1, 135 | 'disable_existing_loggers': False, 136 | 'filters': { 137 | 'require_debug_false': { 138 | '()': 'django.utils.log.RequireDebugFalse' 139 | } 140 | }, 141 | 'handlers': { 142 | 'mail_admins': { 143 | 'level': 'ERROR', 144 | 'filters': ['require_debug_false'], 145 | 'class': 'django.utils.log.AdminEmailHandler' 146 | } 147 | }, 148 | 'loggers': { 149 | 'django.request': { 150 | 'handlers': ['mail_admins'], 151 | 'level': 'ERROR', 152 | 'propagate': True, 153 | }, 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /demo/nested_formset_demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from blocks import views 4 | 5 | # Uncomment the next two lines to enable the admin: 6 | # from django.contrib import admin 7 | # admin.autodiscover() 8 | 9 | urlpatterns = [ 10 | # Examples: 11 | # url(r'^$', 'nested_formset_demo.views.home', name='home'), 12 | # url(r'^nested_formset_demo/', include('nested_formset_demo.foo.urls')), 13 | 14 | # Uncomment the admin/doc line below to enable admin documentation: 15 | # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), 16 | 17 | # Uncomment the next line to enable the admin: 18 | # url(r'^admin/', include(admin.site.urls)), 19 | url('^$', views.ListBlocksView.as_view(), name='blocks-list'), 20 | url('^blocks/new$', views.CreateBlockView.as_view(), name='blocks-new'), 21 | url('^blocks/(?P\d+)/$', views.EditBuildingsView.as_view(), 22 | name='buildings-edit'), 23 | 24 | ] 25 | -------------------------------------------------------------------------------- /demo/nested_formset_demo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for nested_formset_demo project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks 19 | # if running multiple sites in the same mod_wsgi process. To fix this, use 20 | # mod_wsgi daemon mode with each site in its own daemon process, or use 21 | # os.environ["DJANGO_SETTINGS_MODULE"] = "nested_formset_demo.settings" 22 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nested_formset_demo.settings") 23 | 24 | # This application object is used by any WSGI server configured to use this 25 | # file. This includes Django's development server, if the WSGI_APPLICATION 26 | # setting points here. 27 | from django.core.wsgi import get_wsgi_application 28 | application = get_wsgi_application() 29 | 30 | # Apply WSGI middleware here. 31 | # from helloworld.wsgi import HelloWorldApplication 32 | # application = HelloWorldApplication(application) 33 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import sys, os 3 | 4 | here = os.path.abspath(os.path.dirname(__file__)) 5 | README = open(os.path.join(here, 'README.rst')).read() 6 | NEWS = open(os.path.join(here, 'NEWS.txt')).read() 7 | 8 | 9 | version = '0.1.4' 10 | 11 | 12 | setup(name='django-nested-formset', 13 | description='Nest Django formsets for multi-level hierarchical editing', 14 | author='Nathan Yergler', 15 | author_email='nathan@yergler.net', 16 | version=version, 17 | long_description=README + '\n\n' + NEWS, 18 | classifiers=[ 19 | 'Framework :: Django', 20 | 'Framework :: Django :: 1.11', 21 | 'License :: OSI Approved :: BSD License', 22 | 'Development Status :: 5 - Production/Stable', 23 | 'Operating System :: OS Independent', 24 | 'Programming Language :: Python :: 2', 25 | 'Programming Language :: Python :: 2.7', 26 | 'Programming Language :: Python :: 3', 27 | 'Programming Language :: Python :: 3.5', 28 | 'Programming Language :: Python :: 3.6', 29 | ], 30 | keywords='', 31 | url='https://github.com/nyergler/nested-formset', 32 | license='BSD', 33 | packages=find_packages('src'), 34 | package_dir={'': 'src'}, 35 | include_package_data=True, 36 | zip_safe=False, 37 | install_requires=[ 38 | 'Django<2.0', 39 | ], 40 | tests_require=[ 41 | 'rebar', 42 | ], 43 | test_suite='nested_formset.tests.run_tests', 44 | ) 45 | -------------------------------------------------------------------------------- /src/nested_formset/__init__.py: -------------------------------------------------------------------------------- 1 | from django.forms.models import ( 2 | BaseInlineFormSet, 3 | inlineformset_factory, 4 | ModelForm, 5 | ) 6 | 7 | 8 | class BaseNestedFormset(BaseInlineFormSet): 9 | 10 | def add_fields(self, form, index): 11 | 12 | # allow the super class to create the fields as usual 13 | super(BaseNestedFormset, self).add_fields(form, index) 14 | 15 | form.nested = self.nested_formset_class( 16 | instance=form.instance, 17 | data=form.data if form.is_bound else None, 18 | files=form.files if form.is_bound else None, 19 | prefix='%s-%s' % ( 20 | form.prefix, 21 | self.nested_formset_class.get_default_prefix(), 22 | ), 23 | ) 24 | 25 | def is_valid(self): 26 | 27 | result = super(BaseNestedFormset, self).is_valid() 28 | 29 | if self.is_bound: 30 | # look at any nested formsets, as well 31 | for form in self.forms: 32 | if not self._should_delete_form(form): 33 | result = result and form.nested.is_valid() 34 | 35 | return result 36 | 37 | def save(self, commit=True): 38 | 39 | result = super(BaseNestedFormset, self).save(commit=commit) 40 | 41 | for form in self.forms: 42 | if not self._should_delete_form(form): 43 | form.nested.save(commit=commit) 44 | 45 | return result 46 | 47 | @property 48 | def media(self): 49 | return self.empty_form.media + self.empty_form.nested.media 50 | 51 | 52 | class BaseNestedModelForm(ModelForm): 53 | 54 | def has_changed(self): 55 | 56 | return ( 57 | super(BaseNestedModelForm, self).has_changed() or 58 | self.nested.has_changed() 59 | ) 60 | 61 | 62 | def nestedformset_factory(parent_model, model, nested_formset, 63 | form=BaseNestedModelForm, 64 | formset=BaseNestedFormset, fk_name=None, 65 | fields=None, exclude=None, extra=3, 66 | can_order=False, can_delete=True, 67 | max_num=None, formfield_callback=None, 68 | widgets=None, validate_max=False, 69 | localized_fields=None, labels=None, 70 | help_texts=None, error_messages=None, 71 | min_num=None, validate_min=None): 72 | kwargs = { 73 | 'form': form, 74 | 'formset': formset, 75 | 'fk_name': fk_name, 76 | 'fields': fields, 77 | 'exclude': exclude, 78 | 'extra': extra, 79 | 'can_order': can_order, 80 | 'can_delete': can_delete, 81 | 'max_num': max_num, 82 | 'formfield_callback': formfield_callback, 83 | 'widgets': widgets, 84 | 'validate_max': validate_max, 85 | 'localized_fields': localized_fields, 86 | 'labels': labels, 87 | 'help_texts': help_texts, 88 | 'error_messages': error_messages, 89 | 'min_num': min_num, 90 | 'validate_min': validate_min, 91 | } 92 | 93 | if kwargs['fields'] is None: 94 | kwargs['fields'] = [ 95 | field.name 96 | for field in model._meta.local_fields 97 | ] 98 | 99 | NestedFormSet = inlineformset_factory( 100 | parent_model, 101 | model, 102 | **kwargs 103 | ) 104 | NestedFormSet.nested_formset_class = nested_formset 105 | 106 | return NestedFormSet 107 | -------------------------------------------------------------------------------- /src/nested_formset/test_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | You need the ``BASE_PATH`` and ``TEST_DISCOVERY_ROOT`` settings in order for 3 | this test runner to work. 4 | 5 | ``BASE_PATH`` should be the directory containing your top-level package(s); in 6 | other words, the directory that should be on ``sys.path`` for your code to 7 | import. This is the directory containing ``manage.py`` in the new Django 1.4 8 | project layout. 9 | 10 | ``TEST_DISCOVERY_ROOT`` should be the root directory to discover tests 11 | within. You could make this the same as ``BASE_PATH`` if you want tests to be 12 | discovered anywhere in your project. If you want tests to only be discovered 13 | within, say, a top-level ``tests`` directory, you'd set ``TEST_DISCOVERY_ROOT`` 14 | as shown below. 15 | 16 | And you need to point the ``TEST_RUNNER`` setting to the ``DiscoveryRunner`` 17 | class above. 18 | 19 | """ 20 | import os.path 21 | from django import VERSION 22 | 23 | DEBUG = True 24 | TEMPLATE_DEBUG = DEBUG 25 | 26 | ADMINS = ( 27 | # ('Your Name', 'your_email@example.com'), 28 | ) 29 | 30 | MANAGERS = ADMINS 31 | 32 | DATABASES = { 33 | 'default': { 34 | 'ENGINE': 'django.db.backends.sqlite3', 35 | } 36 | } 37 | 38 | # Local time zone for this installation. Choices can be found here: 39 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 40 | # although not all choices may be available on all operating systems. 41 | # In a Windows environment this must be set to your system time zone. 42 | TIME_ZONE = 'America/Chicago' 43 | 44 | # Language code for this installation. All choices can be found here: 45 | # http://www.i18nguy.com/unicode/language-identifiers.html 46 | LANGUAGE_CODE = 'en-us' 47 | 48 | SITE_ID = 1 49 | 50 | # If you set this to False, Django will make some optimizations so as not 51 | # to load the internationalization machinery. 52 | USE_I18N = True 53 | 54 | # If you set this to False, Django will not format dates, numbers and 55 | # calendars according to the current locale. 56 | USE_L10N = True 57 | 58 | # If you set this to False, Django will not use timezone-aware datetimes. 59 | USE_TZ = True 60 | 61 | # Absolute filesystem path to the directory that will hold user-uploaded files. 62 | # Example: "/home/media/media.lawrence.com/media/" 63 | MEDIA_ROOT = '' 64 | 65 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 66 | # trailing slash. 67 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 68 | MEDIA_URL = '' 69 | 70 | # Absolute path to the directory static files should be collected to. 71 | # Don't put anything in this directory yourself; store your static files 72 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 73 | # Example: "/home/media/media.lawrence.com/static/" 74 | STATIC_ROOT = '' 75 | 76 | # URL prefix for static files. 77 | # Example: "http://media.lawrence.com/static/" 78 | STATIC_URL = '/static/' 79 | 80 | # Additional locations of static files 81 | STATICFILES_DIRS = ( 82 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 83 | # Always use forward slashes, even on Windows. 84 | # Don't forget to use absolute paths, not relative paths. 85 | ) 86 | 87 | # List of finder classes that know how to find static files in 88 | # various locations. 89 | STATICFILES_FINDERS = ( 90 | 'django.contrib.staticfiles.finders.FileSystemFinder', 91 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 92 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 93 | ) 94 | 95 | # Make this unique, and don't share it with anybody. 96 | SECRET_KEY = ')7rs)ofal(d5b@b+&ua6w-2)pa*bvw*d(p_z6#=!(ur68j#5u7' 97 | 98 | # List of callables that know how to import templates from various sources. 99 | TEMPLATE_LOADERS = ( 100 | 'django.template.loaders.filesystem.Loader', 101 | 'django.template.loaders.app_directories.Loader', 102 | # 'django.template.loaders.eggs.Loader', 103 | ) 104 | 105 | MIDDLEWARE_CLASSES = ( 106 | 'django.middleware.common.CommonMiddleware', 107 | 'django.contrib.sessions.middleware.SessionMiddleware', 108 | 'django.middleware.csrf.CsrfViewMiddleware', 109 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 110 | 'django.contrib.messages.middleware.MessageMiddleware', 111 | # Uncomment the next line for simple clickjacking protection: 112 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 113 | ) 114 | 115 | TEMPLATE_DIRS = ( 116 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 117 | # Always use forward slashes, even on Windows. 118 | # Don't forget to use absolute paths, not relative paths. 119 | ) 120 | 121 | INSTALLED_APPS = ( 122 | 'django.contrib.auth', 123 | 'django.contrib.contenttypes', 124 | 'django.contrib.sessions', 125 | 'django.contrib.sites', 126 | 'django.contrib.messages', 127 | 'django.contrib.staticfiles', 128 | # Uncomment the next line to enable the admin: 129 | # 'django.contrib.admin', 130 | # Uncomment the next line to enable admin documentation: 131 | # 'django.contrib.admindocs', 132 | 'nested_formset.tests', 133 | ) 134 | 135 | # A sample logging configuration. The only tangible logging 136 | # performed by this configuration is to send an email to 137 | # the site admins on every HTTP 500 error when DEBUG=False. 138 | # See http://docs.djangoproject.com/en/dev/topics/logging for 139 | # more details on how to customize your logging configuration. 140 | LOGGING = { 141 | 'version': 1, 142 | 'disable_existing_loggers': False, 143 | 'filters': { 144 | 'require_debug_false': { 145 | '()': 'django.utils.log.RequireDebugFalse' 146 | } 147 | }, 148 | 'handlers': { 149 | 'mail_admins': { 150 | 'level': 'ERROR', 151 | 'filters': ['require_debug_false'], 152 | 'class': 'django.utils.log.AdminEmailHandler' 153 | } 154 | }, 155 | 'loggers': { 156 | 'django.request': { 157 | 'handlers': ['mail_admins'], 158 | 'level': 'ERROR', 159 | 'propagate': True, 160 | }, 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/nested_formset/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def setup(): 6 | """Perform test runner setup. 7 | 8 | This is its own function so we can easily call it from doctest 9 | ``testsetup`` blocks. 10 | 11 | """ 12 | 13 | os.environ['DJANGO_SETTINGS_MODULE'] = 'nested_formset.test_settings' 14 | 15 | import django 16 | django.setup() 17 | 18 | 19 | def run_tests(): 20 | 21 | setup() 22 | 23 | from django.conf import settings 24 | from django.test.utils import get_runner 25 | 26 | TestRunner = get_runner(settings) 27 | test_runner = TestRunner() 28 | 29 | failures = test_runner.run_tests( 30 | ['nested_formset'], 31 | ) 32 | 33 | sys.exit(failures) 34 | 35 | 36 | if __name__ == '__main__': 37 | 38 | run_tests() 39 | -------------------------------------------------------------------------------- /src/nested_formset/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Block(models.Model): 5 | description = models.CharField(max_length=255) 6 | 7 | class Building(models.Model): 8 | block = models.ForeignKey(Block) 9 | address = models.CharField(max_length=255, blank=True) 10 | 11 | class Tenant(models.Model): 12 | building = models.ForeignKey(Building) 13 | name = models.CharField(max_length=255) 14 | unit = models.CharField( 15 | blank=False, 16 | max_length=255, 17 | ) 18 | -------------------------------------------------------------------------------- /src/nested_formset/tests/test_factory.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import django 4 | from django.forms.models import BaseInlineFormSet, inlineformset_factory 5 | from nested_formset.tests import models as test_models 6 | 7 | from nested_formset import nestedformset_factory 8 | 9 | 10 | class FactoryTests(TestCase): 11 | 12 | def setUp(self): 13 | 14 | self.child_formset = inlineformset_factory( 15 | test_models.Building, 16 | test_models.Tenant, 17 | fields=( 18 | 'name', 19 | 'unit', 20 | ), 21 | ) 22 | 23 | def test_factory_returns_formset(self): 24 | 25 | nested_formset = nestedformset_factory( 26 | test_models.Block, 27 | test_models.Building, 28 | nested_formset=self.child_formset, 29 | ) 30 | 31 | self.assertTrue(issubclass(nested_formset, BaseInlineFormSet)) 32 | 33 | def test_override_fields_for_factory(self): 34 | 35 | nested_formset = nestedformset_factory( 36 | test_models.Block, 37 | test_models.Building, 38 | nested_formset=self.child_formset, 39 | fields=( 40 | 'block', 41 | ), 42 | ) 43 | 44 | self.assertEqual( 45 | tuple(nested_formset.form.base_fields.keys()), 46 | ('block',), 47 | ) 48 | 49 | def test_exclude_fields_for_factory(self): 50 | 51 | nested_formset = nestedformset_factory( 52 | test_models.Block, 53 | test_models.Building, 54 | nested_formset=self.child_formset, 55 | exclude=( 56 | 'address', 57 | ), 58 | ) 59 | 60 | self.assertEqual( 61 | tuple(nested_formset.form.base_fields.keys()), 62 | ('block',), 63 | ) 64 | 65 | def test_fk_name_for_factory(self): 66 | 67 | fk_name = 'block' 68 | # Should pass because fk_name is valid 69 | nested_formset = nestedformset_factory( 70 | test_models.Block, 71 | test_models.Building, 72 | nested_formset=self.child_formset, 73 | fk_name='block' 74 | )() 75 | # Fails because address is not fk 76 | with self.assertRaises(ValueError): 77 | nested_formset = nestedformset_factory( 78 | test_models.Block, 79 | test_models.Building, 80 | nested_formset=self.child_formset, 81 | fk_name='address' 82 | )() 83 | 84 | def test_min_num_for_factory(self): 85 | 86 | num = 3 87 | nested_formset = nestedformset_factory( 88 | test_models.Block, 89 | test_models.Building, 90 | nested_formset=self.child_formset, 91 | min_num=num 92 | ) 93 | 94 | self.assertEqual( 95 | num, 96 | nested_formset.min_num 97 | ) 98 | 99 | 100 | def test_validate_min_for_factory(self): 101 | # Default is False 102 | validate_min = True 103 | nested_formset = nestedformset_factory( 104 | test_models.Block, 105 | test_models.Building, 106 | nested_formset=self.child_formset, 107 | validate_min=validate_min 108 | ) 109 | 110 | self.assertEqual( 111 | validate_min, 112 | nested_formset.validate_min 113 | ) 114 | -------------------------------------------------------------------------------- /src/nested_formset/tests/test_instantiation.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from django.forms.models import BaseInlineFormSet, inlineformset_factory 4 | from nested_formset.tests import models as test_models 5 | from nested_formset.tests.util import get_form_data 6 | 7 | from nested_formset import nestedformset_factory 8 | 9 | 10 | class InstantiationTests(TestCase): 11 | 12 | def setUp(self): 13 | 14 | child_formset = inlineformset_factory( 15 | test_models.Building, 16 | test_models.Tenant, 17 | fields=( 18 | 'name', 19 | 'unit', 20 | ), 21 | ) 22 | self.formset_class = nestedformset_factory( 23 | test_models.Block, 24 | test_models.Building, 25 | nested_formset=child_formset, 26 | ) 27 | 28 | def test_takes_parent_discovers_children(self): 29 | 30 | block = test_models.Block.objects.create() 31 | 32 | for i in range(10): 33 | test_models.Building.objects.create(block=block) 34 | 35 | formset = self.formset_class(instance=block) 36 | 37 | self.assertEqual( 38 | len(formset.forms), 10 + formset.extra 39 | ) 40 | 41 | def test_child_form_contains_nested_formset(self): 42 | 43 | block = test_models.Block.objects.create() 44 | test_models.Building.objects.create(block=block) 45 | 46 | formset = self.formset_class(instance=block) 47 | 48 | self.assertTrue( 49 | isinstance(formset.forms[0].nested, BaseInlineFormSet) 50 | ) 51 | 52 | def test_nested_has_derived_prefix(self): 53 | 54 | block = test_models.Block.objects.create() 55 | test_models.Building.objects.create(block=block) 56 | 57 | formset = self.formset_class(instance=block) 58 | 59 | # first level forms have a "normal" formset prefix 60 | self.assertEqual(formset.forms[0].prefix, 'building_set-0') 61 | 62 | # second level inherit from their parent 63 | self.assertEqual( 64 | formset.forms[0].nested.forms[0].prefix, 65 | 'building_set-0-tenant_set-0', 66 | ) 67 | 68 | def test_empty_form_is_not_bound(self): 69 | 70 | block = test_models.Block.objects.create() 71 | test_models.Building.objects.create(block=block) 72 | 73 | formset = self.formset_class(instance=block) 74 | 75 | self.assertFalse(formset.empty_form.is_bound) 76 | self.assertFalse(formset.forms[0].nested.empty_form.is_bound) 77 | 78 | def test_files_passed_to_nested_forms(self): 79 | 80 | block = test_models.Block.objects.create() 81 | test_models.Building.objects.create(block=block) 82 | 83 | form_data = get_form_data(self.formset_class(instance=block)) 84 | 85 | formset = self.formset_class( 86 | instance=block, 87 | data=form_data, 88 | files={1: 2}, 89 | ) 90 | 91 | self.assertEqual(formset[0].nested.files, {1: 2}) 92 | -------------------------------------------------------------------------------- /src/nested_formset/tests/test_properties.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from django import forms 4 | from django.forms.models import inlineformset_factory 5 | from nested_formset.tests import models as test_models 6 | 7 | from nested_formset import nestedformset_factory 8 | 9 | 10 | class StyledTextInput(forms.TextInput): 11 | 12 | class Media: 13 | css = { 14 | 'all': ('widget.css',), 15 | } 16 | 17 | 18 | class TenantForm(forms.ModelForm): 19 | 20 | name = forms.CharField( 21 | max_length=50, 22 | widget=StyledTextInput, 23 | ) 24 | 25 | class Meta: 26 | model = test_models.Tenant 27 | fields = ('name', 'unit') 28 | 29 | class Media: 30 | css = { 31 | 'all': ('layout.css',), 32 | } 33 | 34 | 35 | class BuildingForm(forms.ModelForm): 36 | 37 | class Meta: 38 | model = test_models.Building 39 | exclude = () 40 | 41 | class Media: 42 | css = { 43 | 'print': ('building.css',), 44 | } 45 | 46 | 47 | class PropertyTests(TestCase): 48 | 49 | def setUp(self): 50 | 51 | child_formset = inlineformset_factory( 52 | test_models.Building, 53 | test_models.Tenant, 54 | fields=( 55 | 'name', 56 | 'unit', 57 | ), 58 | form=TenantForm, 59 | ) 60 | self.formset_class = nestedformset_factory( 61 | test_models.Block, 62 | test_models.Building, 63 | nested_formset=child_formset, 64 | form=BuildingForm, 65 | ) 66 | 67 | def test_media_rolls_up_child_media(self): 68 | 69 | block = test_models.Block.objects.create() 70 | form = self.formset_class( 71 | instance=block, 72 | ) 73 | 74 | self.assertIn('building.css', form.media._css['print']) 75 | 76 | def test_media_rolls_up_grandchild_media(self): 77 | 78 | block = test_models.Block.objects.create() 79 | form = self.formset_class( 80 | instance=block, 81 | ) 82 | 83 | self.assertIn('layout.css', form.media._css['all']) 84 | 85 | def test_widget_media_rolls_up(self): 86 | 87 | block = test_models.Block.objects.create() 88 | form = self.formset_class( 89 | instance=block, 90 | ) 91 | 92 | self.assertIn('widget.css', form.media._css['all']) 93 | -------------------------------------------------------------------------------- /src/nested_formset/tests/test_save.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django.forms.models import inlineformset_factory 4 | from nested_formset.tests import models as test_models 5 | from nested_formset.tests.util import get_form_data 6 | 7 | from nested_formset import nestedformset_factory 8 | 9 | 10 | class EditTests(TestCase): 11 | 12 | def setUp(self): 13 | 14 | child_formset = inlineformset_factory( 15 | test_models.Building, 16 | test_models.Tenant, 17 | fields=( 18 | 'name', 19 | 'unit', 20 | ), 21 | ) 22 | self.formset_class = nestedformset_factory( 23 | test_models.Block, 24 | test_models.Building, 25 | nested_formset=child_formset, 26 | ) 27 | 28 | self.block = test_models.Block.objects.create() 29 | 30 | def test_edit_building(self): 31 | 32 | building = test_models.Building.objects.create( 33 | block=self.block, 34 | address='829 S Mulberry St.', 35 | ) 36 | 37 | unbound_form = self.formset_class(instance=self.block) 38 | form_data = get_form_data(unbound_form) 39 | 40 | form_data.update({ 41 | 'building_set-0-address': '405 S. Wayne St.', 42 | }) 43 | 44 | form = self.formset_class( 45 | instance=self.block, 46 | data=form_data, 47 | ) 48 | 49 | self.assertTrue(form.is_valid()) 50 | form.save() 51 | 52 | self.assertEqual( 53 | test_models.Building.objects.get(id=building.id).address, 54 | '405 S. Wayne St.', 55 | ) 56 | 57 | def test_edit_tenant(self): 58 | 59 | building = test_models.Building.objects.create( 60 | block=self.block, 61 | address='829 S Mulberry St.', 62 | ) 63 | tenant = test_models.Tenant.objects.create( 64 | building=building, 65 | name='John Doe', 66 | unit='42', 67 | ) 68 | 69 | form_data = get_form_data( 70 | self.formset_class(instance=self.block) 71 | ) 72 | 73 | form_data.update({ 74 | 'building_set-0-address': '405 S. Wayne St.', 75 | 'building_set-0-tenant_set-0-unit': '42A', 76 | }) 77 | 78 | form = self.formset_class( 79 | instance=self.block, 80 | data=form_data, 81 | ) 82 | 83 | self.assertTrue(form.is_valid()) 84 | form.save() 85 | 86 | self.assertEqual( 87 | test_models.Tenant.objects.get(id=tenant.id).unit, 88 | '42A', 89 | ) 90 | self.assertEqual( 91 | test_models.Tenant.objects.get(id=tenant.id).building, 92 | building, 93 | ) 94 | 95 | 96 | class CreationTests(TestCase): 97 | 98 | def setUp(self): 99 | 100 | child_formset = inlineformset_factory( 101 | test_models.Building, 102 | test_models.Tenant, 103 | fields=( 104 | 'name', 105 | 'unit', 106 | ), 107 | ) 108 | self.formset_class = nestedformset_factory( 109 | test_models.Block, 110 | test_models.Building, 111 | nested_formset=child_formset, 112 | ) 113 | 114 | self.block = test_models.Block.objects.create() 115 | 116 | def test_create_building(self): 117 | 118 | unbound_form = self.formset_class(instance=self.block) 119 | self.assertEqual(unbound_form.initial_forms, []) 120 | 121 | form_data = get_form_data(unbound_form) 122 | 123 | form_data.update({ 124 | 'building_set-0-address': '405 S. Wayne St.', 125 | }) 126 | 127 | form = self.formset_class( 128 | instance=self.block, 129 | data=form_data, 130 | ) 131 | 132 | self.assertTrue(form.is_valid()) 133 | form.save() 134 | 135 | self.assertEqual(self.block.building_set.count(), 1) 136 | 137 | def test_create_tenant(self): 138 | 139 | building = test_models.Building.objects.create( 140 | block=self.block, 141 | address='829 S Mulberry St.', 142 | ) 143 | self.assertEqual(building.tenant_set.count(), 0) 144 | 145 | form_data = get_form_data( 146 | self.formset_class(instance=self.block) 147 | ) 148 | 149 | form_data.update({ 150 | 'building_set-0-tenant_set-0-name': 'John Doe', 151 | 'building_set-0-tenant_set-0-unit': '42A', 152 | }) 153 | 154 | form = self.formset_class( 155 | instance=self.block, 156 | data=form_data, 157 | ) 158 | 159 | self.assertTrue(form.is_valid()) 160 | form.save() 161 | 162 | self.assertEqual(building.tenant_set.all().count(), 1) 163 | self.assertEqual( 164 | building.tenant_set.all()[0].name, 165 | 'John Doe', 166 | ) 167 | 168 | def test_create_building_tenant(self): 169 | 170 | self.assertEqual(self.block.building_set.count(), 0) 171 | 172 | form_data = get_form_data( 173 | self.formset_class(instance=self.block) 174 | ) 175 | form_data.update({ 176 | 'building_set-0-address': '829 S Mulberry St.', 177 | 'building_set-0-tenant_set-0-name': 'John Doe', 178 | 'building_set-0-tenant_set-0-unit': '42A', 179 | }) 180 | 181 | form = self.formset_class( 182 | instance=self.block, 183 | data=form_data, 184 | ) 185 | 186 | self.assertTrue(form.is_valid()) 187 | form.save() 188 | 189 | # the building was created and linked to the block 190 | self.assertEqual(self.block.building_set.count(), 1) 191 | building = self.block.building_set.all()[0] 192 | self.assertEqual( 193 | building.address, 194 | '829 S Mulberry St.', 195 | ) 196 | 197 | # the tenant was also created and linked to the new building 198 | self.assertEqual(building.tenant_set.count(), 1) 199 | self.assertEqual( 200 | building.tenant_set.all()[0].name, 201 | 'John Doe', 202 | ) 203 | 204 | def test_create_tenant_empty_building(self): 205 | 206 | self.assertEqual(self.block.building_set.count(), 0) 207 | 208 | form_data = get_form_data( 209 | self.formset_class(instance=self.block) 210 | ) 211 | form_data.update({ 212 | 'building_set-0-tenant_set-0-name': 'John Doe', 213 | 'building_set-0-tenant_set-0-unit': '42A', 214 | }) 215 | 216 | form = self.formset_class( 217 | instance=self.block, 218 | data=form_data, 219 | ) 220 | 221 | self.assertTrue(form.is_valid()) 222 | form.save() 223 | 224 | # the building was created and linked to the block 225 | self.assertEqual(self.block.building_set.count(), 1) 226 | building = self.block.building_set.all()[0] 227 | 228 | # the tenant was also created and linked to the new building 229 | self.assertEqual(building.tenant_set.count(), 1) 230 | self.assertEqual( 231 | building.tenant_set.all()[0].name, 232 | 'John Doe', 233 | ) 234 | 235 | 236 | class DeleteTests(TestCase): 237 | 238 | def setUp(self): 239 | 240 | child_formset = inlineformset_factory( 241 | test_models.Building, 242 | test_models.Tenant, 243 | fields=( 244 | 'name', 245 | 'unit', 246 | ), 247 | ) 248 | self.formset_class = nestedformset_factory( 249 | test_models.Block, 250 | test_models.Building, 251 | nested_formset=child_formset, 252 | ) 253 | 254 | self.block = test_models.Block.objects.create() 255 | self.building = test_models.Building.objects.create( 256 | block=self.block, 257 | address='829 S Mulberry St.', 258 | ) 259 | self.tenant = test_models.Tenant.objects.create( 260 | building=self.building, 261 | name='John Doe', 262 | unit='42', 263 | ) 264 | 265 | def test_delete_tenant(self): 266 | 267 | self.assertEqual(self.building.tenant_set.count(), 1) 268 | 269 | unbound_form = self.formset_class(instance=self.block) 270 | form_data = get_form_data(unbound_form) 271 | 272 | form_data.update({ 273 | 'building_set-0-tenant_set-0-DELETE': True, 274 | }) 275 | 276 | form = self.formset_class( 277 | instance=self.block, 278 | data=form_data, 279 | ) 280 | 281 | self.assertTrue(form.is_valid()) 282 | form.save() 283 | 284 | # the building is intact... 285 | self.assertEqual( 286 | test_models.Block.objects.get(id=self.block.id).building_set.count(), 287 | 1) 288 | 289 | # ... and the tenant is deleted 290 | self.assertEqual(self.building.tenant_set.count(), 0) 291 | 292 | def test_delete_building(self): 293 | 294 | self.assertEqual(test_models.Tenant.objects.all().count(), 1) 295 | self.assertEqual(self.block.building_set.count(), 1) 296 | self.assertEqual(self.building.tenant_set.count(), 1) 297 | 298 | unbound_form = self.formset_class(instance=self.block) 299 | form_data = get_form_data(unbound_form) 300 | 301 | form_data.update({ 302 | 'building_set-0-DELETE': True, 303 | }) 304 | 305 | form = self.formset_class( 306 | instance=self.block, 307 | data=form_data, 308 | ) 309 | 310 | self.assertTrue(form.is_valid()) 311 | form.save() 312 | 313 | self.assertEqual(self.block.building_set.count(), 0) 314 | 315 | self.assertEqual(test_models.Tenant.objects.all().count(), 0) 316 | -------------------------------------------------------------------------------- /src/nested_formset/tests/test_validation.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from django.forms.models import inlineformset_factory 4 | from nested_formset.tests import models as test_models 5 | from nested_formset.tests.util import get_form_data 6 | 7 | from nested_formset import nestedformset_factory 8 | 9 | 10 | class ValidationTests(TestCase): 11 | 12 | def setUp(self): 13 | 14 | child_formset = inlineformset_factory( 15 | test_models.Building, 16 | test_models.Tenant, 17 | fields=( 18 | 'name', 19 | 'unit', 20 | ), 21 | ) 22 | self.formset_class = nestedformset_factory( 23 | test_models.Block, 24 | test_models.Building, 25 | nested_formset=child_formset, 26 | ) 27 | 28 | def test_is_valid_calls_is_valid_on_nested(self): 29 | 30 | block = test_models.Block.objects.create() 31 | 32 | form_data = get_form_data(self.formset_class(instance=block)) 33 | form_data.update({ 34 | 'building_set-0-address': '123 Main St', 35 | 'building_set-0-tenant_set-0-name': 'John Doe', 36 | }) 37 | 38 | form = self.formset_class( 39 | instance=block, 40 | data=form_data, 41 | ) 42 | 43 | # this is not valid -- unit is a required field for Tenants 44 | self.assertFalse(form.is_valid()) 45 | 46 | def test_formset_has_changed_reflects_nested_forms(self): 47 | 48 | block = test_models.Block.objects.create() 49 | 50 | form_data = get_form_data(self.formset_class(instance=block)) 51 | form_data.update({ 52 | 'building_set-0-tenant_set-0-name': 'John Doe', 53 | }) 54 | 55 | form = self.formset_class( 56 | instance=block, 57 | data=form_data, 58 | ) 59 | 60 | # the nested element has changed, therefore the formset has 61 | self.assertTrue(form.has_changed()) 62 | -------------------------------------------------------------------------------- /src/nested_formset/tests/util.py: -------------------------------------------------------------------------------- 1 | from rebar.testing import flatten_to_dict 2 | 3 | 4 | def get_form_data(formset): 5 | """Return the form data for formset as a dict.""" 6 | 7 | form_data = flatten_to_dict(formset) 8 | 9 | for form in formset: 10 | form_data.update( 11 | flatten_to_dict(form.nested) 12 | ) 13 | 14 | return form_data 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{27,36}-django111 4 | py{36}-django20 5 | py{36}-djangohead 6 | 7 | [testenv] 8 | deps = 9 | django20: Django~=2.0.0 10 | django111: Django~=1.11.0 11 | djangohead: https://github.com/django/django/archive/master.tar.gz 12 | commands = python setup.py test 13 | ignore_outcome = 14 | djangohead: True 15 | --------------------------------------------------------------------------------