浏览代码

Fixed #35521 -- Allowed overriding BoundField class on fields, forms and renderers.

Thank you Sarah Boyce, Carlton Gibson, Tim Schilling and Adam Johnson
for reviews.

Co-authored-by: Christophe Henry <contact@c-henry.fr>
Co-authored-by: David Smith <smithdc@gmail.com>
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
Co-authored-by: Matthias Kestenholz <mk@feinheit.ch>
Matthias Kestenholz 2 月之前
父节点
当前提交
6a7ee02f59

+ 8 - 1
django/forms/fields.py

@@ -95,6 +95,7 @@ class Field:
         "required": _("This field is required."),
         "required": _("This field is required."),
     }
     }
     empty_values = list(validators.EMPTY_VALUES)
     empty_values = list(validators.EMPTY_VALUES)
+    bound_field_class = None
 
 
     def __init__(
     def __init__(
         self,
         self,
@@ -111,6 +112,7 @@ class Field:
         disabled=False,
         disabled=False,
         label_suffix=None,
         label_suffix=None,
         template_name=None,
         template_name=None,
+        bound_field_class=None,
     ):
     ):
         # required -- Boolean that specifies whether the field is required.
         # required -- Boolean that specifies whether the field is required.
         #             True by default.
         #             True by default.
@@ -135,11 +137,13 @@ class Field:
         #             is its widget is shown in the form but not editable.
         #             is its widget is shown in the form but not editable.
         # label_suffix -- Suffix to be added to the label. Overrides
         # label_suffix -- Suffix to be added to the label. Overrides
         #                 form's label_suffix.
         #                 form's label_suffix.
+        # bound_field_class -- BoundField class to use in Field.get_bound_field.
         self.required, self.label, self.initial = required, label, initial
         self.required, self.label, self.initial = required, label, initial
         self.show_hidden_initial = show_hidden_initial
         self.show_hidden_initial = show_hidden_initial
         self.help_text = help_text
         self.help_text = help_text
         self.disabled = disabled
         self.disabled = disabled
         self.label_suffix = label_suffix
         self.label_suffix = label_suffix
+        self.bound_field_class = bound_field_class or self.bound_field_class
         widget = widget or self.widget
         widget = widget or self.widget
         if isinstance(widget, type):
         if isinstance(widget, type):
             widget = widget()
             widget = widget()
@@ -251,7 +255,10 @@ class Field:
         Return a BoundField instance that will be used when accessing the form
         Return a BoundField instance that will be used when accessing the form
         field in a template.
         field in a template.
         """
         """
-        return BoundField(form, self, field_name)
+        bound_field_class = (
+            self.bound_field_class or form.bound_field_class or BoundField
+        )
+        return bound_field_class(form, self, field_name)
 
 
     def __deepcopy__(self, memo):
     def __deepcopy__(self, memo):
         result = copy.copy(self)
         result = copy.copy(self)

+ 9 - 0
django/forms/forms.py

@@ -68,6 +68,8 @@ class BaseForm(RenderableFormMixin):
     template_name_ul = "django/forms/ul.html"
     template_name_ul = "django/forms/ul.html"
     template_name_label = "django/forms/label.html"
     template_name_label = "django/forms/label.html"
 
 
+    bound_field_class = None
+
     def __init__(
     def __init__(
         self,
         self,
         data=None,
         data=None,
@@ -81,6 +83,7 @@ class BaseForm(RenderableFormMixin):
         field_order=None,
         field_order=None,
         use_required_attribute=None,
         use_required_attribute=None,
         renderer=None,
         renderer=None,
+        bound_field_class=None,
     ):
     ):
         self.is_bound = data is not None or files is not None
         self.is_bound = data is not None or files is not None
         self.data = MultiValueDict() if data is None else data
         self.data = MultiValueDict() if data is None else data
@@ -124,6 +127,12 @@ class BaseForm(RenderableFormMixin):
                     renderer = renderer()
                     renderer = renderer()
         self.renderer = renderer
         self.renderer = renderer
 
 
+        self.bound_field_class = (
+            bound_field_class
+            or self.bound_field_class
+            or getattr(self.renderer, "bound_field_class", None)
+        )
+
     def order_fields(self, field_order):
     def order_fields(self, field_order):
         """
         """
         Rearrange the fields according to field_order.
         Rearrange the fields according to field_order.

+ 2 - 0
django/forms/renderers.py

@@ -21,6 +21,8 @@ class BaseRenderer:
     formset_template_name = "django/forms/formsets/div.html"
     formset_template_name = "django/forms/formsets/div.html"
     field_template_name = "django/forms/field.html"
     field_template_name = "django/forms/field.html"
 
 
