diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index c603210..bd7b62b 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -175,7 +175,6 @@ class BaseModelAdmin(object):
if db_field.name in self.raw_id_fields:
kwargs['widget'] = widgets.ManyToManyRawIdWidget(db_field.rel, using=db)
- 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))
diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py
index ffa5692..3f2b219 100644
--- a/django/db/models/fields/related.py
+++ b/django/db/models/fields/related.py
@@ -9,8 +9,7 @@ from django.db.models.query import QuerySet
from django.db.models.query_utils import QueryWrapper
from django.db.models.deletion import CASCADE
from django.utils.encoding import smart_unicode
-from django.utils.translation import (ugettext_lazy as _, string_concat,
- ungettext, ugettext)
+from django.utils.translation import ugettext_lazy as _, ungettext, ugettext
from django.utils.functional import curry
from django.core import exceptions
from django import forms
@@ -1021,9 +1020,6 @@ class ManyToManyField(RelatedField, Field):
Field.__init__(self, **kwargs)
- msg = _('Hold down "Control", or "Command" on a Mac, to select more than one.')
- self.help_text = string_concat(self.help_text, ' ', msg)
-
def get_choices_default(self):
return Field.get_choices(self, include_blank=False)
diff --git a/django/forms/fields.py b/django/forms/fields.py
index a5ea81d..7bec9b9 100644
--- a/django/forms/fields.py
+++ b/django/forms/fields.py
@@ -80,10 +80,6 @@ class Field(object):
label = smart_unicode(label)
self.required, self.label, self.initial = required, label, initial
self.show_hidden_initial = show_hidden_initial
- if help_text is None:
- self.help_text = u''
- else:
- self.help_text = smart_unicode(help_text)
widget = widget or self.widget
if isinstance(widget, type):
widget = widget()
@@ -103,6 +99,14 @@ class Field(object):
self.widget = widget
+ if help_text is None:
+ self.help_text = u''
+ else:
+ self.help_text = smart_unicode(help_text)
+
+ # Allow the widget to alter the help_text
+ self.help_text = self.widget.alter_help_text(self.help_text)
+
# Increase the creation counter, and save our local copy.
self.creation_counter = Field.creation_counter
Field.creation_counter += 1
diff --git a/django/forms/widgets.py b/django/forms/widgets.py
index 03152ea..9ac2204 100644
--- a/django/forms/widgets.py
+++ b/django/forms/widgets.py
@@ -12,9 +12,9 @@ from util import flatatt
from django.conf import settings
from django.utils.datastructures import MultiValueDict, MergeDict
from django.utils.html import escape, conditional_escape
-from django.utils.translation import ugettext, ugettext_lazy
+from django.utils.translation import ugettext, ugettext_lazy, string_concat
from django.utils.encoding import StrAndUnicode, force_unicode
-from django.utils.safestring import mark_safe
+from django.utils.safestring import mark_safe
from django.utils import datetime_safe, formats
__all__ = (
@@ -155,6 +155,12 @@ class Widget(object):
memo[id(self)] = obj
return obj
+ def alter_help_text(self, help_text):
+ """
+ Usually called by the form field to potentially alter the help text.
+ """
+ return help_text
+
def render(self, name, value, attrs=None):
"""
Returns this Widget rendered as HTML, as a Unicode string.
@@ -575,6 +581,12 @@ class NullBooleanSelect(Select):
return initial != data
class SelectMultiple(Select):
+
+ def alter_help_text(self, help_text):
+ extra_text = ugettext_lazy('Hold down "Control", or "Command" '
+ 'on a Mac, to select more than one.')
+ return string_concat(help_text, ' ', extra_text)
+
def render(self, name, value, attrs=None, choices=()):
if value is None: value = []
final_attrs = self.build_attrs(attrs, name=name)
@@ -690,6 +702,12 @@ class RadioSelect(Select):
id_for_label = classmethod(id_for_label)
class CheckboxSelectMultiple(SelectMultiple):
+
+ def alter_help_text(self, help_text):
+ # Do not show the 'Hold down "Control"' message that appears
+ # for SelectMultiple.
+ return help_text
+
def render(self, name, value, attrs=None, choices=()):
if value is None: value = []
has_id = attrs and 'id' in attrs
diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt
index dbdf109..94f4c07 100644
--- a/docs/ref/forms/widgets.txt
+++ b/docs/ref/forms/widgets.txt
@@ -258,3 +258,18 @@ Django will then include the extra attributes in the rendered output::
Name:
Url:
Comment:
+
+Altering the help text
+----------------------
+
+.. versionadded:: 1.4
+
+In some cases you might want to customize a form field's help text depending
+on the widget used. To do so, simply override the widget's
+:meth:`~alter_help_text` method::
+
+ class MyCustomSelectMultiple(SelectMultiple):
+
+ def alter_help_text(self, help_text):
+ extra_text = 'You can hold down "Control" to select multiple ones.'
+ return '%s %s' % (help_text, extra_text)
diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt
index 1b8510f..61fe723 100644
--- a/docs/releases/1.4.txt
+++ b/docs/releases/1.4.txt
@@ -60,6 +60,17 @@ A new helper function,
``template.Library`` to ease the creation of template tags that store some
data in a specified context variable.
+Widget.alter_help_text
+~~~~~~~~~~~~~~~~~~~~~~
+
+The :meth:`~alter_help_text` method was added to `:class:~forms.widgets.Widget`
+to allow a widget to customize the help text provided by a form field or a
+model field. This introduced a backwards incompatible change if you are
+defining a custom ``SelectMultiple`` widget. See `the notes on backwards
+incompatible changes`_ below to address this change.
+
+.. _the notes on backwards incompatible changes: backwards-incompatible-changes-1.4_
+
.. _backwards-incompatible-changes-1.4:
Backwards incompatible changes in 1.4
@@ -214,3 +225,33 @@ you should add the following lines in your settings file::
Don't forget to escape characters that have a special meaning in a regular
expression.
+
+Custom ``SelectMultiple`` widgets and ``alter_help_text``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The implementation of the new `meth:~Widget.alter_help_text` method was
+originally prompted by the fixing of a bug where the 'Hold down "Control", or
+"Command" on a Mac, to select more than one.' message would systematically be
+appended to a ``ManyToManyField``'s help text, even if that field were
+configured to use a different widget than ``SelectMultiple``. Thus, the
+action of appending that message was transfered from ``ManyToManyField`` to
+``SelectMultiple`` via the new ``alter_help_text`` method::
+
+ class SelectMultiple(Select):
+
+ def alter_help_text(self, help_text):
+ extra_text = ugettext_lazy('Hold down "Control", or "Command" '
+ 'on a Mac, to select more than one.')
+ return string_concat(help_text, ' ', extra_text)
+
+This means that if you are defining a widget inheriting from
+``SelectMultiple``, then that message will now systematically be appended to
+the help text, which may not make sense in the context of use for that widget.
+To cancel this new behaviour, simply override the ``alter_help_text`` method of
+your widget as is done, for example, by the
+`:class:~forms.widgets.CheckboxSelectMultiple` class::
+
+ class CheckboxSelectMultiple(SelectMultiple):
+
+ def alter_help_text(self, help_text):
+ return help_text
diff --git a/tests/regressiontests/admin_widgets/models.py b/tests/regressiontests/admin_widgets/models.py
index c187584..2e7a871 100644
--- a/tests/regressiontests/admin_widgets/models.py
+++ b/tests/regressiontests/admin_widgets/models.py
@@ -67,3 +67,24 @@ class CarTire(models.Model):
A single car tire. This to test that a user can only select their own cars.
"""
car = models.ForeignKey(Car)
+
+class Director(models.Model):
+ name = models.CharField(max_length=40)
+
+ def __unicode__(self):
+ return self.name
+
+class Genre(models.Model):
+ name = models.CharField(max_length=40)
+
+ def __unicode__(self):
+ return self.name
+
+class Orchestra(models.Model):
+ name = models.CharField(max_length=100)
+ members = models.ManyToManyField(Member, help_text='This is the orchestra members help_text.')
+ director = models.ForeignKey(Director)
+ genres = models.ManyToManyField(Genre, help_text='This is the orchestra genres help_text.')
+
+ def __unicode__(self):
+ return self.name
diff --git a/tests/regressiontests/admin_widgets/tests.py b/tests/regressiontests/admin_widgets/tests.py
index 8f7a36c..0524f86 100644
--- a/tests/regressiontests/admin_widgets/tests.py
+++ b/tests/regressiontests/admin_widgets/tests.py
@@ -321,6 +321,41 @@ class ManyToManyRawIdWidgetTest(DjangoTestCase):
self.assertEqual(w._has_changed([1, 2], [u'1']), True)
self.assertEqual(w._has_changed([1, 2], [u'1', u'3']), True)
+
+class ManyToManyRawIdWidgetHelpTextTest(DjangoTestCase):
+ """
+ Ensure that the help_text is displayed for M2M raw_id_fields.
+ Refs #14402.
+ """
+ fixtures = ["admin-widgets-users.xml"]
+ admin_root = '/widget_admin'
+
+ def setUp(self):
+ self.client.login(username="super", password="secret")
+
+ def tearDown(self):
+ self.client.logout()
+
+ def test_m2m_has_helptext(self):
+ """Non raw_id m2m model field help_text shouldn't be affected by the fix for this ticket."""
+ response = self.client.get('%s/admin_widgets/orchestra/add/' % self.admin_root)
+ self.assert_("This is the orchestra genres help_text." in response.content)
+
+ def test_inline_m2m_has_helptext(self):
+ """Non raw_id m2m model field help_text shouldn't be affected by the fix for this ticket (inline case)."""
+ response = self.client.get('%s/admin_widgets/director/add/' % self.admin_root)
+ self.assert_("This is the orchestra genres help_text." in response.content)
+
+ def test_raw_id_m2m_has_helptext(self):
+ """raw_id m2m model field help_text shouldn't be ignored when displaying its admin widget."""
+ response = self.client.get('%s/admin_widgets/orchestra/add/' % self.admin_root)
+ self.assert_("This is the orchestra members help_text." in response.content)
+
+ def test_raw_id_inline_m2m_has_helptext(self):
+ """raw_id m2m model field help_text shouldn't be ignored when displaying the admin widget (inline case)."""
+ response = self.client.get('%s/admin_widgets/director/add/' % self.admin_root)
+ self.assert_("This is the orchestra members help_text." in response.content)
+
class RelatedFieldWidgetWrapperTests(DjangoTestCase):
def test_no_can_add_related(self):
rel = models.Inventory._meta.get_field('parent').rel
diff --git a/tests/regressiontests/admin_widgets/widgetadmin.py b/tests/regressiontests/admin_widgets/widgetadmin.py
index 6f15d92..ae67fce 100644
--- a/tests/regressiontests/admin_widgets/widgetadmin.py
+++ b/tests/regressiontests/admin_widgets/widgetadmin.py
@@ -22,9 +22,21 @@ class CarTireAdmin(admin.ModelAdmin):
class EventAdmin(admin.ModelAdmin):
raw_id_fields = ['band']
+class OrchestraAdmin(admin.ModelAdmin):
+ raw_id_fields = ['members']
+
+class OrchestraInline(admin.StackedInline):
+ model = models.Orchestra
+ raw_id_fields = ['members']
+
+class DirectorAdmin(admin.ModelAdmin):
+ inlines = [OrchestraInline]
+
site = WidgetAdmin(name='widget-admin')
site.register(models.User)
site.register(models.Car, CarAdmin)
site.register(models.CarTire, CarTireAdmin)
site.register(models.Event, EventAdmin)
+site.register(models.Orchestra, OrchestraAdmin)
+site.register(models.Director, DirectorAdmin)
diff --git a/tests/regressiontests/forms/tests/forms.py b/tests/regressiontests/forms/tests/forms.py
index 91a7472..5b2b7b4 100644
--- a/tests/regressiontests/forms/tests/forms.py
+++ b/tests/regressiontests/forms/tests/forms.py
@@ -1092,7 +1092,7 @@ class FormsTestCase(TestCase):
-""")
+ Hold down "Control", or "Command" on a Mac, to select more than one.""")
# The 'initial' parameter is meaningless if you pass data.
p = UserRegistration({}, initial={'username': initial_django, 'options': initial_options}, auto_id=False)
@@ -1102,7 +1102,7 @@ class FormsTestCase(TestCase):
-""")
+ Hold down "Control", or "Command" on a Mac, to select more than one.""")
p = UserRegistration({'username': u''}, initial={'username': initial_django}, auto_id=False)
self.assertEqual(p.as_ul(), """
This field is required.
Username:
This field is required.
Password:
@@ -1110,7 +1110,7 @@ class FormsTestCase(TestCase):
-""")
+ Hold down "Control", or "Command" on a Mac, to select more than one.""")
p = UserRegistration({'username': u'foo', 'options':['f','b']}, initial={'username': initial_django}, auto_id=False)
self.assertEqual(p.as_ul(), """
Username:
This field is required.
Password:
@@ -1118,7 +1118,7 @@ class FormsTestCase(TestCase):
-""")
+ Hold down "Control", or "Command" on a Mac, to select more than one.""")
# A callable 'initial' value is *not* used as a fallback if data is not provided.
# In this example, we don't provide a value for 'username', and the form raises a
@@ -1141,7 +1141,7 @@ class FormsTestCase(TestCase):
-""")
+ Hold down "Control", or "Command" on a Mac, to select more than one.""")
p = UserRegistration(initial={'username': initial_stephane, 'options': initial_options}, auto_id=False)
self.assertEqual(p.as_ul(), """
Username:
Password:
@@ -1149,7 +1149,7 @@ class FormsTestCase(TestCase):
-""")
+ Hold down "Control", or "Command" on a Mac, to select more than one.""")
def test_boundfield_values(self):
# It's possible to get to the value which would be used for rendering
diff --git a/tests/regressiontests/model_forms_regress/tests.py b/tests/regressiontests/model_forms_regress/tests.py
index f536001..97ba60e 100644
--- a/tests/regressiontests/model_forms_regress/tests.py
+++ b/tests/regressiontests/model_forms_regress/tests.py
@@ -3,6 +3,7 @@ from datetime import date
from django import forms
from django.core.exceptions import FieldError, ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
+from django.forms.widgets import CheckboxSelectMultiple
from django.forms.models import (modelform_factory, ModelChoiceField,
fields_for_model, construct_instance)
from django.utils import unittest
@@ -140,6 +141,29 @@ class ManyToManyCallableInitialTests(TestCase):
Hold down "Control", or "Command" on a Mac, to select more than one."""
% (book1.pk, book2.pk, book3.pk))
+class ManyToManyHelpTextTests(TestCase):
+
+ def test_dont_display_hold_down_command_help_text(self):
+ """
+ Ensures that the 'Hold down "Control"' message is not systematically displayed for
+ a M2M field if it does not use the default SelectMultiple widget.
+ Refs #9321.
+ """
+ # Override the widget and help_text
+ def formfield_for_dbfield(db_field, **kwargs):
+ if db_field.name == 'publications':
+ kwargs['widget'] = CheckboxSelectMultiple
+ return db_field.formfield(**kwargs)
+
+ # Set up some Publications to use as data
+ book1 = Publication.objects.create(title="First Book", date_published=date(2007,1,1))
+ book2 = Publication.objects.create(title="Second Book", date_published=date(2008,1,1))
+
+ # Create a ModelForm, instantiate it, and check that the output is as expected
+ ModelForm = modelform_factory(Article, formfield_callback=formfield_for_dbfield)
+ form = ModelForm()
+ self.assertEqual(unicode(form['publications'].help_text), u'')
+
class CFFForm(forms.ModelForm):
class Meta:
model = CustomFF
diff --git a/tests/regressiontests/model_formsets_regress/tests.py b/tests/regressiontests/model_formsets_regress/tests.py
index e6c2633..bf86a85 100644
--- a/tests/regressiontests/model_formsets_regress/tests.py
+++ b/tests/regressiontests/model_formsets_regress/tests.py
@@ -228,7 +228,7 @@ class FormsetTests(TestCase):
self.assertTrue(isinstance(form.errors, ErrorDict))
self.assertTrue(isinstance(form.non_field_errors(), ErrorList))
-class CustomWidget(forms.CharField):
+class CustomWidget(forms.TextInput):
pass