From 2387592e927cfa8ab3d2668cf68f7f32df08436c Mon Sep 17 00:00:00 2001 From: Christophe Henry Date: Tue, 11 Jun 2024 12:25:24 +0200 Subject: [PATCH] Fixed #35192 -- Added possibility to override BoundField at Form level or Field level. --- django/forms/fields.py | 8 +- django/forms/forms.py | 9 ++ django/forms/renderers.py | 6 ++ docs/ref/forms/api.txt | 90 ++++++++++++++++---- docs/ref/forms/fields.txt | 14 ++- docs/ref/forms/renderers.txt | 11 +++ docs/releases/5.2.txt | 32 +++++++ tests/forms_tests/tests/test_forms.py | 118 ++++++++++++++++++++++++++ tests/model_forms/tests.py | 11 ++- 9 files changed, 278 insertions(+), 21 deletions(-) diff --git a/django/forms/fields.py b/django/forms/fields.py index 1a58a60743..1be74e799a 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -18,7 +18,6 @@ from urllib.parse import urlsplit, urlunsplit from django.conf import settings from django.core import validators 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.widgets import ( FILE_INPUT_CONTRADICTION, @@ -95,6 +94,7 @@ class Field: "required": _("This field is required."), } empty_values = list(validators.EMPTY_VALUES) + bound_field_class = None def __init__( self, @@ -111,6 +111,7 @@ class Field: disabled=False, label_suffix=None, template_name=None, + bound_field_class=None, ): # required -- Boolean that specifies whether the field is required. # True by default. @@ -135,11 +136,13 @@ class Field: # is its widget is shown in the form but not editable. # label_suffix -- Suffix to be added to the label. Overrides # 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.show_hidden_initial = show_hidden_initial self.help_text = help_text self.disabled = disabled self.label_suffix = label_suffix + self.bound_field_class = bound_field_class or self.bound_field_class widget = widget or self.widget if isinstance(widget, type): widget = widget() @@ -251,7 +254,8 @@ class Field: Return a BoundField instance that will be used when accessing the form 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): result = copy.copy(self) diff --git a/django/forms/forms.py b/django/forms/forms.py index 452f554e1e..6ee58a0b1f 100644 --- a/django/forms/forms.py +++ b/django/forms/forms.py @@ -68,6 +68,8 @@ class BaseForm(RenderableFormMixin): template_name_ul = "django/forms/ul.html" template_name_label = "django/forms/label.html" + bound_field_class = None + def __init__( self, data=None, @@ -81,6 +83,7 @@ class BaseForm(RenderableFormMixin): field_order=None, use_required_attribute=None, renderer=None, + bound_field_class=None, ): self.is_bound = data is not None or files is not None self.data = MultiValueDict() if data is None else data @@ -124,6 +127,12 @@ class BaseForm(RenderableFormMixin): 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): """ Rearrange the fields according to field_order. diff --git a/django/forms/renderers.py b/django/forms/renderers.py index baf8f74507..b6512824d3 100644 --- a/django/forms/renderers.py +++ b/django/forms/renderers.py @@ -21,6 +21,12 @@ class BaseRenderer: formset_template_name = "django/forms/formsets/div.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): raise NotImplementedError("subclasses must implement get_template()") diff --git a/docs/ref/forms/api.txt b/docs/ref/forms/api.txt index 9ce16ff2ab..6c6a3f2caa 100644 --- a/docs/ref/forms/api.txt +++ b/docs/ref/forms/api.txt @@ -934,6 +934,23 @@ When set to ``True`` (the default), required form fields will have the ``use_required_attribute=False`` to avoid incorrect browser validation when 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 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 --------------------------------------------- @@ -1106,10 +1123,16 @@ they're not the only way a form object can be displayed. .. class:: BoundField - Used to display HTML or access attributes for a single field of a - :class:`Form` instance. + Used to display HTML or access attributes for a single field of a + :class:`Form` instance. - 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 using the field's name as the key: @@ -1416,7 +1439,7 @@ Methods of ``BoundField`` .. method:: BoundField.render(template_name=None, context=None, renderer=None) - The render method is called by ``as_field_group``. All arguments are + The render method is called by ``as_field_group``. All arguments are optional and default to: * ``template_name``: :attr:`.BoundField.template_name` @@ -1441,25 +1464,38 @@ Methods of ``BoundField`` >>> print(bound_form["subject"].value()) hi +.. _custom-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`. +There are multiple ways to use a custom :class:`.BoundField`: -A custom form field can override ``get_bound_field()``: +* A project can define a project-wise :class:`.BoundField` class using + :attr:`.BaseRenderer.bound_field_class` and :setting:`FORM_RENDERER`. + +* A form can override :attr:`.Form.bound_field_class` or use the constructor + argument. + +* A custom form field can override :attr:`.Field.bound_field_class` or use the + constructor argument. + +* A custom form field can override :meth:`.Field.get_bound_field()`: .. 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:`~django.forms.BoundField`. + likely it will be an instance of a subclass of :class:`.BoundField`. + +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``, for example, and want to be +able to access additional information about the coordinates in a template, this +could be implemented as follows:: -If you have a ``GPSCoordinatesField``, for example, and want to be able to -access additional information about the coordinates in a template, this could -be implemented as follows:: class GPSCoordinatesBoundField(BoundField): @property @@ -1476,12 +1512,36 @@ be implemented as follows:: 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 ``{{ 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) + + +A project can define a project-wise ``BoundField`` class using +:attr:`.BaseRenderer.bound_field_class` and :setting:`FORM_RENDERER`. + .. _binding-uploaded-files: Binding uploaded files to a form diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index 6051122617..aae801d5d1 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -392,11 +392,20 @@ be ignored in favor of the value from the form's initial data. .. attribute:: Field.template_name The ``template_name`` argument allows a custom template to be used when the -field is rendered with :meth:`~django.forms.BoundField.as_field_group`. By +field is rendered with :meth:`~django.forms.BoundField.as_field_group`. By 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 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 ====================================== @@ -1626,4 +1635,5 @@ only requirements are that it implement a ``clean()`` method and that its ``label``, ``initial``, ``widget``, ``help_text``). 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`. See :ref:`custom-boundfield` +for examples of overriding a ``BoundField``. diff --git a/docs/ref/forms/renderers.txt b/docs/ref/forms/renderers.txt index e527a70c57..05ccb2eb28 100644 --- a/docs/ref/forms/renderers.txt +++ b/docs/ref/forms/renderers.txt @@ -65,6 +65,17 @@ should return a rendered templates (as a string) or raise 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) Subclasses must implement this method with the appropriate template diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index 3cc71b7f68..bec5710395 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -31,6 +31,38 @@ and only officially support the latest release of each series. What's new in Django 5.2 ======================== +Simplified usage of :class:`~django.forms.BoundField` +----------------------------------------------------- + +Django now supports specifying a custom :class:`~django.forms.BoundField` class +to use at a project, form and field level. This allows fine-grained usage of +the :ref:`ref-forms-api-bound-unbound`:: + + 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 :attr:`.BaseRenderer.bound_field_class`, :attr:`.Form.bound_field_class` +and :attr:`.Field.bound_field_class` for more details. + Minor features -------------- diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py index 3982cc93fe..a61d1c4ea0 100644 --- a/tests/forms_tests/tests/test_forms.py +++ b/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.forms import ( BooleanField, + BoundField, CharField, CheckboxSelectMultiple, ChoiceField, @@ -5329,3 +5330,120 @@ class OverrideTests(SimpleTestCase): '' 'Language:', ) + + +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 setUp(self): + self.maxDiff = None + + def test_renderer_custom_bound_field(self): + t = Template("{{ form }}") + html = t.render(Context({"form": Person()})) + expected = """ +
+
+
+
+ +
""" + 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 = """ +
+
+
+
+ +
""" + 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 = """ +
+
+
+
+ +
""" + 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 = """ +
+
+
+
+ +
""" + self.assertHTMLEqual(html, expected) diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py index 3c4c510440..ace753244e 100644 --- a/tests/model_forms/tests.py +++ b/tests/model_forms/tests.py @@ -847,8 +847,15 @@ class ModelFormBaseTest(TestCase): self.assertEqual(m1.mode, mode) def test_renderer_kwarg(self): - custom = object() - self.assertIs(ProductForm(renderer=custom).renderer, custom) + from django.forms.renderers import BaseRenderer + + 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): class PubForm(forms.ModelForm):