+    bound_field_class = None
+
     def get_template(self, template_name):
     def get_template(self, template_name):
         raise NotImplementedError("subclasses must implement get_template()")
         raise NotImplementedError("subclasses must implement get_template()")
 
 

+ 94 - 12
docs/ref/forms/api.txt

@@ -822,6 +822,9 @@ classes, as needed. The HTML will look something like:
     >>> f["subject"].legend_tag(attrs={"class": "foo"})
     >>> f["subject"].legend_tag(attrs={"class": "foo"})
     <legend for="id_subject" class="foo required">Subject:</legend>
     <legend for="id_subject" class="foo required">Subject:</legend>
 
 
+You may further modify the rendering of form rows by using a
+:ref:`custom BoundField <custom-boundfield>`.
+
 .. _ref-forms-api-configuring-label:
 .. _ref-forms-api-configuring-label:
 
 
 Configuring form elements' HTML ``id`` attributes and ``<label>`` tags
 Configuring form elements' HTML ``id`` attributes and ``<label>`` tags
@@ -1149,6 +1152,12 @@ they're not the only way a form object can be displayed.
 
 
    The ``__str__()`` method of this object displays the HTML for this field.
    The ``__str__()`` method of this object displays the HTML for this field.
 
 
+   You can use :attr:`.Form.bound_field_class` and
+   :attr:`.Field.bound_field_class` to specify a different ``BoundField`` class
+   per form or per field, respectively.
+
+   See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.
+
 To retrieve a single ``BoundField``, use dictionary lookup syntax on your form
 To retrieve a single ``BoundField``, use dictionary lookup syntax on your form
 using the field's name as the key:
 using the field's name as the key:
 
 
@@ -1488,23 +1497,34 @@ Methods of ``BoundField``
         >>> print(bound_form["subject"].value())
         >>> print(bound_form["subject"].value())
         hi
         hi
 
 
+.. _custom-boundfield:
+
 Customizing ``BoundField``
 Customizing ``BoundField``
 ==========================
 ==========================
 
 
-If you need to access some additional information about a form field in a
-template and using a subclass of :class:`~django.forms.Field` isn't
-sufficient, consider also customizing :class:`~django.forms.BoundField`.
+.. attribute:: Form.bound_field_class
 
 
-A custom form field can override ``get_bound_field()``:
+.. versionadded:: 5.2
 
 
-.. method:: Field.get_bound_field(form, field_name)
+Define a custom :class:`~django.forms.BoundField` class to use when rendering
+the form. This takes precedence over the project-level
+:attr:`.BaseRenderer.bound_field_class` (along with a custom
+:setting:`FORM_RENDERER`), but can be overridden by the field-level
+:attr:`.Field.bound_field_class`.
 
 
-    Takes an instance of :class:`~django.forms.Form` and the name of the field.
-    The return value will be used when accessing the field in a template. Most
-    likely it will be an instance of a subclass of
-    :class:`~django.forms.BoundField`.
+If not defined as a class variable, ``bound_field_class`` can be set via the
+``bound_field_class`` argument in the :class:`Form` or :class:`Field`
+constructor.
 
 
-If you have a ``GPSCoordinatesField``, for example, and want to be able to
+For compatibility reasons, a custom form field can still override
+:meth:`.Field.get_bound_field()` to use a custom class, though any of the
+previous options are preferred.
+
+You may want to use a custom :class:`.BoundField` if you need to access some
+additional information about a form field in a template and using a subclass of
+:class:`~django.forms.Field` isn't sufficient.
+
+For example, if you have a ``GPSCoordinatesField``, and want to be able to
 access additional information about the coordinates in a template, this could
 access additional information about the coordinates in a template, this could
 be implemented as follows::
 be implemented as follows::
 
 
@@ -1523,12 +1543,74 @@ be implemented as follows::
 
 
 
 
     class GPSCoordinatesField(Field):
     class GPSCoordinatesField(Field):
-        def get_bound_field(self, form, field_name):
-            return GPSCoordinatesBoundField(form, self, field_name)
+        bound_field_class = GPSCoordinatesBoundField
 
 
 Now you can access the country in a template with
 Now you can access the country in a template with
 ``{{ form.coordinates.country }}``.
 ``{{ form.coordinates.country }}``.
 
 
