├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── config ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py ├── example.png ├── manage.py ├── publishing ├── books │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20180303_1208.py │ │ └── __init__.py │ ├── models.py │ ├── urls.py │ └── views.py ├── media │ └── README.md ├── templates │ └── books │ │ ├── base.html │ │ ├── home.html │ │ ├── publisher_books_update.html │ │ ├── publisher_create.html │ │ ├── publisher_detail.html │ │ └── publisher_list.html └── utils │ ├── __init__.py │ └── forms.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | db.sqlite3 2 | publishing/media/* 3 | !publishing/media/README.md 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Phil Gyford 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | Django = "~=4.1" 8 | Pillow = "~=9.0.0" 9 | 10 | [requires] 11 | python_version = "3.9" 12 | 13 | [dev-packages] 14 | black = "*" 15 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "11a38e205c63334173bc595a8cd45e94ad736bd37316ff2385dbbc52479b3cfd" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "asgiref": { 20 | "hashes": [ 21 | "sha256:71e68008da809b957b7ee4b43dbccff33d1b23519fb8344e33f049897077afac", 22 | "sha256:9567dfe7bd8d3c8c892227827c41cce860b368104c3431da67a0c5a65a949506" 23 | ], 24 | "markers": "python_version >= '3.7'", 25 | "version": "==3.6.0" 26 | }, 27 | "django": { 28 | "hashes": [ 29 | "sha256:44f714b81c5f190d9d2ddad01a532fe502fa01c4cb8faf1d081f4264ed15dcd8", 30 | "sha256:f2f431e75adc40039ace496ad3b9f17227022e8b11566f4b363da44c7e44761e" 31 | ], 32 | "index": "pypi", 33 | "version": "==4.1.7" 34 | }, 35 | "pillow": { 36 | "hashes": [ 37 | "sha256:011233e0c42a4a7836498e98c1acf5e744c96a67dd5032a6f666cc1fb97eab97", 38 | "sha256:0f29d831e2151e0b7b39981756d201f7108d3d215896212ffe2e992d06bfe049", 39 | "sha256:12875d118f21cf35604176872447cdb57b07126750a33748bac15e77f90f1f9c", 40 | "sha256:14d4b1341ac07ae07eb2cc682f459bec932a380c3b122f5540432d8977e64eae", 41 | "sha256:1c3c33ac69cf059bbb9d1a71eeaba76781b450bc307e2291f8a4764d779a6b28", 42 | "sha256:1d19397351f73a88904ad1aee421e800fe4bbcd1aeee6435fb62d0a05ccd1030", 43 | "sha256:253e8a302a96df6927310a9d44e6103055e8fb96a6822f8b7f514bb7ef77de56", 44 | "sha256:2632d0f846b7c7600edf53c48f8f9f1e13e62f66a6dbc15191029d950bfed976", 45 | "sha256:335ace1a22325395c4ea88e00ba3dc89ca029bd66bd5a3c382d53e44f0ccd77e", 46 | "sha256:413ce0bbf9fc6278b2d63309dfeefe452835e1c78398efb431bab0672fe9274e", 47 | "sha256:5100b45a4638e3c00e4d2320d3193bdabb2d75e79793af7c3eb139e4f569f16f", 48 | "sha256:514ceac913076feefbeaf89771fd6febde78b0c4c1b23aaeab082c41c694e81b", 49 | "sha256:528a2a692c65dd5cafc130de286030af251d2ee0483a5bf50c9348aefe834e8a", 50 | "sha256:6295f6763749b89c994fcb6d8a7f7ce03c3992e695f89f00b741b4580b199b7e", 51 | "sha256:6c8bc8238a7dfdaf7a75f5ec5a663f4173f8c367e5a39f87e720495e1eed75fa", 52 | "sha256:718856856ba31f14f13ba885ff13874be7fefc53984d2832458f12c38205f7f7", 53 | "sha256:7f7609a718b177bf171ac93cea9fd2ddc0e03e84d8fa4e887bdfc39671d46b00", 54 | "sha256:80ca33961ced9c63358056bd08403ff866512038883e74f3a4bf88ad3eb66838", 55 | "sha256:80fe64a6deb6fcfdf7b8386f2cf216d329be6f2781f7d90304351811fb591360", 56 | "sha256:81c4b81611e3a3cb30e59b0cf05b888c675f97e3adb2c8672c3154047980726b", 57 | "sha256:855c583f268edde09474b081e3ddcd5cf3b20c12f26e0d434e1386cc5d318e7a", 58 | "sha256:9bfdb82cdfeccec50aad441afc332faf8606dfa5e8efd18a6692b5d6e79f00fd", 59 | "sha256:a5d24e1d674dd9d72c66ad3ea9131322819ff86250b30dc5821cbafcfa0b96b4", 60 | "sha256:a9f44cd7e162ac6191491d7249cceb02b8116b0f7e847ee33f739d7cb1ea1f70", 61 | "sha256:b5b3f092fe345c03bca1e0b687dfbb39364b21ebb8ba90e3fa707374b7915204", 62 | "sha256:b9618823bd237c0d2575283f2939655f54d51b4527ec3972907a927acbcc5bfc", 63 | "sha256:cef9c85ccbe9bee00909758936ea841ef12035296c748aaceee535969e27d31b", 64 | "sha256:d21237d0cd37acded35154e29aec853e945950321dd2ffd1a7d86fe686814669", 65 | "sha256:d3c5c79ab7dfce6d88f1ba639b77e77a17ea33a01b07b99840d6ed08031cb2a7", 66 | "sha256:d9d7942b624b04b895cb95af03a23407f17646815495ce4547f0e60e0b06f58e", 67 | "sha256:db6d9fac65bd08cea7f3540b899977c6dee9edad959fa4eaf305940d9cbd861c", 68 | "sha256:ede5af4a2702444a832a800b8eb7f0a7a1c0eed55b644642e049c98d589e5092", 69 | "sha256:effb7749713d5317478bb3acb3f81d9d7c7f86726d41c1facca068a04cf5bb4c", 70 | "sha256:f154d173286a5d1863637a7dcd8c3437bb557520b01bddb0be0258dcb72696b5", 71 | "sha256:f25ed6e28ddf50de7e7ea99d7a976d6a9c415f03adcaac9c41ff6ff41b6d86ac" 72 | ], 73 | "index": "pypi", 74 | "version": "==9.0.1" 75 | }, 76 | "sqlparse": { 77 | "hashes": [ 78 | "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34", 79 | "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268" 80 | ], 81 | "markers": "python_version >= '3.5'", 82 | "version": "==0.4.3" 83 | } 84 | }, 85 | "develop": { 86 | "black": { 87 | "hashes": [ 88 | "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd", 89 | "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555", 90 | "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481", 91 | "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468", 92 | "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9", 93 | "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a", 94 | "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958", 95 | "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580", 96 | "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26", 97 | "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32", 98 | "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8", 99 | "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753", 100 | "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b", 101 | "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074", 102 | "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651", 103 | "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24", 104 | "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6", 105 | "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad", 106 | "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac", 107 | "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221", 108 | "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06", 109 | "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27", 110 | "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648", 111 | "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739", 112 | "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104" 113 | ], 114 | "index": "pypi", 115 | "version": "==23.1.0" 116 | }, 117 | "click": { 118 | "hashes": [ 119 | "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", 120 | "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" 121 | ], 122 | "markers": "python_version >= '3.7'", 123 | "version": "==8.1.3" 124 | }, 125 | "mypy-extensions": { 126 | "hashes": [ 127 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 128 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 129 | ], 130 | "markers": "python_version >= '3.5'", 131 | "version": "==1.0.0" 132 | }, 133 | "packaging": { 134 | "hashes": [ 135 | "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2", 136 | "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97" 137 | ], 138 | "markers": "python_version >= '3.7'", 139 | "version": "==23.0" 140 | }, 141 | "pathspec": { 142 | "hashes": [ 143 | "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229", 144 | "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc" 145 | ], 146 | "markers": "python_version >= '3.7'", 147 | "version": "==0.11.0" 148 | }, 149 | "platformdirs": { 150 | "hashes": [ 151 | "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9", 152 | "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567" 153 | ], 154 | "markers": "python_version >= '3.7'", 155 | "version": "==3.0.0" 156 | }, 157 | "tomli": { 158 | "hashes": [ 159 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 160 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 161 | ], 162 | "markers": "python_version < '3.11'", 163 | "version": "==2.0.1" 164 | }, 165 | "typing-extensions": { 166 | "hashes": [ 167 | "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb", 168 | "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4" 169 | ], 170 | "markers": "python_version < '3.10'", 171 | "version": "==4.5.0" 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django nested inline formsets example 2 | 3 | This Django project is purely to demonstrate an example of how to create a form that contains [inline formsets][if] that each contains its own inline formset. 4 | 5 | It runs in Django 4.0 using Python 3.9. 6 | 7 | I'm indebted to [this blogpost][post] by Ravi Kumar Gadila for helping me figure this out. 8 | 9 | [if]: https://docs.djangoproject.com/en/2.0/topics/forms/modelforms/#inline-formsets 10 | [post]: https://micropyramid.com/blog/how-to-use-nested-formsets-in-django/ 11 | 12 | ## The situation 13 | 14 | We have a model describing Publishers. Each Publisher can have a number of Books. Each Book can have a number of BookImages (e.g. its cover, back cover, illustrations, etc): 15 | 16 | Publisher #1 17 | |-Book 18 | | |-BookImage 19 | | |-BookImage 20 | | 21 | |-Book 22 | |-BookImage 23 | 24 | Publisher #2 25 | |-Book 26 | | 27 | |-Book 28 | 29 | See these in [`models.py`][models]. 30 | 31 | Using an inline formset we could display a single form that would let the user edit all of the Books belonging to a single Publisher. 32 | 33 | Using another inline formset we could display another form that would let the user edit all of the BookImages belonging to a single Book. 34 | 35 | It becomes trickier if we want to combine these two forms into one: displaying all of the Books for a Publisher, and for each Book, all of its BookImages. 36 | 37 | ## Solution 38 | 39 | You can see in [`forms.py`][forms] how we construct an inline formset, `BookImageFormset` for editing the `BookImage`s belonging to a single `Book`. 40 | 41 | And then we create a custom `BaseBooksWithImagesFormset` that has a custom `nested` property. This contains our `BookImageFormset`. We add custom methods for `is_valid()` and `save()` to ensure the data in these nested formsets are validated and saved. 42 | 43 | Finally we create our `PublisherBooksWithImagesFormset` which is for editing all the `Book`s belonging to a `Publisher`... and we pass it this argument: `formset=BaseBooksWithImagesFormset` so it knows how to handle each of the `Book`s' `BookImage`s. 44 | 45 | See [`views.py`][views] for how we use this in a class-based view to create the page. This expects the `id` of a `Publisher`. And see the [`books/publisher_books_update.html`][template] template for how the outer form, and its `Book` formsets, and their nested `BookImage` formsets, are rendered. 46 | 47 | [models]: publishing/books/models.py 48 | [forms]: publishing/books/forms.py 49 | [views]: publishing/books/views.py 50 | [template]: publishing/books/templates/books/publisher_books_update.html 51 | 52 | Here's an image showing how that page looks: 53 | 54 | 55 | ## Set-up 56 | 57 | If you want to get this project running to see how it works... 58 | 59 | 1. Download or clone the repository. 60 | 61 | 2. Install Django and [Pillow](https://pillow.readthedocs.io/en/latest/) (required for the `ImageField`). For example, using pip with the `requirements.txt` file: 62 | 63 | pip install -r requirements.txt 64 | 65 | Or using pipenv with the `Pipfile`s: 66 | 67 | pipenv install 68 | 69 | (If using pipenv, enter the virtual environment before running the following commands, by doing `pipenv shell`) 70 | 71 | 3. Run the migrations: 72 | 73 | ./manage.py migrate 74 | 75 | 4. Create a superuser if you want to use the Django Admin: 76 | 77 | ./manage.py createsuperuser 78 | 79 | 5. Run the development server: 80 | 81 | ./manage.py runserver 82 | 83 | 6. View the site at http://127.0.0.1:8000/ and add at least one Publisher. 84 | 85 | 7. You can then click the link to add some Books to your Publisher. You'll then be on a page like http://127.0.0.1:8000/publishers/1/books/edit/ which is the form with its inline formsets. 86 | 87 | ![](example.png?raw=true) 88 | 89 | 90 | 91 | ## Thanks 92 | 93 | * [PaperNick](https://github.com/PaperNick) for [PR #3][issue-3] 94 | 95 | 96 | [issue-3]: https://github.com/philgyford/django-nested-inline-formsets-example/pull/3 97 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-nested-inline-formsets-example/250f89f22537eb77767b69ad7a2161ce9aac2c24/config/__init__.py -------------------------------------------------------------------------------- /config/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for publishing project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.0.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "9t4(7dg5mnf_=p@52s4))jb#hx=x)+w!^08#)@uc@vrr=enekg" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "publishing.books", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "config.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [os.path.join(BASE_DIR, "publishing", "templates")], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "config.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 106 | 107 | LANGUAGE_CODE = "en-us" 108 | 109 | TIME_ZONE = "UTC" 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 120 | 121 | STATIC_URL = "/static/" 122 | 123 | MEDIA_URL = "/media/" 124 | MEDIA_ROOT = "publishing/media/" 125 | -------------------------------------------------------------------------------- /config/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import include, path 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("", include("publishing.books.urls")), 9 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 10 | -------------------------------------------------------------------------------- /config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for publishing project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-nested-inline-formsets-example/250f89f22537eb77767b69ad7a2161ce9aac2c24/example.png -------------------------------------------------------------------------------- /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", "config.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /publishing/books/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-nested-inline-formsets-example/250f89f22537eb77767b69ad7a2161ce9aac2c24/publishing/books/__init__.py -------------------------------------------------------------------------------- /publishing/books/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Publisher, Book, BookImage 4 | 5 | 6 | class BookImageInline(admin.TabularInline): 7 | model = BookImage 8 | extra = 1 9 | 10 | 11 | @admin.register(Publisher) 12 | class PublisherAdmin(admin.ModelAdmin): 13 | list_display = ("id", "name") 14 | list_display_links = ("name",) 15 | 16 | 17 | @admin.register(Book) 18 | class BookAdmin(admin.ModelAdmin): 19 | list_display = ("id", "title", "publisher") 20 | list_display_links = ("title",) 21 | 22 | inlines = (BookImageInline,) 23 | -------------------------------------------------------------------------------- /publishing/books/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MyappConfig(AppConfig): 5 | name = "publishing.books" 6 | default_auto_field = "django.db.models.BigAutoField" 7 | -------------------------------------------------------------------------------- /publishing/books/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms.models import BaseInlineFormSet, inlineformset_factory 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from publishing.utils.forms import is_empty_form, is_form_persisted 5 | from .models import Publisher, Book, BookImage 6 | 7 | 8 | # The formset for editing the BookImages that belong to a Book. 9 | BookImageFormset = inlineformset_factory( 10 | Book, BookImage, fields=("image", "alt_text"), extra=1 11 | ) 12 | 13 | 14 | class BaseBooksWithImagesFormset(BaseInlineFormSet): 15 | """ 16 | The base formset for editing Books belonging to a Publisher, and the 17 | BookImages belonging to those Books. 18 | """ 19 | 20 | def add_fields(self, form, index): 21 | super().add_fields(form, index) 22 | 23 | # Save the formset for a Book's Images in the nested property. 24 | form.nested = BookImageFormset( 25 | instance=form.instance, 26 | data=form.data if form.is_bound else None, 27 | files=form.files if form.is_bound else None, 28 | prefix="bookimage-%s-%s" 29 | % (form.prefix, BookImageFormset.get_default_prefix()), 30 | ) 31 | 32 | def is_valid(self): 33 | """ 34 | Also validate the nested formsets. 35 | """ 36 | result = super().is_valid() 37 | 38 | if self.is_bound: 39 | for form in self.forms: 40 | if hasattr(form, "nested"): 41 | result = result and form.nested.is_valid() 42 | 43 | return result 44 | 45 | def clean(self): 46 | """ 47 | If a parent form has no data, but its nested forms do, we should 48 | return an error, because we can't save the parent. 49 | For example, if the Book form is empty, but there are Images. 50 | """ 51 | super().clean() 52 | 53 | for form in self.forms: 54 | if not hasattr(form, "nested") or self._should_delete_form(form): 55 | continue 56 | 57 | if self._is_adding_nested_inlines_to_empty_form(form): 58 | form.add_error( 59 | field=None, 60 | error=_( 61 | "You are trying to add image(s) to a book which " 62 | "does not yet exist. Please add information " 63 | "about the book and choose the image file(s) again." 64 | ), 65 | ) 66 | 67 | def save(self, commit=True): 68 | """ 69 | Also save the nested formsets. 70 | """ 71 | result = super().save(commit=commit) 72 | 73 | for form in self.forms: 74 | if hasattr(form, "nested"): 75 | if not self._should_delete_form(form): 76 | form.nested.save(commit=commit) 77 | 78 | return result 79 | 80 | def _is_adding_nested_inlines_to_empty_form(self, form): 81 | """ 82 | Are we trying to add data in nested inlines to a form that has no data? 83 | e.g. Adding Images to a new Book whose data we haven't entered? 84 | """ 85 | if not hasattr(form, "nested"): 86 | # A basic form; it has no nested forms to check. 87 | return False 88 | 89 | if is_form_persisted(form): 90 | # We're editing (not adding) an existing model. 91 | return False 92 | 93 | if not is_empty_form(form): 94 | # The form has errors, or it contains valid data. 95 | return False 96 | 97 | # All the inline forms that aren't being deleted: 98 | non_deleted_forms = set(form.nested.forms).difference( 99 | set(form.nested.deleted_forms) 100 | ) 101 | 102 | # At this point we know that the "form" is empty. 103 | # In all the inline forms that aren't being deleted, are there any that 104 | # contain data? Return True if so. 105 | return any(not is_empty_form(nested_form) for nested_form in non_deleted_forms) 106 | 107 | 108 | # This is the formset for the Books belonging to a Publisher and the 109 | # BookImages belonging to those Books. 110 | # 111 | # You'd use this by passing in a Publisher: 112 | # PublisherBooksWithImagesFormset(**form_kwargs, instance=self.object) 113 | PublisherBooksWithImagesFormset = inlineformset_factory( 114 | Publisher, 115 | Book, 116 | formset=BaseBooksWithImagesFormset, 117 | # We need to specify at least one Book field: 118 | fields=("title",), 119 | extra=1, 120 | # If you don't want to be able to delete Publishers: 121 | # can_delete=False 122 | ) 123 | -------------------------------------------------------------------------------- /publishing/books/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.2 on 2018-03-03 11:39 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Book", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("title", models.CharField(max_length=255)), 26 | ], 27 | ), 28 | migrations.CreateModel( 29 | name="BookImage", 30 | fields=[ 31 | ( 32 | "id", 33 | models.AutoField( 34 | auto_created=True, 35 | primary_key=True, 36 | serialize=False, 37 | verbose_name="ID", 38 | ), 39 | ), 40 | ("image", models.ImageField(max_length=255, upload_to="")), 41 | ("alt_text", models.CharField(blank=True, max_length=255)), 42 | ( 43 | "book", 44 | models.ForeignKey( 45 | on_delete=django.db.models.deletion.CASCADE, to="books.Book" 46 | ), 47 | ), 48 | ], 49 | ), 50 | migrations.CreateModel( 51 | name="Publisher", 52 | fields=[ 53 | ( 54 | "id", 55 | models.AutoField( 56 | auto_created=True, 57 | primary_key=True, 58 | serialize=False, 59 | verbose_name="ID", 60 | ), 61 | ), 62 | ("name", models.CharField(max_length=255)), 63 | ], 64 | ), 65 | migrations.AddField( 66 | model_name="book", 67 | name="publisher", 68 | field=models.ForeignKey( 69 | on_delete=django.db.models.deletion.CASCADE, to="books.Publisher" 70 | ), 71 | ), 72 | ] 73 | -------------------------------------------------------------------------------- /publishing/books/migrations/0002_auto_20180303_1208.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.2 on 2018-03-03 12:08 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("books", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="book", 15 | name="publisher", 16 | field=models.ForeignKey( 17 | on_delete=django.db.models.deletion.CASCADE, 18 | related_name="books", 19 | to="books.Publisher", 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="bookimage", 24 | name="book", 25 | field=models.ForeignKey( 26 | on_delete=django.db.models.deletion.CASCADE, 27 | related_name="images", 28 | to="books.Book", 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /publishing/books/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-nested-inline-formsets-example/250f89f22537eb77767b69ad7a2161ce9aac2c24/publishing/books/migrations/__init__.py -------------------------------------------------------------------------------- /publishing/books/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | 4 | 5 | class Publisher(models.Model): 6 | name = models.CharField(null=False, blank=False, max_length=255) 7 | 8 | def __str__(self): 9 | return self.name 10 | 11 | def get_absolute_url(self): 12 | return reverse("books:publisher_detail", kwargs={"pk": self.pk}) 13 | 14 | 15 | class Book(models.Model): 16 | title = models.CharField(null=False, blank=False, max_length=255) 17 | 18 | publisher = models.ForeignKey( 19 | "Publisher", 20 | null=False, 21 | blank=False, 22 | on_delete=models.CASCADE, 23 | related_name="books", 24 | ) 25 | 26 | def __str__(self): 27 | return self.title 28 | 29 | 30 | class BookImage(models.Model): 31 | "e.g. image of the cover, backcover, etc." 32 | 33 | book = models.ForeignKey( 34 | "Book", null=False, blank=False, on_delete=models.CASCADE, related_name="images" 35 | ) 36 | 37 | image = models.ImageField(null=False, blank=False, max_length=255) 38 | 39 | alt_text = models.CharField(null=False, blank=True, max_length=255) 40 | -------------------------------------------------------------------------------- /publishing/books/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | 6 | app_name = "books" 7 | 8 | urlpatterns = [ 9 | path("", views.HomeView.as_view(), name="home"), 10 | path("publishers/", views.PublisherListView.as_view(), name="publisher_list"), 11 | path( 12 | "publishers//", 13 | views.PublisherDetailView.as_view(), 14 | name="publisher_detail", 15 | ), 16 | path( 17 | "publishers/add/", views.PublisherCreateView.as_view(), name="publisher_create" 18 | ), 19 | path( 20 | "publishers//books/edit/", 21 | views.PublisherBooksUpdateView.as_view(), 22 | name="publisher_books_update", 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /publishing/books/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.http import HttpResponseRedirect 3 | from django.shortcuts import render 4 | from django.urls import reverse 5 | from django.views.generic import ( 6 | CreateView, 7 | DetailView, 8 | FormView, 9 | ListView, 10 | TemplateView, 11 | ) 12 | from django.views.generic.detail import SingleObjectMixin 13 | 14 | from .forms import PublisherBooksWithImagesFormset 15 | from .models import Publisher, Book, BookImage 16 | 17 | 18 | class HomeView(TemplateView): 19 | template_name = "books/home.html" 20 | 21 | 22 | class PublisherListView(ListView): 23 | model = Publisher 24 | template_name = "books/publisher_list.html" 25 | 26 | 27 | class PublisherDetailView(DetailView): 28 | model = Publisher 29 | template_name = "books/publisher_detail.html" 30 | 31 | 32 | class PublisherCreateView(CreateView): 33 | """ 34 | Only for creating a new publisher. Adding books to it is done in the 35 | PublisherBooksUpdateView(). 36 | """ 37 | 38 | model = Publisher 39 | template_name = "books/publisher_create.html" 40 | fields = [ 41 | "name", 42 | ] 43 | 44 | def form_valid(self, form): 45 | messages.add_message(self.request, messages.SUCCESS, "The publisher was added.") 46 | 47 | return super().form_valid(form) 48 | 49 | 50 | class PublisherBooksUpdateView(SingleObjectMixin, FormView): 51 | """ 52 | For adding books to a Publisher, or editing them. 53 | """ 54 | 55 | model = Publisher 56 | template_name = "books/publisher_books_update.html" 57 | 58 | def get(self, request, *args, **kwargs): 59 | # The Publisher we're editing: 60 | self.object = self.get_object(queryset=Publisher.objects.all()) 61 | return super().get(request, *args, **kwargs) 62 | 63 | def post(self, request, *args, **kwargs): 64 | # The Publisher we're uploading for: 65 | self.object = self.get_object(queryset=Publisher.objects.all()) 66 | return super().post(request, *args, **kwargs) 67 | 68 | def get_form(self, form_class=None): 69 | """ 70 | Use our big formset of formsets, and pass in the Publisher object. 71 | """ 72 | return PublisherBooksWithImagesFormset( 73 | **self.get_form_kwargs(), instance=self.object 74 | ) 75 | 76 | def form_valid(self, form): 77 | """ 78 | If the form is valid, redirect to the supplied URL. 79 | """ 80 | form.save() 81 | 82 | messages.add_message(self.request, messages.SUCCESS, "Changes were saved.") 83 | 84 | return HttpResponseRedirect(self.get_success_url()) 85 | 86 | def get_success_url(self): 87 | return reverse("books:publisher_detail", kwargs={"pk": self.object.pk}) 88 | -------------------------------------------------------------------------------- /publishing/media/README.md: -------------------------------------------------------------------------------- 1 | # Media directory 2 | 3 | Uploaded files will be put in here. 4 | 5 | This file will keep the directory in git. 6 | -------------------------------------------------------------------------------- /publishing/templates/books/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block page_title %}{% endblock %} 8 | 9 | 56 | 57 | 58 | 59 |

{% block body_title %}{% endblock %}

60 | 61 | {% if messages %} 62 | 67 | {% endif %} 68 | 69 |
70 | {% block content %} 71 | {% endblock content %} 72 |
73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /publishing/templates/books/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'books/base.html' %} 2 | 3 | {% block page_title %}Home{% endblock %} 4 | 5 | {% block body_title %}Home{% endblock %} 6 | 7 | 8 | {% block content %} 9 | 10 |

11 | All publishers 12 | | 13 | Add a publisher 14 |

15 | 16 | {% endblock content %} 17 | -------------------------------------------------------------------------------- /publishing/templates/books/publisher_books_update.html: -------------------------------------------------------------------------------- 1 | {% extends 'books/base.html' %} 2 | 3 | {% block page_title %}Editing books for {{ publisher.name }}{% endblock %} 4 | 5 | {% block body_title %}Editing books for {{ publisher.name }}{% endblock %} 6 | 7 | 8 | {% block content %} 9 |
10 | 11 | {% for hidden_field in form.hidden_fields %} 12 | {{ hidden_field.errors }} 13 | {{ hidden_field }} 14 | {% endfor %} 15 | 16 | {% csrf_token %} 17 | 18 | {{ form.management_form }} 19 | {{ form.non_form_errors }} 20 | 21 | {% for book_form in form.forms %} 22 | 23 |
24 | 25 |

26 | {% if book_form.instance.id %} 27 | Book #{{ book_form.instance.id }}

28 | {% else %} 29 | {% if form.forms|length > 1 %} 30 | Add another book 31 | {% else %} 32 | Add a book 33 | {% endif %} 34 | {% endif %} 35 | 36 | 37 | {% for hidden_field in book_form.hidden_fields %} 38 | {{ hidden_field.errors }} 39 | {% endfor %} 40 | 41 | 42 | {{ book_form.as_table }} 43 |
44 | 45 | {% if book_form.nested %} 46 |
47 | 48 |

Images

49 | 50 | {{ book_form.nested.management_form }} 51 | {{ book_form.nested.non_form_errors }} 52 | 53 | 54 | {% for bookimage_form in book_form.nested.forms %} 55 | 56 | 57 | 66 | 75 | 80 | 81 | {% endfor %} 82 |
58 | 59 | {% if bookimage_form.instance.id %} 60 | BookImage #{{ bookimage_form.instance.id }} 61 | {% else %} 62 | Add an image 63 | {% endif %} 64 | 65 | 67 | {% for hidden_field in bookimage_form.hidden_fields %} 68 | {{ hidden_field.errors }} 69 | {% endfor %} 70 | 71 | 72 | {{ bookimage_form.as_table }} 73 |
74 |
76 | {% if bookimage_form.instance.image %} 77 | {{ nested.form.instance.alt_text }} 78 | {% endif %} 79 |
83 |
84 | {% endif %} 85 | 86 | {% endfor %} 87 | 88 |
89 | 90 |

91 | 92 |     93 | Cancel 94 |

95 |
96 | 97 | {% endblock content %} 98 | -------------------------------------------------------------------------------- /publishing/templates/books/publisher_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'books/base.html' %} 2 | 3 | {% block page_title %}Adding a publisher{% endblock %} 4 | 5 | {% block body_title %}Adding a publisher{% endblock %} 6 | 7 | 8 | {% block content %} 9 |
10 | 11 | {% for hidden_field in form.hidden_fields %} 12 | {{ hidden_field.errors }} 13 | {{ hidden_field }} 14 | {% endfor %} 15 | 16 | {% csrf_token %} 17 | 18 | {{ form.non_form_errors }} 19 | 20 | 21 | {{ form.as_table }} 22 |
23 | 24 |

25 | 26 |     27 | Cancel 28 |

29 |
30 | 31 | {% endblock content %} 32 | -------------------------------------------------------------------------------- /publishing/templates/books/publisher_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'books/base.html' %} 2 | 3 | {% block page_title %}{{ publisher.name }}{% endblock %} 4 | 5 | {% block body_title %}{{ publisher.name }}{% endblock %} 6 | 7 | 8 | {% block content %} 9 | 10 | 11 |

Books

12 | 13 | {% if publisher.books.all %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% for book in publisher.books.all %} 25 | 26 | 27 | 28 | 37 | 38 | {% endfor %} 39 | 40 |
IDTitleImages
{{ book.id }}{{ book.title }} 29 | {% if book.images.all %} 30 | {% for image in book.images.all %} 31 | {{ image.alt_text }} 32 | {% endfor %} 33 | {% else %} 34 | (None) 35 | {% endif %} 36 |
41 | 42 | {% else %} 43 | 44 |

This publisher has no books.

45 | 46 | {% endif %} 47 | 48 |

49 | 50 | {% if publisher.books.all %} 51 | Edit these books 52 | {% else %} 53 | Add some books 54 | {% endif %} 55 | 56 | | 57 | All publishers 58 |

59 | 60 | {% endblock content %} 61 | -------------------------------------------------------------------------------- /publishing/templates/books/publisher_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'books/base.html' %} 2 | 3 | {% block page_title %}Publishers{% endblock %} 4 | 5 | {% block body_title %}Publishers{% endblock %} 6 | 7 | 8 | {% block content %} 9 | 10 | {% if publisher_list %} 11 | 12 | 20 | 21 | {% else %} 22 | 23 |

There are no publishers to display.

24 | 25 | {% endif %} 26 | 27 |

28 | Add a publisher 29 |

30 | 31 | {% endblock content %} 32 | -------------------------------------------------------------------------------- /publishing/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-nested-inline-formsets-example/250f89f22537eb77767b69ad7a2161ce9aac2c24/publishing/utils/__init__.py -------------------------------------------------------------------------------- /publishing/utils/forms.py: -------------------------------------------------------------------------------- 1 | def is_empty_form(form): 2 | """ 3 | A form is considered empty if it passes its validation, 4 | but doesn't have any data. 5 | 6 | This is primarily used in formsets, when you want to 7 | validate if an individual form is empty (extra_form). 8 | """ 9 | if form.is_valid() and not form.cleaned_data: 10 | return True 11 | else: 12 | # Either the form has errors (isn't valid) or 13 | # it doesn't have errors and contains data. 14 | return False 15 | 16 | 17 | def is_form_persisted(form): 18 | """ 19 | Does the form have a model instance attached and it's not being added? 20 | e.g. The form is about an existing Book whose data is being edited. 21 | """ 22 | if form.instance and not form.instance._state.adding: 23 | return True 24 | else: 25 | # Either the form has no instance attached or 26 | # it has an instance that is being added. 27 | return False 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | black==23.1.0 3 | click==8.1.3; python_version >= '3.7' 4 | mypy-extensions==1.0.0; python_version >= '3.5' 5 | packaging==23.0; python_version >= '3.7' 6 | pathspec==0.11.0; python_version >= '3.7' 7 | platformdirs==3.0.0; python_version >= '3.7' 8 | tomli==2.0.1; python_version < '3.11' 9 | typing-extensions==4.5.0; python_version < '3.10' 10 | asgiref==3.6.0; python_version >= '3.7' 11 | django==4.1.7 12 | pillow==9.0.1 13 | sqlparse==0.4.3; python_version >= '3.5' 14 | --------------------------------------------------------------------------------