Ian Struble avatar Ian Struble committed b2e63bc

Added initial ChangeList support for ManyToManyField fields (both list_display and list_editable).

All m2m fields should still explicitly added to the autocomplete_fields if they are to be displayed in the ChangeList.

Squashed another subtle client side bug when initial values were passed as [] instead of None.

Comments (0)

Files changed (6)

django/contrib/admin/options.py

         for (field, values) in self.autocomplete_fields.items():
             settings = autocomplete_fields[field] = AUTOCOMPLETE_FIELDS_DEFAULTS.copy()
             settings.update(values)
+            settings['parent_model'] = self.model
             if hasattr(self.model, field):
                 rel = getattr(self.model, field).field.rel
                 settings['id'] = settings.get('id', rel.get_related_field().name)
             kwargs['widget'] = widgets.AutocompleteWidget(
                 self.autocomplete_fields[db_field.name],
                 using=kwargs.get('using'),
-                force_selection=False)
+                force_selection=False, 
+                admin_site=self.admin_site)
 
         # If we've got overrides for the formfield defined, use 'em. **kwargs
         # passed to formfield_for_dbfield override the defaults.
         """
         db = kwargs.get('using')
         if db_field.name in self.raw_id_fields:
-            kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.rel, using=db)
+            kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.rel, using=db, admin_site=self.admin_site)
         elif db_field.name in self.autocomplete_fields:
             kwargs['widget'] = widgets.AutocompleteWidget(
-                self.autocomplete_fields[db_field.name], using=db)
+                self.autocomplete_fields[db_field.name], using=db, admin_site=self.admin_site)
         elif db_field.name in self.radio_fields:
             kwargs['widget'] = widgets.AdminRadioSelect(attrs={
                 'class': get_ul_class(self.radio_fields[db_field.name]),
         db = kwargs.get('using')
 
         if db_field.name in self.raw_id_fields:
-            kwargs['widget'] = widgets.ManyToManyRawIdWidget(db_field.rel, using=db)
+            kwargs['widget'] = widgets.ManyToManyRawIdWidget(db_field.rel, using=db, admin_site=self.admin_site)
             kwargs['help_text'] = ''
         elif db_field.name in self.autocomplete_fields:
             kwargs['widget'] = widgets.MultipleAutocompleteWidget(
-                self.autocomplete_fields[db_field.name], using=db)
+                self.autocomplete_fields[db_field.name], using=db, admin_site=self.admin_site)
             kwargs['help_text'] = ''
         elif db_field.name in (list(self.filter_vertical) + list(self.filter_horizontal)):
             kwargs['widget'] = widgets.FilteredSelectMultiple(db_field.verbose_name, (db_field.name in self.filter_vertical))

django/contrib/admin/validation.py

                         except models.FieldDoesNotExist:
                             raise ImproperlyConfigured("%s.list_display[%d], %r is not a callable or an attribute of %r or found in the model %r."
                                 % (cls.__name__, idx, field, cls.__name__, model._meta.object_name))
-                    else:
-                        # getattr(model, field) could be an X_RelatedObjectsDescriptor
-                        f = fetch_attr(cls, model, opts, "list_display[%d]" % idx, field)
-                        if isinstance(f, models.ManyToManyField):
-                            raise ImproperlyConfigured("'%s.list_display[%d]', '%s' is a ManyToManyField which is not supported."
-                                % (cls.__name__, idx, field))
 
     # list_display_links
     if hasattr(cls, 'list_display_links'):

django/contrib/admin/widgets.py

     template_with_clear = (u'<span class="clearable-file-input">%s</span>'
                            % forms.ClearableFileInput.template_with_clear)
 
-def _get_search_icon(model, name, value, params, attrs):
-    related_url = '../../../%s/%s/' % (model._meta.app_label, model._meta.object_name.lower())
+def _get_search_icon(model, name, value, params, attrs, admin_site=None):
+    info = (model._meta.app_label, model._meta.object_name.lower())
+    try:
+        options = {'current_app': admin_site.name} if admin_site else {}
+        related_url = reverse('admin:%s_%s_changelist' % info, **options)
+    except NoReverseMatch:
+        path_to_root = admin_site.root_path if admin_site else '../../../'
+        info = (path_to_root, model._meta.app_label, model._meta.object_name.lower())
+        related_url = '%s%s/%s/' % info
     if params:
         url = '?' + '&amp;'.join(['%s=%s' % (k, v) for k, v in params.items()])
     else:
     A Widget for displaying ForeignKeys in the "raw_id" interface rather than
     in a <select> box.
     """
