├── setup.cfg ├── grappelli_nested ├── __init__.py ├── views.py ├── models.py ├── static │ └── grappelli_nested │ │ ├── .DS_Store │ │ ├── css │ │ └── grp_nested_inline.css │ │ └── js │ │ └── grp_nested_inline.js ├── helpers.py ├── forms.py ├── templates │ └── admin │ │ └── edit_inline │ │ ├── stacked.html │ │ └── tabular.html └── admin.py ├── MANIFEST.in ├── .gitignore ├── setup.py └── README.md /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /grappelli_nested/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = (0, 6, 0) 2 | -------------------------------------------------------------------------------- /grappelli_nested/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /grappelli_nested/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include grappelli_nested/static * 2 | recursive-include grappelli_nested/templates * 3 | -------------------------------------------------------------------------------- /grappelli_nested/static/grappelli_nested/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datahub/grappelli-nested-inlines/HEAD/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 | } -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | from setuptools import setup, find_packages 3 | 4 | 5 | from grappelli_nested import __version__ 6 | 7 | 8 | github_url = 'https://github.com/datahub/grappelli-nested-inlines' 9 | github_tag_version = '0.6.0' 10 | 11 | 12 | setup( 13 | name='grappelli-nested-inlines', 14 | version='.'.join(str(v) for v in __version__), 15 | description='Enables nested inlines in the Django/Grappelli admin', 16 | url=github_url, 17 | download_url='%s/tarball/%s' % (github_url, github_tag_version), 18 | author='Allan James Vestal (based on code by Alain Trinh)', 19 | author_email='datahub@jrn.com', 20 | packages=find_packages(exclude=['tests']), 21 | include_package_data=True, 22 | license='MIT License', 23 | classifiers=[ 24 | 'Development Status :: 4 - Beta', 25 | 'Environment :: Web Environment', 26 | 'Framework :: Django', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Operating System :: OS Independent', 30 | 'Programming Language :: Python', 31 | 'Topic :: Software Development :: Libraries :: Python Modules' 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 |