├── .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('' + options.addText + ""); 40 | addButton = $parent.find("tr:last a"); 41 | } else { 42 | // Otherwise, insert it immediately after the last form: 43 | $this.filter(":last").after('
' + options.addText + "
"); 44 | addButton = $this.filter(":last").next().find("a"); 45 | } 46 | addButton.click(function(e) { 47 | e.preventDefault(); 48 | addRow(options); 49 | }); 50 | } 51 | return this; 52 | }; 53 | 54 | /* Setup plugin defaults */ 55 | $.fn.formset.defaults = { 56 | prefix : "form", // The form prefix for your django formset 57 | addText : "add another", // Text for the add link 58 | deleteText : "remove", // Text for the delete link 59 | addCssClass : "add-row", // CSS class applied to the add link 60 | deleteCssClass : "delete-row", // CSS class applied to the delete link 61 | emptyCssClass : "empty-row", // CSS class applied to the empty row 62 | formCssClass : "dynamic-form", // CSS class applied to each form in a formset 63 | added : null, // Function called each time a new form is added 64 | removed : null // Function called each time a form is deleted 65 | }; 66 | 67 | // Tabular inlines --------------------------------------------------------- 68 | $.fn.tabularFormset = function(options) { 69 | var $rows = $(this); 70 | var alternatingRows = function(row) { 71 | row_number = 0; 72 | $($rows.selector).not(".add-row").removeClass("row1 row2").each(function() { 73 | $(this).addClass('row' + ((row_number%2)+1)); 74 | next = $(this).next(); 75 | while (next.hasClass('nested-inline-row')) { 76 | next.addClass('row' + ((row_number%2)+1)); 77 | next = next.next(); 78 | } 79 | row_number = row_number + 1; 80 | }); 81 | }; 82 | 83 | var reinitDateTimeShortCuts = function() { 84 | // Reinitialize the calendar and clock widgets by force 85 | if ( typeof DateTimeShortcuts != "undefined") { 86 | $(".datetimeshortcuts").remove(); 87 | DateTimeShortcuts.init(); 88 | } 89 | }; 90 | 91 | var updateSelectFilter = function() { 92 | // If any SelectFilter widgets are a part of the new form, 93 | // instantiate a new SelectFilter instance for it. 94 | if ( typeof SelectFilter != 'undefined') { 95 | $('.selectfilter').each(function(index, value) { 96 | var namearr = value.name.split('-'); 97 | SelectFilter.init(value.id, namearr[namearr.length - 1], false, options.adminStaticPrefix); 98 | }); 99 | $('.selectfilterstacked').each(function(index, value) { 100 | var namearr = value.name.split('-'); 101 | SelectFilter.init(value.id, namearr[namearr.length - 1], true, options.adminStaticPrefix); 102 | }); 103 | } 104 | }; 105 | 106 | var initPrepopulatedFields = function(row) { 107 | row.find('.prepopulated_field').each(function() { 108 | var field = $(this), input = field.find('input, select, textarea'), dependency_list = input.data('dependency_list') || [], dependencies = []; 109 | $.each(dependency_list, function(i, field_name) { 110 | dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); 111 | }); 112 | if (dependencies.length) { 113 | input.prepopulate(dependencies, input.attr('maxlength')); 114 | } 115 | }); 116 | }; 117 | 118 | $rows.formset({ 119 | prefix : options.prefix, 120 | addText : options.addText, 121 | formCssClass : "dynamic-" + options.prefix, 122 | deleteCssClass : "inline-deletelink", 123 | deleteText : options.deleteText, 124 | emptyCssClass : "empty-form", 125 | removed : function(row) { 126 | alternatingRows(row); 127 | if(options.removed) options.removed(row); 128 | }, 129 | added : function(row) { 130 | initPrepopulatedFields(row); 131 | reinitDateTimeShortCuts(); 132 | updateSelectFilter(); 133 | alternatingRows(row); 134 | if(options.added) options.added(row); 135 | } 136 | }); 137 | 138 | return $rows; 139 | }; 140 | 141 | // Stacked inlines --------------------------------------------------------- 142 | $.fn.stackedFormset = function(options) { 143 | var $rows = $(this); 144 | 145 | var update_inline_labels = function(formset_to_update) { 146 | formset_to_update.children('.inline-related').not('.empty-form').children('h3').find('.inline_label').each(function(i) { 147 | var count = i + 1; 148 | $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); 149 | }); 150 | }; 151 | 152 | var reinitDateTimeShortCuts = function() { 153 | // Reinitialize the calendar and clock widgets by force, yuck. 154 | if ( typeof DateTimeShortcuts != "undefined") { 155 | $(".datetimeshortcuts").remove(); 156 | DateTimeShortcuts.init(); 157 | } 158 | }; 159 | 160 | var updateSelectFilter = function() { 161 | // If any SelectFilter widgets were added, instantiate a new instance. 162 | if ( typeof SelectFilter != "undefined") { 163 | $(".selectfilter").each(function(index, value) { 164 | var namearr = value.name.split('-'); 165 | SelectFilter.init(value.id, namearr[namearr.length - 1], false, options.adminStaticPrefix); 166 | }); 167 | $(".selectfilterstacked").each(function(index, value) { 168 | var namearr = value.name.split('-'); 169 | SelectFilter.init(value.id, namearr[namearr.length - 1], true, options.adminStaticPrefix); 170 | }); 171 | } 172 | }; 173 | 174 | var initPrepopulatedFields = function(row) { 175 | row.find('.prepopulated_field').each(function() { 176 | var field = $(this), input = field.find('input, select, textarea'), dependency_list = input.data('dependency_list') || [], dependencies = []; 177 | $.each(dependency_list, function(i, field_name) { 178 | dependencies.push('#' + row.find('.form-row .field-' + field_name).find('input, select, textarea').attr('id')); 179 | }); 180 | if (dependencies.length) { 181 | input.prepopulate(dependencies, input.attr('maxlength')); 182 | } 183 | }); 184 | }; 185 | 186 | $rows.formset({ 187 | prefix : options.prefix, 188 | addText : options.addText, 189 | formCssClass : "dynamic-" + options.prefix, 190 | deleteCssClass : "inline-deletelink", 191 | deleteText : options.deleteText, 192 | emptyCssClass : "empty-form", 193 | removed : function(row) { 194 | update_inline_labels(row); 195 | if(options.removed) options.removed(row); 196 | }, 197 | added : (function(row) { 198 | initPrepopulatedFields(row); 199 | reinitDateTimeShortCuts(); 200 | updateSelectFilter(); 201 | update_inline_labels(row.parent()); 202 | if(options.added) options.added(row); 203 | }) 204 | }); 205 | 206 | return $rows; 207 | }; 208 | 209 | function create_nested_formsets(parentPrefix, rowId) { 210 | // we use the first formset as template. so replace every index by 0 211 | var sourceParentPrefix = parentPrefix.replace(/[-][0-9][-]/g, "-0-"); 212 | 213 | var row_prefix = parentPrefix+'-'+rowId; 214 | var row = $('#'+row_prefix); 215 | 216 | // Check if the form should have nested formsets 217 | // This is horribly hackish. It tries to collect one set of nested inlines from already existing rows and clone these 218 | var search_space = $("#"+sourceParentPrefix+'-0').nextUntil("."+sourceParentPrefix + "-not-nested"); 219 | 220 | //all nested inlines 221 | var nested_inlines = search_space.find("." + sourceParentPrefix + "-nested-inline"); 222 | nested_inlines.each(function(index) { 223 | // prefixes for the nested formset 224 | var normalized_formset_prefix = $(this).attr('id').split('-group')[0]; 225 | // = "parent_formset_prefix"-0-"nested_inline_name"_set 226 | var formset_prefix = normalized_formset_prefix.replace(sourceParentPrefix + "-0", row_prefix); 227 | // = "parent_formset_prefix"-"next_form_id"-"nested_inline_name"_set 228 | // Find the normalized formset and clone it 229 | var template = $(this).clone(); 230 | 231 | //get the options that were used to create the source formset 232 | var options = $(this).data('django_formset'); 233 | //clone, so that we don't modify the old one 234 | options = $.extend({}, options); 235 | options.prefix = formset_prefix; 236 | 237 | var isTabular = template.find('#'+normalized_formset_prefix+'-empty').is('tr'); 238 | 239 | //remove all existing rows from the clone 240 | if (isTabular) { 241 | //tabular 242 | template.find(".form-row").not(".empty-form").remove(); 243 | template.find(".nested-inline-row").remove(); 244 | } else { 245 | //stacked cleanup 246 | template.find(".inline-related").not(".empty-form").remove(); 247 | } 248 | //remove other unnecessary things 249 | template.find('.'+options.addCssClass).remove(); 250 | 251 | //replace the cloned prefix with the new one 252 | update_props(template, normalized_formset_prefix, formset_prefix); 253 | //reset update formset management variables 254 | template.find('#id_' + formset_prefix + '-INITIAL_FORMS').val(0); 255 | template.find('#id_' + formset_prefix + '-TOTAL_FORMS').val(1); 256 | //remove the fk and id values, because these don't exist yet 257 | template.find('.original').empty(); 258 | 259 | 260 | 261 | //postprocess stacked/tabular 262 | if (isTabular) { 263 | var formset = template.find('.tabular.inline-related tbody tr.' + formset_prefix + '-not-nested').tabularFormset(options); 264 | var border_class = (index+1 < nested_inlines.length) ? ' no-bottom-border' : ''; 265 | var wrapped = $('').html($('').html(template)); 266 | //insert the formset after the row 267 | row.after(wrapped); 268 | } else { 269 | var formset = template.find(".inline-related").stackedFormset(options); 270 | 271 | row.after(template); 272 | } 273 | 274 | //add a empty row. This will in turn create the nested formsets 275 | addRow(options); 276 | }); 277 | 278 | return nested_inlines.length; 279 | }; 280 | 281 | 282 | function update_props(template, normalized_formset_prefix, formset_prefix) { 283 | // Fix template id 284 | template.attr('id', template.attr('id').replace(normalized_formset_prefix, formset_prefix)); 285 | template.find('*').each(function() { 286 | if ($(this).attr("for")) { 287 | $(this).attr("for", $(this).attr("for").replace(normalized_formset_prefix, formset_prefix)); 288 | } 289 | if ($(this).attr("class")) { 290 | $(this).attr("class", $(this).attr("class").replace(normalized_formset_prefix, formset_prefix)); 291 | } 292 | if (this.id) { 293 | this.id = this.id.replace(normalized_formset_prefix, formset_prefix); 294 | } 295 | if (this.name) { 296 | this.name = this.name.replace(normalized_formset_prefix, formset_prefix); 297 | } 298 | }); 299 | 300 | }; 301 | 302 | // This returns the amount of forms in the given formset 303 | function get_no_forms(formset_prefix) { 304 | formset_prop = $("#id_" + formset_prefix + "-TOTAL_FORMS") 305 | if (!formset_prop.length) { 306 | return 0; 307 | } 308 | return parseInt(formset_prop.attr("autocomplete", "off").val()); 309 | } 310 | 311 | function change_no_forms(formset_prefix, increase) { 312 | var no_forms = get_no_forms(formset_prefix); 313 | if (increase) { 314 | $("#id_" + formset_prefix + "-TOTAL_FORMS").attr("autocomplete", "off").val(parseInt(no_forms) + 1); 315 | } else { 316 | $("#id_" + formset_prefix + "-TOTAL_FORMS").attr("autocomplete", "off").val(parseInt(no_forms) - 1); 317 | } 318 | }; 319 | 320 | // This return the maximum amount of forms in the given formset 321 | function get_max_forms(formset_prefix) { 322 | var max_forms = $("#id_" + formset_prefix + "-MAX_NUM_FORMS").attr("autocomplete", "off").val(); 323 | if ( typeof max_forms == 'undefined' || max_forms == '') { 324 | return ''; 325 | } 326 | return parseInt(max_forms); 327 | }; 328 | 329 | function addRow(options) { 330 | var nextIndex = get_no_forms(options.prefix); 331 | 332 | var row = insertNewRow(options.prefix, options); 333 | 334 | updateAddButton(options.prefix); 335 | 336 | // Add delete button handler 337 | row.find("a." + options.deleteCssClass).click(function(e) { 338 | e.preventDefault(); 339 | // Find the row that will be deleted by this button 340 | var row = $(this).parents("." + options.formCssClass); 341 | // Remove the parent form containing this button: 342 | var formset_to_update = row.parent(); 343 | //remove nested inlines 344 | while (row.next().hasClass('nested-inline-row')) { 345 | row.next().remove(); 346 | } 347 | row.remove(); 348 | change_no_forms(options.prefix, false); 349 | // If a post-delete callback was provided, call it with the deleted form: 350 | if (options.removed) { 351 | options.removed(formset_to_update); 352 | } 353 | 354 | }); 355 | 356 | var num_formsets = create_nested_formsets(options.prefix, nextIndex); 357 | if(row.is("tr") && num_formsets > 0) { 358 | row.addClass("no-bottom-border"); 359 | } 360 | 361 | // If a post-add callback was supplied, call it with the added form: 362 | if (options.added) { 363 | options.added(row); 364 | } 365 | 366 | nextIndex = nextIndex + 1; 367 | }; 368 | 369 | function insertNewRow(prefix, options) { 370 | var template = $("#" + prefix + "-empty"); 371 | var nextIndex = get_no_forms(prefix); 372 | var row = prepareRowTemplate(template, prefix, nextIndex, options); 373 | // when adding something from a cloned formset the id is the same 374 | 375 | // Insert the new form when it has been fully edited 376 | row.insertBefore($(template)); 377 | 378 | // Update number of total forms 379 | change_no_forms(prefix, true); 380 | 381 | return row; 382 | }; 383 | 384 | function prepareRowTemplate(template, prefix, index, options) { 385 | var row = template.clone(true); 386 | row.removeClass(options.emptyCssClass).addClass(options.formCssClass).attr("id", prefix + "-" + index); 387 | if (row.is("tr")) { 388 | // If the forms are laid out in table rows, insert 389 | // the remove button into the last table cell: 390 | row.children(":last").append('
' + options.deleteText + "
"); 391 | } else if (row.is("ul") || row.is("ol")) { 392 | // If they're laid out as an ordered/unordered list, 393 | // insert an
  • after the last list item: 394 | row.append('
  • ' + options.deleteText + "
  • "); 395 | } else { 396 | // Otherwise, just insert the remove button as the 397 | // last child element of the form's container: 398 | row.children(":first").append('' + options.deleteText + ""); 399 | } 400 | row.find("*").each(function() { 401 | updateElementIndex(this, prefix, index); 402 | }); 403 | return row; 404 | }; 405 | 406 | function updateElementIndex(el, prefix, ndx) { 407 | var id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); 408 | var replacement = prefix + "-" + ndx; 409 | if ($(el).attr("for")) { 410 | $(el).attr("for", $(el).attr("for").replace(id_regex, replacement)); 411 | } 412 | if (el.id) { 413 | el.id = el.id.replace(id_regex, replacement); 414 | } 415 | if (el.name) { 416 | el.name = el.name.replace(id_regex, replacement); 417 | } 418 | }; 419 | 420 | /** show or hide the addButton **/ 421 | function updateAddButton(options) { 422 | // Hide add button in case we've hit the max, except we want to add infinitely 423 | var btn = $("#" + options.prefix + "-empty").parent().children('.'+options.addCssClass); 424 | if (isAddButtonVisible(options)) { 425 | btn.hide(); 426 | } else { 427 | btn.show(); 428 | } 429 | } 430 | 431 | function isAddButtonVisible(options) { 432 | return !(get_max_forms(options.prefix) !== '' && (get_max_forms(options.prefix) - get_no_forms(options.prefix)) <= 0); 433 | } 434 | })(django.jQuery); 435 | 436 | // TODO: 437 | // Remove border between tabular fieldset and nested inline 438 | // Fix alternating rows -------------------------------------------------------------------------------- /nested_inlines/static/admin/js/inlines.min.js: -------------------------------------------------------------------------------- 1 | (function(b){function h(a){formset_prop=b("#id_"+a+"-TOTAL_FORMS");return!formset_prop.length?0:parseInt(formset_prop.attr("autocomplete","off").val())}function m(a,e){var c=h(a);e?b("#id_"+a+"-TOTAL_FORMS").attr("autocomplete","off").val(parseInt(c)+1):b("#id_"+a+"-TOTAL_FORMS").attr("autocomplete","off").val(parseInt(c)-1)}function j(a){a=b("#id_"+a+"-MAX_NUM_FORMS").attr("autocomplete","off").val();return"undefined"==typeof a||""==a?"":parseInt(a)}function n(a){var e=h(a.prefix),c=a.prefix,d=b("#"+ 2 | c+"-empty"),p=h(c),f=d.clone(!0);f.removeClass(a.emptyCssClass).addClass(a.formCssClass).attr("id",c+"-"+p);f.is("tr")?f.children(":last").append('
    '+a.deleteText+"
    "):f.is("ul")||f.is("ol")?f.append('
  • '+a.deleteText+"
  • "):f.children(":first").append(''+a.deleteText+"");f.find("*").each(function(){var a= 3 | RegExp("("+c+"-(\\d+|__prefix__))"),d=c+"-"+p;b(this).attr("for")&&b(this).attr("for",b(this).attr("for").replace(a,d));this.id&&(this.id=this.id.replace(a,d));this.name&&(this.name=this.name.replace(a,d))});f.insertBefore(b(d));m(c,!0);var d=a.prefix,g=b("#"+d.prefix+"-empty").parent().children("."+d.addCssClass);q(d)?g.hide():g.show();f.find("a."+a.deleteCssClass).click(function(d){d.preventDefault();d=b(this).parents("."+a.formCssClass);for(var f=d.parent();d.next().hasClass("nested-inline-row");)d.next().remove(); 4 | d.remove();m(a.prefix,!1);a.removed&&a.removed(f)});var d=a.prefix,g=e,k=d.replace(/[-][0-9][-]/g,"-0-"),r=d+"-"+g,j=b("#"+r),l=b("#"+k+"-0").nextUntil("."+k+"-not-nested").find("."+k+"-nested-inline");l.each(function(a){var d=b(this).attr("id").split("-group")[0],f=d.replace(k+"-0",r),c=b(this).clone(),g=b(this).data("django_formset"),g=b.extend({},g);g.prefix=f;var e=c.find("#"+d+"-empty").is("tr");e?(c.find(".form-row").not(".empty-form").remove(),c.find(".nested-inline-row").remove()):c.find(".inline-related").not(".empty-form").remove(); 5 | c.find("."+g.addCssClass).remove();c.attr("id",c.attr("id").replace(d,f));c.find("*").each(function(){b(this).attr("for")&&b(this).attr("for",b(this).attr("for").replace(d,f));b(this).attr("class")&&b(this).attr("class",b(this).attr("class").replace(d,f));this.id&&(this.id=this.id.replace(d,f));this.name&&(this.name=this.name.replace(d,f))});c.find("#id_"+f+"-INITIAL_FORMS").val(0);c.find("#id_"+f+"-TOTAL_FORMS").val(1);c.find(".original").empty();e?(c.find(".tabular.inline-related tbody tr."+f+"-not-nested").tabularFormset(g), 6 | a=b('').html(b('').html(c)),j.after(a)):(c.find(".inline-related").stackedFormset(g),j.after(c));n(g)});d=l.length;f.is("tr")&&0=j(a.prefix)-h(a.prefix))}b.fn.formset=function(a){var e=b.extend({},b.fn.formset.defaults,a),c=b(this);a=c.parent();h(e.prefix);c.closest(".inline-group").data("django_formset",e); 7 | c.each(function(){b(this).not("."+e.emptyCssClass).addClass(e.formCssClass)});q(e)&&("TR"==c.attr("tagName")?(c=this.eq(-1).children().length,a.append(''+e.addText+""),a=a.find("tr:last a")):(c.filter(":last").after('
    '+e.addText+"
    "),a=c.filter(":last").next().find("a")),a.click(function(a){a.preventDefault();n(e)}));return this};b.fn.formset.defaults= 8 | {prefix:"form",addText:"add another",deleteText:"remove",addCssClass:"add-row",deleteCssClass:"delete-row",emptyCssClass:"empty-row",formCssClass:"dynamic-form",added:null,removed:null};b.fn.tabularFormset=function(a){var e=b(this),c=function(){row_number=0;b(e.selector).not(".add-row").removeClass("row1 row2").each(function(){b(this).addClass("row"+(row_number%2+1));for(next=b(this).next();next.hasClass("nested-inline-row");)next.addClass("row"+(row_number%2+1)),next=next.next();row_number+=1})}; 9 | e.formset({prefix:a.prefix,addText:a.addText,formCssClass:"dynamic-"+a.prefix,deleteCssClass:"inline-deletelink",deleteText:a.deleteText,emptyCssClass:"empty-form",removed:function(b){c(b);a.removed&&a.removed(b)},added:function(d){d.find(".prepopulated_field").each(function(){var a=b(this).find("input, select, textarea"),f=a.data("dependency_list")||[],c=[];b.each(f,function(a,b){c.push("#"+d.find(".field-"+b).find("input, select, textarea").attr("id"))});c.length&&a.prepopulate(c,a.attr("maxlength"))}); 10 | "undefined"!=typeof DateTimeShortcuts&&(b(".datetimeshortcuts").remove(),DateTimeShortcuts.init());"undefined"!=typeof SelectFilter&&(b(".selectfilter").each(function(b,d){var c=d.name.split("-");SelectFilter.init(d.id,c[c.length-1],!1,a.adminStaticPrefix)}),b(".selectfilterstacked").each(function(b,d){var c=d.name.split("-");SelectFilter.init(d.id,c[c.length-1],!0,a.adminStaticPrefix)}));c(d);a.added&&a.added(d)}});return e};b.fn.stackedFormset=function(a){var e=b(this),c=function(a){a.children(".inline-related").not(".empty-form").children("h3").find(".inline_label").each(function(a){a+= 11 | 1;b(this).html(b(this).html().replace(/(#\d+)/g,"#"+a))})};e.formset({prefix:a.prefix,addText:a.addText,formCssClass:"dynamic-"+a.prefix,deleteCssClass:"inline-deletelink",deleteText:a.deleteText,emptyCssClass:"empty-form",removed:function(b){c(b);a.removed&&a.removed(b)},added:function(d){d.find(".prepopulated_field").each(function(){var a=b(this).find("input, select, textarea"),c=a.data("dependency_list")||[],e=[];b.each(c,function(a,b){e.push("#"+d.find(".form-row .field-"+b).find("input, select, textarea").attr("id"))}); 12 | e.length&&a.prepopulate(e,a.attr("maxlength"))});"undefined"!=typeof DateTimeShortcuts&&(b(".datetimeshortcuts").remove(),DateTimeShortcuts.init());"undefined"!=typeof SelectFilter&&(b(".selectfilter").each(function(b,c){var d=c.name.split("-");SelectFilter.init(c.id,d[d.length-1],!1,a.adminStaticPrefix)}),b(".selectfilterstacked").each(function(b,c){var d=c.name.split("-");SelectFilter.init(c.id,d[d.length-1],!0,a.adminStaticPrefix)}));c(d.parent());a.added&&a.added(d)}});return e}})(django.jQuery); -------------------------------------------------------------------------------- /nested_inlines/templates/admin/edit_inline/stacked.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_static %} 2 |
    3 | {% with recursive_formset=inline_admin_formset stacked_template='admin/edit_inline/stacked.html' tabular_template='admin/edit_inline/tabular.html'%} 4 |

    {{ recursive_formset.opts.verbose_name_plural|title }}

    5 | {{ recursive_formset.formset.management_form }} 6 | {{ recursive_formset.formset.non_form_errors }} 7 | 8 | {% for inline_admin_form in recursive_formset %}
    9 |

    {{ recursive_formset.opts.verbose_name|title }}: {% if inline_admin_form.original %}{{ inline_admin_form.original }}{% else %}#{{ forloop.counter }}{% endif %} 10 | {% if inline_admin_form.show_url %}{% trans "View on site" %}{% endif %} 11 | {% if recursive_formset.formset.can_delete and inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}{% endif %} 12 |

    13 | {% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %} 14 | {% for fieldset in inline_admin_form %} 15 | {% include "admin/includes/fieldset.html" %} 16 | {% endfor %} 17 | {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %} 18 | {{ inline_admin_form.fk_field.field }} 19 | {% if inline_admin_form.form.nested_formsets %} 20 | {% for inline_admin_formset in inline_admin_form.form.nested_formsets %} 21 | {% if inline_admin_formset.opts.template == stacked_template %} 22 | {% include stacked_template %} 23 | {% else %} 24 | {% include tabular_template %} 25 | {% endif %} 26 |
    27 | {% endfor %} 28 | {% endif %} 29 |
    {% endfor %} 30 |
    31 | 32 | 42 | {% endwith %} 43 | -------------------------------------------------------------------------------- /nested_inlines/templates/admin/edit_inline/tabular.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_static admin_modify %} 2 |
    3 | {% with recursive_formset=inline_admin_formset stacked_template='admin/edit_inline/stacked.html' tabular_template='admin/edit_inline/tabular.html'%} 4 | 81 |
    82 | 83 | 93 | {% endwith %} 94 | -------------------------------------------------------------------------------- /nested_inlines/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | from setuptools import setup, find_packages 3 | 4 | from nested_inlines import __version__ 5 | 6 | github_url = 'https://github.com/soaa-/django-nested-inlines' 7 | long_desc = open('README.md').read() 8 | 9 | setup( 10 | name='django-nested-inlines', 11 | version='.'.join(str(v) for v in __version__), 12 | description='Adds nested inline support in Django admin', 13 | long_description=long_desc, 14 | url=github_url, 15 | author='Alain Trinh', 16 | author_email='i.am@soaa.me', 17 | packages=find_packages(exclude=['tests']), 18 | include_package_data=True, 19 | license='MIT License', 20 | classifiers=[ 21 | 'Development Status :: 4 - Beta', 22 | 'Environment :: Web Environment', 23 | 'Framework :: Django', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python', 28 | 'Topic :: Software Development :: Libraries :: Python Modules' 29 | ], 30 | ) 31 | 32 | --------------------------------------------------------------------------------