├── .gitignore ├── README.md ├── example ├── example.db ├── example │ ├── __init__.py │ ├── admin.py │ ├── models.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── manage.py ├── nested_inlines ├── __init__.py ├── admin.py ├── forms.py ├── helpers.py ├── models.py ├── static │ └── admin │ │ ├── css │ │ └── nested.css │ │ └── js │ │ ├── inlines.js │ │ └── inlines.min.js ├── templates │ └── admin │ │ └── edit_inline │ │ ├── stacked.html │ │ └── tabular.html └── views.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | .settings -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-nested-inlines 2 | 3 | ## Overview 4 | 5 | [Django issue #9025](http://code.djangoproject.com/ticket/9025) 6 | 7 | Patches have been submitted, and repositories forked, but no one likes to use 8 | either one. Now, nested inlines are available in an easy-to-install package. 9 | 10 | ### Issues 11 | 12 | The Javascript portion of this app is currently buggy. The Python portion 13 | should be solid. Please test and file issues and pull requests to improve 14 | it! 15 | 16 | ## Installation 17 | 18 | `pip install -e git+git://github.com/Soaa-/django-nested-inlines.git#egg=django-nested-inlines` 19 | 20 | ## Usage 21 | 22 | `nested_inlines.admin` contains three `ModelAdmin` subclasses to enable 23 | nested inline support: `NestedModelAdmin`, `NestedStackedInline`, and 24 | `NestedTabularInline`. To use them: 25 | 26 | 1. Add `nested_inlines` to your `INSTALLED_APPS` **before** 27 | `django.contrib.admin`. This is because this app overrides certain admin 28 | templates and media. 29 | 2. Import `NestedModelAdmin`, `NestedStackedInline`, and `NestedTabularInline` 30 | wherever you want to use nested inlines. 31 | 3. On admin classes that will contain nested inlines, use `NestedModelAdmin` 32 | rather than the standard `ModelAdmin`. 33 | 4. On inline classes, use `Nested` versions instead of the standard ones. 34 | 5. Add an `inlines = [MyInline,]` attribute to your inlines and watch the 35 | magic happen. 36 | 37 | ## Example 38 | 39 | from django.contrib import admin 40 | from nested_inlines.admin import NestedModelAdmin, NestedStackedInline, NestedTabularInline 41 | from models import A, B, C 42 | 43 | class MyNestedInline(NestedTabularInline): 44 | model = C 45 | 46 | class MyInline(NestedStackedInline): 47 | model = B 48 | inlines = [MyNestedInline,] 49 | 50 | class MyAdmin(NestedModelAdmin): 51 | pass 52 | 53 | admin.site.register(A, MyAdmin) 54 | 55 | ## Credits 56 | 57 | This package is mainly the work of other developers. I've only taken their 58 | patches and packaged them nicely for ease of use. Credit goes to: 59 | 60 | - Gargamel for providing the base patch on the Django ticket. 61 | - Stefan Klug for providing a fork with the patch applied, and for bugfixes. 62 | 63 | See [Stefan Klug's repository](https://github.com/stefanklug/django/tree/nested-inline-support-1.5.x). 64 | -------------------------------------------------------------------------------- /example/example.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soaa-/django-nested-inlines/aaf995a223720ca7d6b4bfc74fb5707cd1bd08dd/example/example.db -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soaa-/django-nested-inlines/aaf995a223720ca7d6b4bfc74fb5707cd1bd08dd/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from nested_inlines.admin import NestedModelAdmin, NestedTabularInline, NestedStackedInline 3 | 4 | from models import A, B, C 5 | 6 | class CInline(NestedTabularInline): 7 | model = C 8 | 9 | class BInline(NestedStackedInline): 10 | model = B 11 | inlines = [CInline,] 12 | 13 | class AAdmin(NestedModelAdmin): 14 | inlines = [BInline,] 15 | 16 | admin.site.register(A, AAdmin) -------------------------------------------------------------------------------- /example/example/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | class A(models.Model): 4 | name = models.CharField("name", max_length=255) 5 | 6 | class B(models.Model): 7 | a = models.ForeignKey(A) 8 | name = models.CharField("name", max_length=255) 9 | 10 | class C(models.Model): 11 | b = models.ForeignKey(B) 12 | name = models.CharField("name", max_length=255) -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Django settings for example project. 3 | import os, manage 4 | gettext = lambda s: s 5 | PROJECT_PATH = os.path.abspath(os.path.dirname(manage.__file__)) 6 | 7 | DEBUG = True 8 | TEMPLATE_DEBUG = DEBUG 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', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 19 | 'NAME': os.path.join(PROJECT_PATH, 'example.db'), # Or path to database file if using sqlite3. 20 | 'USER': '', # Not used with sqlite3. 21 | 'PASSWORD': '', # Not used with sqlite3. 22 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 23 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 24 | } 25 | } 26 | 27 | # Hosts/domain names that are valid for this site; required if DEBUG is False 28 | # See https://docs.djangoproject.com/en//ref/settings/#allowed-hosts 29 | ALLOWED_HOSTS = [] 30 | 31 | # Local time zone for this installation. Choices can be found here: 32 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 33 | # although not all choices may be available on all operating systems. 34 | # In a Windows environment this must be set to your system time zone. 35 | TIME_ZONE = 'America/Chicago' 36 | 37 | # Language code for this installation. All choices can be found here: 38 | # http://www.i18nguy.com/unicode/language-identifiers.html 39 | LANGUAGE_CODE = 'en-us' 40 | 41 | SITE_ID = 1 42 | 43 | # If you set this to False, Django will make some optimizations so as not 44 | # to load the internationalization machinery. 45 | USE_I18N = True 46 | 47 | # If you set this to False, Django will not format dates, numbers and 48 | # calendars according to the current locale. 49 | USE_L10N = True 50 | 51 | # If you set this to False, Django will not use timezone-aware datetimes. 52 | USE_TZ = True 53 | 54 | # Absolute filesystem path to the directory that will hold user-uploaded files. 55 | # Example: "/home/media/media.lawrence.com/media/" 56 | MEDIA_ROOT = '' 57 | 58 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 59 | # trailing slash. 60 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 61 | MEDIA_URL = '' 62 | 63 | # Absolute path to the directory static files should be collected to. 64 | # Don't put anything in this directory yourself; store your static files 65 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 66 | # Example: "/home/media/media.lawrence.com/static/" 67 | STATIC_ROOT = '' 68 | 69 | # URL prefix for static files. 70 | # Example: "http://media.lawrence.com/static/" 71 | STATIC_URL = '/static/' 72 | 73 | # Additional locations of static files 74 | STATICFILES_DIRS = ( 75 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 76 | # Always use forward slashes, even on Windows. 77 | # Don't forget to use absolute paths, not relative paths. 78 | ) 79 | 80 | # List of finder classes that know how to find static files in 81 | # various locations. 82 | STATICFILES_FINDERS = ( 83 | 'django.contrib.staticfiles.finders.FileSystemFinder', 84 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 85 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 86 | ) 87 | 88 | # Make this unique, and don't share it with anybody. 89 | SECRET_KEY = 'q2r=q_6z+h8$wvqu1g=1a+%l!6lqpiv3i-!js^y66-89be#3h^' 90 | 91 | # List of callables that know how to import templates from various sources. 92 | TEMPLATE_LOADERS = ( 93 | 'django.template.loaders.filesystem.Loader', 94 | 'django.template.loaders.app_directories.Loader', 95 | # 'django.template.loaders.eggs.Loader', 96 | ) 97 | 98 | MIDDLEWARE_CLASSES = ( 99 | 'django.middleware.common.CommonMiddleware', 100 | 'django.contrib.sessions.middleware.SessionMiddleware', 101 | 'django.middleware.csrf.CsrfViewMiddleware', 102 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 103 | 'django.contrib.messages.middleware.MessageMiddleware', 104 | # Uncomment the next line for simple clickjacking protection: 105 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 106 | ) 107 | 108 | ROOT_URLCONF = 'example.urls' 109 | 110 | # Python dotted path to the WSGI application used by Django's runserver. 111 | WSGI_APPLICATION = 'example.wsgi.application' 112 | 113 | TEMPLATE_DIRS = ( 114 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 115 | # Always use forward slashes, even on Windows. 116 | # Don't forget to use absolute paths, not relative paths. 117 | ) 118 | 119 | INSTALLED_APPS = ( 120 | 'django.contrib.auth', 121 | 'django.contrib.contenttypes', 122 | 'django.contrib.sessions', 123 | 'django.contrib.sites', 124 | 'django.contrib.messages', 125 | 'django.contrib.staticfiles', 126 | # Uncomment the next line to enable the admin: 127 | 'nested_inlines', 128 | 'django.contrib.admin', 129 | # Uncomment the next line to enable admin documentation: 130 | # 'django.contrib.admindocs', 131 | 'example', 132 | ) 133 | 134 | # A sample logging configuration. The only tangible logging 135 | # performed by this configuration is to send an email to 136 | # the site admins on every HTTP 500 error when DEBUG=False. 137 | # See http://docs.djangoproject.com/en/dev/topics/logging for 138 | # more details on how to customize your logging configuration. 139 | LOGGING = { 140 | 'version': 1, 141 | 'disable_existing_loggers': False, 142 | 'filters': { 143 | 'require_debug_false': { 144 | '()': 'django.utils.log.RequireDebugFalse' 145 | } 146 | }, 147 | 'handlers': { 148 | 'mail_admins': { 149 | 'level': 'ERROR', 150 | 'filters': ['require_debug_false'], 151 | 'class': 'django.utils.log.AdminEmailHandler' 152 | } 153 | }, 154 | 'loggers': { 155 | 'django.request': { 156 | 'handlers': ['mail_admins'], 157 | 'level': 'ERROR', 158 | 'propagate': True, 159 | }, 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | 3 | # Uncomment the next two lines to enable the admin: 4 | from django.contrib import admin 5 | admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | # Examples: 9 | # url(r'^$', 'example.views.home', name='home'), 10 | # url(r'^example/', include('example.foo.urls')), 11 | 12 | # Uncomment the admin/doc line below to enable admin documentation: 13 | # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), 14 | 15 | # Uncomment the next line to enable the admin: 16 | url(r'^admin/', include(admin.site.urls)), 17 | ) 18 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example 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 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 19 | 20 | # This application object is used by any WSGI server configured to use this 21 | # file. This includes Django's development server, if the WSGI_APPLICATION 22 | # setting points here. 23 | from django.core.wsgi import get_wsgi_application 24 | application = get_wsgi_application() 25 | 26 | # Apply WSGI middleware here. 27 | # from helloworld.wsgi import HelloWorldApplication 28 | # application = HelloWorldApplication(application) 29 | -------------------------------------------------------------------------------- /example/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", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /nested_inlines/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = (0, 1) -------------------------------------------------------------------------------- /nested_inlines/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.options import (ModelAdmin, InlineModelAdmin, 2 | csrf_protect_m, models, transaction, all_valid, 3 | PermissionDenied, unquote, escape, Http404, reverse) 4 | # Fix to make Django 1.5 compatible, maintain backwards compatibility 5 | try: 6 | from django.contrib.admin.options import force_unicode 7 | except ImportError: 8 | from django.utils.encoding import force_unicode 9 | 10 | from django.contrib.admin.helpers import InlineAdminFormSet, AdminForm 11 | from django.utils.translation import ugettext as _ 12 | 13 | from forms import BaseNestedModelForm, BaseNestedInlineFormSet 14 | from helpers import AdminErrorList 15 | 16 | class NestedModelAdmin(ModelAdmin): 17 | class Media: 18 | css = {'all': ('admin/css/nested.css',)} 19 | js = ('admin/js/nested.js',) 20 | 21 | def get_form(self, request, obj=None, **kwargs): 22 | return super(NestedModelAdmin, self).get_form( 23 | request, obj, form=BaseNestedModelForm, **kwargs) 24 | 25 | def get_inline_instances(self, request, obj=None): 26 | inline_instances = [] 27 | for inline_class in self.inlines: 28 | inline = inline_class(self.model, self.admin_site) 29 | if request: 30 | if not (inline.has_add_permission(request) or 31 | inline.has_change_permission(request, obj) or 32 | inline.has_delete_permission(request, obj)): 33 | continue 34 | if not inline.has_add_permission(request): 35 | inline.max_num = 0 36 | inline_instances.append(inline) 37 | 38 | return inline_instances 39 | 40 | def save_formset(self, request, form, formset, change): 41 | """ 42 | Given an inline formset save it to the database. 43 | """ 44 | formset.save() 45 | 46 | #iterate through the nested formsets and save them 47 | #skip formsets, where the parent is marked for deletion 48 | deleted_forms = formset.deleted_forms 49 | for form in formset.forms: 50 | if hasattr(form, 'nested_formsets') and form not in deleted_forms: 51 | for nested_formset in form.nested_formsets: 52 | self.save_formset(request, form, nested_formset, change) 53 | 54 | def add_nested_inline_formsets(self, request, inline, formset, depth=0): 55 | if depth > 5: 56 | raise Exception("Maximum nesting depth reached (5)") 57 | for form in formset.forms: 58 | nested_formsets = [] 59 | for nested_inline in inline.get_inline_instances(request): 60 | InlineFormSet = nested_inline.get_formset(request, form.instance) 61 | prefix = "%s-%s" % (form.prefix, InlineFormSet.get_default_prefix()) 62 | 63 | #because of form nesting with extra=0 it might happen, that the post data doesn't include values for the formset. 64 | #This would lead to a Exception, because the ManagementForm construction fails. So we check if there is data available, and otherwise create an empty form 65 | keys = request.POST.keys() 66 | has_params = any(s.startswith(prefix) for s in keys) 67 | if request.method == 'POST' and has_params: 68 | nested_formset = InlineFormSet(request.POST, request.FILES, 69 | instance=form.instance, 70 | prefix=prefix, queryset=nested_inline.queryset(request)) 71 | else: 72 | nested_formset = InlineFormSet(instance=form.instance, 73 | prefix=prefix, queryset=nested_inline.queryset(request)) 74 | nested_formsets.append(nested_formset) 75 | if nested_inline.inlines: 76 | self.add_nested_inline_formsets(request, nested_inline, nested_formset, depth=depth+1) 77 | form.nested_formsets = nested_formsets 78 | 79 | def wrap_nested_inline_formsets(self, request, inline, formset): 80 | """wraps each formset in a helpers.InlineAdminFormset. 81 | @TODO someone with more inside knowledge should write done why this is done 82 | """ 83 | media = None 84 | def get_media(extra_media): 85 | if media: 86 | return media + extra_media 87 | else: 88 | return extra_media 89 | 90 | for form in formset.forms: 91 | wrapped_nested_formsets = [] 92 | for nested_inline, nested_formset in zip(inline.get_inline_instances(request), form.nested_formsets): 93 | if form.instance.pk: 94 | instance = form.instance 95 | else: 96 | instance = None 97 | fieldsets = list(nested_inline.get_fieldsets(request)) 98 | readonly = list(nested_inline.get_readonly_fields(request)) 99 | prepopulated = dict(nested_inline.get_prepopulated_fields(request)) 100 | wrapped_nested_formset = InlineAdminFormSet(nested_inline, nested_formset, 101 | fieldsets, prepopulated, readonly, model_admin=self) 102 | wrapped_nested_formsets.append(wrapped_nested_formset) 103 | media = get_media(wrapped_nested_formset.media) 104 | if nested_inline.inlines: 105 | media = get_media(self.wrap_nested_inline_formsets(request, nested_inline, nested_formset)) 106 | form.nested_formsets = wrapped_nested_formsets 107 | return media 108 | 109 | def all_valid_with_nesting(self, formsets): 110 | """Recursively validate all nested formsets 111 | """ 112 | if not all_valid(formsets): 113 | return False 114 | for formset in formsets: 115 | if not formset.is_bound: 116 | pass 117 | for form in formset: 118 | if hasattr(form, 'nested_formsets'): 119 | if not self.all_valid_with_nesting(form.nested_formsets): 120 | return False 121 | return True 122 | 123 | @csrf_protect_m 124 | @transaction.commit_on_success 125 | def add_view(self, request, form_url='', extra_context=None): 126 | "The 'add' admin view for this model." 127 | model = self.model 128 | opts = model._meta 129 | 130 | if not self.has_add_permission(request): 131 | raise PermissionDenied 132 | 133 | ModelForm = self.get_form(request) 134 | formsets = [] 135 | inline_instances = self.get_inline_instances(request, None) 136 | if request.method == 'POST': 137 | form = ModelForm(request.POST, request.FILES) 138 | if form.is_valid(): 139 | new_object = self.save_form(request, form, change=False) 140 | form_validated = True 141 | else: 142 | form_validated = False 143 | new_object = self.model() 144 | prefixes = {} 145 | for FormSet, inline in zip(self.get_formsets(request), inline_instances): 146 | prefix = FormSet.get_default_prefix() 147 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 148 | if prefixes[prefix] != 1 or not prefix: 149 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 150 | formset = FormSet(data=request.POST, files=request.FILES, 151 | instance=new_object, 152 | save_as_new="_saveasnew" in request.POST, 153 | prefix=prefix, queryset=inline.queryset(request)) 154 | formsets.append(formset) 155 | if inline.inlines: 156 | self.add_nested_inline_formsets(request, inline, formset) 157 | if self.all_valid_with_nesting(formsets) and form_validated: 158 | self.save_model(request, new_object, form, False) 159 | self.save_related(request, form, formsets, False) 160 | self.log_addition(request, new_object) 161 | return self.response_add(request, new_object) 162 | else: 163 | # Prepare the dict of initial data from the request. 164 | # We have to special-case M2Ms as a list of comma-separated PKs. 165 | initial = dict(request.GET.items()) 166 | for k in initial: 167 | try: 168 | f = opts.get_field(k) 169 | except models.FieldDoesNotExist: 170 | continue 171 | if isinstance(f, models.ManyToManyField): 172 | initial[k] = initial[k].split(",") 173 | form = ModelForm(initial=initial) 174 | prefixes = {} 175 | for FormSet, inline in zip(self.get_formsets(request), inline_instances): 176 | prefix = FormSet.get_default_prefix() 177 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 178 | if prefixes[prefix] != 1 or not prefix: 179 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 180 | formset = FormSet(instance=self.model(), prefix=prefix, 181 | queryset=inline.queryset(request)) 182 | formsets.append(formset) 183 | if inline.inlines: 184 | self.add_nested_inline_formsets(request, inline, formset) 185 | 186 | adminForm = AdminForm(form, list(self.get_fieldsets(request)), 187 | self.get_prepopulated_fields(request), 188 | self.get_readonly_fields(request), 189 | model_admin=self) 190 | media = self.media + adminForm.media 191 | 192 | inline_admin_formsets = [] 193 | for inline, formset in zip(inline_instances, formsets): 194 | fieldsets = list(inline.get_fieldsets(request)) 195 | readonly = list(inline.get_readonly_fields(request)) 196 | prepopulated = dict(inline.get_prepopulated_fields(request)) 197 | inline_admin_formset = InlineAdminFormSet(inline, formset, 198 | fieldsets, prepopulated, readonly, model_admin=self) 199 | inline_admin_formsets.append(inline_admin_formset) 200 | media = media + inline_admin_formset.media 201 | if inline.inlines: 202 | media = media + self.wrap_nested_inline_formsets(request, inline, formset) 203 | 204 | context = { 205 | 'title': _('Add %s') % force_unicode(opts.verbose_name), 206 | 'adminform': adminForm, 207 | 'is_popup': "_popup" in request.REQUEST, 208 | 'show_delete': False, 209 | 'media': media, 210 | 'inline_admin_formsets': inline_admin_formsets, 211 | 'errors': AdminErrorList(form, formsets), 212 | 'app_label': opts.app_label, 213 | } 214 | context.update(extra_context or {}) 215 | return self.render_change_form(request, context, form_url=form_url, add=True) 216 | 217 | @csrf_protect_m 218 | @transaction.commit_on_success 219 | def change_view(self, request, object_id, form_url='', extra_context=None): 220 | "The 'change' admin view for this model." 221 | model = self.model 222 | opts = model._meta 223 | 224 | obj = self.get_object(request, unquote(object_id)) 225 | 226 | if not self.has_change_permission(request, obj): 227 | raise PermissionDenied 228 | 229 | if obj is None: 230 | raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {'name': force_unicode(opts.verbose_name), 'key': escape(object_id)}) 231 | 232 | if request.method == 'POST' and "_saveasnew" in request.POST: 233 | return self.add_view(request, form_url=reverse('admin:%s_%s_add' % 234 | (opts.app_label, opts.module_name), 235 | current_app=self.admin_site.name)) 236 | 237 | ModelForm = self.get_form(request, obj) 238 | formsets = [] 239 | inline_instances = self.get_inline_instances(request, obj) 240 | if request.method == 'POST': 241 | form = ModelForm(request.POST, request.FILES, instance=obj) 242 | if form.is_valid(): 243 | form_validated = True 244 | new_object = self.save_form(request, form, change=True) 245 | else: 246 | form_validated = False 247 | new_object = obj 248 | prefixes = {} 249 | for FormSet, inline in zip(self.get_formsets(request, new_object), inline_instances): 250 | prefix = FormSet.get_default_prefix() 251 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 252 | if prefixes[prefix] != 1 or not prefix: 253 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 254 | formset = FormSet(request.POST, request.FILES, 255 | instance=new_object, prefix=prefix, 256 | queryset=inline.queryset(request)) 257 | formsets.append(formset) 258 | if inline.inlines: 259 | self.add_nested_inline_formsets(request, inline, formset) 260 | 261 | if self.all_valid_with_nesting(formsets) and form_validated: 262 | self.save_model(request, new_object, form, True) 263 | self.save_related(request, form, formsets, True) 264 | change_message = self.construct_change_message(request, form, formsets) 265 | self.log_change(request, new_object, change_message) 266 | return self.response_change(request, new_object) 267 | 268 | else: 269 | form = ModelForm(instance=obj) 270 | prefixes = {} 271 | for FormSet, inline in zip(self.get_formsets(request, obj), inline_instances): 272 | prefix = FormSet.get_default_prefix() 273 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 274 | if prefixes[prefix] != 1 or not prefix: 275 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 276 | formset = FormSet(instance=obj, prefix=prefix, 277 | queryset=inline.queryset(request)) 278 | formsets.append(formset) 279 | if inline.inlines: 280 | self.add_nested_inline_formsets(request, inline, formset) 281 | 282 | adminForm = AdminForm(form, self.get_fieldsets(request, obj), 283 | self.get_prepopulated_fields(request, obj), 284 | self.get_readonly_fields(request, obj), 285 | model_admin=self) 286 | media = self.media + adminForm.media 287 | 288 | inline_admin_formsets = [] 289 | for inline, formset in zip(inline_instances, formsets): 290 | fieldsets = list(inline.get_fieldsets(request, obj)) 291 | readonly = list(inline.get_readonly_fields(request, obj)) 292 | prepopulated = dict(inline.get_prepopulated_fields(request, obj)) 293 | inline_admin_formset = InlineAdminFormSet(inline, formset, 294 | fieldsets, prepopulated, readonly, model_admin=self) 295 | inline_admin_formsets.append(inline_admin_formset) 296 | media = media + inline_admin_formset.media 297 | if inline.inlines: 298 | media = media + self.wrap_nested_inline_formsets(request, inline, formset) 299 | 300 | context = { 301 | 'title': _('Change %s') % force_unicode(opts.verbose_name), 302 | 'adminform': adminForm, 303 | 'object_id': object_id, 304 | 'original': obj, 305 | 'is_popup': "_popup" in request.REQUEST, 306 | 'media': media, 307 | 'inline_admin_formsets': inline_admin_formsets, 308 | 'errors': AdminErrorList(form, formsets), 309 | 'app_label': opts.app_label, 310 | } 311 | context.update(extra_context or {}) 312 | return self.render_change_form(request, context, change=True, obj=obj, form_url=form_url) 313 | 314 | class NestedInlineModelAdmin(InlineModelAdmin): 315 | inlines = [] 316 | formset = BaseNestedInlineFormSet 317 | 318 | def get_form(self, request, obj=None, **kwargs): 319 | return super(NestedModelAdmin, self).get_form( 320 | request, obj, form=BaseNestedModelForm, **kwargs) 321 | 322 | def get_inline_instances(self, request, obj=None): 323 | inline_instances = [] 324 | for inline_class in self.inlines: 325 | inline = inline_class(self.model, self.admin_site) 326 | if request: 327 | if not (inline.has_add_permission(request) or 328 | inline.has_change_permission(request, obj) or 329 | inline.has_delete_permission(request, obj)): 330 | continue 331 | if not inline.has_add_permission(request): 332 | inline.max_num = 0 333 | inline_instances.append(inline) 334 | 335 | return inline_instances 336 | 337 | def get_formsets(self, request, obj=None): 338 | for inline in self.get_inline_instances(request): 339 | yield inline.get_formset(request, obj) 340 | 341 | class NestedStackedInline(NestedInlineModelAdmin): 342 | template = 'admin/edit_inline/stacked.html' 343 | 344 | class NestedTabularInline(NestedInlineModelAdmin): 345 | template = 'admin/edit_inline/tabular.html' 346 | -------------------------------------------------------------------------------- /nested_inlines/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms.forms import BaseForm, ErrorDict 2 | from django.forms.models import ModelForm, BaseInlineFormSet 3 | 4 | class NestedFormMixin(object): 5 | def full_clean(self): 6 | """ 7 | Cleans all of self.data and populates self._errors and 8 | self.cleaned_data. 9 | """ 10 | self._errors = ErrorDict() 11 | if not self.is_bound: # Stop further processing. 12 | return 13 | self.cleaned_data = {} 14 | # If the form is permitted to be empty, and none of the form data has 15 | # changed from the initial data, short circuit any validation. 16 | if self.empty_permitted and not self.has_changed() and not self.dependency_has_changed(): 17 | return 18 | self._clean_fields() 19 | self._clean_form() 20 | self._post_clean() 21 | 22 | def dependency_has_changed(self): 23 | """ 24 | Returns true, if any dependent form has changed. 25 | This is needed to force validation, even if this form wasn't changed but a dependent form 26 | """ 27 | return False 28 | 29 | class BaseNestedForm(NestedFormMixin, BaseForm): 30 | pass 31 | 32 | class NestedFormSetMixin(object): 33 | def dependency_has_changed(self): 34 | for form in self.forms: 35 | if form.has_changed() or form.dependency_has_changed(): 36 | return True 37 | return False 38 | 39 | class BaseNestedInlineFormSet(NestedFormSetMixin, BaseInlineFormSet): 40 | pass 41 | 42 | class NestedModelFormMixin(NestedFormMixin): 43 | def dependency_has_changed(self): 44 | #check for the nested_formsets attribute, added by the admin app. 45 | #TODO this should be generalized 46 | if hasattr(self, 'nested_formsets'): 47 | for f in self.nested_formsets: 48 | return f.dependency_has_changed() 49 | 50 | class BaseNestedModelForm(NestedModelFormMixin, ModelForm): 51 | pass -------------------------------------------------------------------------------- /nested_inlines/helpers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.helpers import AdminErrorList, InlineAdminFormSet 2 | from django.utils import six 3 | 4 | class AdminErrorList(AdminErrorList): 5 | """ 6 | Stores all errors for the form/formsets in an add/change stage view. 7 | """ 8 | def __init__(self, form, inline_formsets): 9 | if form.is_bound: 10 | self.extend(form.errors.values()) 11 | for inline_formset in inline_formsets: 12 | self._add_formset_recursive(inline_formset) 13 | 14 | def _add_formset_recursive(self, formset): 15 | #check if it is a wrapped formset 16 | if isinstance(formset, InlineAdminFormSet): 17 | formset = formset.formset 18 | 19 | self.extend(formset.non_form_errors()) 20 | for errors_in_inline_form in formset.errors: 21 | self.extend(list(six.itervalues(errors_in_inline_form))) 22 | 23 | #support for nested formsets 24 | for form in formset: 25 | if hasattr(form, 'nested_formsets'): 26 | for fs in form.nested_formsets: 27 | self._add_formset_recursive(fs) -------------------------------------------------------------------------------- /nested_inlines/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /nested_inlines/static/admin/css/nested.css: -------------------------------------------------------------------------------- 1 | @import url('widgets.css'); 2 | 3 | /* FORM ROWS */ 4 | 5 | .form-row { 6 | overflow: hidden; 7 | padding: 8px 12px; 8 | font-size: 11px; 9 | border-bottom: 1px solid #eee; 10 | } 11 | 12 | .form-row img, .form-row input { 13 | vertical-align: middle; 14 | } 15 | 16 | form .form-row p { 17 | padding-left: 0; 18 | font-size: 11px; 19 | } 20 | 21 | /* FORM LABELS */ 22 | 23 | form h4 { 24 | margin: 0 !important; 25 | padding: 0 !important; 26 | border: none !important; 27 | } 28 | 29 | label { 30 | font-weight: normal !important; 31 | color: #666; 32 | font-size: 12px; 33 | } 34 | 35 | .required label, label.required { 36 | font-weight: bold !important; 37 | color: #333 !important; 38 | } 39 | 40 | /* RADIO BUTTONS */ 41 | 42 | form ul.radiolist li { 43 | list-style-type: none; 44 | } 45 | 46 | form ul.radiolist label { 47 | float: none; 48 | display: inline; 49 | } 50 | 51 | form ul.inline { 52 | margin-left: 0; 53 | padding: 0; 54 | } 55 | 56 | form ul.inline li { 57 | float: left; 58 | padding-right: 7px; 59 | } 60 | 61 | /* ALIGNED FIELDSETS */ 62 | 63 | .aligned label { 64 | display: block; 65 | padding: 3px 10px 0 0; 66 | float: left; 67 | width: 8em; 68 | } 69 | 70 | .aligned ul label { 71 | display: inline; 72 | float: none; 73 | width: auto; 74 | } 75 | 76 | .colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { 77 | width: 350px; 78 | } 79 | 80 | form .aligned p, form .aligned ul { 81 | margin-left: 7em; 82 | padding-left: 30px; 83 | } 84 | 85 | form .aligned table p { 86 | margin-left: 0; 87 | padding-left: 0; 88 | } 89 | 90 | form .aligned p.help { 91 | padding-left: 38px; 92 | } 93 | 94 | .aligned .vCheckboxLabel { 95 | float: none !important; 96 | display: inline; 97 | padding-left: 4px; 98 | } 99 | 100 | .colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { 101 | width: 610px; 102 | } 103 | 104 | .checkbox-row p.help { 105 | margin-left: 0; 106 | padding-left: 0 !important; 107 | } 108 | 109 | fieldset .field-box { 110 | float: left; 111 | margin-right: 20px; 112 | } 113 | 114 | /* WIDE FIELDSETS */ 115 | 116 | .wide label { 117 | width: 15em !important; 118 | } 119 | 120 | form .wide p { 121 | margin-left: 15em; 122 | } 123 | 124 | form .wide p.help { 125 | padding-left: 38px; 126 | } 127 | 128 | .colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { 129 | width: 450px; 130 | } 131 | 132 | /* COLLAPSED FIELDSETS */ 133 | 134 | fieldset.collapsed * { 135 | display: none; 136 | } 137 | 138 | fieldset.collapsed h2, fieldset.collapsed { 139 | display: block !important; 140 | } 141 | 142 | fieldset.collapsed h2 { 143 | background-image: url(../img/nav-bg.gif); 144 | background-position: bottom left; 145 | color: #999; 146 | } 147 | 148 | fieldset.collapsed .collapse-toggle { 149 | background: transparent; 150 | display: inline !important; 151 | } 152 | 153 | /* MONOSPACE TEXTAREAS */ 154 | 155 | fieldset.monospace textarea { 156 | font-family: "Bitstream Vera Sans Mono",Monaco,"Courier New",Courier,monospace; 157 | } 158 | 159 | /* SUBMIT ROW */ 160 | 161 | .submit-row { 162 | padding: 5px 7px; 163 | text-align: right; 164 | background: white url(../img/nav-bg.gif) 0 100% repeat-x; 165 | border: 1px solid #ccc; 166 | margin: 5px 0; 167 | overflow: hidden; 168 | } 169 | 170 | body.popup .submit-row { 171 | overflow: auto; 172 | } 173 | 174 | .submit-row input { 175 | margin: 0 0 0 5px; 176 | } 177 | 178 | .submit-row p { 179 | margin: 0.3em; 180 | } 181 | 182 | .submit-row p.deletelink-box { 183 | float: left; 184 | } 185 | 186 | .submit-row .deletelink { 187 | background: url(../img/icon_deletelink.gif) 0 50% no-repeat; 188 | padding-left: 14px; 189 | } 190 | 191 | /* CUSTOM FORM FIELDS */ 192 | 193 | .vSelectMultipleField { 194 | vertical-align: top !important; 195 | } 196 | 197 | .vCheckboxField { 198 | border: none; 199 | } 200 | 201 | .vDateField, .vTimeField { 202 | margin-right: 2px; 203 | } 204 | 205 | .vURLField { 206 | width: 30em; 207 | } 208 | 209 | .vLargeTextField, .vXMLLargeTextField { 210 | width: 48em; 211 | } 212 | 213 | .flatpages-flatpage #id_content { 214 | height: 40.2em; 215 | } 216 | 217 | .module table .vPositiveSmallIntegerField { 218 | width: 2.2em; 219 | } 220 | 221 | .vTextField { 222 | width: 20em; 223 | } 224 | 225 | .vIntegerField { 226 | width: 5em; 227 | } 228 | 229 | .vBigIntegerField { 230 | width: 10em; 231 | } 232 | 233 | .vForeignKeyRawIdAdminField { 234 | width: 5em; 235 | } 236 | 237 | /* INLINES */ 238 | 239 | .inline-group { 240 | padding: 0; 241 | border: 1px solid #ccc; 242 | margin: 10px 0; 243 | } 244 | 245 | .inline-group .aligned label { 246 | width: 8em; 247 | } 248 | 249 | .inline-related { 250 | position: relative; 251 | } 252 | 253 | .inline-related h3 { 254 | margin: 0; 255 | color: #666; 256 | padding: 3px 5px; 257 | font-size: 11px; 258 | background: #e1e1e1 url(../img/nav-bg.gif) top left repeat-x; 259 | border-bottom: 1px solid #ddd; 260 | } 261 | 262 | .inline-related h3 span.delete { 263 | float: right; 264 | } 265 | 266 | .inline-related h3 span.delete label { 267 | margin-left: 2px; 268 | font-size: 11px; 269 | } 270 | 271 | .inline-related fieldset { 272 | margin: 0; 273 | background: #fff; 274 | border: none; 275 | width: 100%; 276 | } 277 | 278 | .inline-related fieldset.module h3 { 279 | margin: 0; 280 | padding: 2px 5px 3px 5px; 281 | font-size: 11px; 282 | text-align: left; 283 | font-weight: bold; 284 | background: #bcd; 285 | color: #fff; 286 | } 287 | 288 | .inline-group .tabular > fieldset.module { 289 | border: none; 290 | border-bottom: 1px solid #ddd; 291 | } 292 | 293 | .inline-related.tabular fieldset.module table { 294 | width: 100%; 295 | } 296 | 297 | .last-related fieldset { 298 | border: none; 299 | } 300 | 301 | .inline-group .tabular tr.has_original td { 302 | padding-top: 2em; 303 | } 304 | 305 | .inline-group .tabular tr td.original { 306 | padding: 2px 0 0 0; 307 | width: 0; 308 | _position: relative; 309 | } 310 | 311 | .inline-group .tabular th.original { 312 | width: 0px; 313 | padding: 0; 314 | } 315 | 316 | .inline-group .tabular td.original p { 317 | position: absolute; 318 | left: 0; 319 | height: 1.1em; 320 | padding: 2px 7px; 321 | overflow: hidden; 322 | font-size: 9px; 323 | font-weight: bold; 324 | color: #666; 325 | _width: 700px; 326 | } 327 | 328 | .inline-group ul.tools { 329 | padding: 0; 330 | margin: 0; 331 | list-style: none; 332 | } 333 | 334 | .inline-group ul.tools li { 335 | display: inline; 336 | padding: 0 5px; 337 | } 338 | 339 | .inline-group div.add-row, 340 | .inline-group .tabular tr.add-row td { 341 | color: #666; 342 | padding: 3px 5px; 343 | border-bottom: 1px solid #ddd; 344 | background: #e1e1e1 url(../img/nav-bg.gif) top left repeat-x; 345 | } 346 | 347 | .inline-group .tabular tr.add-row td { 348 | padding: 4px 5px 3px; 349 | border-bottom: none; 350 | } 351 | 352 | .inline-group ul.tools a.add, 353 | .inline-group div.add-row a, 354 | .inline-group .tabular tr.add-row td a { 355 | background: url(../img/icon_addlink.gif) 0 50% no-repeat; 356 | padding-left: 14px; 357 | font-size: 11px; 358 | outline: 0; /* Remove dotted border around link */ 359 | } 360 | 361 | .nested-inline { 362 | margin: 10px; 363 | } 364 | 365 | td > .nested-inline { 366 | margin: 0px; 367 | } 368 | 369 | .nested-inline-bottom-border { 370 | border-bottom: 1px solid #DDDDDD; 371 | } 372 | 373 | .no-bottom-border.row1 > td { 374 | border-bottom: solid #EDF3FE 1px; 375 | } 376 | 377 | .no-bottom-border.row2 > td { 378 | border-bottom: solid white 1px; 379 | } 380 | 381 | .empty-form { 382 | display: none; 383 | } 384 | -------------------------------------------------------------------------------- /nested_inlines/static/admin/js/inlines.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Django admin inlines 3 | * 4 | * Based on jQuery Formset 1.1 5 | * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) 6 | * @requires jQuery 1.2.6 or later 7 | * 8 | * Copyright (c) 2009, Stanislaus Madueke 9 | * All rights reserved. 10 | * 11 | * Spiced up with Code from Zain Memon's GSoC project 2009 12 | * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. 13 | * 14 | * Licensed under the New BSD License 15 | * See: http://www.opensource.org/licenses/bsd-license.php 16 | */ 17 | (function($) { 18 | $.fn.formset = function(opts) { 19 | var options = $.extend({}, $.fn.formset.defaults, opts); 20 | var $this = $(this); 21 | var $parent = $this.parent(); 22 | var nextIndex = get_no_forms(options.prefix); 23 | 24 | //store the options. This is needed for nested inlines, to recreate the same form 25 | var group = $this.closest('.inline-group'); 26 | group.data('django_formset', options); 27 | 28 | // Add form classes for dynamic behaviour 29 | $this.each(function(i) { 30 | $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); 31 | }); 32 | 33 | if (isAddButtonVisible(options)) { 34 | var addButton; 35 | if ($this.attr("tagName") == "TR") { 36 | // If forms are laid out as table rows, insert the 37 | // "add" button in a new table row: 38 | var numCols = this.eq(-1).children().length; 39 | $parent.append('