Code

Ticket #9025: nested_inlines_finished.diff

File nested_inlines_finished.diff, 86.2 KB (added by Gargamel, 21 months ago)

Nested Inlines V1.1

Line 
1diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
2index f4205f2..352cd8b 100644
3--- a/django/contrib/admin/options.py
4+++ b/django/contrib/admin/options.py
5@@ -715,11 +715,16 @@ class ModelAdmin(BaseModelAdmin):
6         """
7         obj.delete()
8 
9-    def save_formset(self, request, form, formset, change):
10+    def save_formset(self, request, formset, change):
11         """
12         Given an inline formset save it to the database.
13         """
14         formset.save()
15+        for form in formset.forms:
16+            if hasattr(form, 'nested_formsets'):
17+                for nested_formset in form.nested_formsets:
18+                    self.save_formset(request, nested_formset, change)
19+                   
20 
21     def save_related(self, request, form, formsets, change):
22         """
23@@ -731,7 +736,7 @@ class ModelAdmin(BaseModelAdmin):
24         """
25         form.save_m2m()
26         for formset in formsets:
27-            self.save_formset(request, form, formset, change=change)
28+            self.save_formset(request, formset, change=change)
29 
30     def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None):
31         opts = self.model._meta
32@@ -920,6 +925,73 @@ class ModelAdmin(BaseModelAdmin):
33             self.message_user(request, msg)
34             return None
35 
36+   
37+       
38+    def add_nested_inline_formsets(self, request, inline, formset, depth=0):
39+        if depth > 5:
40+            raise Exception("Maximum nesting depth reached (5)")
41+        for form in formset.forms:               
42+            nested_formsets = []
43+            for nested_inline in inline.get_inline_instances(request):
44+                InlineFormSet = nested_inline.get_formset(request, form.instance)
45+                prefix = "%s-%s" % (form.prefix, InlineFormSet.get_default_prefix())
46+                if request.method == 'POST':
47+                    nested_formset = InlineFormSet(request.POST, request.FILES,
48+                                                   instance=form.instance,
49+                                                   prefix=prefix, queryset=nested_inline.queryset(request))
50+                else:
51+                    nested_formset = InlineFormSet(instance=form.instance,
52+                                                   prefix=prefix, queryset=nested_inline.queryset(request))
53+                nested_formsets.append(nested_formset)
54+                if nested_inline.inlines:
55+                    self.add_nested_inline_formsets(request, nested_inline, nested_formset, depth=depth+1)
56+            form.nested_formsets = nested_formsets
57+           
58+    def wrap_nested_inline_formsets(self, request, inline, formset):
59+        media = None
60+        def get_media(extra_media):
61+            if media:
62+                return media + extra_media
63+            else:
64+                return extra_media
65+                       
66+        for form in formset.forms:
67+            wrapped_nested_formsets = []
68+            for nested_inline, nested_formset in zip(inline.get_inline_instances(request), form.nested_formsets):
69+                if form.instance.pk:
70+                    instance = form.instance
71+                else:
72+                    instance = None
73+                fieldsets = list(nested_inline.get_fieldsets(request))
74+                readonly = list(nested_inline.get_readonly_fields(request))
75+                prepopulated = dict(nested_inline.get_prepopulated_fields(request))
76+                wrapped_nested_formset = helpers.InlineAdminFormSet(nested_inline, nested_formset,
77+                                                             fieldsets, prepopulated, readonly, model_admin=self)
78+                wrapped_nested_formsets.append(wrapped_nested_formset)
79+                media = get_media(wrapped_nested_formset.media)
80+                if nested_inline.inlines:
81+                    media = get_media(self.wrap_nested_inline_formsets(request, nested_inline, nested_formset))
82+            form.nested_formsets = wrapped_nested_formsets
83+        return media
84+   
85+    def all_valid_with_nesting(self, formsets):
86+        "Recursively validate all nested formsets"
87+        if not all_valid(formsets):
88+            return False
89+        for formset in formsets:
90+            if not formset.is_bound:
91+                pass
92+            for form in formset:
93+                if hasattr(form, 'nested_formsets'):
94+                    if not self.all_valid_with_nesting(form.nested_formsets):
95+                        return False
96+                    # Here be dragons :(
97+                    if not form.cleaned_data:
98+                        form._errors["__all__"] = form.error_class([u"Parent object must be created when creating nested inlines."])
99+                        return False
100+        return True
101+           
102+
103     @csrf_protect_m
104     @transaction.commit_on_success
105     def add_view(self, request, form_url='', extra_context=None):
106@@ -952,7 +1024,9 @@ class ModelAdmin(BaseModelAdmin):
107                                   save_as_new="_saveasnew" in request.POST,
108                                   prefix=prefix, queryset=inline.queryset(request))
109                 formsets.append(formset)
110-            if all_valid(formsets) and form_validated:
111+                if inline.inlines:
112+                    self.add_nested_inline_formsets(request, inline, formset)
113+            if self.all_valid_with_nesting(formsets) and form_validated:
114                 self.save_model(request, new_object, form, False)
115                 self.save_related(request, form, formsets, False)
116                 self.log_addition(request, new_object)
117@@ -978,6 +1052,8 @@ class ModelAdmin(BaseModelAdmin):
118                 formset = FormSet(instance=self.model(), prefix=prefix,
119                                   queryset=inline.queryset(request))
120                 formsets.append(formset)
121+                if inline.inlines:
122+                    self.add_nested_inline_formsets(request, inline, formset)
123 
124         adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)),
125             self.get_prepopulated_fields(request),
126@@ -994,6 +1070,8 @@ class ModelAdmin(BaseModelAdmin):
127                 fieldsets, prepopulated, readonly, model_admin=self)
128             inline_admin_formsets.append(inline_admin_formset)
129             media = media + inline_admin_formset.media
130+            if inline.inlines:
131+                media = media + self.wrap_nested_inline_formsets(request, inline, formset)
132 
133         context = {
134             'title': _('Add %s') % force_text(opts.verbose_name),
135@@ -1047,10 +1125,11 @@ class ModelAdmin(BaseModelAdmin):
136                 formset = FormSet(request.POST, request.FILES,
137                                   instance=new_object, prefix=prefix,
138                                   queryset=inline.queryset(request))
139-
140                 formsets.append(formset)
141+                if inline.inlines:
142+                    self.add_nested_inline_formsets(request, inline, formset)
143 
144-            if all_valid(formsets) and form_validated:
145+            if self.all_valid_with_nesting(formsets) and form_validated:
146                 self.save_model(request, new_object, form, True)
147                 self.save_related(request, form, formsets, True)
148                 change_message = self.construct_change_message(request, form, formsets)
149@@ -1068,6 +1147,8 @@ class ModelAdmin(BaseModelAdmin):
150                 formset = FormSet(instance=obj, prefix=prefix,
151                                   queryset=inline.queryset(request))
152                 formsets.append(formset)
153+                if inline.inlines:
154+                    self.add_nested_inline_formsets(request, inline, formset)
155 
156         adminForm = helpers.AdminForm(form, self.get_fieldsets(request, obj),
157             self.get_prepopulated_fields(request, obj),
158@@ -1084,6 +1165,8 @@ class ModelAdmin(BaseModelAdmin):
159                 fieldsets, prepopulated, readonly, model_admin=self)
160             inline_admin_formsets.append(inline_admin_formset)
161             media = media + inline_admin_formset.media
162+            if inline.inlines:
163+                media = media + self.wrap_nested_inline_formsets(request, inline, formset)
164 
165         context = {
166             'title': _('Change %s') % force_text(opts.verbose_name),
167@@ -1358,6 +1441,7 @@ class InlineModelAdmin(BaseModelAdmin):
168     verbose_name = None
169     verbose_name_plural = None
170     can_delete = True
171+    inlines = []
172 
173     def __init__(self, parent_model, admin_site):
174         self.admin_site = admin_site
175@@ -1369,6 +1453,20 @@ class InlineModelAdmin(BaseModelAdmin):
176         if self.verbose_name_plural is None:
177             self.verbose_name_plural = self.model._meta.verbose_name_plural
178 
179+    def get_inline_instances(self, request):
180+        inline_instances = []
181+        for inline_class in self.inlines:
182+            inline = inline_class(self.model, self.admin_site)
183+            if request:
184+                if not (inline.has_add_permission(request) or
185+                        inline.has_change_permission(request) or
186+                        inline.has_delete_permission(request)):
187+                    continue
188+                if not inline.has_add_permission(request):
189+                    inline.max_num = 0
190+            inline_instances.append(inline)
191+        return inline_instances
192+
193     @property
194     def media(self):
195         extra = '' if settings.DEBUG else '.min'
196diff --git a/django/contrib/admin/static/admin/css/forms.css b/django/contrib/admin/static/admin/css/forms.css
197index efec04b..35224aa 100644
198--- a/django/contrib/admin/static/admin/css/forms.css
199+++ b/django/contrib/admin/static/admin/css/forms.css
200@@ -285,7 +285,7 @@ body.popup .submit-row {
201     color: #fff;
202 }
203 
204-.inline-group .tabular fieldset.module {
205+.inline-group .tabular > fieldset.module {
206     border: none;
207     border-bottom: 1px solid #ddd;
208 }
209@@ -358,6 +358,26 @@ body.popup .submit-row {
210     outline: 0; /* Remove dotted border around link */
211 }
212 
213+.nested-inline {
214+       margin: 10px;
215+}
216+
217+td > .nested-inline {
218+       margin: 0px;
219+}
220+
221+.nested-inline-bottom-border {
222+       border-bottom: 1px solid #DDDDDD;
223+}
224+
225+.no-bottom-border.row1 > td {
226+       border-bottom: solid #EDF3FE 1px;
227+}
228+
229+.no-bottom-border.row2 > td {
230+       border-bottom: solid white 1px;
231+}
232+
233 .empty-form {
234     display: none;
235 }
236diff --git a/django/contrib/admin/static/admin/js/inlines.js b/django/contrib/admin/static/admin/js/inlines.js
237index 4dc9459..4d1943d 100644
238--- a/django/contrib/admin/static/admin/js/inlines.js
239+++ b/django/contrib/admin/static/admin/js/inlines.js
240@@ -15,258 +15,444 @@
241  * See: http://www.opensource.org/licenses/bsd-license.php
242  */
243 (function($) {
244-  $.fn.formset = function(opts) {
245-    var options = $.extend({}, $.fn.formset.defaults, opts);
246-    var $this = $(this);
247-    var $parent = $this.parent();
248-    var updateElementIndex = function(el, prefix, ndx) {
249-      var id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))");
250-      var replacement = prefix + "-" + ndx;
251-      if ($(el).attr("for")) {
252-        $(el).attr("for", $(el).attr("for").replace(id_regex, replacement));
253-      }
254-      if (el.id) {
255-        el.id = el.id.replace(id_regex, replacement);
256-      }
257-      if (el.name) {
258-        el.name = el.name.replace(id_regex, replacement);
259-      }
260-    };
261-    var totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").attr("autocomplete", "off");
262-    var nextIndex = parseInt(totalForms.val(), 10);
263-    var maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").attr("autocomplete", "off");
264-    // only show the add button if we are allowed to add more items,
265-        // note that max_num = None translates to a blank string.
266-    var showAddButton = maxForms.val() === '' || (maxForms.val()-totalForms.val()) > 0;
267-    $this.each(function(i) {
268-      $(this).not("." + options.emptyCssClass).addClass(options.formCssClass);
269-    });
270-    if ($this.length && showAddButton) {
271-      var addButton;
272-      if ($this.attr("tagName") == "TR") {
273-        // If forms are laid out as table rows, insert the
274-        // "add" button in a new table row:
275-        var numCols = this.eq(-1).children().length;
276-        $parent.append('<tr class="' + options.addCssClass + '"><td colspan="' + numCols + '"><a href="javascript:void(0)">' + options.addText + "</a></tr>");
277-        addButton = $parent.find("tr:last a");
278-      } else {
279-        // Otherwise, insert it immediately after the last form:
280-        $this.filter(":last").after('<div class="' + options.addCssClass + '"><a href="javascript:void(0)">' + options.addText + "</a></div>");
281-        addButton = $this.filter(":last").next().find("a");
282-      }
283-      addButton.click(function(e) {
284-        e.preventDefault();
285-        var totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS");
286-        var template = $("#" + options.prefix + "-empty");
287-        var row = template.clone(true);
288-        row.removeClass(options.emptyCssClass)
289-          .addClass(options.formCssClass)
290-          .attr("id", options.prefix + "-" + nextIndex);
291-        if (row.is("tr")) {
292-          // If the forms are laid out in table rows, insert
293-          // the remove button into the last table cell:
294-          row.children(":last").append('<div><a class="' + options.deleteCssClass +'" href="javascript:void(0)">' + options.deleteText + "</a></div>");
295-        } else if (row.is("ul") || row.is("ol")) {
296-          // If they're laid out as an ordered/unordered list,
297-          // insert an <li> after the last list item:
298-          row.append('<li><a class="' + options.deleteCssClass +'" href="javascript:void(0)">' + options.deleteText + "</a></li>");
299-        } else {
300-          // Otherwise, just insert the remove button as the
301-          // last child element of the form's container:
302-          row.children(":first").append('<span><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText + "</a></span>");
303-        }
304-        row.find("*").each(function() {
305-          updateElementIndex(this, options.prefix, totalForms.val());
306-        });
307-        // Insert the new form when it has been fully edited
308-        row.insertBefore($(template));
309-        // Update number of total forms
310-        $(totalForms).val(parseInt(totalForms.val(), 10) + 1);
311-        nextIndex += 1;
312-        // Hide add button in case we've hit the max, except we want to add infinitely
313-        if ((maxForms.val() !== '') && (maxForms.val()-totalForms.val()) <= 0) {
314-          addButton.parent().hide();
315-        }
316-        // The delete button of each row triggers a bunch of other things
317-        row.find("a." + options.deleteCssClass).click(function(e) {
318-          e.preventDefault();
319-          // Remove the parent form containing this button:
320-          var row = $(this).parents("." + options.formCssClass);
321-          row.remove();
322-          nextIndex -= 1;
323-          // If a post-delete callback was provided, call it with the deleted form:
324-          if (options.removed) {
325-            options.removed(row);
326-          }
327-          // Update the TOTAL_FORMS form count.
328-          var forms = $("." + options.formCssClass);
329-          $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length);
330-          // Show add button again once we drop below max
331-          if ((maxForms.val() === '') || (maxForms.val()-forms.length) > 0) {
332-            addButton.parent().show();
333-          }
334-          // Also, update names and ids for all remaining form controls
335-          // so they remain in sequence:
336-          for (var i=0, formCount=forms.length; i<formCount; i++)
337-          {
338-            updateElementIndex($(forms).get(i), options.prefix, i);
339-            $(forms.get(i)).find("*").each(function() {
340-              updateElementIndex(this, options.prefix, i);
341-            });
342-          }
343-        });
344-        // If a post-add callback was supplied, call it with the added form:
345-        if (options.added) {
346-          options.added(row);
347-        }
348-      });
349-    }
350-    return this;
351-  };
352+       $.fn.formset = function(opts) {
353+               var options = $.extend({}, $.fn.formset.defaults, opts);
354+               var $this = $(this);
355+               var $parent = $this.parent();
356+               var updateElementIndex = function(el, prefix, ndx) {
357+                       var id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))");
358+                       var replacement = prefix + "-" + ndx;
359+                       if ($(el).attr("for")) {
360+                               $(el).attr("for", $(el).attr("for").replace(id_regex, replacement));
361+                       }
362+                       if (el.id) {
363+                               el.id = el.id.replace(id_regex, replacement);
364+                       }
365+                       if (el.name) {
366+                               el.name = el.name.replace(id_regex, replacement);
367+                       }
368+               };
369+               var nextIndex = get_no_forms(options.prefix);
370 
371-  /* Setup plugin defaults */
372-  $.fn.formset.defaults = {
373-    prefix: "form",          // The form prefix for your django formset
374-    addText: "add another",      // Text for the add link
375-    deleteText: "remove",      // Text for the delete link
376-    addCssClass: "add-row",      // CSS class applied to the add link
377-    deleteCssClass: "delete-row",  // CSS class applied to the delete link
378-    emptyCssClass: "empty-row",    // CSS class applied to the empty row
379-    formCssClass: "dynamic-form",  // CSS class applied to each form in a formset
380-    added: null,          // Function called each time a new form is added
381-    removed: null          // Function called each time a form is deleted
382-  };
383+               // Add form classes for dynamic behaviour
384+               $this.each(function(i) {
385+                       $(this).not("." + options.emptyCssClass).addClass(options.formCssClass);
386+               });
387+               // Only show the add button if we are allowed to add more items,
388+               // note that max_num = None translates to a blank string.
389+               var showAddButton = get_max_forms(options.prefix) === '' || (get_max_forms(options.prefix) - get_no_forms(options.prefix)) > 0;
390+               if ($this.length && showAddButton) {
391+                       var addButton;
392+                       if ($this.attr("tagName") == "TR") {
393+                               // If forms are laid out as table rows, insert the
394+                               // "add" button in a new table row:
395+                               var numCols = this.eq(-1).children().length;
396+                               $parent.append('<tr class="' + options.addCssClass + '"><td colspan="' + numCols + '"><a href="javascript:void(0)">' + options.addText + "</a></tr>");
397+                               addButton = $parent.find("tr:last a");
398+                       } else {
399+                               // Otherwise, insert it immediately after the last form:
400+                               $this.filter(":last").after('<div class="' + options.addCssClass + '"><a href="javascript:void(0)">' + options.addText + "</a></div>");
401+                               addButton = $this.filter(":last").next().find("a");
402+                       }
403+                       addButton.click(function(e) {
404+                               e.preventDefault();
405+                               var nextIndex = get_no_forms(options.prefix);
406+                               var template = $("#" + options.prefix + "-empty");
407+                               var row = template.clone(true);
408+                               row.removeClass(options.emptyCssClass).addClass(options.formCssClass).attr("id", options.prefix + "-" + nextIndex);
409+                               if (row.is("tr")) {
410+                                       // If the forms are laid out in table rows, insert
411+                                       // the remove button into the last table cell:
412+                                       row.children(":last").append('<div><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText + "</a></div>");
413+                               } else if (row.is("ul") || row.is("ol")) {
414+                                       // If they're laid out as an ordered/unordered list,
415+                                       // insert an <li> after the last list item:
416+                                       row.append('<li><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText + "</a></li>");
417+                               } else {
418+                                       // Otherwise, just insert the remove button as the
419+                                       // last child element of the form's container:
420+                                       row.children(":first").append('<span><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText + "</a></span>");
421+                               }
422+                               row.find("*").each(function() {
423+                                       updateElementIndex(this, options.prefix, nextIndex);
424+                               });
425+                               // when adding something from a cloned formset the id is the same
426 
427+                               // Insert the new form when it has been fully edited
428+                               row.insertBefore($(template));
429 
430-  // Tabular inlines ---------------------------------------------------------
431-  $.fn.tabularFormset = function(options) {
432-    var $rows = $(this);
433-    var alternatingRows = function(row) {
434-      $($rows.selector).not(".add-row").removeClass("row1 row2")
435-        .filter(":even").addClass("row1").end()
436-        .filter(":odd").addClass("row2");
437-    };
438+                               // Update number of total forms
439+                               change_no_forms(options.prefix, true);
440 
441-    var reinitDateTimeShortCuts = function() {
442-      // Reinitialize the calendar and clock widgets by force
443-      if (typeof DateTimeShortcuts != "undefined") {
444-        $(".datetimeshortcuts").remove();
445-        DateTimeShortcuts.init();
446-      }
447-    };
448+                               // Hide add button in case we've hit the max, except we want to add infinitely
449+                               if ((get_max_forms(options.prefix) !== '') && (get_max_forms(options.prefix) - get_no_forms(options.prefix)) <= 0) {
450+                                       addButton.parent().hide();
451+                               }
452 
453-    var updateSelectFilter = function() {
454-      // If any SelectFilter widgets are a part of the new form,
455-      // instantiate a new SelectFilter instance for it.
456-      if (typeof SelectFilter != 'undefined'){
457-        $('.selectfilter').each(function(index, value){
458-          var namearr = value.name.split('-');
459-          SelectFilter.init(value.id, namearr[namearr.length-1], false, options.adminStaticPrefix );
460-        });
461-        $('.selectfilterstacked').each(function(index, value){
462-          var namearr = value.name.split('-');
463-          SelectFilter.init(value.id, namearr[namearr.length-1], true, options.adminStaticPrefix );
464-        });
465-      }
466-    };
467+                               // The delete button of each row triggers a bunch of other things
468+                               row.find("a." + options.deleteCssClass).click(function(e) {
469+                                       e.preventDefault();
470+                                       // Find the row that will be deleted by this button
471+                                       var row = $(this).parents("." + options.formCssClass);
472+                                       // Remove the parent form containing this button:
473+                                       var formset_to_update = row.parent();
474+                                       while (row.next().hasClass('nested-inline-row')) {
475+                                               row.next().remove();
476+                                       }
477+                                       row.remove();
478+                                       change_no_forms(options.prefix, false);
479+                                       // If a post-delete callback was provided, call it with the deleted form:
480+                                       if (options.removed) {
481+                                               options.removed(formset_to_update);
482+                                       }
483 
484-    var initPrepopulatedFields = function(row) {
485-      row.find('.prepopulated_field').each(function() {
486-        var field = $(this),
487-            input = field.find('input, select, textarea'),
488-            dependency_list = input.data('dependency_list') || [],
489-            dependencies = [];
490-        $.each(dependency_list, function(i, field_name) {
491-          dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id'));
492-        });
493-        if (dependencies.length) {
494-          input.prepopulate(dependencies, input.attr('maxlength'));
495-        }
496-      });
497-    };
498+                               });
499 
500-    $rows.formset({
501-      prefix: options.prefix,
502-      addText: options.addText,
503-      formCssClass: "dynamic-" + options.prefix,
504-      deleteCssClass: "inline-deletelink",
505-      deleteText: options.deleteText,
506-      emptyCssClass: "empty-form",
507-      removed: alternatingRows,
508-      added: function(row) {
509-        initPrepopulatedFields(row);
510-        reinitDateTimeShortCuts();
511-        updateSelectFilter();
512-        alternatingRows(row);
513-      }
514-    });
515+                               if (row.is("tr")) {
516+                                       // If the forms are laid out in table rows, insert
517+                                       // the remove button into the last table cell:
518+                                       // Insert the nested formsets into the new form
519+                                       nested_formsets = create_nested_formset(options.prefix, nextIndex, options, false);
520+                                       if (nested_formsets.length) {
521+                                               row.addClass("no-bottom-border");
522+                                       }
523+                                       nested_formsets.each(function() {
524+                                               if (!$(this).next()) {
525+                                                       border_class = "";
526+                                               } else {
527+                                                       border_class = " no-bottom-border";
528+                                               }
529+                                               ($('<tr class="nested-inline-row' + border_class + '">').html(($('<td>', {
530+                                                                       colspan : '100%'
531+                                                               }).html($(this))))).insertBefore($(template));
532+                                       });
533+                               } else {
534+                                       // stacked
535+                                       // Insert the nested formsets into the new form
536+                                       nested_formsets = create_nested_formset(options.prefix, nextIndex, options, true);
537+                                       nested_formsets.each(function() {
538+                                               row.append($(this));
539+                                       });
540+                               }
541 
542-    return $rows;
543-  };
544+                               // If a post-add callback was supplied, call it with the added form:
545+                               if (options.added) {
546+                                       options.added(row);
547+                               }
548 
549-  // Stacked inlines ---------------------------------------------------------
550-  $.fn.stackedFormset = function(options) {
551-    var $rows = $(this);
552-    var updateInlineLabel = function(row) {
553-      $($rows.selector).find(".inline_label").each(function(i) {
554-        var count = i + 1;
555-        $(this).html($(this).html().replace(/(#\d+)/g, "#" + count));
556-      });
557-    };
558+                               nextIndex = nextIndex + 1;
559+                       });
560+               }
561+               return this;
562+       };
563 
564-    var reinitDateTimeShortCuts = function() {
565-      // Reinitialize the calendar and clock widgets by force, yuck.
566-      if (typeof DateTimeShortcuts != "undefined") {
567-        $(".datetimeshortcuts").remove();
568-        DateTimeShortcuts.init();
569-      }
570-    };
571+       /* Setup plugin defaults */
572+       $.fn.formset.defaults = {
573+               prefix : "form", // The form prefix for your django formset
574+               addText : "add another", // Text for the add link
575+               deleteText : "remove", // Text for the delete link
576+               addCssClass : "add-row", // CSS class applied to the add link
577+               deleteCssClass : "delete-row", // CSS class applied to the delete link
578+               emptyCssClass : "empty-row", // CSS class applied to the empty row
579+               formCssClass : "dynamic-form", // CSS class applied to each form in a formset
580+               added : null, // Function called each time a new form is added
581+               removed : null // Function called each time a form is deleted
582+       };
583 
584-    var updateSelectFilter = function() {
585-      // If any SelectFilter widgets were added, instantiate a new instance.
586-      if (typeof SelectFilter != "undefined"){
587-        $(".selectfilter").each(function(index, value){
588-          var namearr = value.name.split('-');
589-          SelectFilter.init(value.id, namearr[namearr.length-1], false, options.adminStaticPrefix);
590-        });
591-        $(".selectfilterstacked").each(function(index, value){
592-          var namearr = value.name.split('-');
593-          SelectFilter.init(value.id, namearr[namearr.length-1], true, options.adminStaticPrefix);
594-        });
595-      }
596-    };
597+       // Tabular inlines ---------------------------------------------------------
598+       $.fn.tabularFormset = function(options) {
599+               var $rows = $(this);
600+               var alternatingRows = function(row) {
601+                       row_number = 0;
602+                       $($rows.selector).not(".add-row").removeClass("row1 row2").each(function() {
603+                               $(this).addClass('row' + ((row_number%2)+1));
604+                               next = $(this).next();
605+                               while (next.hasClass('nested-inline-row')) {
606+                                       next.addClass('row' + ((row_number%2)+1));
607+                                       next = next.next();
608+                               }
609+                               row_number = row_number + 1;
610+                       });
611+               };
612 
613-    var initPrepopulatedFields = function(row) {
614-      row.find('.prepopulated_field').each(function() {
615-        var field = $(this),
616-            input = field.find('input, select, textarea'),
617-            dependency_list = input.data('dependency_list') || [],
618-            dependencies = [];
619-        $.each(dependency_list, function(i, field_name) {
620-          dependencies.push('#' + row.find('.form-row .field-' + field_name).find('input, select, textarea').attr('id'));
621-        });
622-        if (dependencies.length) {
623-          input.prepopulate(dependencies, input.attr('maxlength'));
624-        }
625-      });
626-    };
627+               var reinitDateTimeShortCuts = function() {
628+                       // Reinitialize the calendar and clock widgets by force
629+                       if ( typeof DateTimeShortcuts != "undefined") {
630+                               $(".datetimeshortcuts").remove();
631+                               DateTimeShortcuts.init();
632+                       }
633+               };
634 
635-    $rows.formset({
636-      prefix: options.prefix,
637-      addText: options.addText,
638-      formCssClass: "dynamic-" + options.prefix,
639-      deleteCssClass: "inline-deletelink",
640-      deleteText: options.deleteText,
641-      emptyCssClass: "empty-form",
642-      removed: updateInlineLabel,
643-      added: (function(row) {
644-        initPrepopulatedFields(row);
645-        reinitDateTimeShortCuts();
646-        updateSelectFilter();
647-        updateInlineLabel(row);
648-      })
649-    });
650+               var updateSelectFilter = function() {
651+                       // If any SelectFilter widgets are a part of the new form,
652+                       // instantiate a new SelectFilter instance for it.
653+                       if ( typeof SelectFilter != 'undefined') {
654+                               $('.selectfilter').each(function(index, value) {
655+                                       var namearr = value.name.split('-');
656+                                       SelectFilter.init(value.id, namearr[namearr.length - 1], false, options.adminStaticPrefix);
657+                               });
658+                               $('.selectfilterstacked').each(function(index, value) {
659+                                       var namearr = value.name.split('-');
660+                                       SelectFilter.init(value.id, namearr[namearr.length - 1], true, options.adminStaticPrefix);
661+                               });
662+                       }
663+               };
664 
665-    return $rows;
666-  };
667+               var initPrepopulatedFields = function(row) {
668+                       row.find('.prepopulated_field').each(function() {
669+                               var field = $(this), input = field.find('input, select, textarea'), dependency_list = input.data('dependency_list') || [], dependencies = [];
670+                               $.each(dependency_list, function(i, field_name) {
671+                                       dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id'));
672+                               });
673+                               if (dependencies.length) {
674+                                       input.prepopulate(dependencies, input.attr('maxlength'));
675+                               }
676+                       });
677+               };
678+
679+               $rows.formset({
680+                       prefix : options.prefix,
681+                       addText : options.addText,
682+                       formCssClass : "dynamic-" + options.prefix,
683+                       deleteCssClass : "inline-deletelink",
684+                       deleteText : options.deleteText,
685+                       emptyCssClass : "empty-form",
686+                       removed : alternatingRows,
687+                       added : function(row) {
688+                               initPrepopulatedFields(row);
689+                               reinitDateTimeShortCuts();
690+                               updateSelectFilter();
691+                               alternatingRows(row);
692+                       }
693+               });
694+
695+               return $rows;
696+       };
697+
698+       // Stacked inlines ---------------------------------------------------------
699+       $.fn.stackedFormset = function(options) {
700+               var $rows = $(this);
701+
702+               var update_inline_labels = function(formset_to_update) {
703+                       formset_to_update.children('.inline-related').not('.empty-form').children('h3').find('.inline_label').each(function(i) {
704+                               var count = i + 1;
705+                               $(this).html($(this).html().replace(/(#\d+)/g, "#" + count));
706+                       });
707+               };
708+
709+               var reinitDateTimeShortCuts = function() {
710+                       // Reinitialize the calendar and clock widgets by force, yuck.
711+                       if ( typeof DateTimeShortcuts != "undefined") {
712+                               $(".datetimeshortcuts").remove();
713+                               DateTimeShortcuts.init();
714+                       }
715+               };
716+
717+               var updateSelectFilter = function() {
718+                       // If any SelectFilter widgets were added, instantiate a new instance.
719+                       if ( typeof SelectFilter != "undefined") {
720+                               $(".selectfilter").each(function(index, value) {
721+                                       var namearr = value.name.split('-');
722+                                       SelectFilter.init(value.id, namearr[namearr.length - 1], false, options.adminStaticPrefix);
723+                               });
724+                               $(".selectfilterstacked").each(function(index, value) {
725+                                       var namearr = value.name.split('-');
726+                                       SelectFilter.init(value.id, namearr[namearr.length - 1], true, options.adminStaticPrefix);
727+                               });
728+                       }
729+               };
730+
731+               var initPrepopulatedFields = function(row) {
732+                       row.find('.prepopulated_field').each(function() {
733+                               var field = $(this), input = field.find('input, select, textarea'), dependency_list = input.data('dependency_list') || [], dependencies = [];
734+                               $.each(dependency_list, function(i, field_name) {
735+                                       dependencies.push('#' + row.find('.form-row .field-' + field_name).find('input, select, textarea').attr('id'));
736+                               });
737+                               if (dependencies.length) {
738+                                       input.prepopulate(dependencies, input.attr('maxlength'));
739+                               }
740+                       });
741+               };
742+
743+               $rows.formset({
744+                       prefix : options.prefix,
745+                       addText : options.addText,
746+                       formCssClass : "dynamic-" + options.prefix,
747+                       deleteCssClass : "inline-deletelink",
748+                       deleteText : options.deleteText,
749+                       emptyCssClass : "empty-form",
750+                       removed : update_inline_labels,
751+                       added : (function(row) {
752+                               initPrepopulatedFields(row);
753+                               reinitDateTimeShortCuts();
754+                               updateSelectFilter();
755+                               update_inline_labels(row.parent());
756+                       })
757+               });
758+
759+               return $rows;
760+       };
761+
762+       function create_nested_formset(parent_formset_prefix, next_form_id, options, add_bottom_border) {
763+               var formsets = $(false);
764+               // update options
765+               // Normalize prefix to something we can rely on
766+               var normalized_parent_formset_prefix = parent_formset_prefix.replace(/[-][0-9][-]/g, "-0-");
767+               // Check if the form should have nested formsets
768+               var nested_inlines = $('#' + normalized_parent_formset_prefix + "-group ." + normalized_parent_formset_prefix + "-nested-inline").not('.cloned');
769+               nested_inlines.each(function() {
770+                       // prefixes for the nested formset
771+                       var normalized_formset_prefix = $(this).attr('id').split('-group')[0];
772+                       // = "parent_formset_prefix"-0-"nested_inline_name"_set
773+                       var formset_prefix = normalized_formset_prefix.replace(normalized_parent_formset_prefix + "-0", parent_formset_prefix + "-" + next_form_id);
774+                       // = "parent_formset_prefix"-"next_form_id"-"nested_inline_name"_set
775+                       // Find the normalized formset and clone it
776+                       var template = $("#" + normalized_formset_prefix + "-group").clone();
777+                       template.addClass('cloned');
778+                       if (template.children().first().hasClass('tabular')) {
779+                               // Template is tabular
780+                               template.find(".form-row").not(".empty-form").remove();
781+                               template.find(".nested-inline-row").remove();
782+                               // Make a new form
783+                               template_form = template.find("#" + normalized_formset_prefix + "-empty")
784+                               new_form = template_form.clone().removeClass(options.emptyCssClass).addClass("dynamic-" + formset_prefix);
785+                               new_form.insertBefore(template_form);
786+                               // Update Form Properties
787+                               template.find('#id_' + formset_prefix + '-TOTAL_FORMS').val(1);
788+                               update_props(template, normalized_formset_prefix, formset_prefix);
789+                               var add_text = template.find('.add-row').text();
790+                               template.find('.add-row').remove();
791+                               template.find('.tabular.inline-related tbody tr.' + formset_prefix + '-not-nested').tabularFormset({
792+                                       prefix : formset_prefix,
793+                                       adminStaticPrefix : options.adminStaticPrefix,
794+                                       addText : add_text,
795+                                       deleteText : options.deleteText
796+                               });
797+                               // Create the nested formset
798+                               var nested_formsets = create_nested_formset(formset_prefix, 0, options, false);
799+                               if (nested_formsets.length) {
800+                                       template.find(".form-row").addClass('no-bottom-border');
801+                               }
802+                               // Insert nested formsets
803+                               nested_formsets.each(function() {
804+                                       if (!$(this).next()) {
805+                                               border_class = "";
806+                                       } else {
807+                                               border_class = " no-bottom-border";
808+                                       }
809+                                       template.find("#" + formset_prefix + "-empty").before(($('<tr class="nested-inline-row' + border_class + '">').html(($('<td>', {
810+                                                               colspan : '100%'
811+                                                       }).html($(this))))));
812+                               });
813+                       } else {
814+                               // Template is stacked
815+                               // Create the nested formset
816+                               var nested_formsets = create_nested_formset(formset_prefix, 0, options, true);
817+                               template.find(".inline-related").not(".empty-form").remove();
818+                               // Make a new form
819+                               template_form = template.find("#" + normalized_formset_prefix + "-empty")
820+                               new_form = template_form.clone().removeClass(options.emptyCssClass).addClass("dynamic-" + formset_prefix);
821+                               new_form.insertBefore(template_form);
822+                               // Update Form Properties
823+                               template.find('#id_' + normalized_formset_prefix + '-TOTAL_FORMS').val(1);
824+                               new_form.find('.inline_label').text('#1');
825+                               update_props(template, normalized_formset_prefix, formset_prefix);
826+                               var add_text = template.find('.add-row').text();
827+                               template.find('.add-row').remove();
828+                               template.find(".inline-related").stackedFormset({
829+                                       prefix : formset_prefix,
830+                                       adminStaticPrefix : options.adminStaticPrefix,
831+                                       addText : add_text,
832+                                       deleteText : options.deleteText
833+                               });
834+                               nested_formsets.each(function() {
835+                                       new_form.append($(this));
836+                               });
837+                       }
838+                       if (add_bottom_border) {
839+                               template = template.add($('<div class="nested-inline-bottom-border">'));
840+                       }
841+                       if (formsets.length) {
842+                               formsets = formsets.add(template);
843+                       } else {
844+                               formsets = template;
845+                       }
846+               });
847+               return formsets;
848+       };
849+
850+       function update_props(template, normalized_formset_prefix, formset_prefix) {
851+               // Fix template id
852+               template.attr('id', template.attr('id').replace(normalized_formset_prefix, formset_prefix));
853+               template.find('*').each(function() {
854+                       if ($(this).attr("for")) {
855+                               $(this).attr("for", $(this).attr("for").replace(normalized_formset_prefix, formset_prefix));
856+                       }
857+                       if ($(this).attr("class")) {
858+                               $(this).attr("class", $(this).attr("class").replace(normalized_formset_prefix, formset_prefix));
859+                       }
860+                       if (this.id) {
861+                               this.id = this.id.replace(normalized_formset_prefix, formset_prefix);
862+                       }
863+                       if (this.name) {
864+                               this.name = this.name.replace(normalized_formset_prefix, formset_prefix);
865+                       }
866+               });
867+               // fix __prefix__ where needed
868+               prefix_fix = template.find(".inline-related").first();
869+               nextIndex = get_no_forms(formset_prefix);
870+               if (prefix_fix.hasClass('tabular')) {
871+                       // tabular
872+                       prefix_fix = prefix_fix.find('.form-row').first();
873+                       prefix_fix.attr('id', prefix_fix.attr('id').replace('-empty', '-' + nextIndex));
874+               } else {
875+                       // stacked
876+                       prefix_fix.attr('id', prefix_fix.attr('id').replace('-empty', '-' + nextIndex));
877+               }
878+               prefix_fix.find('*').each(function() {
879+                       if ($(this).attr("for")) {
880+                               $(this).attr("for", $(this).attr("for").replace('__prefix__', '0'));
881+                       }
882+                       if ($(this).attr("class")) {
883+                               $(this).attr("class", $(this).attr("class").replace('__prefix__', '0'));
884+                       }
885+                       if (this.id) {
886+                               this.id = this.id.replace('__prefix__', '0');
887+                       }
888+                       if (this.name) {
889+                               this.name = this.name.replace('__prefix__', '0');
890+                       }
891+               });
892+       };
893+
894+       // This returns the amount of forms in the given formset
895+       function get_no_forms(formset_prefix) {
896+               formset_prop = $("#id_" + formset_prefix + "-TOTAL_FORMS")
897+               if (!formset_prop.length) {
898+                       return 0;
899+               }
900+               return parseInt(formset_prop.attr("autocomplete", "off").val());
901+       }
902+
903+       function change_no_forms(formset_prefix, increase) {
904+               var no_forms = get_no_forms(formset_prefix);
905+               if (increase) {
906+                       $("#id_" + formset_prefix + "-TOTAL_FORMS").attr("autocomplete", "off").val(parseInt(no_forms) + 1);
907+               } else {
908+                       $("#id_" + formset_prefix + "-TOTAL_FORMS").attr("autocomplete", "off").val(parseInt(no_forms) - 1);
909+               }
910+       };
911+
912+       // This return the maximum amount of forms in the given formset
913+       function get_max_forms(formset_prefix) {
914+               var max_forms = $("#id_" + formset_prefix + "-MAX_FORMS").attr("autocomplete", "off").val();
915+               if ( typeof max_forms == 'undefined') {
916+                       return '';
917+               }
918+               return parseInt(max_forms);
919+       };
920 })(django.jQuery);
921+
922+// TODO:
923+// Remove border between tabular fieldset and nested inline
924+// Fix alternating rows
925diff --git a/django/contrib/admin/static/admin/js/inlines.min.js b/django/contrib/admin/static/admin/js/inlines.min.js
926index d48ee0a..c2fec35 100644
927--- a/django/contrib/admin/static/admin/js/inlines.min.js
928+++ b/django/contrib/admin/static/admin/js/inlines.min.js
929@@ -1,9 +1,15 @@
930-(function(b){b.fn.formset=function(d){var a=b.extend({},b.fn.formset.defaults,d),c=b(this),d=c.parent(),i=function(a,e,g){var d=RegExp("("+e+"-(\\d+|__prefix__))"),e=e+"-"+g;b(a).attr("for")&&b(a).attr("for",b(a).attr("for").replace(d,e));a.id&&(a.id=a.id.replace(d,e));a.name&&(a.name=a.name.replace(d,e))},f=b("#id_"+a.prefix+"-TOTAL_FORMS").attr("autocomplete","off"),g=parseInt(f.val(),10),e=b("#id_"+a.prefix+"-MAX_NUM_FORMS").attr("autocomplete","off"),f=""===e.val()||0<e.val()-f.val();c.each(function(){b(this).not("."+
931-a.emptyCssClass).addClass(a.formCssClass)});if(c.length&&f){var h;"TR"==c.attr("tagName")?(c=this.eq(-1).children().length,d.append('<tr class="'+a.addCssClass+'"><td colspan="'+c+'"><a href="javascript:void(0)">'+a.addText+"</a></tr>"),h=d.find("tr:last a")):(c.filter(":last").after('<div class="'+a.addCssClass+'"><a href="javascript:void(0)">'+a.addText+"</a></div>"),h=c.filter(":last").next().find("a"));h.click(function(d){d.preventDefault();var f=b("#id_"+a.prefix+"-TOTAL_FORMS"),d=b("#"+a.prefix+
932-"-empty"),c=d.clone(true);c.removeClass(a.emptyCssClass).addClass(a.formCssClass).attr("id",a.prefix+"-"+g);c.is("tr")?c.children(":last").append('<div><a class="'+a.deleteCssClass+'" href="javascript:void(0)">'+a.deleteText+"</a></div>"):c.is("ul")||c.is("ol")?c.append('<li><a class="'+a.deleteCssClass+'" href="javascript:void(0)">'+a.deleteText+"</a></li>"):c.children(":first").append('<span><a class="'+a.deleteCssClass+'" href="javascript:void(0)">'+a.deleteText+"</a></span>");c.find("*").each(function(){i(this,
933-a.prefix,f.val())});c.insertBefore(b(d));b(f).val(parseInt(f.val(),10)+1);g=g+1;e.val()!==""&&e.val()-f.val()<=0&&h.parent().hide();c.find("a."+a.deleteCssClass).click(function(d){d.preventDefault();d=b(this).parents("."+a.formCssClass);d.remove();g=g-1;a.removed&&a.removed(d);d=b("."+a.formCssClass);b("#id_"+a.prefix+"-TOTAL_FORMS").val(d.length);(e.val()===""||e.val()-d.length>0)&&h.parent().show();for(var c=0,f=d.length;c<f;c++){i(b(d).get(c),a.prefix,c);b(d.get(c)).find("*").each(function(){i(this,
934-a.prefix,c)})}});a.added&&a.added(c)})}return this};b.fn.formset.defaults={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(d){var a=b(this),c=function(){b(a.selector).not(".add-row").removeClass("row1 row2").filter(":even").addClass("row1").end().filter(":odd").addClass("row2")};a.formset({prefix:d.prefix,addText:d.addText,formCssClass:"dynamic-"+
935-d.prefix,deleteCssClass:"inline-deletelink",deleteText:d.deleteText,emptyCssClass:"empty-form",removed:c,added:function(a){a.find(".prepopulated_field").each(function(){var d=b(this).find("input, select, textarea"),c=d.data("dependency_list")||[],e=[];b.each(c,function(d,b){e.push("#"+a.find(".field-"+b).find("input, select, textarea").attr("id"))});e.length&&d.prepopulate(e,d.attr("maxlength"))});"undefined"!=typeof DateTimeShortcuts&&(b(".datetimeshortcuts").remove(),DateTimeShortcuts.init());"undefined"!=
936-typeof SelectFilter&&(b(".selectfilter").each(function(a,b){var c=b.name.split("-");SelectFilter.init(b.id,c[c.length-1],false,d.adminStaticPrefix)}),b(".selectfilterstacked").each(function(a,b){var c=b.name.split("-");SelectFilter.init(b.id,c[c.length-1],true,d.adminStaticPrefix)}));c(a)}});return a};b.fn.stackedFormset=function(d){var a=b(this),c=function(){b(a.selector).find(".inline_label").each(function(a){a+=1;b(this).html(b(this).html().replace(/(#\d+)/g,"#"+a))})};a.formset({prefix:d.prefix,
937-addText:d.addText,formCssClass:"dynamic-"+d.prefix,deleteCssClass:"inline-deletelink",deleteText:d.deleteText,emptyCssClass:"empty-form",removed:c,added:function(a){a.find(".prepopulated_field").each(function(){var d=b(this).find("input, select, textarea"),c=d.data("dependency_list")||[],e=[];b.each(c,function(d,b){e.push("#"+a.find(".form-row .field-"+b).find("input, select, textarea").attr("id"))});e.length&&d.prepopulate(e,d.attr("maxlength"))});"undefined"!=typeof DateTimeShortcuts&&(b(".datetimeshortcuts").remove(),
938-DateTimeShortcuts.init());"undefined"!=typeof SelectFilter&&(b(".selectfilter").each(function(a,b){var c=b.name.split("-");SelectFilter.init(b.id,c[c.length-1],false,d.adminStaticPrefix)}),b(".selectfilterstacked").each(function(a,b){var c=b.name.split("-");SelectFilter.init(b.id,c[c.length-1],true,d.adminStaticPrefix)}));c(a)}});return a}})(django.jQuery);
939+(function(b){function j(d,a,e,m){var h=b(!1),n=d.replace(/[-][0-9][-]/g,"-0-");b("#"+n+"-group ."+n+"-nested-inline").not(".cloned").each(function(){var f=b(this).attr("id").split("-group")[0],g=f.replace(n+"-0",d+"-"+a),c=b("#"+f+"-group").clone();c.addClass("cloned");if(c.children().first().hasClass("tabular")){c.find(".form-row").not(".empty-form").remove();c.find(".nested-inline-row").remove();template_form=c.find("#"+f+"-empty");new_form=template_form.clone().removeClass(e.emptyCssClass).addClass("dynamic-"+
940+g);new_form.insertBefore(template_form);c.find("#id_"+g+"-TOTAL_FORMS").val(1);p(c,f,g);f=c.find(".add-row").text();c.find(".add-row").remove();c.find(".tabular.inline-related tbody tr."+g+"-not-nested").tabularFormset({prefix:g,adminStaticPrefix:e.adminStaticPrefix,addText:f,deleteText:e.deleteText});var k=j(g,0,e,!1);k.length&&c.find(".form-row").addClass("no-bottom-border");k.each(function(){border_class=b(this).next()?" no-bottom-border":"";c.find("#"+g+"-empty").before(b('<tr class="nested-inline-row'+
941+border_class+'">').html(b("<td>",{colspan:"100%"}).html(b(this))))})}else k=j(g,0,e,!0),c.find(".inline-related").not(".empty-form").remove(),template_form=c.find("#"+f+"-empty"),new_form=template_form.clone().removeClass(e.emptyCssClass).addClass("dynamic-"+g),new_form.insertBefore(template_form),c.find("#id_"+f+"-TOTAL_FORMS").val(1),new_form.find(".inline_label").text("#1"),p(c,f,g),f=c.find(".add-row").text(),c.find(".add-row").remove(),c.find(".inline-related").stackedFormset({prefix:g,adminStaticPrefix:e.adminStaticPrefix,
942+addText:f,deleteText:e.deleteText}),k.each(function(){new_form.append(b(this))});m&&(c=c.add(b('<div class="nested-inline-bottom-border">')));h=h.length?h.add(c):c});return h}function p(d,a,e){d.attr("id",d.attr("id").replace(a,e));d.find("*").each(function(){b(this).attr("for")&&b(this).attr("for",b(this).attr("for").replace(a,e));b(this).attr("class")&&b(this).attr("class",b(this).attr("class").replace(a,e));this.id&&(this.id=this.id.replace(a,e));this.name&&(this.name=this.name.replace(a,e))});
943+prefix_fix=d.find(".inline-related").first();nextIndex=i(e);prefix_fix.hasClass("tabular")&&(prefix_fix=prefix_fix.find(".form-row").first());prefix_fix.attr("id",prefix_fix.attr("id").replace("-empty","-"+nextIndex));prefix_fix.find("*").each(function(){b(this).attr("for")&&b(this).attr("for",b(this).attr("for").replace("__prefix__","0"));b(this).attr("class")&&b(this).attr("class",b(this).attr("class").replace("__prefix__","0"));this.id&&(this.id=this.id.replace("__prefix__","0"));this.name&&(this.name=
944+this.name.replace("__prefix__","0"))})}function i(d){formset_prop=b("#id_"+d+"-TOTAL_FORMS");return!formset_prop.length?0:parseInt(formset_prop.attr("autocomplete","off").val())}function q(d,a){var e=i(d);a?b("#id_"+d+"-TOTAL_FORMS").attr("autocomplete","off").val(parseInt(e)+1):b("#id_"+d+"-TOTAL_FORMS").attr("autocomplete","off").val(parseInt(e)-1)}function l(d){d=b("#id_"+d+"-MAX_FORMS").attr("autocomplete","off").val();return"undefined"==typeof d?"":parseInt(d)}b.fn.formset=function(d){var a=
945+b.extend({},b.fn.formset.defaults,d),e=b(this),d=e.parent();i(a.prefix);e.each(function(){b(this).not("."+a.emptyCssClass).addClass(a.formCssClass)});var m=""===l(a.prefix)||0<l(a.prefix)-i(a.prefix);if(e.length&&m){var h;"TR"==e.attr("tagName")?(e=this.eq(-1).children().length,d.append('<tr class="'+a.addCssClass+'"><td colspan="'+e+'"><a href="javascript:void(0)">'+a.addText+"</a></tr>"),h=d.find("tr:last a")):(e.filter(":last").after('<div class="'+a.addCssClass+'"><a href="javascript:void(0)">'+
946+a.addText+"</a></div>"),h=e.filter(":last").next().find("a"));h.click(function(d){d.preventDefault();var f=i(a.prefix),e=b("#"+a.prefix+"-empty"),c=e.clone(!0);c.removeClass(a.emptyCssClass).addClass(a.formCssClass).attr("id",a.prefix+"-"+f);c.is("tr")?c.children(":last").append('<div><a class="'+a.deleteCssClass+'" href="javascript:void(0)">'+a.deleteText+"</a></div>"):c.is("ul")||c.is("ol")?c.append('<li><a class="'+a.deleteCssClass+'" href="javascript:void(0)">'+a.deleteText+"</a></li>"):c.children(":first").append('<span><a class="'+
947+a.deleteCssClass+'" href="javascript:void(0)">'+a.deleteText+"</a></span>");c.find("*").each(function(){var c=a.prefix,d=RegExp("("+c+"-(\\d+|__prefix__))"),c=c+"-"+f;b(this).attr("for")&&b(this).attr("for",b(this).attr("for").replace(d,c));this.id&&(this.id=this.id.replace(d,c));this.name&&(this.name=this.name.replace(d,c))});c.insertBefore(b(e));q(a.prefix,!0);""!==l(a.prefix)&&0>=l(a.prefix)-i(a.prefix)&&h.parent().hide();c.find("a."+a.deleteCssClass).click(function(c){c.preventDefault();for(var c=
948+b(this).parents("."+a.formCssClass),d=c.parent();c.next().hasClass("nested-inline-row");)c.next().remove();c.remove();q(a.prefix,!1);a.removed&&a.removed(d)});c.is("tr")?(nested_formsets=j(a.prefix,f,a,!1),nested_formsets.length&&c.addClass("no-bottom-border"),nested_formsets.each(function(){border_class=b(this).next()?" no-bottom-border":"";b('<tr class="nested-inline-row'+border_class+'">').html(b("<td>",{colspan:"100%"}).html(b(this))).insertBefore(b(e))})):(nested_formsets=j(a.prefix,f,a,!0),
949+nested_formsets.each(function(){c.append(b(this))}));a.added&&a.added(c);f+=1})}return this};b.fn.formset.defaults={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(d){var a=b(this),e=function(){row_number=0;b(a.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"+
950+(row_number%2+1)),next=next.next();row_number+=1})};a.formset({prefix:d.prefix,addText:d.addText,formCssClass:"dynamic-"+d.prefix,deleteCssClass:"inline-deletelink",deleteText:d.deleteText,emptyCssClass:"empty-form",removed:e,added:function(a){a.find(".prepopulated_field").each(function(){var d=b(this).find("input, select, textarea"),e=d.data("dependency_list")||[],f=[];b.each(e,function(b,c){f.push("#"+a.find(".field-"+c).find("input, select, textarea").attr("id"))});f.length&&d.prepopulate(f,d.attr("maxlength"))});
951+"undefined"!=typeof DateTimeShortcuts&&(b(".datetimeshortcuts").remove(),DateTimeShortcuts.init());"undefined"!=typeof SelectFilter&&(b(".selectfilter").each(function(b,a){var e=a.name.split("-");SelectFilter.init(a.id,e[e.length-1],!1,d.adminStaticPrefix)}),b(".selectfilterstacked").each(function(b,a){var e=a.name.split("-");SelectFilter.init(a.id,e[e.length-1],!0,d.adminStaticPrefix)}));e(a)}});return a};b.fn.stackedFormset=function(d){var a=b(this),e=function(a){a.children(".inline-related").not(".empty-form").children("h3").find(".inline_label").each(function(a){a+=
952+1;b(this).html(b(this).html().replace(/(#\d+)/g,"#"+a))})};a.formset({prefix:d.prefix,addText:d.addText,formCssClass:"dynamic-"+d.prefix,deleteCssClass:"inline-deletelink",deleteText:d.deleteText,emptyCssClass:"empty-form",removed:e,added:function(a){a.find(".prepopulated_field").each(function(){var d=b(this).find("input, select, textarea"),e=d.data("dependency_list")||[],f=[];b.each(e,function(b,c){f.push("#"+a.find(".form-row .field-"+c).find("input, select, textarea").attr("id"))});f.length&&d.prepopulate(f,
953+d.attr("maxlength"))});"undefined"!=typeof DateTimeShortcuts&&(b(".datetimeshortcuts").remove(),DateTimeShortcuts.init());"undefined"!=typeof SelectFilter&&(b(".selectfilter").each(function(a,b){var e=b.name.split("-");SelectFilter.init(b.id,e[e.length-1],!1,d.adminStaticPrefix)}),b(".selectfilterstacked").each(function(a,b){var e=b.name.split("-");SelectFilter.init(b.id,e[e.length-1],!0,d.adminStaticPrefix)}));e(a.parent())}});return a}})(django.jQuery);
954diff --git a/django/contrib/admin/templates/admin/edit_inline/stacked.html b/django/contrib/admin/templates/admin/edit_inline/stacked.html
955index 2025dd8..c0c5389 100644
956--- a/django/contrib/admin/templates/admin/edit_inline/stacked.html
957+++ b/django/contrib/admin/templates/admin/edit_inline/stacked.html
958@@ -1,13 +1,14 @@
959 {% load i18n admin_static %}
960-<div class="inline-group" id="{{ inline_admin_formset.formset.prefix }}-group">
961-  <h2>{{ inline_admin_formset.opts.verbose_name_plural|title }}</h2>
962-{{ inline_admin_formset.formset.management_form }}
963-{{ inline_admin_formset.formset.non_form_errors }}
964+<div class="inline-group{% if recursive_formset %} {{ recursive_formset.formset.prefix|default:"Root" }}-nested-inline nested-inline{% endif %}" id="{{ inline_admin_formset.formset.prefix }}-group">
965+{% with recursive_formset=inline_admin_formset stacked_template='admin/edit_inline/stacked.html' tabular_template='admin/edit_inline/tabular.html'%}
966+  <h2>{{ recursive_formset.opts.verbose_name_plural|title }}</h2>
967+{{ recursive_formset.formset.management_form }}
968+{{ recursive_formset.formset.non_form_errors }}
969 
970-{% for inline_admin_form in inline_admin_formset %}<div class="inline-related{% if forloop.last %} empty-form last-related{% endif %}" id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
971-  <h3><b>{{ inline_admin_formset.opts.verbose_name|title }}:</b>&nbsp;<span class="inline_label">{% if inline_admin_form.original %}{{ inline_admin_form.original }}{% else %}#{{ forloop.counter }}{% endif %}</span>
972+{% for inline_admin_form in recursive_formset %}<div class="inline-related{% if forloop.last %} empty-form last-related{% endif %}" id="{{ recursive_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
973+  <h3><b>{{ recursive_formset.opts.verbose_name|title }}:</b>&nbsp;<span class="inline_label">{% if inline_admin_form.original %}{{ inline_admin_form.original }}{% else %}#{{ forloop.counter }}{% endif %}</span>
974     {% if inline_admin_form.show_url %}<a href="{% url 'admin:view_on_site' inline_admin_form.original_content_type_id inline_admin_form.original.pk %}">{% trans "View on site" %}</a>{% endif %}
975-    {% if inline_admin_formset.formset.can_delete and inline_admin_form.original %}<span class="delete">{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}</span>{% endif %}
976+    {% if recursive_formset.formset.can_delete and inline_admin_form.original %}<span class="delete">{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}</span>{% endif %}
977   </h3>
978   {% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %}
979   {% for fieldset in inline_admin_form %}
980@@ -15,16 +16,27 @@
981   {% endfor %}
982   {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
983   {{ inline_admin_form.fk_field.field }}
984+  {% if inline_admin_form.form.nested_formsets %}
985+    {% for inline_admin_formset in inline_admin_form.form.nested_formsets %}
986+         {% if inline_admin_formset.opts.template == stacked_template %}
987+        {% include stacked_template %}
988+         {% else %}
989+               {% include tabular_template %}
990+         {% endif %}
991+         <div class="nested-inline-bottom-border"></div>
992+    {% endfor %}
993+  {% endif %}
994 </div>{% endfor %}
995 </div>
996 
997 <script type="text/javascript">
998 (function($) {
999-  $("#{{ inline_admin_formset.formset.prefix }}-group .inline-related").stackedFormset({
1000-    prefix: '{{ inline_admin_formset.formset.prefix }}',
1001+  $("#{{ recursive_formset.formset.prefix }}-group > .inline-related").stackedFormset({
1002+    prefix: '{{ recursive_formset.formset.prefix }}',
1003     adminStaticPrefix: '{% static "admin/" %}',
1004-    deleteText: "{% trans "Remove" %}",
1005-    addText: "{% blocktrans with verbose_name=inline_admin_formset.opts.verbose_name|title %}Add another {{ verbose_name }}{% endblocktrans %}"
1006+    addText: "{% blocktrans with verbose_name=recursive_formset.opts.verbose_name|title %}Add another {{ verbose_name }}{% endblocktrans %}",
1007+    deleteText: "{% trans "Remove" %}"
1008   });
1009 })(django.jQuery);
1010 </script>
1011+{% endwith %}
1012diff --git a/django/contrib/admin/templates/admin/edit_inline/tabular.html b/django/contrib/admin/templates/admin/edit_inline/tabular.html
1013index f2757ed..1b0de0e 100644
1014--- a/django/contrib/admin/templates/admin/edit_inline/tabular.html
1015+++ b/django/contrib/admin/templates/admin/edit_inline/tabular.html
1016@@ -1,29 +1,30 @@
1017 {% load i18n admin_static admin_modify %}
1018-<div class="inline-group" id="{{ inline_admin_formset.formset.prefix }}-group">
1019+<div class="inline-group{% if recursive_formset %} {{ recursive_formset.formset.prefix|default:"Root" }}-nested-inline nested-inline{% endif %}" id="{{ inline_admin_formset.formset.prefix }}-group">
1020+{% with recursive_formset=inline_admin_formset stacked_template='admin/edit_inline/stacked.html' tabular_template='admin/edit_inline/tabular.html'%}
1021   <div class="tabular inline-related {% if forloop.last %}last-related{% endif %}">
1022-{{ inline_admin_formset.formset.management_form }}
1023+{{ recursive_formset.formset.management_form }}
1024 <fieldset class="module">
1025-   <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
1026-   {{ inline_admin_formset.formset.non_form_errors }}
1027+   <h2>{{ recursive_formset.opts.verbose_name_plural|capfirst }}</h2>
1028+   {{ recursive_formset.formset.non_form_errors }}
1029    <table>
1030      <thead><tr>
1031-     {% for field in inline_admin_formset.fields %}
1032+     {% for field in recursive_formset.fields %}
1033        {% if not field.widget.is_hidden %}
1034          <th{% if forloop.first %} colspan="2"{% endif %}{% if field.required %} class="required"{% endif %}>{{ field.label|capfirst }}
1035          {% if field.help_text %}&nbsp;<img src="{% static "admin/img/icon-unknown.gif" %}" class="help help-tooltip" width="10" height="10" alt="({{ field.help_text|striptags }})" title="{{ field.help_text|striptags }}" />{% endif %}
1036          </th>
1037        {% endif %}
1038      {% endfor %}
1039-     {% if inline_admin_formset.formset.can_delete %}<th>{% trans "Delete?" %}</th>{% endif %}
1040+     {% if recursive_formset.formset.can_delete %}<th>{% trans "Delete?" %}</th>{% endif %}
1041      </tr></thead>
1042 
1043      <tbody>
1044-     {% for inline_admin_form in inline_admin_formset %}
1045+     {% for inline_admin_form in recursive_formset %}
1046         {% if inline_admin_form.form.non_field_errors %}
1047         <tr><td colspan="{{ inline_admin_form|cell_count }}">{{ inline_admin_form.form.non_field_errors }}</td></tr>
1048         {% endif %}
1049-        <tr class="form-row {% cycle "row1" "row2" %} {% if inline_admin_form.original or inline_admin_form.show_url %}has_original{% endif %}{% if forloop.last %} empty-form{% endif %}"
1050-             id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
1051+        <tr class="form-row {% cycle "row1" "row2" as row_number_class %} {% if inline_admin_form.original or inline_admin_form.show_url %}has_original{% endif %}{% if forloop.last %} empty-form{% endif %} {{ recursive_formset.formset.prefix }}-not-nested {% if inline_admin_form.form.nested_formsets %} no-bottom-border {% endif %}"
1052+             id="{{ recursive_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
1053         <td class="original">
1054           {% if inline_admin_form.original or inline_admin_form.show_url %}<p>
1055           {% if inline_admin_form.original %} {{ inline_admin_form.original }}{% endif %}
1056@@ -55,10 +56,23 @@
1057             {% endfor %}
1058           {% endfor %}
1059         {% endfor %}
1060-        {% if inline_admin_formset.formset.can_delete %}
1061+        {% if recursive_formset.formset.can_delete %}
1062           <td class="delete">{% if inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }}{% endif %}</td>
1063         {% endif %}
1064         </tr>
1065+        {% if inline_admin_form.form.nested_formsets %}
1066+                 {% for inline_admin_formset in inline_admin_form.form.nested_formsets %}
1067+                 <tr class="nested-inline-row {{ row_number_class }}{% if not forloop.last %} no-bottom-border{% endif %}">
1068+                   <td colspan="0">
1069+                         {% if inline_admin_formset.opts.template == stacked_template %}
1070+                           {% include stacked_template with indent=0 prev_prefix=recursive_formset.formset.prefix %}
1071+                         {% else %}
1072+                               {% include tabular_template with indent=0 prev_prefix=recursive_formset.formset.prefix %}
1073+                         {% endif %}
1074+                       </td>
1075+                 </tr>
1076+         {% endfor %}
1077+               {% endif %}
1078      {% endfor %}
1079      </tbody>
1080    </table>
1081@@ -67,13 +81,13 @@
1082 </div>
1083 
1084 <script type="text/javascript">
1085-
1086 (function($) {
1087-  $("#{{ inline_admin_formset.formset.prefix }}-group .tabular.inline-related tbody tr").tabularFormset({
1088-    prefix: "{{ inline_admin_formset.formset.prefix }}",
1089+  $("#{{ recursive_formset.formset.prefix }}-group .tabular.inline-related tbody tr.{{ recursive_formset.formset.prefix }}-not-nested").tabularFormset({
1090+    prefix: "{{ recursive_formset.formset.prefix }}",
1091     adminStaticPrefix: '{% static "admin/" %}',
1092-    addText: "{% blocktrans with inline_admin_formset.opts.verbose_name|title as verbose_name %}Add another {{ verbose_name }}{% endblocktrans %}",
1093+    addText: "{% blocktrans with recursive_formset.opts.verbose_name|title as verbose_name %}Add another {{ verbose_name }}{% endblocktrans %}",
1094     deleteText: "{% trans 'Remove' %}"
1095   });
1096 })(django.jQuery);
1097 </script>
1098+{% endwith %}
1099diff --git a/django/contrib/admin/tests.py b/django/contrib/admin/tests.py
1100index 7c62c1a..6fe6216 100644
1101--- a/django/contrib/admin/tests.py
1102+++ b/django/contrib/admin/tests.py
1103@@ -2,6 +2,7 @@ from django.test import LiveServerTestCase
1104 from django.utils.importlib import import_module
1105 from django.utils.unittest import SkipTest
1106 from django.utils.translation import ugettext as _
1107+from selenium import webdriver
1108 
1109 class AdminSeleniumWebDriverTestCase(LiveServerTestCase):
1110     webdriver_class = 'selenium.webdriver.firefox.webdriver.WebDriver'
1111@@ -13,6 +14,7 @@ class AdminSeleniumWebDriverTestCase(LiveServerTestCase):
1112             module, attr = cls.webdriver_class.rsplit('.', 1)
1113             mod = import_module(module)
1114             WebDriver = getattr(mod, attr)
1115+            #Avoid startup screen
1116             cls.selenium = WebDriver()
1117         except Exception as e:
1118             raise SkipTest('Selenium webdriver "%s" not installed or not '
1119diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
1120index 06751df..b610616 100644
1121--- a/docs/ref/contrib/admin/index.txt
1122+++ b/docs/ref/contrib/admin/index.txt
1123@@ -1369,7 +1369,27 @@ information.
1124 
1125     The difference between these two is merely the template used to render
1126     them.
1127+   
1128+    .. versionadded:: 1.5
1129+   
1130+    You can also display inlines inside an inline. Suppose you have a third model::
1131+       
1132+         class Chapter(models.Model):
1133+            name = models.CharField(max_length=100)
1134+            book = models.ForeignKey(Book)
1135+
1136+    These inlines work exactly the same as normal inlines, but they are specified
1137+    inside a ``InlineModelAdmin.inlines``::
1138 
1139+        class BookInline(admin.TabularInline):
1140+           model = Book
1141+           inlines = [
1142+               ChapterInline,
1143+           ]
1144+
1145+        class ChapterInline(admin.StackedInline):
1146+          model = Chapter
1147+       
1148 ``InlineModelAdmin`` options
1149 -----------------------------
1150 
1151@@ -1399,6 +1419,10 @@ adds some of its own (the shared features are actually defined in the
1152 - :meth:`~ModelAdmin.has_change_permission`
1153 - :meth:`~ModelAdmin.has_delete_permission`
1154 
1155+.. versionadded:: 1.5
1156+
1157+- :meth:`~ModelAdmin.inlines`
1158+
1159 The ``InlineModelAdmin`` class adds:
1160 
1161 .. attribute:: InlineModelAdmin.model
1162diff --git a/tests/regressiontests/admin_inlines/admin.py b/tests/regressiontests/admin_inlines/admin.py
1163index cf51fa4..3f2d067 100644
1164--- a/tests/regressiontests/admin_inlines/admin.py
1165+++ b/tests/regressiontests/admin_inlines/admin.py
1166@@ -123,6 +123,34 @@ class ChildModel1Inline(admin.TabularInline):
1167 
1168 class ChildModel2Inline(admin.StackedInline):
1169     model = ChildModel2
1170+   
1171+class FurnitureInline(admin.StackedInline):
1172+    model = Furniture
1173+    extra = 1
1174+   
1175+class InhabitantInline(admin.StackedInline):
1176+    model = Inhabitant
1177+    extra = 1
1178+    inlines = [ FurnitureInline, ]
1179+   
1180+class AppartementInline(admin.TabularInline):
1181+    model = Appartement
1182+    extra = 1
1183+    inlines = [ InhabitantInline, ]
1184+   
1185+class MonumentInline(admin.StackedInline):
1186+    model = Monument
1187+    extra = 1
1188+   
1189+class BuildingInline(admin.TabularInline):
1190+    model = Building
1191+    extra = 1
1192+    inlines = [ AppartementInline, ]
1193+   
1194+class CityInline(admin.StackedInline):
1195+    model = City
1196+    extra = 1
1197+    inlines = [BuildingInline, MonumentInline, ]
1198 
1199 
1200 site.register(TitleCollection, inlines=[TitleInline])
1201@@ -141,4 +169,11 @@ site.register(Holder4, Holder4Admin)
1202 site.register(Author, AuthorAdmin)
1203 site.register(CapoFamiglia, inlines=[ConsigliereInline, SottoCapoInline])
1204 site.register(ProfileCollection, inlines=[ProfileInline])
1205-site.register(ParentModelWithCustomPk, inlines=[ChildModel1Inline, ChildModel2Inline])
1206\ No newline at end of file
1207+site.register(ParentModelWithCustomPk, inlines=[ChildModel1Inline, ChildModel2Inline])
1208+site.register(Country, inlines=[CityInline])
1209+site.register(City)
1210+site.register(Building)
1211+site.register(Monument)
1212+site.register(Appartement)
1213+site.register(Inhabitant)
1214+site.register(Furniture)
1215\ No newline at end of file
1216diff --git a/tests/regressiontests/admin_inlines/models.py b/tests/regressiontests/admin_inlines/models.py
1217index b004d5f..c9c522c 100644
1218--- a/tests/regressiontests/admin_inlines/models.py
1219+++ b/tests/regressiontests/admin_inlines/models.py
1220@@ -180,3 +180,52 @@ class Profile(models.Model):
1221     collection = models.ForeignKey(ProfileCollection, blank=True, null=True)
1222     first_name = models.CharField(max_length=100)
1223     last_name = models.CharField(max_length=100)
1224+   
1225+class Country(models.Model):
1226+    name = models.CharField(max_length=100)
1227+   
1228+    def __unicode__(self):
1229+        return self.name
1230+
1231+class City(models.Model):
1232+    name = models.CharField(max_length=100)
1233+    population = models.IntegerField()
1234+    country = models.ForeignKey(Country)
1235+   
1236+    def __unicode__(self):
1237+        return self.name
1238+
1239+class Building(models.Model):
1240+    name = models.CharField(max_length=100)
1241+    city = models.ForeignKey(City)
1242+   
1243+    def __unicode__(self):
1244+        return self.name
1245+
1246+class Appartement(models.Model):
1247+    name = models.CharField(max_length=100)
1248+    building = models.ForeignKey(Building)
1249+   
1250+    def __unicode__(self):
1251+        return self.name
1252+
1253+class Inhabitant(models.Model):
1254+    name = models.CharField(max_length=100)
1255+    appartement = models.ForeignKey(Appartement)
1256+   
1257+    def __unicode__(self):
1258+        return self.name
1259+
1260+class Furniture(models.Model):
1261+    name = models.CharField(max_length=100)
1262+    inhabitant = models.ForeignKey(Inhabitant)
1263+   
1264+    def __unicode__(self):
1265+        return self.name
1266+   
1267+class Monument(models.Model):
1268+    name = models.CharField(max_length=100)
1269+    city = models.ForeignKey(City)
1270+   
1271+    def __unicode__(self):
1272+        return self.name
1273\ No newline at end of file
1274diff --git a/tests/regressiontests/admin_inlines/tests.py b/tests/regressiontests/admin_inlines/tests.py
1275index 5bb6077..bc9c5d3 100644
1276--- a/tests/regressiontests/admin_inlines/tests.py
1277+++ b/tests/regressiontests/admin_inlines/tests.py
1278@@ -8,11 +8,11 @@ from django.test import TestCase
1279 from django.test.utils import override_settings
1280 
1281 # local test models
1282-from .admin import InnerInline, TitleInline, site
1283+from .admin import InnerInline
1284 from .models import (Holder, Inner, Holder2, Inner2, Holder3, Inner3, Person,
1285     OutfitItem, Fashionista, Teacher, Parent, Child, Author, Book, Profile,
1286-    ProfileCollection, ParentModelWithCustomPk, ChildModel1, ChildModel2,
1287-    Title)
1288+    ProfileCollection, ParentModelWithCustomPk, ChildModel1, ChildModel2,
1289+    Country, City, Building, Appartement, Inhabitant, Furniture, Monument)
1290 
1291 
1292 @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
1293@@ -560,15 +560,19 @@ class SeleniumFirefoxTests(AdminSeleniumWebDriverTestCase):
1294             'form#profilecollection_form tr.dynamic-profile_set#profile_set-1 td.delete a').click()
1295         self.selenium.find_element_by_css_selector(
1296             'form#profilecollection_form tr.dynamic-profile_set#profile_set-2 td.delete a').click()
1297-        # Verify that they're gone and that the IDs have been re-sequenced
1298+        # Verify that they're gone
1299         self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1300             '#profile_set-group table tr.dynamic-profile_set')), 3)
1301         self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1302             'form#profilecollection_form tr.dynamic-profile_set#profile_set-0')), 1)
1303         self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1304-            'form#profilecollection_form tr.dynamic-profile_set#profile_set-1')), 1)
1305+            'form#profilecollection_form tr.dynamic-profile_set#profile_set-1')), 0)
1306         self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1307-            'form#profilecollection_form tr.dynamic-profile_set#profile_set-2')), 1)
1308+            'form#profilecollection_form tr.dynamic-profile_set#profile_set-2')), 0)
1309+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1310+            'form#profilecollection_form tr.dynamic-profile_set#profile_set-3')), 1)
1311+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1312+            'form#profilecollection_form tr.dynamic-profile_set#profile_set-4')), 1)
1313 
1314     def test_alternating_rows(self):
1315         self.admin_login(username='super', password='secret')
1316@@ -583,9 +587,193 @@ class SeleniumFirefoxTests(AdminSeleniumWebDriverTestCase):
1317         self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1318             "%s.row1" % row_selector)), 2, msg="Expect two row1 styled rows")
1319         self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1320-            "%s.row2" % row_selector)), 1, msg="Expect one row2 styled row")
1321+            "%s.row2" % row_selector)), 1, msg="Expect one row2 styled row")   
1322 
1323+    def test_add_nested_inlines(self):
1324+        self.admin_login(username='super', password='secret')
1325+        self.selenium.get('%s%s' % (self.live_server_url,
1326+            '/admin/admin_inlines/country/add/'))
1327+       
1328+        # Add some cities
1329+        self.selenium.find_element_by_link_text('Add another City').click()
1330+        self.selenium.find_element_by_link_text('Add another City').click()
1331+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1332+            "#city_set-1 .nested-inline-row .nested-inline-row #city_set-1-building_set-0-appartement_set-0-inhabitant_set-0 " +
1333+            "#city_set-1-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0")), 1, "Expected furniture set in second city");
1334+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1335+            "#city_set-2 .nested-inline-row #city_set-2-building_set-0-appartement_set-0-inhabitant_set-0 " +
1336+            "#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0")), 1, "Expected furniture set in third city");
1337+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1338+            "#city_set-1 #city_set-1-monument_set-0")), 1, "Expected monument set in second city");
1339+        # Add monument in first city
1340+        self.selenium.find_elements_by_css_selector('#city_set-0-monument_set-group > .add-row a')[0].click()
1341+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1342+            "#city_set-0 #city_set-0-monument_set-1")), 1, "Expected second monument in first city")
1343+        # Add building in second city
1344+        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-0 ~ .add-row a')[0].click()
1345+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1346+            "#city_set-1 #city_set-1-building_set-1")), 1, "Expected second building in second city");
1347+        # Add apartement in second building of second city
1348+        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-1-appartement_set-0 ~ .add-row a')[0].click()
1349+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1350+            "#city_set-1 .nested-inline-row #city_set-1-building_set-1-appartement_set-1")), 1, "Expected second appartement in second building of second city");
1351+        # Add inhabitants in third city
1352+        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0 ~ .add-row a')[0].click()
1353+        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0 ~ .add-row a')[0].click()
1354+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1355+            "#city_set-2 .nested-inline-row .nested-inline-row #city_set-2-building_set-0-appartement_set-0-inhabitant_set-1")), 1, "Expected second inhabitant in third city");
1356+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1357+            "#city_set-2 .nested-inline-row .nested-inline-row #city_set-2-building_set-0-appartement_set-0-inhabitant_set-2")), 1, "Expected third inhabitant in third city");
1358+        # Add furniture in first city
1359+        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0 ~ .add-row a')[0].click()
1360+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
1361+            "#city_set-0 .nested-inline-row .nested-inline-row #city_set-0-building_set-0-appartement_set-0-inhabitant_set-0 "+
1362+            "#city_set-0-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-1")), 1, "Expected second furniture in first city");       
1363+   
1364+    def test_delete_nested_inlines(self):
1365+        self.admin_login(username='super', password='secret')
1366+        self.selenium.get('%s%s' % (self.live_server_url,
1367+            '/admin/admin_inlines/country/add/'))
1368+       
1369+        # Add 2 cities
1370+        self.selenium.find_element_by_link_text('Add another City').click()
1371+        self.selenium.find_element_by_link_text('Add another City').click()
1372+        # Delete second city
1373+        self.selenium.find_elements_by_css_selector('#city_set-1 > h3 a.inline-deletelink')[0].click()
1374+        # Check if only two cities
1375+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(".dynamic-city_set")), 2, "Expected 2 cities")
1376+        # Add 2 appartements in first city
1377+        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-0 ~ .add-row a')[0].click()
1378+        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-0 ~ .add-row a')[0].click()
1379+        # Delete second appartement
1380+        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-1 > td.delete a')[0].click()
1381+        # Check if only two appartements in first city
1382+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(".dynamic-city_set-0-building_set-0-appartement_set")), 2, "Expected 2 Inhabitants")
1383+        # Check that nested inlines have also been deleted
1384+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(".dynamic-city_set-0-building_set-0-appartement_set")), 2, "Expected 2 Inhabitants")
1385+        # Add 4 furniture in second city
1386+        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0 ~ .add-row a')[0].click()
1387+        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0 ~ .add-row a')[0].click()
1388+        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0 ~ .add-row a')[0].click()
1389+        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0 ~ .add-row a')[0].click()
1390+        # Delete second and fourth
1391+        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-1 > h3 a.inline-deletelink')[0].click()
1392+        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-3 > h3 a.inline-deletelink')[0].click()
1393+        # Check if only 3 furniture
1394+        self.assertEqual(len(self.selenium.find_elements_by_css_selector(".dynamic-city_set-2-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set")), 3, "Expected 3 furniture")
1395+   
1396+    def test_save_nested_inlines(self):
1397+        self.admin_login(username='super', password='secret')
1398+        self.selenium.get('%s%s' % (self.live_server_url,
1399+            '/admin/admin_inlines/country/add/'))
1400+       
1401+        # Add City
1402+        self.selenium.find_element_by_link_text('Add another City').click()
1403+        # Add Buildings
1404+        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0 ~ .add-row a')[0].click()
1405+        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-0 ~ .add-row a')[0].click()
1406+        # Add Appartements
1407+        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-0 ~ .add-row a')[0].click()
1408+        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-1-appartement_set-0 ~ .add-row a')[0].click()
1409+        # Add Inhabitant
1410+        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-0-inhabitant_set-0 ~ .add-row a')[0].click()
1411+        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-1-appartement_set-0-inhabitant_set-0 ~ .add-row a')[0].click()
1412+        # Add Furniture
1413+        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-1-inhabitant_set-0-furniture_set-0 ~ .add-row a')[0].click()
1414+        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-1-appartement_set-1-inhabitant_set-0-furniture_set-0 ~ .add-row a')[0].click()
1415+        # Add Monument
1416+        self.selenium.find_elements_by_css_selector('#city_set-0-monument_set-0 ~ .add-row a')[0].click()
1417+        self.selenium.find_elements_by_css_selector('#city_set-1-monument_set-0 ~ .add-row a')[0].click()
1418+        # Input Data
1419+        self.selenium.find_element_by_css_selector('#id_name').send_keys('Belgium')
1420+        self.selenium.find_element_by_css_selector('#id_city_set-0-name').send_keys('C 1')
1421+        self.selenium.find_element_by_css_selector('#id_city_set-0-population').send_keys('10')
1422+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-name').send_keys('B 1.1')
1423+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-0-name').send_keys('A 1.1.1')
1424+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-0-inhabitant_set-0-name').send_keys('I 1.1.1.1')
1425+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0-name').send_keys('F 1.1.1.1.1')
1426+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-0-inhabitant_set-1-name').send_keys('I 1.1.1.2')
1427+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-0-inhabitant_set-1-furniture_set-0-name').send_keys('F 1.1.1.2.1')
1428+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-1-name').send_keys('A 1.1.2')
1429+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-1-inhabitant_set-0-name').send_keys('I 1.1.2.1')
1430+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-1-inhabitant_set-0-furniture_set-0-name').send_keys('F 1.1.2.1.1')
1431+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-1-inhabitant_set-0-furniture_set-1-name').send_keys('F 1.1.2.1.2')
1432+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-1-name').send_keys('B 1.2')
1433+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-1-appartement_set-0-name').send_keys('A 1.2.1')
1434+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-1-appartement_set-0-inhabitant_set-0-name').send_keys('I 1.2.1.1')
1435+        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-1-appartement_set-0-inhabitant_set-0-furniture_set-0-name').send_keys('F 1.2.1.1.1')
1436+        self.selenium.find_element_by_css_selector('#id_city_set-0-monument_set-0-name').send_keys('M 1.1')
1437+        self.selenium.find_element_by_css_selector('#id_city_set-0-monument_set-1-name').send_keys('M 1.2')
1438+        self.selenium.find_element_by_css_selector('#id_city_set-1-name').send_keys('C 2')
1439+        self.selenium.find_element_by_css_selector('#id_city_set-1-population').send_keys('10')
1440+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-0-name').send_keys('B 2.1')
1441+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-0-appartement_set-0-name').send_keys('A 2.1.1')
1442+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-0-appartement_set-0-inhabitant_set-0-name').send_keys('I 2.1.1.1')
1443+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0-name').send_keys('F 2.1.1.1.1')
1444+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-name').send_keys('B 2.2')
1445+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-0-name').send_keys('A 2.2.1')
1446+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-0-inhabitant_set-0-name').send_keys('I 2.2.1.1')
1447+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-0-inhabitant_set-0-furniture_set-0-name').send_keys('F 2.2.1.1.1')
1448+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-0-inhabitant_set-1-name').send_keys('I 2.2.1.2')
1449+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-0-inhabitant_set-1-furniture_set-0-name').send_keys('F 2.2.1.2.1')
1450+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-1-name').send_keys('A 2.2.2')
1451+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-1-inhabitant_set-0-name').send_keys('I 2.2.2.1')
1452+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-1-inhabitant_set-0-furniture_set-0-name').send_keys('F 2.2.2.1.1')
1453+        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-1-inhabitant_set-0-furniture_set-1-name').send_keys('F 2.2.2.1.2')
1454+        self.selenium.find_element_by_css_selector('#id_city_set-1-monument_set-0-name').send_keys('M 2.1')
1455+        self.selenium.find_element_by_css_selector('#id_city_set-1-monument_set-1-name').send_keys('M 2.2')
1456+        # Delete inhabitant 2.2.1.2
1457+        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-1-appartement_set-0-inhabitant_set-1 > h3 a.inline-deletelink')[0].click()
1458+        # Delete furniture 1.1.2.1.2
1459+        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-1-inhabitant_set-0-furniture_set-1 > h3 a.inline-deletelink')[0].click()
1460+        # Save
1461+        self.selenium.find_element_by_xpath('//input[@value="Save"]').click()
1462+       
1463+        try:
1464+            # Wait for the next page to be loaded.
1465+            self.wait_loaded_tag('body')
1466+        except TimeoutException:
1467+            # IE7 occasionnally returns an error "Internet Explorer cannot
1468+            # display the webpage" and doesn't load the next page. We just
1469+            # ignore it.
1470+            pass
1471 
1472+        # Check if saved correctly
1473+        self.assertEqual(Country.objects.all().count(), 1)
1474+        self.assertEqual(City.objects.get(name="C 1").country, Country.objects.get(name="Belgium"))
1475+        self.assertEqual(Building.objects.get(name="B 1.1").city, City.objects.get(name="C 1"))
1476+        self.assertEqual(Building.objects.get(name="B 1.2").city, City.objects.get(name="C 1"))
1477+        self.assertEqual(Building.objects.get(name="B 2.1").city, City.objects.get(name="C 2"))
1478+        self.assertEqual(Building.objects.get(name="B 2.2").city, City.objects.get(name="C 2"))
1479+        self.assertEqual(Appartement.objects.get(name="A 1.1.1").building, Building.objects.get(name="B 1.1"))
1480+        self.assertEqual(Appartement.objects.get(name="A 1.1.2").building, Building.objects.get(name="B 1.1"))
1481+        self.assertEqual(Appartement.objects.get(name="A 1.2.1").building, Building.objects.get(name="B 1.2"))
1482+        self.assertEqual(Appartement.objects.get(name="A 2.1.1").building, Building.objects.get(name="B 2.1"))
1483+        self.assertEqual(Appartement.objects.get(name="A 2.2.1").building, Building.objects.get(name="B 2.2"))
1484+        self.assertEqual(Appartement.objects.get(name="A 2.2.2").building, Building.objects.get(name="B 2.2"))
1485+        self.assertEqual(Inhabitant.objects.get(name="I 1.1.1.1").appartement, Appartement.objects.get(name="A 1.1.1"))
1486+        self.assertEqual(Inhabitant.objects.get(name="I 1.1.1.2").appartement, Appartement.objects.get(name="A 1.1.1"))
1487+        self.assertEqual(Inhabitant.objects.get(name="I 1.1.2.1").appartement, Appartement.objects.get(name="A 1.1.2"))
1488+        self.assertEqual(Inhabitant.objects.get(name="I 1.2.1.1").appartement, Appartement.objects.get(name="A 1.2.1"))
1489+        self.assertEqual(Inhabitant.objects.get(name="I 2.1.1.1").appartement, Appartement.objects.get(name="A 2.1.1"))
1490+        self.assertEqual(Inhabitant.objects.get(name="I 2.2.1.1").appartement, Appartement.objects.get(name="A 2.2.1"))
1491+        self.assertEqual(len(Inhabitant.objects.filter(name="I 2.2.1.2")), 0)
1492+        self.assertEqual(Inhabitant.objects.get(name="I 2.2.2.1").appartement, Appartement.objects.get(name="A 2.2.2"))
1493+        self.assertEqual(Furniture.objects.get(name="F 1.1.1.1.1").inhabitant, Inhabitant.objects.get(name="I 1.1.1.1"))
1494+        self.assertEqual(Furniture.objects.get(name="F 1.1.1.2.1").inhabitant, Inhabitant.objects.get(name="I 1.1.1.2"))
1495+        self.assertEqual(Furniture.objects.get(name="F 1.1.2.1.1").inhabitant, Inhabitant.objects.get(name="I 1.1.2.1"))
1496+        self.assertEqual(len(Furniture.objects.filter(name="F 1.1.2.1.2")), 0)
1497+        self.assertEqual(Furniture.objects.get(name="F 1.2.1.1.1").inhabitant, Inhabitant.objects.get(name="I 1.2.1.1"))
1498+        self.assertEqual(Furniture.objects.get(name="F 2.1.1.1.1").inhabitant, Inhabitant.objects.get(name="I 2.1.1.1"))
1499+        self.assertEqual(Furniture.objects.get(name="F 2.2.1.1.1").inhabitant, Inhabitant.objects.get(name="I 2.2.1.1"))
1500+        self.assertEqual(len(Furniture.objects.filter(name="F 2.2.1.2.1")), 0)
1501+        self.assertEqual(Furniture.objects.get(name="F 2.2.2.1.1").inhabitant, Inhabitant.objects.get(name="I 2.2.2.1"))
1502+        self.assertEqual(Furniture.objects.get(name="F 2.2.2.1.2").inhabitant, Inhabitant.objects.get(name="I 2.2.2.1"))
1503+        self.assertEqual(Monument.objects.get(name="M 1.1").city, City.objects.get(name="C 1"))
1504+        self.assertEqual(Monument.objects.get(name="M 1.2").city, City.objects.get(name="C 1"))
1505+        self.assertEqual(Monument.objects.get(name="M 2.1").city, City.objects.get(name="C 2"))
1506+        self.assertEqual(Monument.objects.get(name="M 2.2").city, City.objects.get(name="C 2"))
1507+   
1508 class SeleniumChromeTests(SeleniumFirefoxTests):
1509     webdriver_class = 'selenium.webdriver.chrome.webdriver.WebDriver'
1510