diff --git a/django/forms/renderers.py b/django/forms/renderers.py index 0e406c9c7e..43340c6c88 100644 --- a/django/forms/renderers.py +++ b/django/forms/renderers.py @@ -15,6 +15,9 @@ def get_default_renderer(): class BaseRenderer: + # RemovedInDjango50Warning: When the deprecation ends, replace with + # form_template_name = "django/forms/div.html" + # formset_template_name = "django/forms/formsets/div.html" form_template_name = "django/forms/default.html" formset_template_name = "django/forms/formsets/default.html" @@ -64,6 +67,31 @@ class Jinja2(EngineMixin, BaseRenderer): return Jinja2 +class DjangoDivFormRenderer(DjangoTemplates): + """ + Load Django templates from django/forms/templates and from apps' + 'templates' directory and use the 'div.html' template to render forms and + formsets. + """ + + # RemovedInDjango50Warning Deprecate this class in 5.0 and remove in 6.0. + + form_template_name = "django/forms/div.html" + formset_template_name = "django/forms/formsets/div.html" + + +class Jinja2DivFormRenderer(Jinja2): + """ + Load Jinja2 templates from the built-in widget templates in + django/forms/jinja2 and from apps' 'jinja2' directory. + """ + + # RemovedInDjango50Warning Deprecate this class in 5.0 and remove in 6.0. + + form_template_name = "django/forms/div.html" + formset_template_name = "django/forms/formsets/div.html" + + class TemplatesSetting(BaseRenderer): """ Load templates using template.loader.get_template() which is configured diff --git a/django/forms/utils.py b/django/forms/utils.py index 77678054db..905babce4d 100644 --- a/django/forms/utils.py +++ b/django/forms/utils.py @@ -1,13 +1,16 @@ import json +import warnings from collections import UserList from django.conf import settings from django.core.exceptions import ValidationError from django.forms.renderers import get_default_renderer from django.utils import timezone +from django.utils.deprecation import RemovedInDjango50Warning from django.utils.html import escape, format_html_join from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from django.utils.version import get_docs_version def pretty_name(name): @@ -42,6 +45,16 @@ def flatatt(attrs): ) +DEFAULT_TEMPLATE_DEPRECATION_MSG = ( + 'The "default.html" templates for forms and formsets will be removed. These were ' + 'proxies to the equivalent "table.html" templates, but the new "div.html" ' + "templates will be the default from Django 5.0. Transitional renderers are " + "provided to allow you to opt-in to the new output style now. See " + "https://docs.djangoproject.com/en/%s/releases/4.1/ for more details" + % get_docs_version() +) + + class RenderableMixin: def get_context(self): raise NotImplementedError( @@ -49,12 +62,17 @@ class RenderableMixin: ) def render(self, template_name=None, context=None, renderer=None): - return mark_safe( - (renderer or self.renderer).render( - template_name or self.template_name, - context or self.get_context(), + renderer = renderer or self.renderer + template = template_name or self.template_name + context = context or self.get_context() + if ( + template == "django/forms/default.html" + or template == "django/forms/formsets/default.html" + ): + warnings.warn( + DEFAULT_TEMPLATE_DEPRECATION_MSG, RemovedInDjango50Warning, stacklevel=2 ) - ) + return mark_safe(renderer.render(template, context)) __str__ = render __html__ = render diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index bb6b889f91..73ed9a3c6b 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -105,6 +105,9 @@ details on these changes. * The ``django.contrib.auth.hashers.CryptPasswordHasher`` will be removed. +* The ``"django/forms/default.html"`` and + ``"django/forms/formsets/default.html"`` templates will be removed. + * The ability to pass ``nulls_first=False`` or ``nulls_last=False`` to ``Expression.asc()`` and ``Expression.desc()`` methods, and the ``OrderBy`` expression will be removed. diff --git a/docs/ref/forms/renderers.txt b/docs/ref/forms/renderers.txt index 8c0263e051..f11b23ab12 100644 --- a/docs/ref/forms/renderers.txt +++ b/docs/ref/forms/renderers.txt @@ -56,6 +56,12 @@ should return a rendered templates (as a string) or raise Defaults to ``"django/forms/default.html"``, which is a proxy for ``"django/forms/table.html"``. + .. deprecated:: 4.1 + + The ``"django/forms/default.html"`` template is deprecated and will be + removed in Django 5.0. The default will become + ``"django/forms/default.html"`` at that time. + .. attribute:: formset_template_name .. versionadded:: 4.1 @@ -65,6 +71,12 @@ should return a rendered templates (as a string) or raise Defaults to ``"django/forms/formsets/default.html"``, which is a proxy for ``"django/forms/formsets/table.html"``. + .. deprecated:: 4.1 + + The ``"django/forms/formset/default.html"`` template is deprecated and + will be removed in Django 5.0. The default will become + ``"django/forms/formset/div.html"`` template. + .. method:: get_template(template_name) Subclasses must implement this method with the appropriate template @@ -97,6 +109,26 @@ If you want to render templates with customizations from your :setting:`TEMPLATES` setting, such as context processors for example, use the :class:`TemplatesSetting` renderer. +.. class:: DjangoDivFormRenderer + +.. versionadded:: 4.1 + +Subclass of :class:`DjangoTemplates` that specifies +:attr:`~BaseRenderer.form_template_name` and +:attr:`~BaseRenderer.formset_template_name` as ``"django/forms/div.html"`` and +``"django/forms/formset/div.html"`` respectively. + +This is a transitional renderer for opt-in to the new ``
`` based +templates, which are the default from Django 5.0. + +Apply this via the :setting:`FORM_RENDERER` setting:: + + FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer" + +Once the ``
`` templates are the default, this transitional renderer will +be deprecated, for removal in Django 6.0. The ``FORM_RENDERER`` declaration can +be removed at that time. + ``Jinja2`` ---------- @@ -113,6 +145,17 @@ templates for widgets that don't have any, you can't use this renderer. For example, :mod:`django.contrib.admin` doesn't include Jinja2 templates for its widgets due to their usage of Django template tags. +.. class:: Jinja2DivFormRenderer + +.. versionadded:: 4.1 + +A transitional renderer as per :class:`DjangoDivFormRenderer` above, but +subclassing :class:`Jinja2` for use with the Jinja2 backend. + +Apply this via the :setting:`FORM_RENDERER` setting:: + + FORM_RENDERER = "django.forms.renderers.Jinja2DivFormRenderer" + ``TemplatesSetting`` -------------------- diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt index 36a510b460..49f74a93b3 100644 --- a/docs/releases/4.1.txt +++ b/docs/releases/4.1.txt @@ -74,6 +74,24 @@ Validation of Constraints in the :attr:`Meta.constraints ` option are now checked during :ref:`model validation `. +Form rendering accessibility +---------------------------- + +In order to aid users with screen readers, and other assistive technology, new +``
`` based form templates are available from this release. These provide +more accessible navigation than the older templates, and are able to correctly +group related controls, such as radio-lists, into fieldsets. + +The new templates are recommended, and will become the default form rendering +style when outputting a form, like ``{{ form }}`` in a template, from Django +5.0. + +In order to ease adopting the new output style, the default form and formset +templates are now configurable at the project level via the +:setting:`FORM_RENDERER` setting. + +See :ref:`the Forms section (below)` for full details. + .. _csrf-cookie-masked-usage: ``CSRF_COOKIE_MASKED`` setting @@ -253,6 +271,8 @@ File Uploads * ... +.. _forms-4.1: + Forms ~~~~~ @@ -279,6 +299,34 @@ Forms as the template implements ``
`` and ```` to group related inputs and is easier for screen reader users to navigate. + The div-based output will become the default rendering style from Django 5.0. + +* In order to smooth adoption of the new ``
`` output style, two + transitional form renderer classes are available: + :class:`django.forms.renderers.DjangoDivFormRenderer` and + :class:`django.forms.renderers.Jinja2DivFormRenderer`, for the Django and + Jinja2 template backends respectively. + + You can apply one of these via the :setting:`FORM_RENDERER` setting. For + example:: + + FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer" + + Once the ``
`` output style is the default, from Django 5.0, these + transitional renderers will be deprecated, for removal in Django 6.0. The + ``FORM_RENDERER`` declaration can be removed at that time. + +* If the new ``
`` output style is not appropriate for your project, you should + define a renderer subclass specifying + :attr:`~django.forms.renderers.BaseRenderer.form_template_name` and + :attr:`~django.forms.renderers.BaseRenderer.formset_template_name` for your + required style, and set :setting:`FORM_RENDERER` accordingly. + + For example, for the ``

