1
0
mirror of https://github.com/django/django.git synced 2025-03-06 15:32:33 +00:00

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>
This commit is contained in:
Matthias Kestenholz 2025-01-15 21:04:26 +01:00 committed by GitHub
parent 0cabed9efa
commit 6a7ee02f59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 362 additions and 14 deletions

View File

@ -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)

View File

@ -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.

View File

@ -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()")

View File

@ -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 .. attribute:: Form.bound_field_class
template and using a subclass of :class:`~django.forms.Field` isn't
sufficient, consider also customizing :class:`~django.forms.BoundField`.
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. If not defined as a class variable, ``bound_field_class`` can be set via the
The return value will be used when accessing the field in a template. Most ``bound_field_class`` argument in the :class:`Form` or :class:`Field`
likely it will be an instance of a subclass of constructor.
:class:`~django.forms.BoundField`.
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): bound_field_class = GPSCoordinatesBoundField
return GPSCoordinatesBoundField(form, self, field_name)
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

View File

@ -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``.

View File

@ -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

View File

@ -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
-------------- --------------

View File

@ -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)