1
0
mirror of https://github.com/django/django.git synced 2024-12-23 01:25:58 +00:00

Fixed #35192 -- Added possibility to override BoundField at Form level or Field level.

This commit is contained in:
Christophe Henry 2024-06-11 12:25:24 +02:00
parent 7e41a7a47d
commit 9122e8542b
9 changed files with 283 additions and 17 deletions

View File

@ -18,7 +18,6 @@ from urllib.parse import urlsplit, urlunsplit
from django.conf import settings from django.conf import settings
from django.core import validators from django.core import validators
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.forms.boundfield import BoundField
from django.forms.utils import from_current_timezone, to_current_timezone from django.forms.utils import from_current_timezone, to_current_timezone
from django.forms.widgets import ( from django.forms.widgets import (
FILE_INPUT_CONTRADICTION, FILE_INPUT_CONTRADICTION,
@ -95,6 +94,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 +111,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 +136,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 +254,8 @@ 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) BoundFieldClass = self.bound_field_class or form.bound_field_class
return BoundFieldClass(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 self.renderer.bound_field_class
)
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,12 @@ 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"
@property
def bound_field_class(self):
from django.forms.boundfield import BoundField
return BoundField
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

@ -934,6 +934,30 @@ When set to ``True`` (the default), required form fields will have the
``use_required_attribute=False`` to avoid incorrect browser validation when ``use_required_attribute=False`` to avoid incorrect browser validation when
adding and deleting forms from a formset. adding and deleting forms from a formset.
.. attribute:: Form.bound_field_class
.. versionadded:: 5.2
Specify a :class:`~django.forms.BoundField` class to be used on the form. For
example::
from django import forms
class CustomBoundField(forms.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 MyForm(forms.Form):
bound_field_class = CustomBoundField
This takes precedence over :attr:`.BaseRenderer.bound_field_class`.
See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.
Configuring the rendering of a form's widgets Configuring the rendering of a form's widgets
--------------------------------------------- ---------------------------------------------
@ -1125,6 +1149,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 use a different ``BoundField`` class
per-form or per-field.
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:
@ -1464,26 +1494,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 There are multiple ways to use a custom :class:`.BoundField`:
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()``: * A project can define a project-wide :class:`.BoundField` class using
:attr:`.BaseRenderer.bound_field_class` and :setting:`FORM_RENDERER`.
.. method:: Field.get_bound_field(form, field_name) * A form can override :attr:`.Form.bound_field_class` or set the constructor
``bound_field_class`` argument in the constructor.
Takes an instance of :class:`~django.forms.Form` and the name of the field. * A custom form field can override :attr:`.Field.bound_field_class` or set the
The return value will be used when accessing the field in a template. Most ``bound_field_class`` argument in the constructor.
likely it will be an instance of a subclass of
:class:`~django.forms.BoundField`.
If you have a ``GPSCoordinatesField``, for example, and want to be able to * Lastly, a custom form field can override :meth:`.Field.get_bound_field()`
though any of the previous options is 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::
class GPSCoordinatesBoundField(BoundField): class GPSCoordinatesBoundField(BoundField):
@property @property
def country(self): def country(self):
@ -1499,12 +1537,36 @@ 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>
A project can define a project-wise ``BoundField`` class using
:attr:`.BaseRenderer.bound_field_class` and a custom :setting:`FORM_RENDERER`.
.. _binding-uploaded-files: .. _binding-uploaded-files:
Binding uploaded files to a form Binding uploaded files to a form

View File

@ -397,6 +397,15 @@ 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``
---------------------
.. versionadded:: 5.2
.. attribute:: Field.bound_field_class
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
====================================== ======================================
@ -1626,4 +1635,15 @@ 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 return value will be used when accessing the field in a template. Most
likely it will be an instance of a subclass of
:class:`.BoundField`.
See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.

View File

@ -65,6 +65,17 @@ 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:`~django.forms.BoundField` to use when rendering
forms. If not overridden by :attr:`.Form.bound_field_class` or
:attr:`.Field.bound_field_class`, this is the ``BoundField`` class that
gets used during rendering.
Defaults to :class:`django.forms.BoundField`.
.. 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

@ -50,6 +50,38 @@ be a ``CompositePrimaryKey``::
See :doc:`/topics/composite-primary-key` for more details. See :doc:`/topics/composite-primary-key` for more details.
Simplified usage of :class:`~django.forms.BoundField`
-----------------------------------------------------
Prior to 5.2, it was necessary to override :meth:`.Field.get_bound_field()` to
use custom :class:`~django.forms.BoundField`. Django now supports specifying a
:class:`~django.forms.BoundField` class to use at a project, form and
field level::
from django.forms import Form
from django.forms.boundfield import BoundField
from django.forms.renderers import DjangoTemplates
class BoundFieldWithCssClass(BoundField):
def css_classes(self, extra_classes=None):
return super().css_classes("user-form")
class CustomForm(Form):
# Override per-form.
bound_field_class = BoundFieldWithCssClass
# Or per-field.
name = CharField(bound_field_class=BoundFieldWithCssClass)
# Alternatively, set bound_field_class on your custom FORM_RENDERER.
class CustomRenderer(DjangoTemplates):
bound_field_class = BoundFieldWithCssClass
See :ref:`custom-boundfield` for more details.
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,
@ -5385,3 +5386,117 @@ 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 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)
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)

View File

@ -847,8 +847,15 @@ class ModelFormBaseTest(TestCase):
self.assertEqual(m1.mode, mode) self.assertEqual(m1.mode, mode)
def test_renderer_kwarg(self): def test_renderer_kwarg(self):
custom = object() from django.forms.renderers import BaseRenderer
self.assertIs(ProductForm(renderer=custom).renderer, custom)
class CustomRenderer(BaseRenderer):
custom_attribute = "test"
custom = CustomRenderer()
form = ProductForm(renderer=custom)
self.assertIs(form.renderer, custom)
self.assertEqual(form.renderer.custom_attribute, "test")
def test_default_splitdatetime_field(self): def test_default_splitdatetime_field(self):
class PubForm(forms.ModelForm): class PubForm(forms.ModelForm):