+You may also want to customize the default form field template rendering. For
+example, you can override :meth:`.BoundField.label_tag` to add a custom class::
+
+   class StyledLabelBoundField(BoundField):
+       def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+           attrs = attrs or {}
+           attrs["class"] = "wide"
+           return super().label_tag(contents, attrs, label_suffix, tag)
+
+
+   class UserForm(forms.Form):
+       bound_field_class = StyledLabelBoundField
+       name = CharField()
+
+This would update the default form rendering:
+
+.. code-block:: pycon
+
+   >>> f = UserForm()
+   >>> print(f["name"].label_tag)
+   <label for="id_name" class="wide">Name:</label>
+
+To add a CSS class to the wrapping HTML element of all fields, a ``BoundField``
+can be overridden to return a different collection of CSS classes::
+
+    class WrappedBoundField(BoundField):
+        def css_classes(self, extra_classes=None):
+            parent_css_classes = super().css_classes(extra_classes)
+            return f"field-class {parent_css_classes}".strip()
+
+
+    class UserForm(forms.Form):
+        bound_field_class = WrappedBoundField
+        name = CharField()
+
+This would update the form rendering as follows:
+
+.. code-block:: pycon
+
+   >>> f = UserForm()
+   >>> print(f)
+   <div class="field-class"><label for="id_name">Name:</label><input type="text" name="name" required id="id_name"></div>
+
+Alternatively, to override the ``BoundField`` class at the project level,
+:attr:`.BaseRenderer.bound_field_class` can be defined on a custom
+:setting:`FORM_RENDERER`:
+
+.. code-block:: python
+    :caption: ``mysite/renderers.py``
+
+    from django.forms.renderers import DjangoTemplates
+
+    from .forms import CustomBoundField
+
+
+    class CustomRenderer(DjangoTemplates):
+        bound_field_class = CustomBoundField
+
+.. code-block:: python
+    :caption: ``settings.py``
+
+    FORM_RENDERER = "mysite.renderers.CustomRenderer"
+
 .. _binding-uploaded-files:
 .. _binding-uploaded-files:
 
 
 Binding uploaded files to a form
 Binding uploaded files to a form

+ 21 - 1
docs/ref/forms/fields.txt

@@ -397,6 +397,16 @@ default this value is set to ``"django/forms/field.html"``. Can be changed per
 field by overriding this attribute or more generally by overriding the default
 field by overriding this attribute or more generally by overriding the default
 template, see also :ref:`overriding-built-in-field-templates`.
 template, see also :ref:`overriding-built-in-field-templates`.
 
 
+``bound_field_class``
+---------------------
+
+.. attribute:: Field.bound_field_class
+
+.. versionadded:: 5.2
+
+The ``bound_field_class`` attribute allows a per-field override of
+:attr:`.Form.bound_field_class`.
+
 Checking if the field data has changed
 Checking if the field data has changed
 ======================================
 ======================================
 
 
@@ -1635,4 +1645,14 @@ only requirements are that it implement a ``clean()`` method and that its
 ``label``, ``initial``, ``widget``, ``help_text``).
 ``label``, ``initial``, ``widget``, ``help_text``).
 
 
 You can also customize how a field will be accessed by overriding
 You can also customize how a field will be accessed by overriding
-:meth:`~django.forms.Field.get_bound_field()`.
+:attr:`~django.forms.Field.bound_field_class` or override
+:meth:`.Field.get_bound_field()` if you need more flexibility when creating
+the ``BoundField``:
+
+.. method:: Field.get_bound_field(form, field_name)
+
+    Takes an instance of :class:`~django.forms.Form` and the name of the field.
+    The returned :class:`.BoundField` instance will be used when accessing the
+    field in a template.
+
+See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.

+ 12 - 0
docs/ref/forms/renderers.txt

@@ -65,6 +65,18 @@ should return a rendered templates (as a string) or raise
 
 
         Defaults to ``"django/forms/field.html"``
         Defaults to ``"django/forms/field.html"``
 
 
+    .. attribute:: bound_field_class
+
+        .. versionadded:: 5.2
+
+        The default class used to represent form fields across the project.
+
+        Defaults to :class:`.BoundField` class.
+
+        This can be customized further using :attr:`.Form.bound_field_class`
+        for per-form overrides, or :attr:`.Field.bound_field_class` for
+        per-field overrides.
+
     .. method:: get_template(template_name)
     .. method:: get_template(template_name)
 
 
         Subclasses must implement this method with the appropriate template
         Subclasses must implement this method with the appropriate template

+ 56 - 0
docs/releases/5.2.txt

