├── .gitignore ├── MANIFEST.in ├── README.md ├── grappelli_nested ├── __init__.py ├── admin.py ├── forms.py ├── helpers.py ├── models.py ├── static │ └── grappelli_nested │ │ ├── .DS_Store │ │ ├── css │ │ └── grp_nested_inline.css │ │ └── js │ │ └── grp_nested_inline.js ├── templates │ └── admin │ │ └── edit_inline │ │ ├── stacked.html │ │ └── tabular.html └── views.py ├── setup.cfg └── 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 -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include grappelli_nested/static * 2 | recursive-include grappelli_nested/templates * 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## We are no longer supporting this project. It has moved: https://github.com/allanjamesvestal/grappelli-nested-inlines 2 | 3 | # django-nested-inlines 4 | 5 | ## Overview 6 | 7 | Extends Alain Trinh ([http://github.com/Soaa-/](http://github.com/Soaa-/))'s django-nested-inlines code to work with the latest version of [Django Grappelli](http://github.com/sehmaschine/django-grappelli). 8 | 9 | [Django issue #9025](http://code.djangoproject.com/ticket/9025) 10 | 11 | Patches have been submitted, and repositories forked, but no one likes to use 12 | either one. Now, nested inlines are available in an easy-to-install package. 13 | 14 | ### Issues 15 | 16 | This is still beta-quality software, and certainly has its share of bugs. Use it in production sites at your own risk. 17 | 18 | ## Installation 19 | 20 | `pip install grappelli-nested-inlines` 21 | 22 | ## Usage 23 | 24 | `grappelli_nested.admin` contains three `ModelAdmin` subclasses to enable 25 | nested inline support: `NestedModelAdmin`, `NestedStackedInline`, and 26 | `NestedTabularInline`. To use them: 27 | 28 | 1. Add `grappelli_nested` to your `INSTALLED_APPS` **before** `grappelli` and 29 | `django.contrib.admin`. This is because this app overrides certain admin 30 | templates and media. 31 | 2. Run `./manage.py collectstatic` to get the new CSS and Javascript files that come with grappelli-nested-inlines. 32 | 3. Import `NestedModelAdmin`, `NestedStackedInline`, and `NestedTabularInline` 33 | wherever you want to use nested inlines. 34 | 4. On admin classes that will contain nested inlines, use `NestedModelAdmin` 35 | rather than the standard `ModelAdmin`. 36 | 5. On inline classes, use `Nested` versions instead of the standard ones. 37 | 6. Add an `inlines = [MyInline,]` attribute to your inlines and watch the 38 | magic happen. 39 | 40 | ## Example 41 | 42 | from django.contrib import admin 43 | from grappelli_nested.admin import NestedModelAdmin, NestedStackedInline, NestedTabularInline 44 | from models import A, B, C 45 | 46 | class MyNestedInline(NestedTabularInline): 47 | model = C 48 | 49 | class MyInline(NestedStackedInline): 50 | model = B 51 | inlines = [MyNestedInline,] 52 | 53 | class MyAdmin(NestedModelAdmin): 54 | pass 55 | 56 | admin.site.register(A, MyAdmin) 57 | 58 | ## Credits 59 | 60 | As Trinh said himself, this package is mainly the work of other developers. I (Vestal) have merely adapted this package to support Django Grappelli (as Trinh says he's taken other developers' patches "and packaged them nicely for ease of use"). 61 | 62 | Besides Trinh, additional credit goes to: 63 | 64 | - Gargamel for providing the base patch on the Django ticket. 65 | - Stefan Klug for providing a fork with the patch applied, and for bugfixes. 66 | 67 | See [Stefan Klug's repository](https://github.com/stefanklug/django/tree/nested-inline-support-1.5.x). 68 | -------------------------------------------------------------------------------- /grappelli_nested/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = (0, 6, 0) 2 | -------------------------------------------------------------------------------- /grappelli_nested/admin.py: -------------------------------------------------------------------------------- 1 | from django import VERSION as DJANGO_VERSION 2 | from django.contrib.admin.options import (ModelAdmin, InlineModelAdmin, 3 | csrf_protect_m, models, transaction, all_valid, 4 | PermissionDenied, unquote, escape, Http404, reverse) 5 | # Fix to make Django 1.5 compatible, maintain backwards compatibility 6 | try: 7 | from django.contrib.admin.options import force_unicode 8 | except ImportError: 9 | from django.utils.encoding import force_text as force_unicode 10 | 11 | from django.contrib.admin.helpers import InlineAdminFormSet, AdminForm 12 | from django.utils.translation import ugettext as _ 13 | 14 | from grappelli_nested.forms import BaseNestedModelForm, BaseNestedInlineFormSet 15 | from grappelli_nested.helpers import AdminErrorList 16 | 17 | class NestedModelAdmin(ModelAdmin): 18 | form = BaseNestedModelForm 19 | 20 | class Media: 21 | css = {'all': ('grappelli_nested/css/grp_nested_inline.css',)} 22 | js = ('grappelli_nested/js/grp_nested_inline.js',) 23 | 24 | def get_form(self, request, obj=None, **kwargs): 25 | if not issubclass(self.form, BaseNestedModelForm): 26 | raise ValueError('self.form must to be an instance of BaseNestedModelForm') 27 | return super(NestedModelAdmin, self).get_form(request, obj, **kwargs) 28 | 29 | def get_inline_instances(self, request, obj=None): 30 | inline_instances = [] 31 | for inline_class in self.inlines: 32 | inline = inline_class(self.model, self.admin_site) 33 | if request: 34 | if not (inline.has_add_permission(request) or 35 | inline.has_change_permission(request, obj) or 36 | inline.has_delete_permission(request, obj)): 37 | continue 38 | if not inline.has_add_permission(request): 39 | inline.max_num = 0 40 | inline_instances.append(inline) 41 | 42 | return inline_instances 43 | 44 | def save_formset(self, request, form, formset, change): 45 | """ 46 | Given an inline formset save it to the database. 47 | """ 48 | formset.save() 49 | 50 | #iterate through the nested formsets and save them 51 | #skip formsets, where the parent is marked for deletion 52 | if formset.can_delete: 53 | deleted_forms = formset.deleted_forms 54 | else: 55 | deleted_forms = [] 56 | for form in formset.forms: 57 | if hasattr(form, 'nested_formsets') and form not in deleted_forms: 58 | for nested_formset in form.nested_formsets: 59 | self.save_formset(request, form, nested_formset, change) 60 | 61 | def add_nested_inline_formsets(self, request, inline, formset, depth=0): 62 | if depth > 5: 63 | raise Exception("Maximum nesting depth reached (5)") 64 | empty_form = formset.empty_form 65 | for form in formset.forms + [empty_form]: 66 | nested_formsets = [] 67 | for nested_inline in inline.get_inline_instances(request): 68 | InlineFormSet = nested_inline.get_formset(request, form.instance) 69 | prefix = "%s-%s" % (form.prefix, InlineFormSet.get_default_prefix()) 70 | #because of form nesting with extra=0 it might happen, that the post data doesn't include values for the formset. 71 | #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 72 | keys = request.POST.keys() 73 | has_params = any(s.startswith(prefix) for s in keys) 74 | if request.method == 'POST' and has_params: 75 | nested_formset = InlineFormSet( 76 | request.POST, 77 | request.FILES, 78 | save_as_new="_saveasnew" in request.POST, 79 | instance=form.instance, 80 | prefix=prefix, 81 | queryset=nested_inline.queryset(request) 82 | ) 83 | else: 84 | nested_formset = InlineFormSet(instance=form.instance, 85 | prefix=prefix, queryset=nested_inline.queryset(request)) 86 | nested_formsets.append(nested_formset) 87 | if nested_inline.inlines: 88 | self.add_nested_inline_formsets(request, nested_inline, nested_formset, depth=depth+1) 89 | form.nested_formsets = nested_formsets 90 | form = empty_form 91 | media = None 92 | def get_media(extra_media): 93 | if media: 94 | return media + extra_media 95 | else: 96 | return extra_media 97 | wrapped_nested_formsets = [] 98 | for nested_inline, nested_formset in zip(inline.get_inline_instances(request), form.nested_formsets): 99 | if form.instance.pk: 100 | instance = form.instance 101 | else: 102 | instance = None 103 | fieldsets = list(nested_inline.get_fieldsets(request)) 104 | readonly = list(nested_inline.get_readonly_fields(request)) 105 | prepopulated = dict(nested_inline.get_prepopulated_fields(request)) 106 | wrapped_nested_formset = InlineAdminFormSet(nested_inline, nested_formset, 107 | fieldsets, prepopulated, readonly, model_admin=self) 108 | wrapped_nested_formsets.append(wrapped_nested_formset) 109 | media = get_media(wrapped_nested_formset.media) 110 | if nested_inline.inlines: 111 | media = get_media(self.wrap_nested_inline_formsets(request, nested_inline, nested_formset)) 112 | form.nested_formsets = wrapped_nested_formsets 113 | formset.__class__.empty_form = empty_form 114 | 115 | def wrap_nested_inline_formsets(self, request, inline, formset): 116 | """wraps each formset in a helpers.InlineAdminFormset. 117 | @TODO someone with more inside knowledge should write done why this is done 118 | """ 119 | media = None 120 | def get_media(extra_media): 121 | if media: 122 | return media + extra_media 123 | else: 124 | return extra_media 125 | 126 | for form in formset.forms: 127 | wrapped_nested_formsets = [] 128 | for nested_inline, nested_formset in zip(inline.get_inline_instances(request), form.nested_formsets): 129 | if form.instance.pk: 130 | instance = form.instance 131 | else: 132 | instance = None 133 | fieldsets = list(nested_inline.get_fieldsets(request)) 134 | readonly = list(nested_inline.get_readonly_fields(request)) 135 | prepopulated = dict(nested_inline.get_prepopulated_fields(request)) 136 | wrapped_nested_formset = InlineAdminFormSet(nested_inline, nested_formset, 137 | fieldsets, prepopulated, readonly, model_admin=self) 138 | wrapped_nested_formsets.append(wrapped_nested_formset) 139 | media = get_media(wrapped_nested_formset.media) 140 | if nested_inline.inlines: 141 | media = get_media(self.wrap_nested_inline_formsets(request, nested_inline, nested_formset)) 142 | form.nested_formsets = wrapped_nested_formsets 143 | return media 144 | 145 | def all_valid_with_nesting(self, formsets): 146 | """Recursively validate all nested formsets 147 | """ 148 | if not all_valid(formsets): 149 | return False 150 | for formset in formsets: 151 | if not formset.is_bound: 152 | pass 153 | for form in formset: 154 | if hasattr(form, 'nested_formsets'): 155 | if not self.all_valid_with_nesting(form.nested_formsets): 156 | return False 157 | return True 158 | 159 | @csrf_protect_m 160 | @transaction.atomic 161 | def add_view(self, request, form_url='', extra_context=None): 162 | "The 'add' admin view for this model." 163 | model = self.model 164 | opts = model._meta 165 | 166 | if not self.has_add_permission(request): 167 | raise PermissionDenied 168 | 169 | ModelForm = self.get_form(request) 170 | formsets = [] 171 | inline_instances = self.get_inline_instances(request, None) 172 | if request.method == 'POST': 173 | form = ModelForm(request.POST, request.FILES) 174 | if form.is_valid(): 175 | new_object = self.save_form(request, form, change=False) 176 | form_validated = True 177 | else: 178 | form_validated = False 179 | new_object = self.model() 180 | prefixes = {} 181 | for FormSet, inline in zip(self.get_formsets(request), inline_instances): 182 | prefix = FormSet.get_default_prefix() 183 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 184 | if prefixes[prefix] != 1 or not prefix: 185 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 186 | formset = FormSet(data=request.POST, files=request.FILES, 187 | instance=new_object, 188 | save_as_new="_saveasnew" in request.POST, 189 | prefix=prefix, queryset=inline.queryset(request)) 190 | formsets.append(formset) 191 | if inline.inlines: 192 | self.add_nested_inline_formsets(request, inline, formset) 193 | if self.all_valid_with_nesting(formsets) and form_validated: 194 | self.save_model(request, new_object, form, False) 195 | self.save_related(request, form, formsets, False) 196 | self.log_addition(request, new_object) 197 | return self.response_add(request, new_object) 198 | else: 199 | # Prepare the dict of initial data from the request. 200 | # We have to special-case M2Ms as a list of comma-separated PKs. 201 | initial = dict(request.GET.items()) 202 | for k in initial: 203 | try: 204 | f = opts.get_field(k) 205 | except models.FieldDoesNotExist: 206 | continue 207 | if isinstance(f, models.ManyToManyField): 208 | initial[k] = initial[k].split(",") 209 | form = ModelForm(initial=initial) 210 | prefixes = {} 211 | for FormSet, inline in zip(self.get_formsets(request), inline_instances): 212 | prefix = FormSet.get_default_prefix() 213 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 214 | if prefixes[prefix] != 1 or not prefix: 215 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 216 | formset = FormSet(instance=self.model(), prefix=prefix, 217 | queryset=inline.queryset(request)) 218 | formsets.append(formset) 219 | if inline.inlines: 220 | self.add_nested_inline_formsets(request, inline, formset) 221 | 222 | adminForm = AdminForm(form, list(self.get_fieldsets(request)), 223 | self.get_prepopulated_fields(request), 224 | self.get_readonly_fields(request), 225 | model_admin=self) 226 | media = self.media + adminForm.media 227 | 228 | inline_admin_formsets = [] 229 | for inline, formset in zip(inline_instances, formsets): 230 | fieldsets = list(inline.get_fieldsets(request)) 231 | readonly = list(inline.get_readonly_fields(request)) 232 | prepopulated = dict(inline.get_prepopulated_fields(request)) 233 | inline_admin_formset = InlineAdminFormSet(inline, formset, 234 | fieldsets, prepopulated, readonly, model_admin=self) 235 | inline_admin_formsets.append(inline_admin_formset) 236 | media = media + inline_admin_formset.media 237 | if inline.inlines: 238 | media = media + self.wrap_nested_inline_formsets(request, inline, formset) 239 | 240 | context = { 241 | 'title': _('Add %s') % force_unicode(opts.verbose_name), 242 | 'adminform': adminForm, 243 | 'is_popup': "_popup" in request.REQUEST, 244 | 'show_delete': False, 245 | 'media': media, 246 | 'inline_admin_formsets': inline_admin_formsets, 247 | 'errors': AdminErrorList(form, formsets), 248 | 'app_label': opts.app_label, 249 | 'django_version_lt_1_6': DJANGO_VERSION < (1, 6) 250 | } 251 | context.update(extra_context or {}) 252 | return self.render_change_form(request, context, form_url=form_url, add=True) 253 | 254 | @csrf_protect_m 255 | @transaction.atomic 256 | def change_view(self, request, object_id, form_url='', extra_context=None): 257 | "The 'change' admin view for this model." 258 | model = self.model 259 | opts = model._meta 260 | 261 | obj = self.get_object(request, unquote(object_id)) 262 | 263 | if not self.has_change_permission(request, obj): 264 | raise PermissionDenied 265 | 266 | if obj is None: 267 | raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {'name': force_unicode(opts.verbose_name), 'key': escape(object_id)}) 268 | 269 | if request.method == 'POST' and "_saveasnew" in request.POST: 270 | return self.add_view(request, form_url=reverse('admin:%s_%s_add' % 271 | (opts.app_label, opts.module_name), 272 | current_app=self.admin_site.name)) 273 | 274 | ModelForm = self.get_form(request, obj) 275 | formsets = [] 276 | inline_instances = self.get_inline_instances(request, obj) 277 | if request.method == 'POST': 278 | form = ModelForm(request.POST, request.FILES, instance=obj) 279 | if form.is_valid(): 280 | form_validated = True 281 | new_object = self.save_form(request, form, change=True) 282 | else: 283 | form_validated = False 284 | new_object = obj 285 | prefixes = {} 286 | for FormSet, inline in zip(self.get_formsets(request, new_object), inline_instances): 287 | prefix = FormSet.get_default_prefix() 288 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 289 | if prefixes[prefix] != 1 or not prefix: 290 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 291 | formset = FormSet(request.POST, request.FILES, 292 | instance=new_object, prefix=prefix, 293 | queryset=inline.get_queryset(request)) 294 | formsets.append(formset) 295 | if inline.inlines: 296 | self.add_nested_inline_formsets(request, inline, formset) 297 | 298 | if self.all_valid_with_nesting(formsets) and form_validated: 299 | self.save_model(request, new_object, form, True) 300 | self.save_related(request, form, formsets, True) 301 | change_message = self.construct_change_message(request, form, formsets) 302 | self.log_change(request, new_object, change_message) 303 | return self.response_change(request, new_object) 304 | 305 | else: 306 | form = ModelForm(instance=obj) 307 | prefixes = {} 308 | for FormSet, inline in zip(self.get_formsets(request, obj), inline_instances): 309 | prefix = FormSet.get_default_prefix() 310 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 311 | if prefixes[prefix] != 1 or not prefix: 312 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 313 | formset = FormSet(instance=obj, prefix=prefix, 314 | queryset=inline.queryset(request)) 315 | formsets.append(formset) 316 | if inline.inlines: 317 | self.add_nested_inline_formsets(request, inline, formset) 318 | 319 | adminForm = AdminForm(form, self.get_fieldsets(request, obj), 320 | self.get_prepopulated_fields(request, obj), 321 | self.get_readonly_fields(request, obj), 322 | model_admin=self) 323 | media = self.media + adminForm.media 324 | 325 | inline_admin_formsets = [] 326 | for inline, formset in zip(inline_instances, formsets): 327 | fieldsets = list(inline.get_fieldsets(request, obj)) 328 | readonly = list(inline.get_readonly_fields(request, obj)) 329 | prepopulated = dict(inline.get_prepopulated_fields(request, obj)) 330 | inline_admin_formset = InlineAdminFormSet(inline, formset, 331 | fieldsets, prepopulated, readonly, model_admin=self) 332 | inline_admin_formsets.append(inline_admin_formset) 333 | media = media + inline_admin_formset.media 334 | if inline.inlines: 335 | media = media + self.wrap_nested_inline_formsets(request, inline, formset) 336 | 337 | context = { 338 | 'title': _('Change %s') % force_unicode(opts.verbose_name), 339 | 'adminform': adminForm, 340 | 'object_id': object_id, 341 | 'original': obj, 342 | 'is_popup': "_popup" in request.REQUEST, 343 | 'media': media, 344 | 'inline_admin_formsets': inline_admin_formsets, 345 | 'errors': AdminErrorList(form, formsets), 346 | 'app_label': opts.app_label, 347 | 'django_version_lt_1_6': DJANGO_VERSION < (1, 6) 348 | } 349 | context.update(extra_context or {}) 350 | return self.render_change_form(request, context, change=True, obj=obj, form_url=form_url) 351 | 352 | class NestedInlineModelAdmin(InlineModelAdmin): 353 | inlines = [] 354 | formset = BaseNestedInlineFormSet 355 | form = BaseNestedModelForm 356 | 357 | def get_form(self, request, obj=None, **kwargs): 358 | return super(NestedModelAdmin, self).get_form( 359 | request, obj, form=BaseNestedModelForm, **kwargs) 360 | 361 | def get_inline_instances(self, request, obj=None): 362 | inline_instances = [] 363 | for inline_class in self.inlines: 364 | inline = inline_class(self.model, self.admin_site) 365 | if request: 366 | if not (inline.has_add_permission(request) or 367 | inline.has_change_permission(request, obj) or 368 | inline.has_delete_permission(request, obj)): 369 | continue 370 | if not inline.has_add_permission(request): 371 | inline.max_num = 0 372 | inline_instances.append(inline) 373 | 374 | return inline_instances 375 | 376 | def get_formsets(self, request, obj=None): 377 | for inline in self.get_inline_instances(request, obj): 378 | yield inline.get_formset(request, obj) 379 | 380 | class NestedStackedInline(NestedInlineModelAdmin): 381 | template = 'admin/edit_inline/stacked.html' 382 | 383 | class NestedTabularInline(NestedInlineModelAdmin): 384 | template = 'admin/edit_inline/tabular.html' 385 | -------------------------------------------------------------------------------- /grappelli_nested/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 | def has_changed(self): 30 | """ 31 | Returns True if data or nested data differs from initial. 32 | """ 33 | if self.instance.pk is None: 34 | nested_formsets = getattr(self, 'nested_formsets', ()) 35 | nested_has_changed = any( 36 | (formset.has_changed() for formset in nested_formsets)) 37 | else: 38 | nested_has_changed = False 39 | return (super(NestedFormMixin, self).has_changed() or 40 | nested_has_changed) 41 | 42 | class BaseNestedForm(NestedFormMixin, BaseForm): 43 | pass 44 | 45 | class NestedFormSetMixin(object): 46 | def dependency_has_changed(self): 47 | for form in self.forms: 48 | if form.has_changed() or form.dependency_has_changed(): 49 | return True 50 | return False 51 | 52 | class BaseNestedInlineFormSet(NestedFormSetMixin, BaseInlineFormSet): 53 | pass 54 | 55 | class NestedModelFormMixin(NestedFormMixin): 56 | def dependency_has_changed(self): 57 | #check for the nested_formsets attribute, added by the admin app. 58 | #TODO this should be generalized 59 | if hasattr(self, 'nested_formsets'): 60 | for f in self.nested_formsets: 61 | return f.dependency_has_changed() 62 | 63 | class BaseNestedModelForm(NestedModelFormMixin, ModelForm): 64 | pass 65 | -------------------------------------------------------------------------------- /grappelli_nested/helpers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.helpers import (AdminErrorList as ParentErrorList, 2 | InlineAdminFormSet) 3 | from django.utils import six 4 | 5 | class AdminErrorList(ParentErrorList): 6 | """ 7 | Stores all errors for the form/formsets in an add/change stage view. 8 | """ 9 | def __init__(self, form, inline_formsets): 10 | super(AdminErrorList, self).__init__(form, inline_formsets) 11 | 12 | if form.is_bound: 13 | self.extend(form.errors.values()) 14 | for inline_formset in inline_formsets: 15 | self._add_formset_recursive(inline_formset) 16 | 17 | def _add_formset_recursive(self, formset): 18 | #check if it is a wrapped formset 19 | if isinstance(formset, InlineAdminFormSet): 20 | formset = formset.formset 21 | 22 | self.extend(formset.non_form_errors()) 23 | for errors_in_inline_form in formset.errors: 24 | self.data.extend(list(six.itervalues(errors_in_inline_form))) 25 | 26 | #support for nested formsets 27 | for form in formset: 28 | if hasattr(form, 'nested_formsets'): 29 | for fs in form.nested_formsets: 30 | self._add_formset_recursive(fs) 31 | -------------------------------------------------------------------------------- /grappelli_nested/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /grappelli_nested/static/grappelli_nested/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datahub/grappelli-nested-inlines/8c02e7f683ba5c981b5f70e917ddc472970ab142/grappelli_nested/static/grappelli_nested/.DS_Store -------------------------------------------------------------------------------- /grappelli_nested/static/grappelli_nested/css/grp_nested_inline.css: -------------------------------------------------------------------------------- 1 | /* INLINES */ 2 | .nested-inline { 3 | margin: 0; 4 | } 5 | .nested-inline .nested-inline { 6 | border: 1px solid #AAAAAA; 7 | margin: 12px 1% 17px 1%; 8 | width: 97.6%; 9 | } -------------------------------------------------------------------------------- /grappelli_nested/static/grappelli_nested/js/grp_nested_inline.js: -------------------------------------------------------------------------------- 1 | /** 2 | * GRAPPELLI INLINES 3 | * jquery-plugin for inlines (stacked and tabular) 4 | */ 5 | 6 | 7 | (function($) { 8 | $.fn.grp_inline = function(options) { 9 | var defaults = { 10 | prefix: "form", // The form prefix for your django formset 11 | addText: "add another", // Text for the add link 12 | deleteText: "remove", // Text for the delete link 13 | addCssClass: "grp-add-handler", // CSS class applied to the add link 14 | removeCssClass: "grp-remove-handler", // CSS class applied to the remove link 15 | deleteCssClass: "grp-delete-handler", // CSS class applied to the delete link 16 | emptyCssClass: "grp-empty-form", // CSS class applied to the empty row 17 | formCssClass: "grp-dynamic-form", // CSS class applied to each form in a formset 18 | predeleteCssClass: "grp-predelete", 19 | onBeforeInit: function(form) {}, // Function called before a form is initialized 20 | onBeforeAdded: function(inline) {}, // Function called before a form is added 21 | onBeforeRemoved: function(form) {}, // Function called before a form is removed 22 | onBeforeDeleted: function(form) {}, // Function called before a form is deleted 23 | onAfterInit: function(form) {}, // Function called after a form has been initialized 24 | onAfterAdded: function(form) {}, // Function called after a form has been added 25 | onAfterRemoved: function(inline) {}, // Function called after a form has been removed 26 | onAfterDeleted: function(form) {} // Function called after a form has been deleted 27 | }; 28 | options = $.extend(defaults, options); 29 | 30 | return this.each(function() { 31 | var inline = $(this); // the current inline node 32 | var totalForms = inline.find("#id_" + options.prefix + "-TOTAL_FORMS"); 33 | // set autocomplete to off in order to prevent the browser from keeping the current value after reload 34 | totalForms.attr("autocomplete", "off"); 35 | // init inline and add-buttons 36 | initInlineForms(inline, options); 37 | initAddButtons(inline, options); 38 | // button handlers 39 | addButtonHandler(inline.find("a." + options.addCssClass + "." + inline.attr('id')), options); 40 | removeButtonHandler(inline.find("a." + options.removeCssClass + "." + inline.attr('id')), options); 41 | deleteButtonHandler(inline.find("a." + options.deleteCssClass + "." + inline.attr('id')), options); 42 | }); 43 | }; 44 | 45 | updateFormIndex = function(elem, options, replace_regex, replace_with) { 46 | elem.find(':input,span,table,iframe,label,a,ul,p,img,div').each(function() { 47 | var node = $(this), 48 | node_id = node.attr('id'), 49 | node_name = node.attr('name'), 50 | node_for = node.attr('for'), 51 | node_href = node.attr("href"), 52 | node_class = node.attr("class"), 53 | node_onclick = node.attr("onclick"); 54 | if (node_id) { node.attr('id', node_id.replace(replace_regex, replace_with)); } 55 | if (node_name) { node.attr('name', node_name.replace(replace_regex, replace_with)); } 56 | if (node_for) { node.attr('for', node_for.replace(replace_regex, replace_with)); } 57 | if (node_href) { node.attr('href', node_href.replace(replace_regex, replace_with)); } 58 | if (node_class) { node.attr('class', node_class.replace(replace_regex, replace_with)); } 59 | if (node_onclick) { node.attr('onclick', node_onclick.replace(replace_regex, replace_with)); } 60 | }); 61 | }; 62 | 63 | initInlineForms = function(elem, options) { 64 | if (options.prefix.split('__prefix__').length != 1) return; 65 | elem.find("div.grp-module").each(function() { 66 | var form = $(this); 67 | // callback 68 | options.onBeforeInit(form); 69 | // add options.formCssClass to all forms in the inline 70 | // except table/theader/add-item 71 | if (form.attr('id') !== "") { 72 | form.not("." + options.emptyCssClass).not(".grp-table").not(".grp-thead").not(".add-item").addClass(options.formCssClass); 73 | } 74 | // add options.predeleteCssClass to forms with the delete checkbox checked 75 | form.find("li.grp-delete-handler-container input").each(function() { 76 | if ($(this).is(":checked") && form.hasClass("has_original")) { 77 | form.toggleClass(options.predeleteCssClass); 78 | } 79 | }); 80 | // callback 81 | options.onAfterInit(form); 82 | }); 83 | }; 84 | 85 | initAddButtons = function(elem, options) { 86 | if (options.prefix.split('__prefix__').length != 1) return; 87 | var totalForms = elem.find("#id_" + options.prefix + "-TOTAL_FORMS"); 88 | var maxForms = elem.find("#id_" + options.prefix + "-MAX_NUM_FORMS"); 89 | var addButtons = elem.find("a." + options.addCssClass + "." + elem.attr('id')); 90 | // hide add button in case we've hit the max, except we want to add infinitely 91 | if ((maxForms.val() !== '') && (maxForms.val()-totalForms.val()) <= 0) { 92 | hideAddButtons(elem, options); 93 | } 94 | }; 95 | 96 | addButtonHandler = function(elem, options) { 97 | if (options.prefix.split('__prefix__').length != 1) return; 98 | elem.bind("click", function() { 99 | var inline = elem.closest(".grp-group"), 100 | totalForms = inline.find("#id_" + options.prefix + "-TOTAL_FORMS"), 101 | maxForms = inline.find("#id_" + options.prefix + "-MAX_NUM_FORMS"), 102 | addButtons = inline.find("a." + options.addCssClass + "." + inline.attr('id')), 103 | empty_template = inline.find("#" + options.prefix + "-empty"); 104 | // callback 105 | options.onBeforeAdded(inline); 106 | // create new form 107 | var index = parseInt(totalForms.val(), 10), 108 | form = empty_template.clone(true); 109 | form.removeClass(options.emptyCssClass) 110 | .attr("id", empty_template.attr('id').replace("-empty", '-' + index)); 111 | // update form index 112 | var re = /__prefix__/; 113 | updateFormIndex(form, options, re, index); 114 | // after "__prefix__" strings has been substituted with the number 115 | // of the inline, we can add the form to DOM, not earlier. 116 | // This way we can support handlers that track live element 117 | // adding/removing, like those used in django-autocomplete-light 118 | form.insertBefore(empty_template) 119 | .addClass(options.formCssClass).grp_inline(options); 120 | // update total forms 121 | totalForms.val(index + 1); 122 | // hide add button in case we've hit the max, except we want to add infinitely 123 | if ((maxForms.val() !== 0) && (maxForms.val() !== "") && (maxForms.val() - totalForms.val()) <= 0) { 124 | hideAddButtons(inline, options); 125 | } 126 | // callback 127 | options.onAfterAdded(form); 128 | }); 129 | }; 130 | 131 | removeButtonHandler = function(elem, options) { 132 | if (options.prefix.split('__prefix__').length != 1) return; 133 | elem.bind("click", function() { 134 | var inline = elem.parents(".grp-group").first(), 135 | form = $(this).parents("." + options.formCssClass).first(), 136 | totalForms = inline.find("#id_" + options.prefix + "-TOTAL_FORMS"), 137 | maxForms = inline.find("#id_" + options.prefix + "-MAX_NUM_FORMS"); 138 | // callback 139 | options.onBeforeRemoved(form); 140 | // remove form 141 | form.remove(); 142 | // update total forms 143 | var index = parseInt(totalForms.val(), 10); 144 | totalForms.val(index - 1); 145 | // show add button in case we've dropped below max 146 | if ((maxForms.val() !== 0) && (maxForms.val() - totalForms.val()) > 0) { 147 | showAddButtons(inline, options); 148 | } 149 | // update form index (for all forms) 150 | var re = new RegExp(options.prefix + "-\\d+-", 'g'), 151 | i = 0; 152 | inline.find("." + options.formCssClass).each(function() { 153 | updateFormIndex($(this), options, re, options.prefix + "-" + i + "-"); 154 | i++; 155 | }); 156 | // callback 157 | options.onAfterRemoved(inline); 158 | }); 159 | }; 160 | 161 | deleteButtonHandler = function(elem, options) { 162 | if (options.prefix.split('__prefix__').length != 1) return; 163 | elem.bind("click", function() { 164 | var deleteInput = $(this).prev(), 165 | form = $(this).parents("." + options.formCssClass).first(); 166 | // callback 167 | options.onBeforeDeleted(form); 168 | // toggle options.predeleteCssClass and toggle checkbox 169 | if (form.hasClass("has_original")) { 170 | form.toggleClass(options.predeleteCssClass); 171 | if (deleteInput.prop("checked")) { 172 | deleteInput.removeAttr("checked"); 173 | } else { 174 | deleteInput.prop("checked", true); 175 | } 176 | } 177 | // callback 178 | options.onAfterDeleted(form); 179 | }); 180 | }; 181 | 182 | hideAddButtons = function(elem, options) { 183 | if (options.prefix.split('__prefix__').length != 1) return; 184 | var addButtons = elem.find("a." + options.addCssClass + "." + elem.attr('id')); 185 | // addButtons.hide().parents('.grp-add-item').hide(); 186 | addButtons.hide(); 187 | }; 188 | 189 | showAddButtons = function(elem, options) { 190 | if (options.prefix.split('__prefix__').length != 1) return; 191 | var addButtons = elem.find("a." + options.addCssClass + "." + elem.attr('id')); 192 | // addButtons.show().parents('.grp-add-item').show(); 193 | addButtons.show(); 194 | }; 195 | 196 | })(grp.jQuery); 197 | -------------------------------------------------------------------------------- /grappelli_nested/templates/admin/edit_inline/stacked.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_static grp_tags %} 2 | {% with recursive_formset=inline_admin_formset stacked_template='admin/edit_inline/stacked.html' tabular_template='admin/edit_inline/tabular.html'%} 3 | {% with recursive_formset.opts.sortable_field_name|default:"" as sortable_field_name %} 4 | 5 | 6 |
{{ field.field.help_text|striptags }}
{% endif %} 55 |