`` output style used by :meth:`~.Form.as_p`, you + would define a form renderer setting ``form_template_name`` to + ``"django/forms/p.html"`` and ``formset_template_name`` to + ``"django/forms/formsets/p.html"``. + * The new :meth:`~django.forms.BoundField.legend_tag` allows rendering field labels in ```` tags via the new ``tag`` argument of :meth:`~django.forms.BoundField.label_tag`. @@ -718,6 +766,10 @@ Miscellaneous ``Expression.asc()`` and ``Expression.desc()`` methods, and the ``OrderBy`` expression is deprecated. Use ``None`` instead. +* The ``"django/forms/default.html"`` and + ``"django/forms/formsets/default.html"`` templates which are a proxy to the + table-based templates are deprecated. Use the specific template instead. + Features removed in 4.1 ======================= diff --git a/tests/forms_tests/tests/__init__.py b/tests/forms_tests/tests/__init__.py index 193a7149a1..1878eaf6e5 100644 --- a/tests/forms_tests/tests/__init__.py +++ b/tests/forms_tests/tests/__init__.py @@ -11,6 +11,8 @@ except ImportError: def jinja2_tests(test_func): test_func = skipIf(jinja2 is None, "this test requires jinja2")(test_func) return override_settings( - FORM_RENDERER="django.forms.renderers.Jinja2", + # RemovedInDjango50Warning: When the deprecation ends, revert to + # FORM_RENDERER="django.forms.renderers.Jinja2", + FORM_RENDERER="django.forms.renderers.Jinja2DivFormRenderer", TEMPLATES={"BACKEND": "django.template.backends.jinja2.Jinja2"}, )(test_func) diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py index e6e396c8a1..ec911ee961 100644 --- a/tests/forms_tests/tests/test_forms.py +++ b/tests/forms_tests/tests/test_forms.py @@ -42,8 +42,9 @@ from django.forms.utils import ErrorList from django.http import QueryDict from django.template import Context, Template from django.test import SimpleTestCase -from django.test.utils import override_settings +from django.test.utils import isolate_lru_cache, override_settings from django.utils.datastructures import MultiValueDict +from django.utils.deprecation import RemovedInDjango50Warning from django.utils.safestring import mark_safe from . import jinja2_tests @@ -149,17 +150,12 @@ class FormsTestCase(SimpleTestCase): ) self.assertHTMLEqual( str(p), - """ - - - - - - - """, + '

' + '
' + '
', ) self.assertHTMLEqual( p.as_div(), @@ -182,15 +178,15 @@ class FormsTestCase(SimpleTestCase): self.assertEqual(p.cleaned_data, {}) self.assertHTMLEqual( str(p), - """ -
  • This field is required.
- - -
  • This field is required.
- - -
  • This field is required.
-""", + '
' + '
  • This field is required.
' + '
' + '
' + '
  • This field is required.
' + '
' + '' + '
  • This field is required.
' + '
', ) self.assertHTMLEqual( p.as_table(), @@ -261,12 +257,12 @@ class FormsTestCase(SimpleTestCase): self.assertHTMLEqual( str(p), - """ - - - - -""", + '
", ) self.assertHTMLEqual( p.as_table(), @@ -4932,9 +4928,7 @@ class TemplateTests(SimpleTestCase): t = Template( '
' - "" "{{ form }}" - "
" '' "
" ) @@ -4944,14 +4938,12 @@ class TemplateTests(SimpleTestCase): self.assertHTMLEqual( my_function("GET", {}), '
' - "" - "' - "' - "' - "
Username:" - '
Password1:" - '
Password2:" - '
" + "
Username:" + '
' + "
Password1:" + '
' + "
Password2:" + '
' '' "
", ) @@ -4966,18 +4958,16 @@ class TemplateTests(SimpleTestCase): }, ), '
' - "" - '" - '' - "' - "' - "
    ' - "
  • Please make sure your passwords match.
Username:
    ' + '
      ' + "
    • Please make sure your passwords match.
    " + '
    Username:
      ' "
    • Ensure this value has at most 10 characters (it has 23).
    " '
Password1:" - '
Password2:" - '
" + 'value="this-is-a-long-username" maxlength="10" required>
' + "
Password1:" + '
' + "
Password2:" + '
' '' "", ) @@ -5054,7 +5044,7 @@ class OverrideTests(SimpleTestCase): f = FirstNameForm() try: - self.assertInHTML("1", f.render()) + f.render() except RecursionError: self.fail("Cyclic reference in BoundField.render().") @@ -5069,3 +5059,16 @@ class OverrideTests(SimpleTestCase): '' 'Language:', ) + + +class DeprecationTests(SimpleTestCase): + def test_warning(self): + from django.forms.utils import DEFAULT_TEMPLATE_DEPRECATION_MSG + + with isolate_lru_cache(get_default_renderer), self.settings( + FORM_RENDERER="django.forms.renderers.DjangoTemplates" + ), self.assertRaisesMessage( + RemovedInDjango50Warning, DEFAULT_TEMPLATE_DEPRECATION_MSG + ): + form = Person() + str(form) diff --git a/tests/forms_tests/tests/test_formsets.py b/tests/forms_tests/tests/test_formsets.py index 0868b41644..d159409afa 100644 --- a/tests/forms_tests/tests/test_formsets.py +++ b/tests/forms_tests/tests/test_formsets.py @@ -23,10 +23,12 @@ from django.forms.formsets import ( all_valid, formset_factory, ) -from django.forms.renderers import TemplatesSetting +from django.forms.renderers import TemplatesSetting, get_default_renderer from django.forms.utils import ErrorList from django.forms.widgets import HiddenInput from django.test import SimpleTestCase +from django.test.utils import isolate_lru_cache +from django.utils.deprecation import RemovedInDjango50Warning from . import jinja2_tests @@ -125,8 +127,8 @@ class FormsFormsetTestCase(SimpleTestCase): -Choice: -Votes:""", +
Choice:
+
Votes:
""", ) # FormSet are treated similarly to Forms. FormSet has an is_valid() # method, and a cleaned_data or errors attribute depending on whether @@ -976,12 +978,12 @@ class FormsFormsetTestCase(SimpleTestCase): formset = LimitedFavoriteDrinkFormSet() self.assertHTMLEqual( "\n".join(str(form) for form in formset.forms), - """ - - - - -""", + """
+
+
+
+
+
""", ) # If max_num is 0 then no form is rendered at all. LimitedFavoriteDrinkFormSet = formset_factory( @@ -997,10 +999,10 @@ class FormsFormsetTestCase(SimpleTestCase): formset = LimitedFavoriteDrinkFormSet() self.assertHTMLEqual( "\n".join(str(form) for form in formset.forms), - """ - - -""", + """
+
+
+
""", ) def test_limiting_extra_lest_than_max_num(self): @@ -1011,8 +1013,8 @@ class FormsFormsetTestCase(SimpleTestCase): formset = LimitedFavoriteDrinkFormSet() self.assertHTMLEqual( "\n".join(str(form) for form in formset.forms), - """ -""", + """
+
""", ) def test_max_num_with_initial_data(self): @@ -1024,11 +1026,11 @@ class FormsFormsetTestCase(SimpleTestCase): self.assertHTMLEqual( "\n".join(str(form) for form in formset.forms), """ - - - - +
+
+
+
""", ) @@ -1056,12 +1058,12 @@ class FormsFormsetTestCase(SimpleTestCase): self.assertHTMLEqual( "\n".join(str(form) for form in formset.forms), """ - - - - +
+
+
+
""", ) @@ -1082,18 +1084,15 @@ class FormsFormsetTestCase(SimpleTestCase): self.assertHTMLEqual( "\n".join(str(form) for form in formset.forms), """ - - +
- - - +
+
- - + value="Bloody Mary">
+
+ value="Jack and Coke">
""", ) @@ -1173,12 +1172,11 @@ class FormsFormsetTestCase(SimpleTestCase): self.assertHTMLEqual( "\n".join(str(form) for form in formset.forms), """ - - +
- - - """, +
+
+
""", ) def test_management_form_field_names(self): @@ -1701,16 +1699,16 @@ class TestIsBoundBehavior(SimpleTestCase): # Can still render the formset. self.assertHTMLEqual( str(formset), - '' '
    ' "
  • (Hidden field TOTAL_FORMS) This field is required.
  • " "
  • (Hidden field INITIAL_FORMS) This field is required.
  • " "