@@ -70,6 +70,62 @@ to be a ``CompositePrimaryKey``::
 
 
 See :doc:`/topics/composite-primary-key` for more details.
 See :doc:`/topics/composite-primary-key` for more details.
 
 
+Simplified override of :class:`~django.forms.BoundField`
+--------------------------------------------------------
+
+Prior to version 5.2, overriding :meth:`.Field.get_bound_field()` was the only
+option to use a custom :class:`~django.forms.BoundField`. Django now supports
+specifying the following attributes to customize form rendering:
+
+* :attr:`.BaseRenderer.bound_field_class` at the project level,
+* :attr:`.Form.bound_field_class` at the form level, and
+* :attr:`.Field.bound_field_class` at the field level.
+
+For example, to customize the ``BoundField`` of a ``Form`` class::
+
+    from django.forms import Form
+
+
+    class CustomBoundField(forms.BoundField):
+
+        custom_class = "custom"
+
+        def css_classes(self, extra_classes=None):
+            result = super().css_classes(extra_classes)
+            if self.custom_class not in result:
+                result += f" {self.custom_class}"
+            return result.strip()
+
+
+    class CustomForm(forms.Form):
+        bound_field_class = CustomBoundField
+
+        name = forms.CharField(
+            label="Your Name",
+            max_length=100,
+            required=False,
+            widget=forms.TextInput(attrs={"class": "name-input-class"}),
+        )
+        email = forms.EmailField(label="Your Email")
+
+
+When rendering a ``CustomForm`` instance, the following HTML is included:
+
+.. code:: html
+
+    <div class="custom">
+      <label for="id_name">Your Name:</label>
+      <input type="text" name="name" class="name-input-class" maxlength="100" id="id_name">
+    </div>
+
+    <div class="custom">
+      <label for="id_email">Your Email:</label>
+      <input type="email" name="email" maxlength="320" required="" id="id_email">
+    </div>
+
+
+See :ref:`custom-boundfield` for more details about this feature.
+
 Minor features
 Minor features
 --------------
 --------------
 
 

+ 160 - 0
tests/forms_tests/tests/test_forms.py

@@ -8,6 +8,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.validators import MaxValueValidator, RegexValidator
 from django.core.validators import MaxValueValidator, RegexValidator
 from django.forms import (
 from django.forms import (
     BooleanField,
     BooleanField,
+    BoundField,
     CharField,
     CharField,
     CheckboxSelectMultiple,
     CheckboxSelectMultiple,
     ChoiceField,
     ChoiceField,
@@ -4971,6 +4972,22 @@ class RendererTests(SimpleTestCase):
         context = form.get_context()
         context = form.get_context()
         self.assertEqual(context["errors"].renderer, custom)
         self.assertEqual(context["errors"].renderer, custom)
 
 
+    def test_boundfield_fallback(self):
+        class RendererWithoutBoundFieldClassAttribute:
+            form_template_name = "django/forms/div.html"
+            formset_template_name = "django/forms/formsets/div.html"
+            field_template_name = "django/forms/field.html"
+
+            def render(self, template_name, context, request=None):
+                return "Nice"
+
+        class UserForm(Form):
+            name = CharField()
+
+        form = UserForm(renderer=RendererWithoutBoundFieldClassAttribute())
+        self.assertIsInstance(form["name"], BoundField)
+        self.assertEqual(form["name"].as_field_group(), "Nice")
+
 
 
 class TemplateTests(SimpleTestCase):
 class TemplateTests(SimpleTestCase):
     def test_iterate_radios(self):
     def test_iterate_radios(self):
@@ -5473,3 +5490,146 @@ class OverrideTests(SimpleTestCase):
             '<label for="id_name" class="required">Name:</label>'
             '<label for="id_name" class="required">Name:</label>'
             '<legend class="required">Language:</legend>',
             '<legend class="required">Language:</legend>',
         )
         )