-    def __init__(self, rel, attrs=None, using=None):
+    def __init__(self, rel, attrs=None, using=None, admin_site=None):
         self.rel = rel
         self.db = using
+        self.admin_site = admin_site
         super(ForeignKeyRawIdWidget, self).__init__(attrs)
 
     def render(self, name, value, attrs=None):
             attrs['class'] = 'vForeignKeyRawIdAdminField' # The JavaScript looks for this hook.
         output = [super(ForeignKeyRawIdWidget, self).render(name, value, attrs)]
 
-        output += _get_search_icon(self.rel.to, name, value, self.url_parameters(), attrs)
+        admin_site = self.admin_site if hasattr(self, 'admin_site') else None
+        output += _get_search_icon(self.rel.to, name, value, self.url_parameters(), attrs, admin_site=admin_site)
         if value:
             output.append(self.label_for_value(value))
         return mark_safe(u''.join(output))
             settings.ADMIN_MEDIA_PREFIX + 'js/admin/autocomplete.js',
         )
 
-    def __init__(self, settings, attrs=None, using=None, **js_options):
+    def __init__(self, settings, attrs=None, using=None, admin_site=None, **js_options):
         self.settings = settings
         self.db = using
+        self.admin_site = admin_site
         self.js_options = dict(
             source = settings.get('source'),
             multiple = settings.get('multiple', False),
         super(AutocompleteWidget, self).__init__(attrs)
 
     def get_autocomplete_url(self, name):
-        return '../autocomplete/%s/' % name
+        model = self.settings.get('parent_model', None)
+        if model:
+            info = (model._meta.app_label, model._meta.object_name.lower())
+            # look for the real related name incase this is used by a formset
+            qs = self.settings['queryset']
+            if self.settings.get('multiple', False):
+                names = [o.name for o in model._meta.many_to_many if o.rel.to == qs.model]
+                # formsset forms are named "form-##-<name>", hence the "-%s"
+                clean_name = [n for n in names if name == n or name.endswith("-%s" % n)][0]
+            else:
+                # @@@TODO check for multiple references to same model 
+                #         as above with m2m names?
+                clean_name = qs.model._meta.module_name
+            try:
+                url = reverse('admin:%s_%s_autocomplete' % info, args=[clean_name], current_app=self.admin_site.name)
+            except NoReverseMatch:
+                info = (self.admin_site.root_path, model._meta.app_label, model._meta.object_name.lower(), clean_name)
+                url = '%s%s/%s/autocomplete/%s/' % info
+        else:
+            url = '../autocomplete/%s/' % name
+        return url
 
     def render(self, name, value, attrs=None, hattrs=None, initial_objects=u''):
-        if value is None:
+        if value is None or (type(value) != int and len(value) == 0):
             value = ''
         hidden_id = 'id_hidden_%s' % name
         hidden_attrs = self.build_attrs(type='hidden', name=name, value=value, id=hidden_id)
         if self.settings.get('show_search'):
             target_key = self.settings.get('id')
             search_icon = _get_search_icon(self.settings.get('queryset').model, 
-                                           name, value, {'t': target_key}, attrs)
+                                           name, value, {'t': target_key}, 
+                                           attrs, admin_site=self.admin_site)
             search_icon = u''.join(search_icon) + '\n'
         else:
             search_icon = u''
 class MultipleAutocompleteWidget(AutocompleteWidget):
 
     def __init__(self, settings, attrs=None, using=None, **js_options):
