Ticket #9025: nested_inlines_finished.diff

File nested_inlines_finished.diff, 86.2 KB (added by Gargamel, 3 years ago)

Nested Inlines V1.1

  • django/contrib/admin/options.py

    diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
    index f4205f2..352cd8b 100644
    a b class ModelAdmin(BaseModelAdmin): 
    715715        """
    716716        obj.delete()
    717717
    718     def save_formset(self, request, form, formset, change):
     718    def save_formset(self, request, formset, change):
    719719        """
    720720        Given an inline formset save it to the database.
    721721        """
    722722        formset.save()
     723        for form in formset.forms:
     724            if hasattr(form, 'nested_formsets'):
     725                for nested_formset in form.nested_formsets:
     726                    self.save_formset(request, nested_formset, change)
     727                   
    723728
    724729    def save_related(self, request, form, formsets, change):
    725730        """
    class ModelAdmin(BaseModelAdmin): 
    731736        """
    732737        form.save_m2m()
    733738        for formset in formsets:
    734             self.save_formset(request, form, formset, change=change)
     739            self.save_formset(request, formset, change=change)
    735740
    736741    def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None):
    737742        opts = self.model._meta
    class ModelAdmin(BaseModelAdmin): 
    920925            self.message_user(request, msg)
    921926            return None
    922927
     928   
     929       
     930    def add_nested_inline_formsets(self, request, inline, formset, depth=0):
     931        if depth > 5:
     932            raise Exception("Maximum nesting depth reached (5)")
     933        for form in formset.forms:               
     934            nested_formsets = []
     935            for nested_inline in inline.get_inline_instances(request):
     936                InlineFormSet = nested_inline.get_formset(request, form.instance)
     937                prefix = "%s-%s" % (form.prefix, InlineFormSet.get_default_prefix())
     938                if request.method == 'POST':
     939                    nested_formset = InlineFormSet(request.POST, request.FILES,
     940                                                   instance=form.instance,
     941                                                   prefix=prefix, queryset=nested_inline.queryset(request))
     942                else:
     943                    nested_formset = InlineFormSet(instance=form.instance,
     944                                                   prefix=prefix, queryset=nested_inline.queryset(request))
     945                nested_formsets.append(nested_formset)
     946                if nested_inline.inlines:
     947                    self.add_nested_inline_formsets(request, nested_inline, nested_formset, depth=depth+1)
     948            form.nested_formsets = nested_formsets
     949           
     950    def wrap_nested_inline_formsets(self, request, inline, formset):
     951        media = None
     952        def get_media(extra_media):
     953            if media:
     954                return media + extra_media
     955            else:
     956                return extra_media
     957                       
     958        for form in formset.forms:
     959            wrapped_nested_formsets = []
     960            for nested_inline, nested_formset in zip(inline.get_inline_instances(request), form.nested_formsets):
     961                if form.instance.pk:
     962                    instance = form.instance
     963                else:
     964                    instance = None
     965                fieldsets = list(nested_inline.get_fieldsets(request))
     966                readonly = list(nested_inline.get_readonly_fields(request))
     967                prepopulated = dict(nested_inline.get_prepopulated_fields(request))
     968                wrapped_nested_formset = helpers.InlineAdminFormSet(nested_inline, nested_formset,
     969                                                             fieldsets, prepopulated, readonly, model_admin=self)
     970                wrapped_nested_formsets.append(wrapped_nested_formset)
     971                media = get_media(wrapped_nested_formset.media)
     972                if nested_inline.inlines:
     973                    media = get_media(self.wrap_nested_inline_formsets(request, nested_inline, nested_formset))
     974            form.nested_formsets = wrapped_nested_formsets
     975        return media
     976   
     977    def all_valid_with_nesting(self, formsets):
     978        "Recursively validate all nested formsets"
     979        if not all_valid(formsets):
     980            return False
     981        for formset in formsets:
     982            if not formset.is_bound:
     983                pass
     984            for form in formset:
     985                if hasattr(form, 'nested_formsets'):
     986                    if not self.all_valid_with_nesting(form.nested_formsets):
     987                        return False
     988                    # Here be dragons :(
     989                    if not form.cleaned_data:
     990                        form._errors["__all__"] = form.error_class([u"Parent object must be created when creating nested inlines."])
     991                        return False
     992        return True
     993           
     994
    923995    @csrf_protect_m
    924996    @transaction.commit_on_success
    925997    def add_view(self, request, form_url='', extra_context=None):
    class ModelAdmin(BaseModelAdmin): 
    9521024                                  save_as_new="_saveasnew" in request.POST,
    9531025                                  prefix=prefix, queryset=inline.queryset(request))
    9541026                formsets.append(formset)
    955             if all_valid(formsets) and form_validated:
     1027                if inline.inlines:
     1028                    self.add_nested_inline_formsets(request, inline, formset)
     1029            if self.all_valid_with_nesting(formsets) and form_validated:
    9561030                self.save_model(request, new_object, form, False)
    9571031                self.save_related(request, form, formsets, False)
    9581032                self.log_addition(request, new_object)
    class ModelAdmin(BaseModelAdmin): 
    9781052                formset = FormSet(instance=self.model(), prefix=prefix,
    9791053                                  queryset=inline.queryset(request))
    9801054                formsets.append(formset)
     1055                if inline.inlines:
     1056                    self.add_nested_inline_formsets(request, inline, formset)
    9811057
    9821058        adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)),
    9831059            self.get_prepopulated_fields(request),
    class ModelAdmin(BaseModelAdmin): 
    9941070                fieldsets, prepopulated, readonly, model_admin=self)
    9951071            inline_admin_formsets.append(inline_admin_formset)
    9961072            media = media + inline_admin_formset.media
     1073            if inline.inlines:
     1074                media = media + self.wrap_nested_inline_formsets(request, inline, formset)
    9971075
    9981076        context = {
    9991077            'title': _('Add %s') % force_text(opts.verbose_name),
    class ModelAdmin(BaseModelAdmin): 
    10471125                formset = FormSet(request.POST, request.FILES,
    10481126                                  instance=new_object, prefix=prefix,
    10491127                                  queryset=inline.queryset(request))
    1050 
    10511128                formsets.append(formset)
     1129                if inline.inlines:
     1130                    self.add_nested_inline_formsets(request, inline, formset)
    10521131
    1053             if all_valid(formsets) and form_validated:
     1132            if self.all_valid_with_nesting(formsets) and form_validated:
    10541133                self.save_model(request, new_object, form, True)
    10551134                self.save_related(request, form, formsets, True)
    10561135                change_message = self.construct_change_message(request, form, formsets)
    class ModelAdmin(BaseModelAdmin): 
    10681147                formset = FormSet(instance=obj, prefix=prefix,
    10691148                                  queryset=inline.queryset(request))
    10701149                formsets.append(formset)
     1150                if inline.inlines:
     1151                    self.add_nested_inline_formsets(request, inline, formset)
    10711152
    10721153        adminForm = helpers.AdminForm(form, self.get_fieldsets(request, obj),
    10731154            self.get_prepopulated_fields(request, obj),
    class ModelAdmin(BaseModelAdmin): 
    10841165                fieldsets, prepopulated, readonly, model_admin=self)
    10851166            inline_admin_formsets.append(inline_admin_formset)
    10861167            media = media + inline_admin_formset.media
     1168            if inline.inlines:
     1169                media = media + self.wrap_nested_inline_formsets(request, inline, formset)
    10871170
    10881171        context = {
    10891172            'title': _('Change %s') % force_text(opts.verbose_name),
    class InlineModelAdmin(BaseModelAdmin): 
    13581441    verbose_name = None
    13591442    verbose_name_plural = None
    13601443    can_delete = True
     1444    inlines = []
    13611445
    13621446    def __init__(self, parent_model, admin_site):
    13631447        self.admin_site = admin_site
    class InlineModelAdmin(BaseModelAdmin): 
    13691453        if self.verbose_name_plural is None:
    13701454            self.verbose_name_plural = self.model._meta.verbose_name_plural
    13711455
     1456    def get_inline_instances(self, request):
     1457        inline_instances = []
     1458        for inline_class in self.inlines:
     1459            inline = inline_class(self.model, self.admin_site)
     1460            if request:
     1461                if not (inline.has_add_permission(request) or
     1462                        inline.has_change_permission(request) or
     1463                        inline.has_delete_permission(request)):
     1464                    continue
     1465                if not inline.has_add_permission(request):
     1466                    inline.max_num = 0
     1467            inline_instances.append(inline)
     1468        return inline_instances
     1469
    13721470    @property
    13731471    def media(self):
    13741472        extra = '' if settings.DEBUG else '.min'
  • django/contrib/admin/static/admin/css/forms.css

    diff --git a/django/contrib/admin/static/admin/css/forms.css b/django/contrib/admin/static/admin/css/forms.css
    index efec04b..35224aa 100644
    a b body.popup .submit-row { 
    285285    color: #fff;
    286286}
    287287
    288 .inline-group .tabular fieldset.module {
     288.inline-group .tabular > fieldset.module {
    289289    border: none;
    290290    border-bottom: 1px solid #ddd;
    291291}
    body.popup .submit-row { 
    358358    outline: 0; /* Remove dotted border around link */
    359359}
    360360
     361.nested-inline {
     362        margin: 10px;
     363}
     364
     365td > .nested-inline {
     366        margin: 0px;
     367}
     368
     369.nested-inline-bottom-border {
     370        border-bottom: 1px solid #DDDDDD;
     371}
     372
     373.no-bottom-border.row1 > td {
     374        border-bottom: solid #EDF3FE 1px;
     375}
     376
     377.no-bottom-border.row2 > td {
     378        border-bottom: solid white 1px;
     379}
     380
    361381.empty-form {
    362382    display: none;
    363383}
  • django/contrib/admin/static/admin/js/inlines.js

    diff --git a/django/contrib/admin/static/admin/js/inlines.js b/django/contrib/admin/static/admin/js/inlines.js
    index 4dc9459..4d1943d 100644
    a b  
    1515 * See: http://www.opensource.org/licenses/bsd-license.php
    1616 */
    1717(function($) {
    18   $.fn.formset = function(opts) {
    19     var options = $.extend({}, $.fn.formset.defaults, opts);
    20     var $this = $(this);
    21     var $parent = $this.parent();
    22     var updateElementIndex = function(el, prefix, ndx) {
    23       var id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))");
    24       var replacement = prefix + "-" + ndx;
    25       if ($(el).attr("for")) {
    26         $(el).attr("for", $(el).attr("for").replace(id_regex, replacement));
    27       }
    28       if (el.id) {
    29         el.id = el.id.replace(id_regex, replacement);
    30       }
    31       if (el.name) {
    32         el.name = el.name.replace(id_regex, replacement);
    33       }
    34     };
    35     var totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").attr("autocomplete", "off");
    36     var nextIndex = parseInt(totalForms.val(), 10);
    37     var maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").attr("autocomplete", "off");
    38     // only show the add button if we are allowed to add more items,
    39         // note that max_num = None translates to a blank string.
    40     var showAddButton = maxForms.val() === '' || (maxForms.val()-totalForms.val()) > 0;
    41     $this.each(function(i) {
    42       $(this).not("." + options.emptyCssClass).addClass(options.formCssClass);
    43     });
    44     if ($this.length && showAddButton) {
    45       var addButton;
    46       if ($this.attr("tagName") == "TR") {
    47         // If forms are laid out as table rows, insert the
    48         // "add" button in a new table row:
    49         var numCols = this.eq(-1).children().length;
    50         $parent.append('<tr class="' + options.addCssClass + '"><td colspan="' + numCols + '"><a href="javascript:void(0)">' + options.addText + "</a></tr>");
    51         addButton = $parent.find("tr:last a");
    52       } else {
    53         // Otherwise, insert it immediately after the last form:
    54         $this.filter(":last").after('<div class="' + options.addCssClass + '"><a href="javascript:void(0)">' + options.addText + "</a></div>");
    55         addButton = $this.filter(":last").next().find("a");
    56       }
    57       addButton.click(function(e) {
    58         e.preventDefault();
    59         var totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS");
    60         var template = $("#" + options.prefix + "-empty");
    61         var row = template.clone(true);
    62         row.removeClass(options.emptyCssClass)
    63           .addClass(options.formCssClass)
    64           .attr("id", options.prefix + "-" + nextIndex);
    65         if (row.is("tr")) {
    66           // If the forms are laid out in table rows, insert
    67           // the remove button into the last table cell:
    68           row.children(":last").append('<div><a class="' + options.deleteCssClass +'" href="javascript:void(0)">' + options.deleteText + "</a></div>");
    69         } else if (row.is("ul") || row.is("ol")) {
    70           // If they're laid out as an ordered/unordered list,
    71           // insert an <li> after the last list item:
    72           row.append('<li><a class="' + options.deleteCssClass +'" href="javascript:void(0)">' + options.deleteText + "</a></li>");
    73         } else {
    74           // Otherwise, just insert the remove button as the
    75           // last child element of the form's container:
    76           row.children(":first").append('<span><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText + "</a></span>");
    77         }
    78         row.find("*").each(function() {
    79           updateElementIndex(this, options.prefix, totalForms.val());
    80         });
    81         // Insert the new form when it has been fully edited
    82         row.insertBefore($(template));
    83         // Update number of total forms
    84         $(totalForms).val(parseInt(totalForms.val(), 10) + 1);
    85         nextIndex += 1;
    86         // Hide add button in case we've hit the max, except we want to add infinitely
    87         if ((maxForms.val() !== '') && (maxForms.val()-totalForms.val()) <= 0) {
    88           addButton.parent().hide();
    89         }
    90         // The delete button of each row triggers a bunch of other things
    91         row.find("a." + options.deleteCssClass).click(function(e) {
    92           e.preventDefault();
    93           // Remove the parent form containing this button:
    94           var row = $(this).parents("." + options.formCssClass);
    95           row.remove();
    96           nextIndex -= 1;
    97           // If a post-delete callback was provided, call it with the deleted form:
    98           if (options.removed) {
    99             options.removed(row);
    100           }
    101           // Update the TOTAL_FORMS form count.
    102           var forms = $("." + options.formCssClass);
    103           $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length);
    104           // Show add button again once we drop below max
    105           if ((maxForms.val() === '') || (maxForms.val()-forms.length) > 0) {
    106             addButton.parent().show();
    107           }
    108           // Also, update names and ids for all remaining form controls
    109           // so they remain in sequence:
    110           for (var i=0, formCount=forms.length; i<formCount; i++)
    111           {
    112             updateElementIndex($(forms).get(i), options.prefix, i);
    113             $(forms.get(i)).find("*").each(function() {
    114               updateElementIndex(this, options.prefix, i);
    115             });
    116           }
    117         });
    118         // If a post-add callback was supplied, call it with the added form:
    119         if (options.added) {
    120           options.added(row);
    121         }
    122       });
    123     }
    124     return this;
    125   };
     18        $.fn.formset = function(opts) {
     19                var options = $.extend({}, $.fn.formset.defaults, opts);
     20                var $this = $(this);
     21                var $parent = $this.parent();
     22                var updateElementIndex = function(el, prefix, ndx) {
     23                        var id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))");
     24                        var replacement = prefix + "-" + ndx;
     25                        if ($(el).attr("for")) {
     26                                $(el).attr("for", $(el).attr("for").replace(id_regex, replacement));
     27                        }
     28                        if (el.id) {
     29                                el.id = el.id.replace(id_regex, replacement);
     30                        }
     31                        if (el.name) {
     32                                el.name = el.name.replace(id_regex, replacement);
     33                        }
     34                };
     35                var nextIndex = get_no_forms(options.prefix);
    12636
    127   /* Setup plugin defaults */
    128   $.fn.formset.defaults = {
    129     prefix: "form",          // The form prefix for your django formset
    130     addText: "add another",      // Text for the add link
    131     deleteText: "remove",      // Text for the delete link
    132     addCssClass: "add-row",      // CSS class applied to the add link
    133     deleteCssClass: "delete-row",  // CSS class applied to the delete link
    134     emptyCssClass: "empty-row",    // CSS class applied to the empty row
    135     formCssClass: "dynamic-form",  // CSS class applied to each form in a formset
    136     added: null,          // Function called each time a new form is added
    137     removed: null          // Function called each time a form is deleted
    138   };
     37                // Add form classes for dynamic behaviour
     38                $this.each(function(i) {
     39                        $(this).not("." + options.emptyCssClass).addClass(options.formCssClass);
     40                });
     41                // Only show the add button if we are allowed to add more items,
     42                // note that max_num = None translates to a blank string.
     43                var showAddButton = get_max_forms(options.prefix) === '' || (get_max_forms(options.prefix) - get_no_forms(options.prefix)) > 0;
     44                if ($this.length && showAddButton) {
     45                        var addButton;
     46                        if ($this.attr("tagName") == "TR") {
     47                                // If forms are laid out as table rows, insert the
     48                                // "add" button in a new table row:
     49                                var numCols = this.eq(-1).children().length;
     50                                $parent.append('<tr class="' + options.addCssClass + '"><td colspan="' + numCols + '"><a href="javascript:void(0)">' + options.addText + "</a></tr>");
     51                                addButton = $parent.find("tr:last a");
     52                        } else {
     53                                // Otherwise, insert it immediately after the last form:
     54                                $this.filter(":last").after('<div class="' + options.addCssClass + '"><a href="javascript:void(0)">' + options.addText + "</a></div>");
     55                                addButton = $this.filter(":last").next().find("a");
     56                        }
     57                        addButton.click(function(e) {
     58                                e.preventDefault();
     59                                var nextIndex = get_no_forms(options.prefix);
     60                                var template = $("#" + options.prefix + "-empty");
     61                                var row = template.clone(true);
     62                                row.removeClass(options.emptyCssClass).addClass(options.formCssClass).attr("id", options.prefix + "-" + nextIndex);
     63                                if (row.is("tr")) {
     64                                        // If the forms are laid out in table rows, insert
     65                                        // the remove button into the last table cell:
     66                                        row.children(":last").append('<div><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText + "</a></div>");
     67                                } else if (row.is("ul") || row.is("ol")) {
     68                                        // If they're laid out as an ordered/unordered list,
     69                                        // insert an <li> after the last list item:
     70                                        row.append('<li><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText + "</a></li>");
     71                                } else {
     72                                        // Otherwise, just insert the remove button as the
     73                                        // last child element of the form's container:
     74                                        row.children(":first").append('<span><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText + "</a></span>");
     75                                }
     76                                row.find("*").each(function() {
     77                                        updateElementIndex(this, options.prefix, nextIndex);
     78                                });
     79                                // when adding something from a cloned formset the id is the same
    13980
     81                                // Insert the new form when it has been fully edited
     82                                row.insertBefore($(template));
    14083
    141   // Tabular inlines ---------------------------------------------------------
    142   $.fn.tabularFormset = function(options) {
    143     var $rows = $(this);
    144     var alternatingRows = function(row) {
    145       $($rows.selector).not(".add-row").removeClass("row1 row2")
    146         .filter(":even").addClass("row1").end()
    147         .filter(":odd").addClass("row2");
    148     };
     84                                // Update number of total forms
     85                                change_no_forms(options.prefix, true);
    14986
    150     var reinitDateTimeShortCuts = function() {
    151       // Reinitialize the calendar and clock widgets by force
    152       if (typeof DateTimeShortcuts != "undefined") {
    153         $(".datetimeshortcuts").remove();
    154         DateTimeShortcuts.init();
    155       }
    156     };
     87                                // Hide add button in case we've hit the max, except we want to add infinitely
     88                                if ((get_max_forms(options.prefix) !== '') && (get_max_forms(options.prefix) - get_no_forms(options.prefix)) <= 0) {
     89                                        addButton.parent().hide();
     90                                }
    15791
    158     var updateSelectFilter = function() {
    159       // If any SelectFilter widgets are a part of the new form,
    160       // instantiate a new SelectFilter instance for it.
    161       if (typeof SelectFilter != 'undefined'){
    162         $('.selectfilter').each(function(index, value){
    163           var namearr = value.name.split('-');
    164           SelectFilter.init(value.id, namearr[namearr.length-1], false, options.adminStaticPrefix );
    165         });
    166         $('.selectfilterstacked').each(function(index, value){
    167           var namearr = value.name.split('-');
    168           SelectFilter.init(value.id, namearr[namearr.length-1], true, options.adminStaticPrefix );
    169         });
    170       }
    171     };
     92                                // The delete button of each row triggers a bunch of other things
     93                                row.find("a." + options.deleteCssClass).click(function(e) {
     94                                        e.preventDefault();
     95                                        // Find the row that will be deleted by this button
     96                                        var row = $(this).parents("." + options.formCssClass);
     97                                        // Remove the parent form containing this button:
     98                                        var formset_to_update = row.parent();
     99                                        while (row.next().hasClass('nested-inline-row')) {
     100                                                row.next().remove();
     101                                        }
     102                                        row.remove();
     103                                        change_no_forms(options.prefix, false);
     104                                        // If a post-delete callback was provided, call it with the deleted form:
     105                                        if (options.removed) {
     106                                                options.removed(formset_to_update);
     107                                        }
    172108
    173     var initPrepopulatedFields = function(row) {
    174       row.find('.prepopulated_field').each(function() {
    175         var field = $(this),
    176             input = field.find('input, select, textarea'),
    177             dependency_list = input.data('dependency_list') || [],
    178             dependencies = [];
    179         $.each(dependency_list, function(i, field_name) {
    180           dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id'));
    181         });
    182         if (dependencies.length) {
    183           input.prepopulate(dependencies, input.attr('maxlength'));
    184         }
    185       });
    186     };
     109                                });
    187110
    188     $rows.formset({
    189       prefix: options.prefix,
    190       addText: options.addText,
    191       formCssClass: "dynamic-" + options.prefix,
    192       deleteCssClass: "inline-deletelink",
    193       deleteText: options.deleteText,
    194       emptyCssClass: "empty-form",
    195       removed: alternatingRows,
    196       added: function(row) {
    197         initPrepopulatedFields(row);
    198         reinitDateTimeShortCuts();
    199         updateSelectFilter();
    200         alternatingRows(row);
    201       }
    202     });
     111                                if (row.is("tr")) {
     112                                        // If the forms are laid out in table rows, insert
     113                                        // the remove button into the last table cell:
     114                                        // Insert the nested formsets into the new form
     115                                        nested_formsets = create_nested_formset(options.prefix, nextIndex, options, false);
     116                                        if (nested_formsets.length) {
     117                                                row.addClass("no-bottom-border");
     118                                        }
     119                                        nested_formsets.each(function() {
     120                                                if (!$(this).next()) {
     121                                                        border_class = "";
     122                                                } else {
     123                                                        border_class = " no-bottom-border";
     124                                                }
     125                                                ($('<tr class="nested-inline-row' + border_class + '">').html(($('<td>', {
     126                                                                        colspan : '100%'
     127                                                                }).html($(this))))).insertBefore($(template));
     128                                        });
     129                                } else {
     130                                        // stacked
     131                                        // Insert the nested formsets into the new form
     132                                        nested_formsets = create_nested_formset(options.prefix, nextIndex, options, true);
     133                                        nested_formsets.each(function() {
     134                                                row.append($(this));
     135                                        });
     136                                }
    203137
    204     return $rows;
    205   };
     138                                // If a post-add callback was supplied, call it with the added form:
     139                                if (options.added) {
     140                                        options.added(row);
     141                                }
    206142
    207   // Stacked inlines ---------------------------------------------------------
    208   $.fn.stackedFormset = function(options) {
    209     var $rows = $(this);
    210     var updateInlineLabel = function(row) {
    211       $($rows.selector).find(".inline_label").each(function(i) {
    212         var count = i + 1;
    213         $(this).html($(this).html().replace(/(#\d+)/g, "#" + count));
    214       });
    215     };
     143                                nextIndex = nextIndex + 1;
     144                        });
     145                }
     146                return this;
     147        };
    216148
    217     var reinitDateTimeShortCuts = function() {
    218       // Reinitialize the calendar and clock widgets by force, yuck.
    219       if (typeof DateTimeShortcuts != "undefined") {
    220         $(".datetimeshortcuts").remove();
    221         DateTimeShortcuts.init();
    222       }
    223     };
     149        /* Setup plugin defaults */
     150        $.fn.formset.defaults = {
     151                prefix : "form", // The form prefix for your django formset
     152                addText : "add another", // Text for the add link
     153                deleteText : "remove", // Text for the delete link
     154                addCssClass : "add-row", // CSS class applied to the add link
     155                deleteCssClass : "delete-row", // CSS class applied to the delete link
     156                emptyCssClass : "empty-row", // CSS class applied to the empty row
     157                formCssClass : "dynamic-form", // CSS class applied to each form in a formset
     158                added : null, // Function called each time a new form is added
     159                removed : null // Function called each time a form is deleted
     160        };
    224161
    225     var updateSelectFilter = function() {
    226       // If any SelectFilter widgets were added, instantiate a new instance.
    227       if (typeof SelectFilter != "undefined"){
    228         $(".selectfilter").each(function(index, value){
    229           var namearr = value.name.split('-');
    230           SelectFilter.init(value.id, namearr[namearr.length-1], false, options.adminStaticPrefix);
    231         });
    232         $(".selectfilterstacked").each(function(index, value){
    233           var namearr = value.name.split('-');
    234           SelectFilter.init(value.id, namearr[namearr.length-1], true, options.adminStaticPrefix);
    235         });
    236       }
    237     };
     162        // Tabular inlines ---------------------------------------------------------
     163        $.fn.tabularFormset = function(options) {
     164                var $rows = $(this);
     165                var alternatingRows = function(row) {
     166                        row_number = 0;
     167                        $($rows.selector).not(".add-row").removeClass("row1 row2").each(function() {
     168                                $(this).addClass('row' + ((row_number%2)+1));
     169                                next = $(this).next();
     170                                while (next.hasClass('nested-inline-row')) {
     171                                        next.addClass('row' + ((row_number%2)+1));
     172                                        next = next.next();
     173                                }
     174                                row_number = row_number + 1;
     175                        });
     176                };
    238177
    239     var initPrepopulatedFields = function(row) {
    240       row.find('.prepopulated_field').each(function() {
    241         var field = $(this),
    242             input = field.find('input, select, textarea'),
    243             dependency_list = input.data('dependency_list') || [],
    244             dependencies = [];
    245         $.each(dependency_list, function(i, field_name) {
    246           dependencies.push('#' + row.find('.form-row .field-' + field_name).find('input, select, textarea').attr('id'));
    247         });
    248         if (dependencies.length) {
    249           input.prepopulate(dependencies, input.attr('maxlength'));
    250         }
    251       });
    252     };
     178                var reinitDateTimeShortCuts = function() {
     179                        // Reinitialize the calendar and clock widgets by force
     180                        if ( typeof DateTimeShortcuts != "undefined") {
     181                                $(".datetimeshortcuts").remove();
     182                                DateTimeShortcuts.init();
     183                        }
     184                };
    253185
    254     $rows.formset({
    255       prefix: options.prefix,
    256       addText: options.addText,
    257       formCssClass: "dynamic-" + options.prefix,
    258       deleteCssClass: "inline-deletelink",
    259       deleteText: options.deleteText,
    260       emptyCssClass: "empty-form",
    261       removed: updateInlineLabel,
    262       added: (function(row) {
    263         initPrepopulatedFields(row);
    264         reinitDateTimeShortCuts();
    265         updateSelectFilter();
    266         updateInlineLabel(row);
    267       })
    268     });
     186                var updateSelectFilter = function() {
     187                        // If any SelectFilter widgets are a part of the new form,
     188                        // instantiate a new SelectFilter instance for it.
     189                        if ( typeof SelectFilter != 'undefined') {
     190                                $('.selectfilter').each(function(index, value) {
     191                                        var namearr = value.name.split('-');
     192                                        SelectFilter.init(value.id, namearr[namearr.length - 1], false, options.adminStaticPrefix);
     193                                });
     194                                $('.selectfilterstacked').each(function(index, value) {
     195                                        var namearr = value.name.split('-');
     196                                        SelectFilter.init(value.id, namearr[namearr.length - 1], true, options.adminStaticPrefix);
     197                                });
     198                        }
     199                };
    269200
    270     return $rows;
    271   };
     201                var initPrepopulatedFields = function(row) {
     202                        row.find('.prepopulated_field').each(function() {
     203                                var field = $(this), input = field.find('input, select, textarea'), dependency_list = input.data('dependency_list') || [], dependencies = [];
     204                                $.each(dependency_list, function(i, field_name) {
     205                                        dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id'));
     206                                });
     207                                if (dependencies.length) {
     208                                        input.prepopulate(dependencies, input.attr('maxlength'));
     209                                }
     210                        });
     211                };
     212
     213                $rows.formset({
     214                        prefix : options.prefix,
     215                        addText : options.addText,
     216                        formCssClass : "dynamic-" + options.prefix,
     217                        deleteCssClass : "inline-deletelink",
     218                        deleteText : options.deleteText,
     219                        emptyCssClass : "empty-form",
     220                        removed : alternatingRows,
     221                        added : function(row) {
     222                                initPrepopulatedFields(row);
     223                                reinitDateTimeShortCuts();
     224                                updateSelectFilter();
     225                                alternatingRows(row);
     226                        }
     227                });
     228
     229                return $rows;
     230        };
     231
     232        // Stacked inlines ---------------------------------------------------------
     233        $.fn.stackedFormset = function(options) {
     234                var $rows = $(this);
     235
     236                var update_inline_labels = function(formset_to_update) {
     237                        formset_to_update.children('.inline-related').not('.empty-form').children('h3').find('.inline_label').each(function(i) {
     238                                var count = i + 1;
     239                                $(this).html($(this).html().replace(/(#\d+)/g, "#" + count));
     240                        });
     241                };
     242
     243                var reinitDateTimeShortCuts = function() {
     244                        // Reinitialize the calendar and clock widgets by force, yuck.
     245                        if ( typeof DateTimeShortcuts != "undefined") {
     246                                $(".datetimeshortcuts").remove();
     247                                DateTimeShortcuts.init();
     248                        }
     249                };
     250
     251                var updateSelectFilter = function() {
     252                        // If any SelectFilter widgets were added, instantiate a new instance.
     253                        if ( typeof SelectFilter != "undefined") {
     254                                $(".selectfilter").each(function(index, value) {
     255                                        var namearr = value.name.split('-');
     256                                        SelectFilter.init(value.id, namearr[namearr.length - 1], false, options.adminStaticPrefix);
     257                                });
     258                                $(".selectfilterstacked").each(function(index, value) {
     259                                        var namearr = value.name.split('-');
     260                                        SelectFilter.init(value.id, namearr[namearr.length - 1], true, options.adminStaticPrefix);
     261                                });
     262                        }
     263                };
     264
     265                var initPrepopulatedFields = function(row) {
     266                        row.find('.prepopulated_field').each(function() {
     267                                var field = $(this), input = field.find('input, select, textarea'), dependency_list = input.data('dependency_list') || [], dependencies = [];
     268                                $.each(dependency_list, function(i, field_name) {
     269                                        dependencies.push('#' + row.find('.form-row .field-' + field_name).find('input, select, textarea').attr('id'));
     270                                });
     271                                if (dependencies.length) {
     272                                        input.prepopulate(dependencies, input.attr('maxlength'));
     273                                }
     274                        });
     275                };
     276
     277                $rows.formset({
     278                        prefix : options.prefix,
     279                        addText : options.addText,
     280                        formCssClass : "dynamic-" + options.prefix,
     281                        deleteCssClass : "inline-deletelink",
     282                        deleteText : options.deleteText,
     283                        emptyCssClass : "empty-form",
     284                        removed : update_inline_labels,
     285                        added : (function(row) {
     286                                initPrepopulatedFields(row);
     287                                reinitDateTimeShortCuts();
     288                                updateSelectFilter();
     289                                update_inline_labels(row.parent());
     290                        })
     291                });
     292
     293                return $rows;
     294        };
     295
     296        function create_nested_formset(parent_formset_prefix, next_form_id, options, add_bottom_border) {
     297                var formsets = $(false);
     298                // update options
     299                // Normalize prefix to something we can rely on
     300                var normalized_parent_formset_prefix = parent_formset_prefix.replace(/[-][0-9][-]/g, "-0-");
     301                // Check if the form should have nested formsets
     302                var nested_inlines = $('#' + normalized_parent_formset_prefix + "-group ." + normalized_parent_formset_prefix + "-nested-inline").not('.cloned');
     303                nested_inlines.each(function() {
     304                        // prefixes for the nested formset
     305                        var normalized_formset_prefix = $(this).attr('id').split('-group')[0];
     306                        // = "parent_formset_prefix"-0-"nested_inline_name"_set
     307                        var formset_prefix = normalized_formset_prefix.replace(normalized_parent_formset_prefix + "-0", parent_formset_prefix + "-" + next_form_id);
     308                        // = "parent_formset_prefix"-"next_form_id"-"nested_inline_name"_set
     309                        // Find the normalized formset and clone it
     310                        var template = $("#" + normalized_formset_prefix + "-group").clone();
     311                        template.addClass('cloned');
     312                        if (template.children().first().hasClass('tabular')) {
     313                                // Template is tabular
     314                                template.find(".form-row").not(".empty-form").remove();
     315                                template.find(".nested-inline-row").remove();
     316                                // Make a new form
     317                                template_form = template.find("#" + normalized_formset_prefix + "-empty")
     318                                new_form = template_form.clone().removeClass(options.emptyCssClass).addClass("dynamic-" + formset_prefix);
     319                                new_form.insertBefore(template_form);
     320                                // Update Form Properties
     321                                template.find('#id_' + formset_prefix + '-TOTAL_FORMS').val(1);
     322                                update_props(template, normalized_formset_prefix, formset_prefix);
     323                                var add_text = template.find('.add-row').text();
     324                                template.find('.add-row').remove();
     325                                template.find('.tabular.inline-related tbody tr.' + formset_prefix + '-not-nested').tabularFormset({
     326                                        prefix : formset_prefix,
     327                                        adminStaticPrefix : options.adminStaticPrefix,
     328                                        addText : add_text,
     329                                        deleteText : options.deleteText
     330                                });
     331                                // Create the nested formset
     332                                var nested_formsets = create_nested_formset(formset_prefix, 0, options, false);
     333                                if (nested_formsets.length) {
     334                                        template.find(".form-row").addClass('no-bottom-border');
     335                                }
     336                                // Insert nested formsets
     337                                nested_formsets.each(function() {
     338                                        if (!$(this).next()) {
     339                                                border_class = "";
     340                                        } else {
     341                                                border_class = " no-bottom-border";
     342                                        }
     343                                        template.find("#" + formset_prefix + "-empty").before(($('<tr class="nested-inline-row' + border_class + '">').html(($('<td>', {
     344                                                                colspan : '100%'
     345                                                        }).html($(this))))));
     346                                });
     347                        } else {
     348                                // Template is stacked
     349                                // Create the nested formset
     350                                var nested_formsets = create_nested_formset(formset_prefix, 0, options, true);
     351                                template.find(".inline-related").not(".empty-form").remove();
     352                                // Make a new form
     353                                template_form = template.find("#" + normalized_formset_prefix + "-empty")
     354                                new_form = template_form.clone().removeClass(options.emptyCssClass).addClass("dynamic-" + formset_prefix);
     355                                new_form.insertBefore(template_form);
     356                                // Update Form Properties
     357                                template.find('#id_' + normalized_formset_prefix + '-TOTAL_FORMS').val(1);
     358                                new_form.find('.inline_label').text('#1');
     359                                update_props(template, normalized_formset_prefix, formset_prefix);
     360                                var add_text = template.find('.add-row').text();
     361                                template.find('.add-row').remove();
     362                                template.find(".inline-related").stackedFormset({
     363                                        prefix : formset_prefix,
     364                                        adminStaticPrefix : options.adminStaticPrefix,
     365                                        addText : add_text,
     366                                        deleteText : options.deleteText
     367                                });
     368                                nested_formsets.each(function() {
     369                                        new_form.append($(this));
     370                                });
     371                        }
     372                        if (add_bottom_border) {
     373                                template = template.add($('<div class="nested-inline-bottom-border">'));
     374                        }
     375                        if (formsets.length) {
     376                                formsets = formsets.add(template);
     377                        } else {
     378                                formsets = template;
     379                        }
     380                });
     381                return formsets;
     382        };
     383
     384        function update_props(template, normalized_formset_prefix, formset_prefix) {
     385                // Fix template id
     386                template.attr('id', template.attr('id').replace(normalized_formset_prefix, formset_prefix));
     387                template.find('*').each(function() {
     388                        if ($(this).attr("for")) {
     389                                $(this).attr("for", $(this).attr("for").replace(normalized_formset_prefix, formset_prefix));
     390                        }
     391                        if ($(this).attr("class")) {
     392                                $(this).attr("class", $(this).attr("class").replace(normalized_formset_prefix, formset_prefix));
     393                        }
     394                        if (this.id) {
     395                                this.id = this.id.replace(normalized_formset_prefix, formset_prefix);
     396                        }
     397                        if (this.name) {
     398                                this.name = this.name.replace(normalized_formset_prefix, formset_prefix);
     399                        }
     400                });
     401                // fix __prefix__ where needed
     402                prefix_fix = template.find(".inline-related").first();
     403                nextIndex = get_no_forms(formset_prefix);
     404                if (prefix_fix.hasClass('tabular')) {
     405                        // tabular
     406                        prefix_fix = prefix_fix.find('.form-row').first();
     407                        prefix_fix.attr('id', prefix_fix.attr('id').replace('-empty', '-' + nextIndex));
     408                } else {
     409                        // stacked
     410                        prefix_fix.attr('id', prefix_fix.attr('id').replace('-empty', '-' + nextIndex));
     411                }
     412                prefix_fix.find('*').each(function() {
     413                        if ($(this).attr("for")) {
     414                                $(this).attr("for", $(this).attr("for").replace('__prefix__', '0'));
     415                        }
     416                        if ($(this).attr("class")) {
     417                                $(this).attr("class", $(this).attr("class").replace('__prefix__', '0'));
     418                        }
     419                        if (this.id) {
     420                                this.id = this.id.replace('__prefix__', '0');
     421                        }
     422                        if (this.name) {
     423                                this.name = this.name.replace('__prefix__', '0');
     424                        }
     425                });
     426        };
     427
     428        // This returns the amount of forms in the given formset
     429        function get_no_forms(formset_prefix) {
     430                formset_prop = $("#id_" + formset_prefix + "-TOTAL_FORMS")
     431                if (!formset_prop.length) {
     432                        return 0;
     433                }
     434                return parseInt(formset_prop.attr("autocomplete", "off").val());
     435        }
     436
     437        function change_no_forms(formset_prefix, increase) {
     438                var no_forms = get_no_forms(formset_prefix);
     439                if (increase) {
     440                        $("#id_" + formset_prefix + "-TOTAL_FORMS").attr("autocomplete", "off").val(parseInt(no_forms) + 1);
     441                } else {
     442                        $("#id_" + formset_prefix + "-TOTAL_FORMS").attr("autocomplete", "off").val(parseInt(no_forms) - 1);
     443                }
     444        };
     445
     446        // This return the maximum amount of forms in the given formset
     447        function get_max_forms(formset_prefix) {
     448                var max_forms = $("#id_" + formset_prefix + "-MAX_FORMS").attr("autocomplete", "off").val();
     449                if ( typeof max_forms == 'undefined') {
     450                        return '';
     451                }
     452                return parseInt(max_forms);
     453        };
    272454})(django.jQuery);
     455
     456// TODO:
     457// Remove border between tabular fieldset and nested inline
     458// Fix alternating rows
  • django/contrib/admin/static/admin/js/inlines.min.js

    diff --git a/django/contrib/admin/static/admin/js/inlines.min.js b/django/contrib/admin/static/admin/js/inlines.min.js
    index d48ee0a..c2fec35 100644
    a b  
    1 (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("."+
    2 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+
    3 "-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,
    4 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,
    5 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-"+
    6 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"!=
    7 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,
    8 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(),
    9 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);
     1(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-"+
     2g);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'+
     3border_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,
     4addText: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))});
     5prefix_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=
     6this.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=
     7b.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)">'+
     8a.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="'+
     9a.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=
     10b(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),
     11nested_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"+
     12(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"))});
     13"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+=
     141;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,
     15d.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);
  • django/contrib/admin/templates/admin/edit_inline/stacked.html

    diff --git a/django/contrib/admin/templates/admin/edit_inline/stacked.html b/django/contrib/admin/templates/admin/edit_inline/stacked.html
    index 2025dd8..c0c5389 100644
    a b  
    11{% load i18n admin_static %}
    2 <div class="inline-group" id="{{ inline_admin_formset.formset.prefix }}-group">
    3   <h2>{{ inline_admin_formset.opts.verbose_name_plural|title }}</h2>
    4 {{ inline_admin_formset.formset.management_form }}
    5 {{ inline_admin_formset.formset.non_form_errors }}
     2<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">
     3{% with recursive_formset=inline_admin_formset stacked_template='admin/edit_inline/stacked.html' tabular_template='admin/edit_inline/tabular.html'%}
     4  <h2>{{ recursive_formset.opts.verbose_name_plural|title }}</h2>
     5{{ recursive_formset.formset.management_form }}
     6{{ recursive_formset.formset.non_form_errors }}
    67
    7 {% 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 %}">
    8   <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>
     8{% 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 %}">
     9  <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>
    910    {% 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 %}
    10     {% 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 %}
     11    {% 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 %}
    1112  </h3>
    1213  {% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %}
    1314  {% for fieldset in inline_admin_form %}
     
    1516  {% endfor %}
    1617  {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
    1718  {{ inline_admin_form.fk_field.field }}
     19  {% if inline_admin_form.form.nested_formsets %}
     20    {% for inline_admin_formset in inline_admin_form.form.nested_formsets %}
     21          {% if inline_admin_formset.opts.template == stacked_template %}
     22        {% include stacked_template %}
     23          {% else %}
     24                {% include tabular_template %}
     25          {% endif %}
     26          <div class="nested-inline-bottom-border"></div>
     27    {% endfor %}
     28  {% endif %}
    1829</div>{% endfor %}
    1930</div>
    2031
    2132<script type="text/javascript">
    2233(function($) {
    23   $("#{{ inline_admin_formset.formset.prefix }}-group .inline-related").stackedFormset({
    24     prefix: '{{ inline_admin_formset.formset.prefix }}',
     34  $("#{{ recursive_formset.formset.prefix }}-group > .inline-related").stackedFormset({
     35    prefix: '{{ recursive_formset.formset.prefix }}',
    2536    adminStaticPrefix: '{% static "admin/" %}',
    26     deleteText: "{% trans "Remove" %}",
    27     addText: "{% blocktrans with verbose_name=inline_admin_formset.opts.verbose_name|title %}Add another {{ verbose_name }}{% endblocktrans %}"
     37    addText: "{% blocktrans with verbose_name=recursive_formset.opts.verbose_name|title %}Add another {{ verbose_name }}{% endblocktrans %}",
     38    deleteText: "{% trans "Remove" %}"
    2839  });
    2940})(django.jQuery);
    3041</script>
     42{% endwith %}
  • django/contrib/admin/templates/admin/edit_inline/tabular.html

    diff --git a/django/contrib/admin/templates/admin/edit_inline/tabular.html b/django/contrib/admin/templates/admin/edit_inline/tabular.html
    index f2757ed..1b0de0e 100644
    a b  
    11{% load i18n admin_static admin_modify %}
    2 <div class="inline-group" id="{{ inline_admin_formset.formset.prefix }}-group">
     2<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">
     3{% with recursive_formset=inline_admin_formset stacked_template='admin/edit_inline/stacked.html' tabular_template='admin/edit_inline/tabular.html'%}
    34  <div class="tabular inline-related {% if forloop.last %}last-related{% endif %}">
    4 {{ inline_admin_formset.formset.management_form }}
     5{{ recursive_formset.formset.management_form }}
    56<fieldset class="module">
    6    <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
    7    {{ inline_admin_formset.formset.non_form_errors }}
     7   <h2>{{ recursive_formset.opts.verbose_name_plural|capfirst }}</h2>
     8   {{ recursive_formset.formset.non_form_errors }}
    89   <table>
    910     <thead><tr>
    10      {% for field in inline_admin_formset.fields %}
     11     {% for field in recursive_formset.fields %}
    1112       {% if not field.widget.is_hidden %}
    1213         <th{% if forloop.first %} colspan="2"{% endif %}{% if field.required %} class="required"{% endif %}>{{ field.label|capfirst }}
    1314         {% 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 %}
    1415         </th>
    1516       {% endif %}
    1617     {% endfor %}
    17      {% if inline_admin_formset.formset.can_delete %}<th>{% trans "Delete?" %}</th>{% endif %}
     18     {% if recursive_formset.formset.can_delete %}<th>{% trans "Delete?" %}</th>{% endif %}
    1819     </tr></thead>
    1920
    2021     <tbody>
    21      {% for inline_admin_form in inline_admin_formset %}
     22     {% for inline_admin_form in recursive_formset %}
    2223        {% if inline_admin_form.form.non_field_errors %}
    2324        <tr><td colspan="{{ inline_admin_form|cell_count }}">{{ inline_admin_form.form.non_field_errors }}</td></tr>
    2425        {% endif %}
    25         <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 %}"
    26              id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
     26        <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 %}"
     27             id="{{ recursive_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
    2728        <td class="original">
    2829          {% if inline_admin_form.original or inline_admin_form.show_url %}<p>
    2930          {% if inline_admin_form.original %} {{ inline_admin_form.original }}{% endif %}
     
    5556            {% endfor %}
    5657          {% endfor %}
    5758        {% endfor %}
    58         {% if inline_admin_formset.formset.can_delete %}
     59        {% if recursive_formset.formset.can_delete %}
    5960          <td class="delete">{% if inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }}{% endif %}</td>
    6061        {% endif %}
    6162        </tr>
     63        {% if inline_admin_form.form.nested_formsets %}
     64                  {% for inline_admin_formset in inline_admin_form.form.nested_formsets %}
     65                  <tr class="nested-inline-row {{ row_number_class }}{% if not forloop.last %} no-bottom-border{% endif %}">
     66                    <td colspan="0">
     67                          {% if inline_admin_formset.opts.template == stacked_template %}
     68                            {% include stacked_template with indent=0 prev_prefix=recursive_formset.formset.prefix %}
     69                          {% else %}
     70                                {% include tabular_template with indent=0 prev_prefix=recursive_formset.formset.prefix %}
     71                          {% endif %}
     72                        </td>
     73                  </tr>
     74          {% endfor %}
     75                {% endif %}
    6276     {% endfor %}
    6377     </tbody>
    6478   </table>
     
    6781</div>
    6882
    6983<script type="text/javascript">
    70 
    7184(function($) {
    72   $("#{{ inline_admin_formset.formset.prefix }}-group .tabular.inline-related tbody tr").tabularFormset({
    73     prefix: "{{ inline_admin_formset.formset.prefix }}",
     85  $("#{{ recursive_formset.formset.prefix }}-group .tabular.inline-related tbody tr.{{ recursive_formset.formset.prefix }}-not-nested").tabularFormset({
     86    prefix: "{{ recursive_formset.formset.prefix }}",
    7487    adminStaticPrefix: '{% static "admin/" %}',
    75     addText: "{% blocktrans with inline_admin_formset.opts.verbose_name|title as verbose_name %}Add another {{ verbose_name }}{% endblocktrans %}",
     88    addText: "{% blocktrans with recursive_formset.opts.verbose_name|title as verbose_name %}Add another {{ verbose_name }}{% endblocktrans %}",
    7689    deleteText: "{% trans 'Remove' %}"
    7790  });
    7891})(django.jQuery);
    7992</script>
     93{% endwith %}
  • django/contrib/admin/tests.py

    diff --git a/django/contrib/admin/tests.py b/django/contrib/admin/tests.py
    index 7c62c1a..6fe6216 100644
    a b from django.test import LiveServerTestCase 
    22from django.utils.importlib import import_module
    33from django.utils.unittest import SkipTest
    44from django.utils.translation import ugettext as _
     5from selenium import webdriver
    56
    67class AdminSeleniumWebDriverTestCase(LiveServerTestCase):
    78    webdriver_class = 'selenium.webdriver.firefox.webdriver.WebDriver'
    class AdminSeleniumWebDriverTestCase(LiveServerTestCase): 
    1314            module, attr = cls.webdriver_class.rsplit('.', 1)
    1415            mod = import_module(module)
    1516            WebDriver = getattr(mod, attr)
     17            #Avoid startup screen
    1618            cls.selenium = WebDriver()
    1719        except Exception as e:
    1820            raise SkipTest('Selenium webdriver "%s" not installed or not '
  • docs/ref/contrib/admin/index.txt

    diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
    index 06751df..b610616 100644
    a b information. 
    13691369
    13701370    The difference between these two is merely the template used to render
    13711371    them.
     1372   
     1373    .. versionadded:: 1.5
     1374   
     1375    You can also display inlines inside an inline. Suppose you have a third model::
     1376       
     1377         class Chapter(models.Model):
     1378            name = models.CharField(max_length=100)
     1379            book = models.ForeignKey(Book)
     1380
     1381    These inlines work exactly the same as normal inlines, but they are specified
     1382    inside a ``InlineModelAdmin.inlines``::
    13721383
     1384        class BookInline(admin.TabularInline):
     1385           model = Book
     1386           inlines = [
     1387                ChapterInline,
     1388           ]
     1389
     1390        class ChapterInline(admin.StackedInline):
     1391           model = Chapter
     1392       
    13731393``InlineModelAdmin`` options
    13741394-----------------------------
    13751395
    adds some of its own (the shared features are actually defined in the 
    13991419- :meth:`~ModelAdmin.has_change_permission`
    14001420- :meth:`~ModelAdmin.has_delete_permission`
    14011421
     1422.. versionadded:: 1.5
     1423
     1424- :meth:`~ModelAdmin.inlines`
     1425
    14021426The ``InlineModelAdmin`` class adds:
    14031427
    14041428.. attribute:: InlineModelAdmin.model
  • tests/regressiontests/admin_inlines/admin.py

    diff --git a/tests/regressiontests/admin_inlines/admin.py b/tests/regressiontests/admin_inlines/admin.py
    index cf51fa4..3f2d067 100644
    a b class ChildModel1Inline(admin.TabularInline): 
    123123
    124124class ChildModel2Inline(admin.StackedInline):
    125125    model = ChildModel2
     126   
     127class FurnitureInline(admin.StackedInline):
     128    model = Furniture
     129    extra = 1
     130   
     131class InhabitantInline(admin.StackedInline):
     132    model = Inhabitant
     133    extra = 1
     134    inlines = [ FurnitureInline, ]
     135   
     136class AppartementInline(admin.TabularInline):
     137    model = Appartement
     138    extra = 1
     139    inlines = [ InhabitantInline, ]
     140   
     141class MonumentInline(admin.StackedInline):
     142    model = Monument
     143    extra = 1
     144   
     145class BuildingInline(admin.TabularInline):
     146    model = Building
     147    extra = 1
     148    inlines = [ AppartementInline, ]
     149   
     150class CityInline(admin.StackedInline):
     151    model = City
     152    extra = 1
     153    inlines = [BuildingInline, MonumentInline, ]
    126154
    127155
    128156site.register(TitleCollection, inlines=[TitleInline])
    site.register(Holder4, Holder4Admin) 
    141169site.register(Author, AuthorAdmin)
    142170site.register(CapoFamiglia, inlines=[ConsigliereInline, SottoCapoInline])
    143171site.register(ProfileCollection, inlines=[ProfileInline])
    144 site.register(ParentModelWithCustomPk, inlines=[ChildModel1Inline, ChildModel2Inline])
    145  No newline at end of file
     172site.register(ParentModelWithCustomPk, inlines=[ChildModel1Inline, ChildModel2Inline])
     173site.register(Country, inlines=[CityInline])
     174site.register(City)
     175site.register(Building)
     176site.register(Monument)
     177site.register(Appartement)
     178site.register(Inhabitant)
     179site.register(Furniture)
     180 No newline at end of file
  • tests/regressiontests/admin_inlines/models.py

    diff --git a/tests/regressiontests/admin_inlines/models.py b/tests/regressiontests/admin_inlines/models.py
    index b004d5f..c9c522c 100644
    a b class Profile(models.Model): 
    180180    collection = models.ForeignKey(ProfileCollection, blank=True, null=True)
    181181    first_name = models.CharField(max_length=100)
    182182    last_name = models.CharField(max_length=100)
     183   
     184class Country(models.Model):
     185    name = models.CharField(max_length=100)
     186   
     187    def __unicode__(self):
     188        return self.name
     189
     190class City(models.Model):
     191    name = models.CharField(max_length=100)
     192    population = models.IntegerField()
     193    country = models.ForeignKey(Country)
     194   
     195    def __unicode__(self):
     196        return self.name
     197
     198class Building(models.Model):
     199    name = models.CharField(max_length=100)
     200    city = models.ForeignKey(City)
     201   
     202    def __unicode__(self):
     203        return self.name
     204
     205class Appartement(models.Model):
     206    name = models.CharField(max_length=100)
     207    building = models.ForeignKey(Building)
     208   
     209    def __unicode__(self):
     210        return self.name
     211
     212class Inhabitant(models.Model):
     213    name = models.CharField(max_length=100)
     214    appartement = models.ForeignKey(Appartement)
     215   
     216    def __unicode__(self):
     217        return self.name
     218
     219class Furniture(models.Model):
     220    name = models.CharField(max_length=100)
     221    inhabitant = models.ForeignKey(Inhabitant)
     222   
     223    def __unicode__(self):
     224        return self.name
     225   
     226class Monument(models.Model):
     227    name = models.CharField(max_length=100)
     228    city = models.ForeignKey(City)
     229   
     230    def __unicode__(self):
     231        return self.name
     232 No newline at end of file
  • tests/regressiontests/admin_inlines/tests.py

    diff --git a/tests/regressiontests/admin_inlines/tests.py b/tests/regressiontests/admin_inlines/tests.py
    index 5bb6077..bc9c5d3 100644
    a b from django.test import TestCase 
    88from django.test.utils import override_settings
    99
    1010# local test models
    11 from .admin import InnerInline, TitleInline, site
     11from .admin import InnerInline
    1212from .models import (Holder, Inner, Holder2, Inner2, Holder3, Inner3, Person,
    1313    OutfitItem, Fashionista, Teacher, Parent, Child, Author, Book, Profile,
    14     ProfileCollection, ParentModelWithCustomPk, ChildModel1, ChildModel2,
    15     Title)
     14    ProfileCollection, ParentModelWithCustomPk, ChildModel1, ChildModel2, 
     15    Country, City, Building, Appartement, Inhabitant, Furniture, Monument)
    1616
    1717
    1818@override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
    class SeleniumFirefoxTests(AdminSeleniumWebDriverTestCase): 
    560560            'form#profilecollection_form tr.dynamic-profile_set#profile_set-1 td.delete a').click()
    561561        self.selenium.find_element_by_css_selector(
    562562            'form#profilecollection_form tr.dynamic-profile_set#profile_set-2 td.delete a').click()
    563         # Verify that they're gone and that the IDs have been re-sequenced
     563        # Verify that they're gone
    564564        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
    565565            '#profile_set-group table tr.dynamic-profile_set')), 3)
    566566        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
    567567            'form#profilecollection_form tr.dynamic-profile_set#profile_set-0')), 1)
    568568        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
    569             'form#profilecollection_form tr.dynamic-profile_set#profile_set-1')), 1)
     569            'form#profilecollection_form tr.dynamic-profile_set#profile_set-1')), 0)
    570570        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
    571             'form#profilecollection_form tr.dynamic-profile_set#profile_set-2')), 1)
     571            'form#profilecollection_form tr.dynamic-profile_set#profile_set-2')), 0)
     572        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     573            'form#profilecollection_form tr.dynamic-profile_set#profile_set-3')), 1)
     574        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     575            'form#profilecollection_form tr.dynamic-profile_set#profile_set-4')), 1)
    572576
    573577    def test_alternating_rows(self):
    574578        self.admin_login(username='super', password='secret')
    class SeleniumFirefoxTests(AdminSeleniumWebDriverTestCase): 
    583587        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
    584588            "%s.row1" % row_selector)), 2, msg="Expect two row1 styled rows")
    585589        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
    586             "%s.row2" % row_selector)), 1, msg="Expect one row2 styled row")
     590            "%s.row2" % row_selector)), 1, msg="Expect one row2 styled row")   
    587591
     592    def test_add_nested_inlines(self):
     593        self.admin_login(username='super', password='secret')
     594        self.selenium.get('%s%s' % (self.live_server_url,
     595            '/admin/admin_inlines/country/add/'))
     596       
     597        # Add some cities
     598        self.selenium.find_element_by_link_text('Add another City').click()
     599        self.selenium.find_element_by_link_text('Add another City').click()
     600        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     601            "#city_set-1 .nested-inline-row .nested-inline-row #city_set-1-building_set-0-appartement_set-0-inhabitant_set-0 " +
     602            "#city_set-1-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0")), 1, "Expected furniture set in second city");
     603        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     604            "#city_set-2 .nested-inline-row #city_set-2-building_set-0-appartement_set-0-inhabitant_set-0 " +
     605            "#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-0")), 1, "Expected furniture set in third city");
     606        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     607            "#city_set-1 #city_set-1-monument_set-0")), 1, "Expected monument set in second city");
     608        # Add monument in first city
     609        self.selenium.find_elements_by_css_selector('#city_set-0-monument_set-group > .add-row a')[0].click()
     610        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     611            "#city_set-0 #city_set-0-monument_set-1")), 1, "Expected second monument in first city")
     612        # Add building in second city
     613        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-0 ~ .add-row a')[0].click()
     614        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     615            "#city_set-1 #city_set-1-building_set-1")), 1, "Expected second building in second city");
     616        # Add apartement in second building of second city
     617        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-1-appartement_set-0 ~ .add-row a')[0].click()
     618        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     619            "#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");
     620        # Add inhabitants in third city
     621        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0 ~ .add-row a')[0].click()
     622        self.selenium.find_elements_by_css_selector('#city_set-2-building_set-0-appartement_set-0-inhabitant_set-0 ~ .add-row a')[0].click()
     623        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     624            "#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");
     625        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     626            "#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");
     627        # Add furniture in first city
     628        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()
     629        self.assertEqual(len(self.selenium.find_elements_by_css_selector(
     630            "#city_set-0 .nested-inline-row .nested-inline-row #city_set-0-building_set-0-appartement_set-0-inhabitant_set-0 "+
     631            "#city_set-0-building_set-0-appartement_set-0-inhabitant_set-0-furniture_set-1")), 1, "Expected second furniture in first city");       
     632   
     633    def test_delete_nested_inlines(self):
     634        self.admin_login(username='super', password='secret')
     635        self.selenium.get('%s%s' % (self.live_server_url,
     636            '/admin/admin_inlines/country/add/'))
     637       
     638        # Add 2 cities
     639        self.selenium.find_element_by_link_text('Add another City').click()
     640        self.selenium.find_element_by_link_text('Add another City').click()
     641        # Delete second city
     642        self.selenium.find_elements_by_css_selector('#city_set-1 > h3 a.inline-deletelink')[0].click()
     643        # Check if only two cities
     644        self.assertEqual(len(self.selenium.find_elements_by_css_selector(".dynamic-city_set")), 2, "Expected 2 cities")
     645        # Add 2 appartements in first city
     646        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-0 ~ .add-row a')[0].click()
     647        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-0 ~ .add-row a')[0].click()
     648        # Delete second appartement
     649        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-1 > td.delete a')[0].click()
     650        # Check if only two appartements in first city
     651        self.assertEqual(len(self.selenium.find_elements_by_css_selector(".dynamic-city_set-0-building_set-0-appartement_set")), 2, "Expected 2 Inhabitants")
     652        # Check that nested inlines have also been deleted
     653        self.assertEqual(len(self.selenium.find_elements_by_css_selector(".dynamic-city_set-0-building_set-0-appartement_set")), 2, "Expected 2 Inhabitants")
     654        # Add 4 furniture in second city
     655        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()
     656        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()
     657        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()
     658        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()
     659        # Delete second and fourth
     660        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()
     661        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()
     662        # Check if only 3 furniture
     663        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")
     664   
     665    def test_save_nested_inlines(self):
     666        self.admin_login(username='super', password='secret')
     667        self.selenium.get('%s%s' % (self.live_server_url,
     668            '/admin/admin_inlines/country/add/'))
     669       
     670        # Add City
     671        self.selenium.find_element_by_link_text('Add another City').click()
     672        # Add Buildings
     673        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0 ~ .add-row a')[0].click()
     674        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-0 ~ .add-row a')[0].click()
     675        # Add Appartements
     676        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-0 ~ .add-row a')[0].click()
     677        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-1-appartement_set-0 ~ .add-row a')[0].click()
     678        # Add Inhabitant
     679        self.selenium.find_elements_by_css_selector('#city_set-0-building_set-0-appartement_set-0-inhabitant_set-0 ~ .add-row a')[0].click()
     680        self.selenium.find_elements_by_css_selector('#city_set-1-building_set-1-appartement_set-0-inhabitant_set-0 ~ .add-row a')[0].click()
     681        # Add Furniture
     682        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()
     683        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()
     684        # Add Monument
     685        self.selenium.find_elements_by_css_selector('#city_set-0-monument_set-0 ~ .add-row a')[0].click()
     686        self.selenium.find_elements_by_css_selector('#city_set-1-monument_set-0 ~ .add-row a')[0].click()
     687        # Input Data
     688        self.selenium.find_element_by_css_selector('#id_name').send_keys('Belgium')
     689        self.selenium.find_element_by_css_selector('#id_city_set-0-name').send_keys('C 1')
     690        self.selenium.find_element_by_css_selector('#id_city_set-0-population').send_keys('10')
     691        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-name').send_keys('B 1.1')
     692        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-0-name').send_keys('A 1.1.1')
     693        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')
     694        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')
     695        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')
     696        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')
     697        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-0-appartement_set-1-name').send_keys('A 1.1.2')
     698        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')
     699        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')
     700        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')
     701        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-1-name').send_keys('B 1.2')
     702        self.selenium.find_element_by_css_selector('#id_city_set-0-building_set-1-appartement_set-0-name').send_keys('A 1.2.1')
     703        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')
     704        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')
     705        self.selenium.find_element_by_css_selector('#id_city_set-0-monument_set-0-name').send_keys('M 1.1')
     706        self.selenium.find_element_by_css_selector('#id_city_set-0-monument_set-1-name').send_keys('M 1.2')
     707        self.selenium.find_element_by_css_selector('#id_city_set-1-name').send_keys('C 2')
     708        self.selenium.find_element_by_css_selector('#id_city_set-1-population').send_keys('10')
     709        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-0-name').send_keys('B 2.1')
     710        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-0-appartement_set-0-name').send_keys('A 2.1.1')
     711        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')
     712        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')
     713        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-name').send_keys('B 2.2')
     714        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-0-name').send_keys('A 2.2.1')
     715        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')
     716        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')
     717        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')
     718        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')
     719        self.selenium.find_element_by_css_selector('#id_city_set-1-building_set-1-appartement_set-1-name').send_keys('A 2.2.2')
     720        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')
     721        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')
     722        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')
     723        self.selenium.find_element_by_css_selector('#id_city_set-1-monument_set-0-name').send_keys('M 2.1')
     724        self.selenium.find_element_by_css_selector('#id_city_set-1-monument_set-1-name').send_keys('M 2.2')
     725        # Delete inhabitant 2.2.1.2
     726        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()
     727        # Delete furniture 1.1.2.1.2
     728        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()
     729        # Save
     730        self.selenium.find_element_by_xpath('//input[@value="Save"]').click()
     731       
     732        try:
     733            # Wait for the next page to be loaded.
     734            self.wait_loaded_tag('body')
     735        except TimeoutException:
     736            # IE7 occasionnally returns an error "Internet Explorer cannot
     737            # display the webpage" and doesn't load the next page. We just
     738            # ignore it.
     739            pass
    588740
     741        # Check if saved correctly
     742        self.assertEqual(Country.objects.all().count(), 1)
     743        self.assertEqual(City.objects.get(name="C 1").country, Country.objects.get(name="Belgium"))
     744        self.assertEqual(Building.objects.get(name="B 1.1").city, City.objects.get(name="C 1"))
     745        self.assertEqual(Building.objects.get(name="B 1.2").city, City.objects.get(name="C 1"))
     746        self.assertEqual(Building.objects.get(name="B 2.1").city, City.objects.get(name="C 2"))
     747        self.assertEqual(Building.objects.get(name="B 2.2").city, City.objects.get(name="C 2"))
     748        self.assertEqual(Appartement.objects.get(name="A 1.1.1").building, Building.objects.get(name="B 1.1"))
     749        self.assertEqual(Appartement.objects.get(name="A 1.1.2").building, Building.objects.get(name="B 1.1"))
     750        self.assertEqual(Appartement.objects.get(name="A 1.2.1").building, Building.objects.get(name="B 1.2"))
     751        self.assertEqual(Appartement.objects.get(name="A 2.1.1").building, Building.objects.get(name="B 2.1"))
     752        self.assertEqual(Appartement.objects.get(name="A 2.2.1").building, Building.objects.get(name="B 2.2"))
     753        self.assertEqual(Appartement.objects.get(name="A 2.2.2").building, Building.objects.get(name="B 2.2"))
     754        self.assertEqual(Inhabitant.objects.get(name="I 1.1.1.1").appartement, Appartement.objects.get(name="A 1.1.1"))
     755        self.assertEqual(Inhabitant.objects.get(name="I 1.1.1.2").appartement, Appartement.objects.get(name="A 1.1.1"))
     756        self.assertEqual(Inhabitant.objects.get(name="I 1.1.2.1").appartement, Appartement.objects.get(name="A 1.1.2"))
     757        self.assertEqual(Inhabitant.objects.get(name="I 1.2.1.1").appartement, Appartement.objects.get(name="A 1.2.1"))
     758        self.assertEqual(Inhabitant.objects.get(name="I 2.1.1.1").appartement, Appartement.objects.get(name="A 2.1.1"))
     759        self.assertEqual(Inhabitant.objects.get(name="I 2.2.1.1").appartement, Appartement.objects.get(name="A 2.2.1"))
     760        self.assertEqual(len(Inhabitant.objects.filter(name="I 2.2.1.2")), 0)
     761        self.assertEqual(Inhabitant.objects.get(name="I 2.2.2.1").appartement, Appartement.objects.get(name="A 2.2.2"))
     762        self.assertEqual(Furniture.objects.get(name="F 1.1.1.1.1").inhabitant, Inhabitant.objects.get(name="I 1.1.1.1"))
     763        self.assertEqual(Furniture.objects.get(name="F 1.1.1.2.1").inhabitant, Inhabitant.objects.get(name="I 1.1.1.2"))
     764        self.assertEqual(Furniture.objects.get(name="F 1.1.2.1.1").inhabitant, Inhabitant.objects.get(name="I 1.1.2.1"))
     765        self.assertEqual(len(Furniture.objects.filter(name="F 1.1.2.1.2")), 0)
     766        self.assertEqual(Furniture.objects.get(name="F 1.2.1.1.1").inhabitant, Inhabitant.objects.get(name="I 1.2.1.1"))
     767        self.assertEqual(Furniture.objects.get(name="F 2.1.1.1.1").inhabitant, Inhabitant.objects.get(name="I 2.1.1.1"))
     768        self.assertEqual(Furniture.objects.get(name="F 2.2.1.1.1").inhabitant, Inhabitant.objects.get(name="I 2.2.1.1"))
     769        self.assertEqual(len(Furniture.objects.filter(name="F 2.2.1.2.1")), 0)
     770        self.assertEqual(Furniture.objects.get(name="F 2.2.2.1.1").inhabitant, Inhabitant.objects.get(name="I 2.2.2.1"))
     771        self.assertEqual(Furniture.objects.get(name="F 2.2.2.1.2").inhabitant, Inhabitant.objects.get(name="I 2.2.2.1"))
     772        self.assertEqual(Monument.objects.get(name="M 1.1").city, City.objects.get(name="C 1"))
     773        self.assertEqual(Monument.objects.get(name="M 1.2").city, City.objects.get(name="C 1"))
     774        self.assertEqual(Monument.objects.get(name="M 2.1").city, City.objects.get(name="C 2"))
     775        self.assertEqual(Monument.objects.get(name="M 2.2").city, City.objects.get(name="C 2"))
     776   
    589777class SeleniumChromeTests(SeleniumFirefoxTests):
    590778    webdriver_class = 'selenium.webdriver.chrome.webdriver.WebDriver'
    591779
Back to Top