+
+
+class BoundFieldWithoutColon(BoundField):
+    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+        return super().label_tag(
+            contents=contents, attrs=attrs, label_suffix="", tag=None
+        )
+
+
+class BoundFieldWithTwoColons(BoundField):
+    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+        return super().label_tag(
+            contents=contents, attrs=attrs, label_suffix="::", tag=None
+        )
+
+
+class BoundFieldWithCustomClass(BoundField):
+    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+        attrs = attrs or {}
+        attrs["class"] = "custom-class"
+        return super().label_tag(contents, attrs, label_suffix, tag)
+
+
+class BoundFieldWithWrappingClass(BoundField):
+    def css_classes(self, extra_classes=None):
+        parent_classes = super().css_classes(extra_classes)
+        return f"field-class {parent_classes}"
+
+
+class BoundFieldOverrideRenderer(DjangoTemplates):
+    bound_field_class = BoundFieldWithoutColon
+
+
+@override_settings(
+    FORM_RENDERER="forms_tests.tests.test_forms.BoundFieldOverrideRenderer"
+)
+class CustomBoundFieldTest(SimpleTestCase):
+    def test_renderer_custom_bound_field(self):
+        t = Template("{{ form }}")
+        html = t.render(Context({"form": Person()}))
+        expected = """
+            <div><label for="id_first_name">First name</label>
+            <input type="text" name="first_name" required
+            id="id_first_name"></div>
+            <div><label for="id_last_name">Last name</label>
+            <input type="text" name="last_name" required
+            id="id_last_name"></div><div>
+            <label for="id_birthday">Birthday</label>
+            <input type="text" name="birthday" required
+            id="id_birthday"></div>"""
+        self.assertHTMLEqual(html, expected)
+
+    def test_form_custom_boundfield(self):
+        class CustomBoundFieldPerson(Person):
+            bound_field_class = BoundFieldWithTwoColons
+
+        with self.subTest("form's BoundField takes over renderer's BoundField"):
+            t = Template("{{ form }}")
+            html = t.render(Context({"form": CustomBoundFieldPerson()}))
+            expected = """
+                <div><label for="id_first_name">First name::</label>
+                <input type="text" name="first_name" required
+                id="id_first_name"></div>
+                <div><label for="id_last_name">Last name::</label>
+                <input type="text" name="last_name" required
+                id="id_last_name"></div><div>
+                <label for="id_birthday">Birthday::</label>
+                <input type="text" name="birthday" required
+                id="id_birthday"></div>"""
+            self.assertHTMLEqual(html, expected)
+
+        with self.subTest("Constructor argument takes over class property"):
+            t = Template("{{ form }}")
+            html = t.render(
+                Context(
+                    {
+                        "form": CustomBoundFieldPerson(
+                            bound_field_class=BoundFieldWithCustomClass
+                        )
+                    }
+                )
+            )
+            expected = """
+                <div><label class="custom-class" for="id_first_name">First name:</label>
+                <input type="text" name="first_name" required
+                id="id_first_name"></div>
+                <div><label class="custom-class" for="id_last_name">Last name:</label>
+                <input type="text" name="last_name" required
+                id="id_last_name"></div><div>
+                <label class="custom-class" for="id_birthday">Birthday:</label>
+                <input type="text" name="birthday" required
+                id="id_birthday"></div>"""
+            self.assertHTMLEqual(html, expected)
+
+        with self.subTest("Overriding css_classes works as expected"):
+            t = Template("{{ form }}")
+            html = t.render(
+                Context(
+                    {
+                        "form": CustomBoundFieldPerson(
+                            bound_field_class=BoundFieldWithWrappingClass
+                        )
+                    }
+                )
+            )
+            expected = """
+                <div class="field-class"><label for="id_first_name">First name:</label>
+                <input type="text" name="first_name" required
+                id="id_first_name"></div>
+                <div class="field-class"><label for="id_last_name">Last name:</label>
+                <input type="text" name="last_name" required
+                id="id_last_name"></div><div class="field-class">
+                <label for="id_birthday">Birthday:</label>
+                <input type="text" name="birthday" required
+                id="id_birthday"></div>"""
+            self.assertHTMLEqual(html, expected)
+
+    def test_field_custom_bound_field(self):
+        class BoundFieldWithTwoColonsCharField(CharField):
+            bound_field_class = BoundFieldWithTwoColons
+
+        class CustomFieldBoundFieldPerson(Person):
+            bound_field_class = BoundField
+
+            first_name = BoundFieldWithTwoColonsCharField()
+            last_name = BoundFieldWithTwoColonsCharField(
+                bound_field_class=BoundFieldWithCustomClass
+            )
+
+        html = Template("{{ form }}").render(
+            Context({"form": CustomFieldBoundFieldPerson()})
+        )
+        expected = """
+            <div><label for="id_first_name">First name::</label>
+            <input type="text" name="first_name" required
+            id="id_first_name"></div>
+            <div><label class="custom-class" for="id_last_name">Last name:</label>
+            <input type="text" name="last_name" required
+            id="id_last_name"></div><div>
+            <label for="id_birthday">Birthday:</label>
+            <input type="text" name="birthday" required
+            id="id_birthday"></div>"""
+        self.assertHTMLEqual(html, expected)