" + "
" '' '' '' '' - "\n", + "
\n", ) def test_management_form_invalid_data(self): @@ -1732,18 +1730,18 @@ class TestIsBoundBehavior(SimpleTestCase): # Can still render the formset. self.assertHTMLEqual( str(formset), - '' '
    ' "
  • (Hidden field TOTAL_FORMS) Enter a whole number.
  • " "
  • (Hidden field INITIAL_FORMS) Enter a whole number.
  • " "
" + "
" '' '' '' '' - "\n", + "
\n", ) def test_customize_management_form_error(self): @@ -1889,3 +1887,17 @@ class AllValidTests(SimpleTestCase): ] self.assertEqual(formset1._errors, expected_errors) self.assertEqual(formset2._errors, expected_errors) + + +class DeprecationTests(SimpleTestCase): + def test_warning(self): + from django.forms.utils import DEFAULT_TEMPLATE_DEPRECATION_MSG + + with isolate_lru_cache(get_default_renderer), self.settings( + FORM_RENDERER="django.forms.renderers.DjangoTemplates" + ), self.assertRaisesMessage( + RemovedInDjango50Warning, DEFAULT_TEMPLATE_DEPRECATION_MSG + ): + ChoiceFormSet = formset_factory(Choice) + formset = ChoiceFormSet() + str(formset) diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py index a8617444c5..eb9c2484dc 100644 --- a/tests/model_forms/tests.py +++ b/tests/model_forms/tests.py @@ -687,12 +687,12 @@ class ModelFormBaseTest(TestCase): self.assertHTMLEqual( str(SubclassMeta()), - """ - - - - -""", + '
' + '' + '
' + '' + '
', ) def test_orderfields_form(self): @@ -704,10 +704,10 @@ class ModelFormBaseTest(TestCase): self.assertEqual(list(OrderFields.base_fields), ["url", "name"]) self.assertHTMLEqual( str(OrderFields()), - """ - - -""", + '
' + '' + '
', ) def test_orderfields2_form(self): @@ -1460,12 +1460,11 @@ class ModelFormBasicTests(TestCase): f = BaseCategoryForm() self.assertHTMLEqual( str(f), - """ - - - - -""", + '
' + '
', ) self.assertHTMLEqual( str(f.as_ul()), @@ -1538,12 +1537,9 @@ class ModelFormBasicTests(TestCase): f = RoykoForm(auto_id=False, instance=self.w_royko) self.assertHTMLEqual( str(f), - """ - Name: - -
- Use both first and last names. - """, + '
Name:
Use both first and last names.
' + '
", ) art = Article.objects.create( @@ -1703,30 +1699,39 @@ class ModelFormBasicTests(TestCase): self.assertHTMLEqual( str(f), """ - Headline: - - Slug: - - Pub date: - - Writer: - Article: - - Categories: - Status: +
Headline: + +
+
Slug: + +
+
Pub date: + +
+
Writer: + +
+
Article: + +
+
Categories: + +
+
Status: + +
""" % (self.w_woodward.pk, self.w_royko.pk, self.c1.pk, self.c2.pk, self.c3.pk), ) @@ -1791,12 +1796,8 @@ class ModelFormBasicTests(TestCase): f = PartialArticleForm(auto_id=False) self.assertHTMLEqual( str(f), - """ - Headline: - - Pub date: - - """, + '
Headline:' + '
Pub date:
', ) class PartialArticleFormWithSlug(forms.ModelForm): @@ -2990,10 +2991,10 @@ class OtherModelFormTests(TestCase): self.assertHTMLEqual( str(CategoryForm()), - """ - - -""", + '
' + '
', ) # to_field_name should also work on ModelMultipleChoiceField ################## @@ -3014,8 +3015,8 @@ class OtherModelFormTests(TestCase): self.assertEqual(list(CustomFieldForExclusionForm.base_fields), ["name"]) self.assertHTMLEqual( str(CustomFieldForExclusionForm()), - """ -""", + '
', ) def test_iterable_model_m2m(self): diff --git a/tests/postgres_tests/test_array.py b/tests/postgres_tests/test_array.py index 7243ab6a5a..1100e8f3b0 100644 --- a/tests/postgres_tests/test_array.py +++ b/tests/postgres_tests/test_array.py @@ -1196,14 +1196,12 @@ class TestSplitFormField(PostgreSQLSimpleTestCase): self.assertHTMLEqual( str(SplitForm()), """ - - - - - - - - +
+ + + + +
""", ) diff --git a/tests/postgres_tests/test_ranges.py b/tests/postgres_tests/test_ranges.py index 1b155ed51a..7f8fc6bb8c 100644 --- a/tests/postgres_tests/test_ranges.py +++ b/tests/postgres_tests/test_ranges.py @@ -687,17 +687,15 @@ class TestFormField(PostgreSQLSimpleTestCase): self.assertHTMLEqual( str(form), """ - - - - - +
+
+ Field: - - +
+
""", ) form = SplitForm( @@ -788,13 +786,13 @@ class TestFormField(PostgreSQLSimpleTestCase): self.assertHTMLEqual( str(RangeForm()), """ - - - +
+
+ Ints: - - +
+
""", ) diff --git a/tests/runtests.py b/tests/runtests.py index e3a60d777b..e5adb902c3 100755 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -243,6 +243,9 @@ def setup_collect_tests(start_at, start_after, test_labels=None): "fields.W342", # ForeignKey(unique=True) -> OneToOneField ] + # RemovedInDjango50Warning + settings.FORM_RENDERER = "django.forms.renderers.DjangoDivFormRenderer" + # Load all the ALWAYS_INSTALLED_APPS. django.setup()