-        js_options['multiple'] = True
+        settings['multiple'] = True
         super(MultipleAutocompleteWidget, self).__init__(settings, attrs,
             using, **js_options)
 
         rel_to = self.rel.to
         info = (rel_to._meta.app_label, rel_to._meta.object_name.lower())
         try:
-            related_url = reverse('admin:%s_%s_add' % info, current_app=self.admin_site.name)
+            options = {'current_app': self.admin_site.name} if hasattr(self, 'admin_site') else {} 
+            related_url = reverse('admin:%s_%s_add' % info, **options)
         except NoReverseMatch:
-            info = (self.admin_site.root_path, rel_to._meta.app_label, rel_to._meta.object_name.lower())
+            path_to_root = self.admin_site.root_path if hasattr(self, 'admin_site') else '../../../'
+            info = (path_to_root, rel_to._meta.app_label, rel_to._meta.object_name.lower())
             related_url = '%s%s/%s/add/' % info
         self.widget.choices = self.choices
         output = [self.widget.render(name, value, *args, **kwargs)]

tests/regressiontests/admin_widgets/models.py

 class Band(models.Model):
     name = models.CharField(max_length=100)
     members = models.ManyToManyField(Member)
+    roadies = models.ManyToManyField(Member, related_name="roady_set")
 
     def __unicode__(self):
         return self.name

tests/regressiontests/admin_widgets/tests.py

 
 import models
 
