├── .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 |
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 |
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 |
--------------------------------------------------------------------------------