Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Cleaned up and refactored `ModelAdmin.formfield_for_dbfield`:

  * The new method uses an admin configuration option (`formfield_overrides`); this makes custom admin widgets especially easy.
  * Refactored what was left of `formfield_for_dbfield` into a handful of smaller methods so that it's easier to hook in and return custom fields where needed.
  * These `formfield_for_*` methods now pass around `request` so that you can easily modify fields based on request (as in #3987).

Fixes #8306, #3987, #9148.

Thanks to James Bennet for the original patch; Alex Gaynor and Brian Rosner also contributed.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@9760 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
commit f212b24b6469b66424354bf970f3051df180b88d 1 parent d579e71
@jacobian jacobian authored
View
182 django/contrib/admin/options.py
@@ -13,6 +13,7 @@
from django.utils.functional import update_wrapper
from django.utils.html import escape
from django.utils.safestring import mark_safe
+from django.utils.functional import curry
from django.utils.text import capfirst, get_text_list
from django.utils.translation import ugettext as _
from django.utils.encoding import force_unicode
@@ -28,8 +29,28 @@
class IncorrectLookupParameters(Exception):
pass
+# Defaults for formfield_overrides. ModelAdmin subclasses can change this
+# by adding to ModelAdmin.formfield_overrides.
+
+FORMFIELD_FOR_DBFIELD_DEFAULTS = {
+ models.DateTimeField: {
+ 'form_class': forms.SplitDateTimeField,
+ 'widget': widgets.AdminSplitDateTime
+ },
+ models.DateField: {'widget': widgets.AdminDateWidget},
+ models.TimeField: {'widget': widgets.AdminTimeWidget},
+ models.TextField: {'widget': widgets.AdminTextareaWidget},
+ models.URLField: {'widget': widgets.AdminURLFieldWidget},
+ models.IntegerField: {'widget': widgets.AdminIntegerFieldWidget},
+ models.CharField: {'widget': widgets.AdminTextInputWidget},
+ models.ImageField: {'widget': widgets.AdminFileWidget},
+ models.FileField: {'widget': widgets.AdminFileWidget},
+}
+
+
class BaseModelAdmin(object):
"""Functionality common to both ModelAdmin and InlineAdmin."""
+
raw_id_fields = ()
fields = None
exclude = None
@@ -39,6 +60,10 @@ class BaseModelAdmin(object):
filter_horizontal = ()
radio_fields = {}
prepopulated_fields = {}
+ formfield_overrides = {}
+
+ def __init__(self):
+ self.formfield_overrides = dict(FORMFIELD_FOR_DBFIELD_DEFAULTS, **self.formfield_overrides)
def formfield_for_dbfield(self, db_field, **kwargs):
"""
@@ -47,101 +72,92 @@ def formfield_for_dbfield(self, db_field, **kwargs):
If kwargs are given, they're passed to the form Field's constructor.
"""
+ request = kwargs.pop("request", None)
# If the field specifies choices, we don't need to look for special
# admin widgets - we just need to use a select widget of some kind.
if db_field.choices:
- if db_field.name in self.radio_fields:
- # If the field is named as a radio_field, use a RadioSelect
+ return self.formfield_for_choice_field(db_field, request, **kwargs)
+
+ # ForeignKey or ManyToManyFields
+ if isinstance(db_field, (models.ForeignKey, models.ManyToManyField)):
+ # Combine the field kwargs with any options for formfield_overrides.
+ # Make sure the passed in **kwargs override anything in
+ # formfield_overrides because **kwargs is more specific, and should
+ # always win.
+ if db_field.__class__ in self.formfield_overrides:
+ kwargs = dict(self.formfield_overrides[db_field.__class__], **kwargs)
+
+ # Get the correct formfield.
+ if isinstance(db_field, models.ForeignKey):
+ formfield = self.formfield_for_foreignkey(db_field, request, **kwargs)
+ elif isinstance(db_field, models.ManyToManyField):
+ formfield = self.formfield_for_manytomany(db_field, request, **kwargs)
+
+ # For non-raw_id fields, wrap the widget with a wrapper that adds
+ # extra HTML -- the "add other" interface -- to the end of the
+ # rendered output. formfield can be None if it came from a
+ # OneToOneField with parent_link=True or a M2M intermediary.
+ if formfield and db_field.name not in self.raw_id_fields:
+ formfield.widget = widgets.RelatedFieldWidgetWrapper(formfield.widget, db_field.rel, self.admin_site)
+
+ return formfield
+
+ # If we've got overrides for the formfield defined, use 'em. **kwargs
+ # passed to formfield_for_dbfield override the defaults.
+ if db_field.__class__ in self.formfield_overrides:
+ kwargs = dict(self.formfield_overrides[db_field.__class__], **kwargs)
+ return db_field.formfield(**kwargs)
+
+ # For any other type of field, just call its formfield() method.
+ return db_field.formfield(**kwargs)
+
+ def formfield_for_choice_field(self, db_field, request=None, **kwargs):
+ """
+ Get a form Field for a database Field that has declared choices.
+ """
+ # If the field is named as a radio_field, use a RadioSelect
+ if db_field.name in self.radio_fields:
+ # Avoid stomping on custom widget/choices arguments.
+ if 'widget' not in kwargs:
kwargs['widget'] = widgets.AdminRadioSelect(attrs={
'class': get_ul_class(self.radio_fields[db_field.name]),
})
+ if 'choices' not in kwargs:
kwargs['choices'] = db_field.get_choices(
include_blank = db_field.blank,
blank_choice=[('', _('None'))]
)
- return db_field.formfield(**kwargs)
- else:
- # Otherwise, use the default select widget.
- return db_field.formfield(**kwargs)
-
- # For DateTimeFields, use a special field and widget.
- if isinstance(db_field, models.DateTimeField):
- kwargs['form_class'] = forms.SplitDateTimeField
- kwargs['widget'] = widgets.AdminSplitDateTime()
- return db_field.formfield(**kwargs)
-
- # For DateFields, add a custom CSS class.
- if isinstance(db_field, models.DateField):
- kwargs['widget'] = widgets.AdminDateWidget
- return db_field.formfield(**kwargs)
-
- # For TimeFields, add a custom CSS class.
- if isinstance(db_field, models.TimeField):
- kwargs['widget'] = widgets.AdminTimeWidget
- return db_field.formfield(**kwargs)
-
- # For TextFields, add a custom CSS class.
- if isinstance(db_field, models.TextField):
- kwargs['widget'] = widgets.AdminTextareaWidget
- return db_field.formfield(**kwargs)
-
- # For URLFields, add a custom CSS class.
- if isinstance(db_field, models.URLField):
- kwargs['widget'] = widgets.AdminURLFieldWidget
- return db_field.formfield(**kwargs)
-
- # For IntegerFields, add a custom CSS class.
- if isinstance(db_field, models.IntegerField):
- kwargs['widget'] = widgets.AdminIntegerFieldWidget
- return db_field.formfield(**kwargs)
-
- # For CommaSeparatedIntegerFields, add a custom CSS class.
- if isinstance(db_field, models.CommaSeparatedIntegerField):
- kwargs['widget'] = widgets.AdminCommaSeparatedIntegerFieldWidget
- return db_field.formfield(**kwargs)
-
- # For TextInputs, add a custom CSS class.
- if isinstance(db_field, models.CharField):
- kwargs['widget'] = widgets.AdminTextInputWidget
- return db_field.formfield(**kwargs)
+ return db_field.formfield(**kwargs)
+
+ def formfield_for_foreignkey(self, db_field, request=None, **kwargs):
+ """
+ Get a form Field for a ForeignKey.
+ """
+ if db_field.name in self.raw_id_fields:
+ kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.rel)
+ elif db_field.name in self.radio_fields:
+ kwargs['widget'] = widgets.AdminRadioSelect(attrs={
+ 'class': get_ul_class(self.radio_fields[db_field.name]),
+ })
+ kwargs['empty_label'] = db_field.blank and _('None') or None
- # For FileFields and ImageFields add a link to the current file.
- if isinstance(db_field, models.ImageField) or isinstance(db_field, models.FileField):
- kwargs['widget'] = widgets.AdminFileWidget
- return db_field.formfield(**kwargs)
+ return db_field.formfield(**kwargs)
+
+ def formfield_for_manytomany(self, db_field, request=None, **kwargs):
+ """
+ Get a form Field for a ManyToManyField.
+ """
+ # If it uses an intermediary model, don't show field in admin.
+ if db_field.rel.through is not None:
+ return None
- # For ForeignKey or ManyToManyFields, use a special widget.
- if isinstance(db_field, (models.ForeignKey, models.ManyToManyField)):
- if isinstance(db_field, models.ForeignKey) and db_field.name in self.raw_id_fields:
- kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.rel)
- elif isinstance(db_field, models.ForeignKey) and db_field.name in self.radio_fields:
- kwargs['widget'] = widgets.AdminRadioSelect(attrs={
- 'class': get_ul_class(self.radio_fields[db_field.name]),
- })
- kwargs['empty_label'] = db_field.blank and _('None') or None
- else:
- if isinstance(db_field, models.ManyToManyField):
- # If it uses an intermediary model, don't show field in admin.
- if db_field.rel.through is not None:
- return None
- elif db_field.name in self.raw_id_fields:
- kwargs['widget'] = widgets.ManyToManyRawIdWidget(db_field.rel)
- 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))
- # Wrap the widget's render() method with a method that adds
- # extra HTML to the end of the rendered output.
- formfield = db_field.formfield(**kwargs)
- # Don't wrap raw_id fields. Their add function is in the popup window.
- if not db_field.name in self.raw_id_fields:
- # formfield can be None if it came from a OneToOneField with
- # parent_link=True
- if formfield is not None:
- formfield.widget = widgets.RelatedFieldWidgetWrapper(formfield.widget, db_field.rel, self.admin_site)
- return formfield
+ if db_field.name in self.raw_id_fields:
+ kwargs['widget'] = widgets.ManyToManyRawIdWidget(db_field.rel)
+ 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))
- # For any other type of field, just call its formfield() method.
return db_field.formfield(**kwargs)
def _declared_fieldsets(self):
@@ -292,7 +308,7 @@ def get_form(self, request, obj=None, **kwargs):
"form": self.form,
"fields": fields,
"exclude": exclude + kwargs.get("exclude", []),
- "formfield_callback": self.formfield_for_dbfield,
+ "formfield_callback": curry(self.formfield_for_dbfield, request=request),
}
defaults.update(kwargs)
return modelform_factory(self.model, **defaults)
@@ -837,7 +853,7 @@ def get_formset(self, request, obj=None, **kwargs):
"fk_name": self.fk_name,
"fields": fields,
"exclude": exclude + kwargs.get("exclude", []),
- "formfield_callback": self.formfield_for_dbfield,
+ "formfield_callback": curry(self.formfield_for_dbfield, request=request),
"extra": self.extra,
"max_num": self.max_num,
}
View
58 docs/ref/contrib/admin.txt
@@ -597,6 +597,47 @@ with an operator:
Performs a full-text match. This is like the default search method but uses
an index. Currently this is only available for MySQL.
+``formfield_overrides``
+~~~~~~~~~~~~~~~~~~~~~~~
+
+This provides a quick-and-dirty way to override some of the
+:class:`~django.forms.Field` options for use in the admin.
+``formfield_overrides`` is a dictionary mapping a field class to a dict of
+arguments to pass to the field at construction time.
+
+Since that's a bit abstract, let's look at a concrete example. The most common
+use of ``formfield_overrides`` is to add a custom widget for a certain type of
+field. So, imagine we've written a ``RichTextEditorWidget`` that we'd like to
+use for large text fields instead of the default ``<textarea>``. Here's how we'd
+do that::
+
+ from django.db import models
+ from django.contrib import admin
+
+ # Import our custom widget and our model from where they're defined
+ from myapp.widgets import RichTextEditorWidget
+ from myapp.models import MyModel
+
+ class MyModelAdmin(admin.ModelAdmin):
+ formfield_overrides = {
+ models.TextField: {'widget': RichTextEditorWidget},
+ }
+
+Note that the key in the dictionary is the actual field class, *not* a string.
+The value is another dictionary; these arguments will be passed to
+:meth:`~django.forms.Field.__init__`. See :ref:`ref-forms-api` for details.
+
+.. warning::
+
+ If you want to use a custom widget with a relation field (i.e.
+ :class:`~django.db.models.ForeignKey` or
+ :class:`~django.db.models.ManyToManyField`), make sure you haven't included
+ that field's name in ``raw_id_fields`` or ``radio_fields``.
+
+ ``formfield_overrides`` won't let you change the widget on relation fields
+ that have ``raw_id_fields`` or ``radio_fields`` set. That's because
+ ``raw_id_fields`` and ``radio_fields`` imply custom widgets of their own.
+
``ModelAdmin`` methods
----------------------
@@ -675,6 +716,23 @@ Notice the wrapped view in the fifth line above::
This wrapping will protect ``self.my_view`` from unauthorized access.
+``formfield_for_foreignkey(self, db_field, request, **kwargs)``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The ``formfield_for_foreignkey`` method on a ``ModelAdmin`` allows you to
+override the default formfield for a foreign key field. For example, to
+return a subset of objects for this foreign key field based on the user::
+
+ class MyModelAdmin(admin.ModelAdmin):
+ def formfield_for_foreignkey(self, db_field, request, **kwargs):
+ if db_field.name == "car":
+ kwargs["queryset"] = Car.object.filter(owner=request.user)
+ return db_field.formfield(**kwargs)
+ return super(MyModelAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
+This uses the ``HttpRequest`` instance to filter the ``Car`` foreign key field
+to only the cars owned by the ``User`` instance.
+
``ModelAdmin`` media definitions
--------------------------------
View
43 tests/regressiontests/admin_widgets/fixtures/admin-widgets-users.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<django-objects version="1.0">
+ <object pk="100" model="auth.user">
+ <field type="CharField" name="username">super</field>
+ <field type="CharField" name="first_name">Super</field>
+ <field type="CharField" name="last_name">User</field>
+ <field type="CharField" name="email">super@example.com</field>
+ <field type="CharField" name="password">sha1$995a3$6011485ea3834267d719b4c801409b8b1ddd0158</field>
+ <field type="BooleanField" name="is_staff">True</field>
+ <field type="BooleanField" name="is_active">True</field>
+ <field type="BooleanField" name="is_superuser">True</field>
+ <field type="DateTimeField" name="last_login">2007-05-30 13:20:10</field>
+ <field type="DateTimeField" name="date_joined">2007-05-30 13:20:10</field>
+ <field to="auth.group" name="groups" rel="ManyToManyRel"></field>
+ <field to="auth.permission" name="user_permissions" rel="ManyToManyRel"></field>
+ </object>
+ <object pk="101" model="auth.user">
+ <field type="CharField" name="username">testser</field>
+ <field type="CharField" name="first_name">Add</field>
+ <field type="CharField" name="last_name">User</field>
+ <field type="CharField" name="email">auser@example.com</field>
+ <field type="CharField" name="password">sha1$995a3$6011485ea3834267d719b4c801409b8b1ddd0158</field>
+ <field type="BooleanField" name="is_staff">True</field>
+ <field type="BooleanField" name="is_active">True</field>
+ <field type="BooleanField" name="is_superuser">False</field>
+ <field type="DateTimeField" name="last_login">2007-05-30 13:20:10</field>
+ <field type="DateTimeField" name="date_joined">2007-05-30 13:20:10</field>
+ <field to="auth.group" name="groups" rel="ManyToManyRel"></field>
+ <field to="auth.permission" name="user_permissions" rel="ManyToManyRel"></field>
+ </object>
+
+ <object pk="1" model="admin_widgets.car">
+ <field to="auth.user" name="owner" rel="ManyToOneRel">100</field>
+ <field type="CharField" name="make">Volkswagon</field>
+ <field type="CharField" name="model">Passat</field>
+ </object>
+ <object pk="2" model="admin_widgets.car">
+ <field to="auth.user" name="owner" rel="ManyToOneRel">101</field>
+ <field type="CharField" name="make">BMW</field>
+ <field type="CharField" name="model">M3</field>
+ </object>
+
+</django-objects>
View
25 tests/regressiontests/admin_widgets/models.py
@@ -2,9 +2,12 @@
from django.conf import settings
from django.db import models
from django.core.files.storage import default_storage
+from django.contrib.auth.models import User
class Member(models.Model):
name = models.CharField(max_length=100)
+ birthdate = models.DateTimeField(blank=True, null=True)
+ gender = models.CharField(max_length=1, blank=True, choices=[('M','Male'), ('F', 'Female')])
def __unicode__(self):
return self.name
@@ -40,6 +43,28 @@ class Inventory(models.Model):
def __unicode__(self):
return self.name
+
+class Event(models.Model):
+ band = models.ForeignKey(Band)
+ date = models.DateField(blank=True, null=True)
+ start_time = models.TimeField(blank=True, null=True)
+ description = models.TextField(blank=True)
+ link = models.URLField(blank=True)
+ min_age = models.IntegerField(blank=True, null=True)
+
+class Car(models.Model):
+ owner = models.ForeignKey(User)
+ make = models.CharField(max_length=30)
+ model = models.CharField(max_length=30)
+
+ def __unicode__(self):
+ return u"%s %s" % (self.make, self.model)
+
+class CarTire(models.Model):
+ """
+ A single car tire. This to test that a user can only select their own cars.
+ """
+ car = models.ForeignKey(Car)
__test__ = {'WIDGETS_TESTS': """
>>> from datetime import datetime
View
112 tests/regressiontests/admin_widgets/tests.py
@@ -0,0 +1,112 @@
+from django import forms
+from django.contrib import admin
+from django.contrib.admin import widgets
+from unittest import TestCase
+from django.test import TestCase as DjangoTestCase
+import models
+
+class AdminFormfieldForDBFieldTests(TestCase):
+ """
+ Tests for correct behavior of ModelAdmin.formfield_for_dbfield
+ """
+
+ def assertFormfield(self, model, fieldname, widgetclass, **admin_overrides):
+ """
+ Helper to call formfield_for_dbfield for a given model and field name
+ and verify that the returned formfield is appropriate.
+ """
+ # Override any settings on the model admin
+ class MyModelAdmin(admin.ModelAdmin): pass
+ for k in admin_overrides:
+ setattr(MyModelAdmin, k, admin_overrides[k])
+
+ # Construct the admin, and ask it for a formfield
+ ma = MyModelAdmin(model, admin.site)
+ ff = ma.formfield_for_dbfield(model._meta.get_field(fieldname), request=None)
+
+ # "unwrap" the widget wrapper, if needed
+ if isinstance(ff.widget, widgets.RelatedFieldWidgetWrapper):
+ widget = ff.widget.widget
+ else:
+ widget = ff.widget
+
+ # Check that we got a field of the right type
+ self.assert_(
+ isinstance(widget, widgetclass),
+ "Wrong widget for %s.%s: expected %s, got %s" % \
+ (model.__class__.__name__, fieldname, widgetclass, type(widget))
+ )
+
+ # Return the formfield so that other tests can continue
+ return ff
+
+ def testDateField(self):
+ self.assertFormfield(models.Event, 'date', widgets.AdminDateWidget)
+
+ def testDateTimeField(self):
+ self.assertFormfield(models.Member, 'birthdate', widgets.AdminSplitDateTime)
+
+ def testTimeField(self):
+ self.assertFormfield(models.Event, 'start_time', widgets.AdminTimeWidget)
+
+ def testTextField(self):
+ self.assertFormfield(models.Event, 'description', widgets.AdminTextareaWidget)
+
+ def testURLField(self):
+ self.assertFormfield(models.Event, 'link', widgets.AdminURLFieldWidget)
+
+ def testIntegerField(self):
+ self.assertFormfield(models.Event, 'min_age', widgets.AdminIntegerFieldWidget)
+
+ def testCharField(self):
+ self.assertFormfield(models.Member, 'name', widgets.AdminTextInputWidget)
+
+ def testFileField(self):
+ self.assertFormfield(models.Album, 'cover_art', widgets.AdminFileWidget)
+
+ def testForeignKey(self):
+ self.assertFormfield(models.Event, 'band', forms.Select)
+
+ def testRawIDForeignKey(self):
+ self.assertFormfield(models.Event, 'band', widgets.ForeignKeyRawIdWidget,
+ raw_id_fields=['band'])
+
+ def testRadioFieldsForeignKey(self):
+ ff = self.assertFormfield(models.Event, 'band', widgets.AdminRadioSelect,
+ radio_fields={'band':admin.VERTICAL})
+ self.assertEqual(ff.empty_label, None)
+
+ def testManyToMany(self):
+ self.assertFormfield(models.Band, 'members', forms.SelectMultiple)
+
+ def testRawIDManyTOMany(self):
+ self.assertFormfield(models.Band, 'members', widgets.ManyToManyRawIdWidget,
+ raw_id_fields=['members'])
+
+ def testFilteredManyToMany(self):
+ self.assertFormfield(models.Band, 'members', widgets.FilteredSelectMultiple,
+ filter_vertical=['members'])
+
+ def testFormfieldOverrides(self):
+ self.assertFormfield(models.Event, 'date', forms.TextInput,
+ formfield_overrides={'widget': forms.TextInput})
+
+ def testFieldWithChoices(self):
+ self.assertFormfield(models.Member, 'gender', forms.Select)
+
+ def testChoicesWithRadioFields(self):
+ self.assertFormfield(models.Member, 'gender', widgets.AdminRadioSelect,
+ radio_fields={'gender':admin.VERTICAL})
+
+
+class AdminFormfieldForDBFieldWithRequestTests(DjangoTestCase):
+ fixtures = ["admin-widgets-users.xml"]
+
+ def testFilterChoicesByRequestUser(self):
+ """
+ Ensure the user can only see their own cars in the foreign key dropdown.
+ """
+ self.client.login(username="super", password="secret")
+ response = self.client.get("/widget_admin/admin_widgets/cartire/add/")
+ self.assert_("BMW M3" not in response.content)
+ self.assert_("Volkswagon Passat" in response.content)
View
7 tests/regressiontests/admin_widgets/urls.py
@@ -0,0 +1,7 @@
+
+from django.conf.urls.defaults import *
+import widgetadmin
+
+urlpatterns = patterns('',
+ (r'^', include(widgetadmin.site.urls)),
+)
View
22 tests/regressiontests/admin_widgets/widgetadmin.py
@@ -0,0 +1,22 @@
+"""
+
+"""
+from django.contrib import admin
+
+import models
+
+class WidgetAdmin(admin.AdminSite):
+ pass
+
+
+class CarTireAdmin(admin.ModelAdmin):
+ def formfield_for_foreignkey(self, db_field, request, **kwargs):
+ if db_field.name == "car":
+ kwargs["queryset"] = models.Car.objects.filter(owner=request.user)
+ return db_field.formfield(**kwargs)
+ return super(CarTireAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
+site = WidgetAdmin()
+
+site.register(models.Car)
+site.register(models.CarTire, CarTireAdmin)
View
3  tests/urls.py
@@ -24,6 +24,9 @@
# admin view tests
(r'^test_admin/', include('regressiontests.admin_views.urls')),
(r'^generic_inline_admin/', include('regressiontests.generic_inline_admin.urls')),
+
+ # admin widget tests
+ (r'widget_admin/', include('regressiontests.admin_widgets.urls')),
(r'^utils/', include('regressiontests.utils.urls')),
Please sign in to comment.
Something went wrong with that request. Please try again.