+class AdminSiteStub(object):
+    root_path = '/admin/'
+    name = 'widgets-admin'
 
 class AdminFormfieldForDBFieldTests(TestCase):
     """
             name='Hybrid Theory', cover_art=r'albums\hybrid_theory.jpg'
         )
         rel = models.Album._meta.get_field('band').rel
+        admin_site = AdminSiteStub()
 
         w = ForeignKeyRawIdWidget(rel)
+        w_admin_site = ForeignKeyRawIdWidget(rel, admin_site=admin_site)
         self.assertEqual(
             conditional_escape(w.render('test', band.pk, attrs={})),
             '<input type="text" name="test" value="%(bandpk)s" class="vForeignKeyRawIdAdminField" /><a href="../../../admin_widgets/band/?t=id" class="related-lookup" id="lookup_id_test" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%(ADMIN_MEDIA_PREFIX)simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>&nbsp;<strong>Linkin Park</strong>' % {"ADMIN_MEDIA_PREFIX": settings.ADMIN_MEDIA_PREFIX, "bandpk": band.pk},
         )
+        self.assertEqual(
+            conditional_escape(w_admin_site.render('test', band.pk, attrs={})),
+            '<input type="text" name="test" value="%(bandpk)s" class="vForeignKeyRawIdAdminField" /><a href="%(admin_site_root_path)sadmin_widgets/band/?t=id" class="related-lookup" id="lookup_id_test" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%(ADMIN_MEDIA_PREFIX)simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>&nbsp;<strong>Linkin Park</strong>' % {"ADMIN_MEDIA_PREFIX": settings.ADMIN_MEDIA_PREFIX, "bandpk": band.pk, "admin_site_root_path": admin_site.root_path},
+        )
 
     def test_relations_to_non_primary_key(self):
         # Check that ForeignKeyRawIdWidget works with fields which aren't
             barcode=87, name='Core', parent=apple
         )
         rel = models.Inventory._meta.get_field('parent').rel
+        admin_site = AdminSiteStub()
+
         w = ForeignKeyRawIdWidget(rel)
+        w_admin_site = ForeignKeyRawIdWidget(rel, admin_site=admin_site)
         self.assertEqual(
             w.render('test', core.parent_id, attrs={}),
             '<input type="text" name="test" value="86" class="vForeignKeyRawIdAdminField" /><a href="../../../admin_widgets/inventory/?t=barcode" class="related-lookup" id="lookup_id_test" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%(ADMIN_MEDIA_PREFIX)simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>&nbsp;<strong>Apple</strong>' % {"ADMIN_MEDIA_PREFIX": settings.ADMIN_MEDIA_PREFIX},
         )
+        self.assertEqual(
+            w_admin_site.render('test', core.parent_id, attrs={}),
+            '<input type="text" name="test" value="86" class="vForeignKeyRawIdAdminField" /><a href="%(admin_site_root_path)sadmin_widgets/inventory/?t=barcode" class="related-lookup" id="lookup_id_test" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%(ADMIN_MEDIA_PREFIX)simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>&nbsp;<strong>Apple</strong>' % {"ADMIN_MEDIA_PREFIX": settings.ADMIN_MEDIA_PREFIX, "admin_site_root_path": admin_site.root_path},
+        )
 
 
     def test_proper_manager_for_label_lookup(self):
         band.members.add(m1, m2)
         rel = models.Band._meta.get_field('members').rel
 
+        admin_site = AdminSiteStub()
         w = ManyToManyRawIdWidget(rel)
+        w_admin_site = ManyToManyRawIdWidget(rel, admin_site=admin_site)
         self.assertEqual(
             conditional_escape(w.render('test', [m1.pk, m2.pk], attrs={})),
             '<input type="text" name="test" value="%(m1pk)s,%(m2pk)s" class="vManyToManyRawIdAdminField" /><a href="../../../admin_widgets/member/" class="related-lookup" id="lookup_id_test" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%(ADMIN_MEDIA_PREFIX)simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>' % {"ADMIN_MEDIA_PREFIX": settings.ADMIN_MEDIA_PREFIX, "m1pk": m1.pk, "m2pk": m2.pk},
             conditional_escape(w.render('test', [m1.pk])),
             '<input type="text" name="test" value="%(m1pk)s" class="vManyToManyRawIdAdminField" /><a href="../../../admin_widgets/member/" class="related-lookup" id="lookup_id_test" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%(ADMIN_MEDIA_PREFIX)simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>' % {"ADMIN_MEDIA_PREFIX": settings.ADMIN_MEDIA_PREFIX, "m1pk": m1.pk},
         )
+        self.assertEqual(
+            conditional_escape(w_admin_site.render('test', [m1.pk])),
+            '<input type="text" name="test" value="%(m1pk)s" class="vManyToManyRawIdAdminField" /><a href="%(admin_site_root_path)sadmin_widgets/member/" class="related-lookup" id="lookup_id_test" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%(ADMIN_MEDIA_PREFIX)simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>' % {"ADMIN_MEDIA_PREFIX": settings.ADMIN_MEDIA_PREFIX, "m1pk": m1.pk, "admin_site_root_path": admin_site.root_path},
+        )
 
         self.assertEqual(w._has_changed(None, None), False)
         self.assertEqual(w._has_changed([], None), False)
         band.album_set.create(
             name='Hybrid Theory', cover_art=r'albums\hybrid_theory.jpg'
         )
+        album = band.album_set.all()[0]
 
         widget_settings = {
             'fields': ('name',),
             'limit': 5,
             'value': lambda o: unicode(o),
             'label': lambda o: unicode(o),
-            'queryset': models.Band.objects.all(),
+            'queryset': models.Album.objects.all(),
             }
-        w = AutocompleteWidget(widget_settings)
+        admin_site = AdminSiteStub()
+
+        w = AutocompleteWidget(widget_settings, admin_site=admin_site)
         field_name = 'test'
         expected = "\n".join((
             '<div class="djangoautocomplete-wrapper">',
-            '<input type="hidden" name="test" value="%(band_pk)s" id="id_hidden_%(field_name)s" />',
-            '<input type="text" value="%(band_name)s" />',
+            '<input type="hidden" name="test" value="%(_pk)s" id="id_hidden_%(field_name)s" />',
+            '<input type="text" value="%(_name)s" />',
             '</div>',
             '<script type="text/javascript">django.jQuery("#id_%(field_name)s").djangoautocomplete({"force_selection": true, "multiple": %(multiple)s, "source": "../autocomplete/%(field_name)s/"});</script>',
             ''
             )) % {'field_name': field_name,
-                  'band_pk': band.pk,
-                  'band_name': band.name,
+                  '_pk': album.pk,
+                  '_name': album.name,
                   'multiple': 'false',
                   }
         self.assertEqual(
-            conditional_escape(w.render('test', band.pk, attrs={})),
+            conditional_escape(w.render('test', album.pk, attrs={})),
             expected,
         )
 
             expected += ['</ul>']
         expected += ['</div>']
         if show_search:
-            expected += ['<a href="../../../admin_widgets/band/?t=id" class="related-lookup" id="lookup_id_%s" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>' % (name, settings.ADMIN_MEDIA_PREFIX)]
+            expected += ['<a href="/admin/admin_widgets/band/?t=id" class="related-lookup" id="lookup_id_%s" onclick="return showRelatedObjectLookupPopup(this);"> <img src="%simg/admin/selector-search.gif" width="16" height="16" alt="Lookup" /></a>' % (name, settings.ADMIN_MEDIA_PREFIX)]
         expected += [
             '<script type="text/javascript">django.jQuery("#id_%(field_name)s").djangoautocomplete({"force_selection": true, "multiple": %(multiple)s, "source": "../autocomplete/%(field_name)s/"});</script>',
             ''
             }
         with_search_settings = widget_settings.copy()
         with_search_settings['show_search'] = True
-
-        w = MultipleAutocompleteWidget(widget_settings)
+        admin_site = AdminSiteStub()
+        
+        w = MultipleAutocompleteWidget(widget_settings, admin_site=admin_site)
         expected_multiple = self._get_expected('test_multiple', bands)
         self.assertEqual(
             conditional_escape(w.render('test_multiple', [b.pk for b in bands], attrs={})),
             expected_multiple,
         )
 
-        w = MultipleAutocompleteWidget(widget_settings)
+        w = MultipleAutocompleteWidget(widget_settings, admin_site=admin_site)
         bands_just_one = bands[:1]
         expected_single = self._get_expected('test_single', bands_just_one)
         self.assertEqual(
             expected_single,
         )
 
-        w = MultipleAutocompleteWidget(widget_settings)
+        w = MultipleAutocompleteWidget(widget_settings, admin_site=admin_site)
         bands_none = None
         expected_none = self._get_expected('test_none', bands_none)
         self.assertEqual(
             expected_none,
         )
 
-        w = MultipleAutocompleteWidget(with_search_settings)
+        w = MultipleAutocompleteWidget(widget_settings, admin_site=admin_site)
+        bands_empty = []
+        expected_empty = self._get_expected('test_empty', bands_empty)
+        self.assertEqual(
+            conditional_escape(w.render('test_empty', bands_empty, attrs={})),
+            expected_empty,
+        )
+
+        w = MultipleAutocompleteWidget(with_search_settings, admin_site=admin_site)
         bands_none = None
-        expected_none = self._get_expected('test_none', bands_none, show_search=True)
+        expected_search = self._get_expected('test_search', bands_none, show_search=True)
         self.assertEqual(
-            conditional_escape(w.render('test_none', bands_none, attrs={})),
-            expected_none,
+            conditional_escape(w.render('test_search', bands_none, attrs={})),
+            expected_search,
         )
         
 

tests/regressiontests/modeladmin/tests.py

         class ValidationTestModelAdmin(ModelAdmin):
             list_display = ('users',)
 
-        self.assertRaisesRegexp(
-            ImproperlyConfigured,
-            "'ValidationTestModelAdmin.list_display\[0\]', 'users' is a ManyToManyField which is not supported.",
-            validate,
-            ValidationTestModelAdmin,
-            ValidationTestModel,
-        )
+        validate(ValidationTestModelAdmin, ValidationTestModel)
 
         class ValidationTestModelAdmin(ModelAdmin):
             list_display = ('name',)
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.