From 456466d932830b096d39806e291fe23ec5ed38d5 Mon Sep 17 00:00:00 2001 From: David Smith Date: Fri, 10 Sep 2021 08:06:01 +0100 Subject: [PATCH] Fixed #31026 -- Switched form rendering to template engine. Thanks Carlton Gibson, Keryn Knight, Mariusz Felisiak, and Nick Pope for reviews. Co-authored-by: Johannes Hoppe --- django/forms/boundfield.py | 20 +- django/forms/forms.py | 95 ++++---- django/forms/formsets.py | 65 +++--- django/forms/jinja2/django/forms/attrs.html | 1 + django/forms/jinja2/django/forms/default.html | 1 + .../django/forms/errors/dict/default.html | 1 + .../jinja2/django/forms/errors/dict/text.txt | 3 + .../jinja2/django/forms/errors/dict/ul.html | 1 + .../django/forms/errors/list/default.html | 1 + .../jinja2/django/forms/errors/list/text.txt | 2 + .../jinja2/django/forms/errors/list/ul.html | 1 + .../jinja2/django/forms/formsets/default.html | 1 + .../forms/jinja2/django/forms/formsets/p.html | 1 + .../jinja2/django/forms/formsets/table.html | 1 + .../jinja2/django/forms/formsets/ul.html | 1 + django/forms/jinja2/django/forms/label.html | 1 + django/forms/jinja2/django/forms/p.html | 20 ++ django/forms/jinja2/django/forms/table.html | 29 +++ django/forms/jinja2/django/forms/ul.html | 24 +++ django/forms/models.py | 18 +- .../forms/templates/django/forms/attrs.html | 1 + .../forms/templates/django/forms/default.html | 1 + .../django/forms/errors/dict/default.html | 1 + .../django/forms/errors/dict/text.txt | 3 + .../django/forms/errors/dict/ul.html | 1 + .../django/forms/errors/list/default.html | 1 + .../django/forms/errors/list/text.txt | 2 + .../django/forms/errors/list/ul.html | 1 + .../django/forms/formsets/default.html | 1 + .../templates/django/forms/formsets/p.html | 1 + .../django/forms/formsets/table.html | 1 + .../templates/django/forms/formsets/ul.html | 1 + .../forms/templates/django/forms/label.html | 1 + django/forms/templates/django/forms/p.html | 20 ++ .../forms/templates/django/forms/table.html | 29 +++ django/forms/templates/django/forms/ul.html | 24 +++ django/forms/utils.py | 116 ++++++---- docs/internals/deprecation.txt | 5 + docs/ref/forms/api.txt | 204 +++++++++++++++--- docs/ref/forms/formsets.txt | 6 +- docs/ref/forms/models.txt | 18 +- docs/ref/forms/renderers.txt | 59 ++++- docs/ref/settings.txt | 5 +- docs/releases/4.0.txt | 17 ++ docs/topics/forms/formsets.txt | 95 +++++++- docs/topics/forms/index.txt | 24 ++- tests/admin_views/tests.py | 2 +- .../templates/forms_tests/error.html | 1 + .../templates/forms_tests/form_snippet.html | 6 + tests/forms_tests/tests/__init__.py | 30 +++ .../tests/test_deprecation_forms.py | 183 ++++++++++++++++ tests/forms_tests/tests/test_forms.py | 174 ++++----------- tests/forms_tests/tests/test_formsets.py | 32 ++- tests/forms_tests/tests/test_i18n.py | 2 + tests/forms_tests/tests/tests.py | 2 + tests/model_formsets/tests.py | 19 ++ 56 files changed, 1047 insertions(+), 329 deletions(-) create mode 100644 django/forms/jinja2/django/forms/attrs.html create mode 100644 django/forms/jinja2/django/forms/default.html create mode 100644 django/forms/jinja2/django/forms/errors/dict/default.html create mode 100644 django/forms/jinja2/django/forms/errors/dict/text.txt create mode 100644 django/forms/jinja2/django/forms/errors/dict/ul.html create mode 100644 django/forms/jinja2/django/forms/errors/list/default.html create mode 100644 django/forms/jinja2/django/forms/errors/list/text.txt create mode 100644 django/forms/jinja2/django/forms/errors/list/ul.html create mode 100644 django/forms/jinja2/django/forms/formsets/default.html create mode 100644 django/forms/jinja2/django/forms/formsets/p.html create mode 100644 django/forms/jinja2/django/forms/formsets/table.html create mode 100644 django/forms/jinja2/django/forms/formsets/ul.html create mode 100644 django/forms/jinja2/django/forms/label.html create mode 100644 django/forms/jinja2/django/forms/p.html create mode 100644 django/forms/jinja2/django/forms/table.html create mode 100644 django/forms/jinja2/django/forms/ul.html create mode 100644 django/forms/templates/django/forms/attrs.html create mode 100644 django/forms/templates/django/forms/default.html create mode 100644 django/forms/templates/django/forms/errors/dict/default.html create mode 100644 django/forms/templates/django/forms/errors/dict/text.txt create mode 100644 django/forms/templates/django/forms/errors/dict/ul.html create mode 100644 django/forms/templates/django/forms/errors/list/default.html create mode 100644 django/forms/templates/django/forms/errors/list/text.txt create mode 100644 django/forms/templates/django/forms/errors/list/ul.html create mode 100644 django/forms/templates/django/forms/formsets/default.html create mode 100644 django/forms/templates/django/forms/formsets/p.html create mode 100644 django/forms/templates/django/forms/formsets/table.html create mode 100644 django/forms/templates/django/forms/formsets/ul.html create mode 100644 django/forms/templates/django/forms/label.html create mode 100644 django/forms/templates/django/forms/p.html create mode 100644 django/forms/templates/django/forms/table.html create mode 100644 django/forms/templates/django/forms/ul.html create mode 100644 tests/forms_tests/templates/forms_tests/error.html create mode 100644 tests/forms_tests/templates/forms_tests/form_snippet.html create mode 100644 tests/forms_tests/tests/test_deprecation_forms.py diff --git a/django/forms/boundfield.py b/django/forms/boundfield.py index 3e92cba549..d1e98719d2 100644 --- a/django/forms/boundfield.py +++ b/django/forms/boundfield.py @@ -1,11 +1,10 @@ import re from django.core.exceptions import ValidationError -from django.forms.utils import flatatt, pretty_name +from django.forms.utils import pretty_name from django.forms.widgets import MultiWidget, Textarea, TextInput from django.utils.functional import cached_property -from django.utils.html import conditional_escape, format_html, html_safe -from django.utils.safestring import mark_safe +from django.utils.html import format_html, html_safe from django.utils.translation import gettext_lazy as _ __all__ = ('BoundField',) @@ -75,7 +74,7 @@ class BoundField: """ Return an ErrorList (empty if there are no errors) for this field. """ - return self.form.errors.get(self.name, self.form.error_class()) + return self.form.errors.get(self.name, self.form.error_class(renderer=self.form.renderer)) def as_widget(self, widget=None, attrs=None, only_initial=False): """ @@ -177,11 +176,14 @@ class BoundField: attrs['class'] += ' ' + self.form.required_css_class else: attrs['class'] = self.form.required_css_class - attrs = flatatt(attrs) if attrs else '' - contents = format_html('{}', attrs, contents) - else: - contents = conditional_escape(contents) - return mark_safe(contents) + context = { + 'form': self.form, + 'field': self, + 'label': contents, + 'attrs': attrs, + 'use_tag': bool(id_), + } + return self.form.render(self.form.template_name_label, context) def css_classes(self, extra_classes=None): """ diff --git a/django/forms/forms.py b/django/forms/forms.py index 2bf268ae76..589b4693fd 100644 --- a/django/forms/forms.py +++ b/django/forms/forms.py @@ -4,15 +4,17 @@ Form classes import copy import datetime +import warnings from django.core.exceptions import NON_FIELD_ERRORS, ValidationError from django.forms.fields import Field, FileField -from django.forms.utils import ErrorDict, ErrorList +from django.forms.utils import ErrorDict, ErrorList, RenderableFormMixin from django.forms.widgets import Media, MediaDefiningClass from django.utils.datastructures import MultiValueDict +from django.utils.deprecation import RemovedInDjango50Warning from django.utils.functional import cached_property -from django.utils.html import conditional_escape, html_safe -from django.utils.safestring import mark_safe +from django.utils.html import conditional_escape +from django.utils.safestring import SafeString, mark_safe from django.utils.translation import gettext as _ from .renderers import get_default_renderer @@ -49,8 +51,7 @@ class DeclarativeFieldsMetaclass(MediaDefiningClass): return new_class -@html_safe -class BaseForm: +class BaseForm(RenderableFormMixin): """ The main implementation of all the Form logic. Note that this class is different than Form. See the comments by the Form class for more info. Any @@ -62,6 +63,12 @@ class BaseForm: prefix = None use_required_attribute = True + template_name = 'django/forms/default.html' + template_name_p = 'django/forms/p.html' + template_name_table = 'django/forms/table.html' + template_name_ul = 'django/forms/ul.html' + template_name_label = 'django/forms/label.html' + def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, initial=None, error_class=ErrorList, label_suffix=None, empty_permitted=False, field_order=None, use_required_attribute=None, renderer=None): @@ -129,9 +136,6 @@ class BaseForm: fields.update(self.fields) # add remaining fields in original order self.fields = fields - def __str__(self): - return self.as_table() - def __repr__(self): if self._errors is None: is_valid = "Unknown" @@ -206,6 +210,12 @@ class BaseForm: def _html_output(self, normal_row, error_row, row_ender, help_text_html, errors_on_separate_row): "Output HTML. Used by as_table(), as_ul(), as_p()." + warnings.warn( + 'django.forms.BaseForm._html_output() is deprecated. ' + 'Please use .render() and .get_context() instead.', + RemovedInDjango50Warning, + stacklevel=2, + ) # Errors that should be displayed above all fields. top_errors = self.non_field_errors().copy() output, hidden_fields = [], [] @@ -282,35 +292,37 @@ class BaseForm: output.append(str_hidden) return mark_safe('\n'.join(output)) - def as_table(self): - "Return this form rendered as HTML s -- excluding the
." - return self._html_output( - normal_row='%(label)s%(errors)s%(field)s%(help_text)s', - error_row='%s', - row_ender='', - help_text_html='
%s', - errors_on_separate_row=False, - ) - - def as_ul(self): - "Return this form rendered as HTML
  • s -- excluding the
      ." - return self._html_output( - normal_row='%(errors)s%(label)s %(field)s%(help_text)s
    • ', - error_row='
    • %s
    • ', - row_ender='', - help_text_html=' %s', - errors_on_separate_row=False, - ) - - def as_p(self): - "Return this form rendered as HTML

      s." - return self._html_output( - normal_row='%(label)s %(field)s%(help_text)s

      ', - error_row='%s', - row_ender='

      ', - help_text_html=' %s', - errors_on_separate_row=True, - ) + def get_context(self): + fields = [] + hidden_fields = [] + top_errors = self.non_field_errors().copy() + for name, bf in self._bound_items(): + bf_errors = self.error_class(bf.errors, renderer=self.renderer) + if bf.is_hidden: + if bf_errors: + top_errors += [ + _('(Hidden field %(name)s) %(error)s') % {'name': name, 'error': str(e)} + for e in bf_errors + ] + hidden_fields.append(bf) + else: + errors_str = str(bf_errors) + # RemovedInDjango50Warning. + if not isinstance(errors_str, SafeString): + warnings.warn( + f'Returning a plain string from ' + f'{self.error_class.__name__} is deprecated. Please ' + f'customize via the template system instead.', + RemovedInDjango50Warning, + ) + errors_str = mark_safe(errors_str) + fields.append((bf, errors_str)) + return { + 'form': self, + 'fields': fields, + 'hidden_fields': hidden_fields, + 'errors': top_errors, + } def non_field_errors(self): """ @@ -318,7 +330,10 @@ class BaseForm: field -- i.e., from Form.clean(). Return an empty ErrorList if there are none. """ - return self.errors.get(NON_FIELD_ERRORS, self.error_class(error_class='nonfield')) + return self.errors.get( + NON_FIELD_ERRORS, + self.error_class(error_class='nonfield', renderer=self.renderer), + ) def add_error(self, field, error): """ @@ -360,9 +375,9 @@ class BaseForm: raise ValueError( "'%s' has no field named '%s'." % (self.__class__.__name__, field)) if field == NON_FIELD_ERRORS: - self._errors[field] = self.error_class(error_class='nonfield') + self._errors[field] = self.error_class(error_class='nonfield', renderer=self.renderer) else: - self._errors[field] = self.error_class() + self._errors[field] = self.error_class(renderer=self.renderer) self._errors[field].extend(error_list) if field in self.cleaned_data: del self.cleaned_data[field] diff --git a/django/forms/formsets.py b/django/forms/formsets.py index 25f8378354..383ad6f6af 100644 --- a/django/forms/formsets.py +++ b/django/forms/formsets.py @@ -1,11 +1,10 @@ from django.core.exceptions import ValidationError from django.forms import Form from django.forms.fields import BooleanField, IntegerField -from django.forms.utils import ErrorList +from django.forms.renderers import get_default_renderer +from django.forms.utils import ErrorList, RenderableFormMixin from django.forms.widgets import CheckboxInput, HiddenInput, NumberInput from django.utils.functional import cached_property -from django.utils.html import html_safe -from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _, ngettext __all__ = ('BaseFormSet', 'formset_factory', 'all_valid') @@ -50,8 +49,7 @@ class ManagementForm(Form): return cleaned_data -@html_safe -class BaseFormSet: +class BaseFormSet(RenderableFormMixin): """ A collection of instances of the same Form class. """ @@ -63,6 +61,10 @@ class BaseFormSet: '%(field_names)s. You may need to file a bug report if the issue persists.' ), } + template_name = 'django/forms/formsets/default.html' + template_name_p = 'django/forms/formsets/p.html' + template_name_table = 'django/forms/formsets/table.html' + template_name_ul = 'django/forms/formsets/ul.html' def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, initial=None, error_class=ErrorList, form_kwargs=None, @@ -85,9 +87,6 @@ class BaseFormSet: messages.update(error_messages) self.error_messages = messages - def __str__(self): - return self.as_table() - def __iter__(self): """Yield the forms in the order they should be rendered.""" return iter(self.forms) @@ -110,15 +109,20 @@ class BaseFormSet: def management_form(self): """Return the ManagementForm instance for this FormSet.""" if self.is_bound: - form = ManagementForm(self.data, auto_id=self.auto_id, prefix=self.prefix) + form = ManagementForm(self.data, auto_id=self.auto_id, prefix=self.prefix, renderer=self.renderer) form.full_clean() else: - form = ManagementForm(auto_id=self.auto_id, prefix=self.prefix, initial={ - TOTAL_FORM_COUNT: self.total_form_count(), - INITIAL_FORM_COUNT: self.initial_form_count(), - MIN_NUM_FORM_COUNT: self.min_num, - MAX_NUM_FORM_COUNT: self.max_num - }) + form = ManagementForm( + auto_id=self.auto_id, + prefix=self.prefix, + initial={ + TOTAL_FORM_COUNT: self.total_form_count(), + INITIAL_FORM_COUNT: self.initial_form_count(), + MIN_NUM_FORM_COUNT: self.min_num, + MAX_NUM_FORM_COUNT: self.max_num, + }, + renderer=self.renderer, + ) return form def total_form_count(self): @@ -177,6 +181,7 @@ class BaseFormSet: # incorrect validation for extra, optional, and deleted # forms in the formset. 'use_required_attribute': False, + 'renderer': self.renderer, } if self.is_bound: defaults['data'] = self.data @@ -212,7 +217,8 @@ class BaseFormSet: prefix=self.add_prefix('__prefix__'), empty_permitted=True, use_required_attribute=False, - **self.get_form_kwargs(None) + **self.get_form_kwargs(None), + renderer=self.renderer, ) self.add_fields(form, None) return form @@ -338,7 +344,7 @@ class BaseFormSet: self._non_form_errors. """ self._errors = [] - self._non_form_errors = self.error_class(error_class='nonform') + self._non_form_errors = self.error_class(error_class='nonform', renderer=self.renderer) empty_forms_count = 0 if not self.is_bound: # Stop further processing. @@ -387,7 +393,8 @@ class BaseFormSet: except ValidationError as e: self._non_form_errors = self.error_class( e.error_list, - error_class='nonform' + error_class='nonform', + renderer=self.renderer, ) def clean(self): @@ -450,29 +457,14 @@ class BaseFormSet: else: return self.empty_form.media - def as_table(self): - "Return this formset rendered as HTML s -- excluding the
      ." - # XXX: there is no semantic division between forms here, there - # probably should be. It might make sense to render each form as a - # table row with each field as a td. - forms = ' '.join(form.as_table() for form in self) - return mark_safe(str(self.management_form) + '\n' + forms) - - def as_p(self): - "Return this formset rendered as HTML

      s." - forms = ' '.join(form.as_p() for form in self) - return mark_safe(str(self.management_form) + '\n' + forms) - - def as_ul(self): - "Return this formset rendered as HTML

    • s." - forms = ' '.join(form.as_ul() for form in self) - return mark_safe(str(self.management_form) + '\n' + forms) + def get_context(self): + return {'formset': self} def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, can_delete=False, max_num=None, validate_max=False, min_num=None, validate_min=False, absolute_max=None, - can_delete_extra=True): + can_delete_extra=True, renderer=None): """Return a FormSet for the given form class.""" if min_num is None: min_num = DEFAULT_MIN_NUM @@ -498,6 +490,7 @@ def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, 'absolute_max': absolute_max, 'validate_min': validate_min, 'validate_max': validate_max, + 'renderer': renderer or get_default_renderer(), } return type(form.__name__ + 'FormSet', (formset,), attrs) diff --git a/django/forms/jinja2/django/forms/attrs.html b/django/forms/jinja2/django/forms/attrs.html new file mode 100644 index 0000000000..b7e3b8e018 --- /dev/null +++ b/django/forms/jinja2/django/forms/attrs.html @@ -0,0 +1 @@ +{% for name, value in attrs.items() %}{% if value is not sameas False %} {{ name }}{% if value is not sameas True %}="{{ value }}"{% endif %}{% endif %}{% endfor %} diff --git a/django/forms/jinja2/django/forms/default.html b/django/forms/jinja2/django/forms/default.html new file mode 100644 index 0000000000..d034b60d57 --- /dev/null +++ b/django/forms/jinja2/django/forms/default.html @@ -0,0 +1 @@ +{% include "django/forms/table.html" %} diff --git a/django/forms/jinja2/django/forms/errors/dict/default.html b/django/forms/jinja2/django/forms/errors/dict/default.html new file mode 100644 index 0000000000..19e4fba33e --- /dev/null +++ b/django/forms/jinja2/django/forms/errors/dict/default.html @@ -0,0 +1 @@ +{% include "django/forms/errors/dict/ul.html" %} diff --git a/django/forms/jinja2/django/forms/errors/dict/text.txt b/django/forms/jinja2/django/forms/errors/dict/text.txt new file mode 100644 index 0000000000..dc9fd80c99 --- /dev/null +++ b/django/forms/jinja2/django/forms/errors/dict/text.txt @@ -0,0 +1,3 @@ +{% for field, errors in errors %}* {{ field }} +{% for error in errors %} * {{ error }} +{% endfor %}{% endfor %} diff --git a/django/forms/jinja2/django/forms/errors/dict/ul.html b/django/forms/jinja2/django/forms/errors/dict/ul.html new file mode 100644 index 0000000000..c16fd65914 --- /dev/null +++ b/django/forms/jinja2/django/forms/errors/dict/ul.html @@ -0,0 +1 @@ +{% if errors %}
        {% for field, error in errors %}
      • {{ field }}{{ error }}
      • {% endfor %}
      {% endif %} diff --git a/django/forms/jinja2/django/forms/errors/list/default.html b/django/forms/jinja2/django/forms/errors/list/default.html new file mode 100644 index 0000000000..fccc328188 --- /dev/null +++ b/django/forms/jinja2/django/forms/errors/list/default.html @@ -0,0 +1 @@ +{% include "django/forms/errors/list/ul.html" %} diff --git a/django/forms/jinja2/django/forms/errors/list/text.txt b/django/forms/jinja2/django/forms/errors/list/text.txt new file mode 100644 index 0000000000..aa7f870b47 --- /dev/null +++ b/django/forms/jinja2/django/forms/errors/list/text.txt @@ -0,0 +1,2 @@ +{% for error in errors %}* {{ error }} +{% endfor %} diff --git a/django/forms/jinja2/django/forms/errors/list/ul.html b/django/forms/jinja2/django/forms/errors/list/ul.html new file mode 100644 index 0000000000..752f7c2c8b --- /dev/null +++ b/django/forms/jinja2/django/forms/errors/list/ul.html @@ -0,0 +1 @@ +{% if errors %}
        {% for error in errors %}
      • {{ error }}
      • {% endfor %}
      {% endif %} diff --git a/django/forms/jinja2/django/forms/formsets/default.html b/django/forms/jinja2/django/forms/formsets/default.html new file mode 100644 index 0000000000..d8284c5da1 --- /dev/null +++ b/django/forms/jinja2/django/forms/formsets/default.html @@ -0,0 +1 @@ +{{ formset.management_form }}{% for form in formset %}{{ form }}{% endfor %} diff --git a/django/forms/jinja2/django/forms/formsets/p.html b/django/forms/jinja2/django/forms/formsets/p.html new file mode 100644 index 0000000000..3ed889e6df --- /dev/null +++ b/django/forms/jinja2/django/forms/formsets/p.html @@ -0,0 +1 @@ +{{ formset.management_form }}{% for form in formset %}{{ form.as_p() }}{% endfor %} diff --git a/django/forms/jinja2/django/forms/formsets/table.html b/django/forms/jinja2/django/forms/formsets/table.html new file mode 100644 index 0000000000..25033775b0 --- /dev/null +++ b/django/forms/jinja2/django/forms/formsets/table.html @@ -0,0 +1 @@ +{{ formset.management_form }}{% for form in formset %}{{ form.as_table() }}{% endfor %} diff --git a/django/forms/jinja2/django/forms/formsets/ul.html b/django/forms/jinja2/django/forms/formsets/ul.html new file mode 100644 index 0000000000..335e91e0e6 --- /dev/null +++ b/django/forms/jinja2/django/forms/formsets/ul.html @@ -0,0 +1 @@ +{{ formset.management_form }}{% for form in formset %}{{ form.as_ul() }}{% endfor %} diff --git a/django/forms/jinja2/django/forms/label.html b/django/forms/jinja2/django/forms/label.html new file mode 100644 index 0000000000..7ad5257a71 --- /dev/null +++ b/django/forms/jinja2/django/forms/label.html @@ -0,0 +1 @@ +{% if use_tag %}{{ label }}{% else %}{{ label }}{% endif %} diff --git a/django/forms/jinja2/django/forms/p.html b/django/forms/jinja2/django/forms/p.html new file mode 100644 index 0000000000..999c4d963a --- /dev/null +++ b/django/forms/jinja2/django/forms/p.html @@ -0,0 +1,20 @@ +{{ errors }} +{% if errors and not fields %} +

      {% for field in hidden_fields %}{{ field }}{% endfor %}

      +{% endif %} +{% for field, errors in fields %} + {{ errors }} + + {% if field.label %}{{ field.label_tag() }}{% endif %} + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% if loop.last %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} +

      +{% endfor %} +{% if not fields and not errors %} + {% for field in hidden_fields %}{{ field }}{% endfor %} +{% endif %} diff --git a/django/forms/jinja2/django/forms/table.html b/django/forms/jinja2/django/forms/table.html new file mode 100644 index 0000000000..92cd746a49 --- /dev/null +++ b/django/forms/jinja2/django/forms/table.html @@ -0,0 +1,29 @@ +{% if errors %} + + + {{ errors }} + {% if not fields %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} + + +{% endif %} +{% for field, errors in fields %} + + {% if field.label %}{{ field.label_tag() }}{% endif %} + + {{ errors }} + {{ field }} + {% if field.help_text %} +
      + {{ field.help_text }} + {% endif %} + {% if loop.last %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} + + +{% endfor %} +{% if not fields and not errors %} + {% for field in hidden_fields %}{{ field }}{% endfor %} +{% endif %} diff --git a/django/forms/jinja2/django/forms/ul.html b/django/forms/jinja2/django/forms/ul.html new file mode 100644 index 0000000000..116a9b0808 --- /dev/null +++ b/django/forms/jinja2/django/forms/ul.html @@ -0,0 +1,24 @@ +{% if errors %} +
    • + {{ errors }} + {% if not fields %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} +
    • +{% endif %} +{% for field, errors in fields %} + + {{ errors }} + {% if field.label %}{{ field.label_tag() }}{% endif %} + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% if loop.last %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} + +{% endfor %} +{% if not fields and not errors %} + {% for field in hidden_fields %}{{ field }}{% endfor %} +{% endif %} diff --git a/django/forms/models.py b/django/forms/models.py index 16681ba80b..5dcf923c12 100644 --- a/django/forms/models.py +++ b/django/forms/models.py @@ -718,7 +718,10 @@ class BaseModelFormSet(BaseFormSet): # poke error messages into the right places and mark # the form as invalid errors.append(self.get_unique_error_message(unique_check)) - form._errors[NON_FIELD_ERRORS] = self.error_class([self.get_form_error()]) + form._errors[NON_FIELD_ERRORS] = self.error_class( + [self.get_form_error()], + renderer=self.renderer, + ) # remove the data from the cleaned_data dict since it was invalid for field in unique_check: if field in form.cleaned_data: @@ -747,7 +750,10 @@ class BaseModelFormSet(BaseFormSet): # poke error messages into the right places and mark # the form as invalid errors.append(self.get_date_error_message(date_check)) - form._errors[NON_FIELD_ERRORS] = self.error_class([self.get_form_error()]) + form._errors[NON_FIELD_ERRORS] = self.error_class( + [self.get_form_error()], + renderer=self.renderer, + ) # remove the data from the cleaned_data dict since it was invalid del form.cleaned_data[field] # mark the data as seen @@ -869,7 +875,7 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, - absolute_max=None, can_delete_extra=True): + absolute_max=None, can_delete_extra=True, renderer=None): """Return a FormSet class for the given Django model class.""" meta = getattr(form, 'Meta', None) if (getattr(meta, 'fields', fields) is None and @@ -887,7 +893,8 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None, FormSet = formset_factory(form, formset, extra=extra, min_num=min_num, max_num=max_num, can_order=can_order, can_delete=can_delete, validate_min=validate_min, validate_max=validate_max, - absolute_max=absolute_max, can_delete_extra=can_delete_extra) + absolute_max=absolute_max, can_delete_extra=can_delete_extra, + renderer=renderer) FormSet.model = model return FormSet @@ -1069,7 +1076,7 @@ def inlineformset_factory(parent_model, model, form=ModelForm, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, - absolute_max=None, can_delete_extra=True): + absolute_max=None, can_delete_extra=True, renderer=None): """ Return an ``InlineFormSet`` for the given kwargs. @@ -1101,6 +1108,7 @@ def inlineformset_factory(parent_model, model, form=ModelForm, 'field_classes': field_classes, 'absolute_max': absolute_max, 'can_delete_extra': can_delete_extra, + 'renderer': renderer, } FormSet = modelformset_factory(model, **kwargs) FormSet.fk = fk diff --git a/django/forms/templates/django/forms/attrs.html b/django/forms/templates/django/forms/attrs.html new file mode 100644 index 0000000000..50de36bae0 --- /dev/null +++ b/django/forms/templates/django/forms/attrs.html @@ -0,0 +1 @@ +{% for name, value in attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %} \ No newline at end of file diff --git a/django/forms/templates/django/forms/default.html b/django/forms/templates/django/forms/default.html new file mode 100644 index 0000000000..d034b60d57 --- /dev/null +++ b/django/forms/templates/django/forms/default.html @@ -0,0 +1 @@ +{% include "django/forms/table.html" %} diff --git a/django/forms/templates/django/forms/errors/dict/default.html b/django/forms/templates/django/forms/errors/dict/default.html new file mode 100644 index 0000000000..8a833c658d --- /dev/null +++ b/django/forms/templates/django/forms/errors/dict/default.html @@ -0,0 +1 @@ +{% include "django/forms/errors/dict/ul.html" %} \ No newline at end of file diff --git a/django/forms/templates/django/forms/errors/dict/text.txt b/django/forms/templates/django/forms/errors/dict/text.txt new file mode 100644 index 0000000000..dc9fd80c99 --- /dev/null +++ b/django/forms/templates/django/forms/errors/dict/text.txt @@ -0,0 +1,3 @@ +{% for field, errors in errors %}* {{ field }} +{% for error in errors %} * {{ error }} +{% endfor %}{% endfor %} diff --git a/django/forms/templates/django/forms/errors/dict/ul.html b/django/forms/templates/django/forms/errors/dict/ul.html new file mode 100644 index 0000000000..c16fd65914 --- /dev/null +++ b/django/forms/templates/django/forms/errors/dict/ul.html @@ -0,0 +1 @@ +{% if errors %}
        {% for field, error in errors %}
      • {{ field }}{{ error }}
      • {% endfor %}
      {% endif %} diff --git a/django/forms/templates/django/forms/errors/list/default.html b/django/forms/templates/django/forms/errors/list/default.html new file mode 100644 index 0000000000..b174f26f4f --- /dev/null +++ b/django/forms/templates/django/forms/errors/list/default.html @@ -0,0 +1 @@ +{% include "django/forms/errors/list/ul.html" %} \ No newline at end of file diff --git a/django/forms/templates/django/forms/errors/list/text.txt b/django/forms/templates/django/forms/errors/list/text.txt new file mode 100644 index 0000000000..aa7f870b47 --- /dev/null +++ b/django/forms/templates/django/forms/errors/list/text.txt @@ -0,0 +1,2 @@ +{% for error in errors %}* {{ error }} +{% endfor %} diff --git a/django/forms/templates/django/forms/errors/list/ul.html b/django/forms/templates/django/forms/errors/list/ul.html new file mode 100644 index 0000000000..57b34ccb88 --- /dev/null +++ b/django/forms/templates/django/forms/errors/list/ul.html @@ -0,0 +1 @@ +{% if errors %}
        {% for error in errors %}
      • {{ error }}
      • {% endfor %}
      {% endif %} \ No newline at end of file diff --git a/django/forms/templates/django/forms/formsets/default.html b/django/forms/templates/django/forms/formsets/default.html new file mode 100644 index 0000000000..d8284c5da1 --- /dev/null +++ b/django/forms/templates/django/forms/formsets/default.html @@ -0,0 +1 @@ +{{ formset.management_form }}{% for form in formset %}{{ form }}{% endfor %} diff --git a/django/forms/templates/django/forms/formsets/p.html b/django/forms/templates/django/forms/formsets/p.html new file mode 100644 index 0000000000..00c2df6b3e --- /dev/null +++ b/django/forms/templates/django/forms/formsets/p.html @@ -0,0 +1 @@ +{{ formset.management_form }}{% for form in formset %}{{ form.as_p }}{% endfor %} diff --git a/django/forms/templates/django/forms/formsets/table.html b/django/forms/templates/django/forms/formsets/table.html new file mode 100644 index 0000000000..4fa5e42548 --- /dev/null +++ b/django/forms/templates/django/forms/formsets/table.html @@ -0,0 +1 @@ +{{ formset.management_form }}{% for form in formset %}{{ form.as_table }}{% endfor %} diff --git a/django/forms/templates/django/forms/formsets/ul.html b/django/forms/templates/django/forms/formsets/ul.html new file mode 100644 index 0000000000..272e1290ee --- /dev/null +++ b/django/forms/templates/django/forms/formsets/ul.html @@ -0,0 +1 @@ +{{ formset.management_form }}{% for form in formset %}{{ form.as_ul }}{% endfor %} diff --git a/django/forms/templates/django/forms/label.html b/django/forms/templates/django/forms/label.html new file mode 100644 index 0000000000..eb2a9f7973 --- /dev/null +++ b/django/forms/templates/django/forms/label.html @@ -0,0 +1 @@ +{% if use_tag %}{{ label }}{% else %}{{ label }}{% endif %} diff --git a/django/forms/templates/django/forms/p.html b/django/forms/templates/django/forms/p.html new file mode 100644 index 0000000000..1835b7a461 --- /dev/null +++ b/django/forms/templates/django/forms/p.html @@ -0,0 +1,20 @@ +{{ errors }} +{% if errors and not fields %} +

      {% for field in hidden_fields %}{{ field }}{% endfor %}

      +{% endif %} +{% for field, errors in fields %} + {{ errors }} + + {% if field.label %}{{ field.label_tag }}{% endif %} + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% if forloop.last %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} +

      +{% endfor %} +{% if not fields and not errors %} + {% for field in hidden_fields %}{{ field }}{% endfor %} +{% endif %} diff --git a/django/forms/templates/django/forms/table.html b/django/forms/templates/django/forms/table.html new file mode 100644 index 0000000000..a553776f2f --- /dev/null +++ b/django/forms/templates/django/forms/table.html @@ -0,0 +1,29 @@ +{% if errors %} + + + {{ errors }} + {% if not fields %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} + + +{% endif %} +{% for field, errors in fields %} + + {% if field.label %}{{ field.label_tag }}{% endif %} + + {{ errors }} + {{ field }} + {% if field.help_text %} +
      + {{ field.help_text }} + {% endif %} + {% if forloop.last %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} + + +{% endfor %} +{% if not fields and not errors %} + {% for field in hidden_fields %}{{ field }}{% endfor %} +{% endif %} diff --git a/django/forms/templates/django/forms/ul.html b/django/forms/templates/django/forms/ul.html new file mode 100644 index 0000000000..9ce6a49f07 --- /dev/null +++ b/django/forms/templates/django/forms/ul.html @@ -0,0 +1,24 @@ +{% if errors %} +
    • + {{ errors }} + {% if not fields %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} +
    • +{% endif %} +{% for field, errors in fields %} + + {{ errors }} + {% if field.label %}{{ field.label_tag }}{% endif %} + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% if forloop.last %} + {% for field in hidden_fields %}{{ field }}{% endfor %} + {% endif %} + +{% endfor %} +{% if not fields and not errors %} + {% for field in hidden_fields %}{{ field }}{% endfor %} +{% endif %} diff --git a/django/forms/utils.py b/django/forms/utils.py index 50412f414b..44447b5cf5 100644 --- a/django/forms/utils.py +++ b/django/forms/utils.py @@ -1,10 +1,12 @@ import json -from collections import UserList +from collections import UserDict, 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.html import escape, format_html, format_html_join, html_safe +from django.utils.html import escape, format_html_join +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -41,53 +43,90 @@ def flatatt(attrs): ) -@html_safe -class ErrorDict(dict): +class RenderableMixin: + def get_context(self): + raise NotImplementedError( + 'Subclasses of RenderableMixin must provide a get_context() method.' + ) + + 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(), + )) + + __str__ = render + __html__ = render + + +class RenderableFormMixin(RenderableMixin): + def as_p(self): + """Render as

      elements.""" + return self.render(self.template_name_p) + + def as_table(self): + """Render as elements excluding the surrounding tag.""" + return self.render(self.template_name_table) + + def as_ul(self): + """Render as
    • elements excluding the surrounding
        tag.""" + return self.render(self.template_name_ul) + + +class RenderableErrorMixin(RenderableMixin): + def as_json(self, escape_html=False): + return json.dumps(self.get_json_data(escape_html)) + + def as_text(self): + return self.render(self.template_name_text) + + def as_ul(self): + return self.render(self.template_name_ul) + + +class ErrorDict(UserDict, RenderableErrorMixin): """ A collection of errors that knows how to display itself in various formats. The dictionary keys are the field names, and the values are the errors. """ + template_name = 'django/forms/errors/dict/default.html' + template_name_text = 'django/forms/errors/dict/text.txt' + template_name_ul = 'django/forms/errors/dict/ul.html' + + def __init__(self, data=None, renderer=None): + super().__init__(data) + self.renderer = renderer or get_default_renderer() + def as_data(self): return {f: e.as_data() for f, e in self.items()} def get_json_data(self, escape_html=False): return {f: e.get_json_data(escape_html) for f, e in self.items()} - def as_json(self, escape_html=False): - return json.dumps(self.get_json_data(escape_html)) - - def as_ul(self): - if not self: - return '' - return format_html( - '
          {}
        ', - format_html_join('', '
      • {}{}
      • ', self.items()) - ) - - def as_text(self): - output = [] - for field, errors in self.items(): - output.append('* %s' % field) - output.append('\n'.join(' * %s' % e for e in errors)) - return '\n'.join(output) - - def __str__(self): - return self.as_ul() + def get_context(self): + return { + 'errors': self.items(), + 'error_class': 'errorlist', + } -@html_safe -class ErrorList(UserList, list): +class ErrorList(UserList, list, RenderableErrorMixin): """ A collection of errors that knows how to display itself in various formats. """ - def __init__(self, initlist=None, error_class=None): + template_name = 'django/forms/errors/list/default.html' + template_name_text = 'django/forms/errors/list/text.txt' + template_name_ul = 'django/forms/errors/list/ul.html' + + def __init__(self, initlist=None, error_class=None, renderer=None): super().__init__(initlist) if error_class is None: self.error_class = 'errorlist' else: self.error_class = 'errorlist {}'.format(error_class) + self.renderer = renderer or get_default_renderer() def as_data(self): return ValidationError(self.data).error_list @@ -107,24 +146,11 @@ class ErrorList(UserList, list): }) return errors - def as_json(self, escape_html=False): - return json.dumps(self.get_json_data(escape_html)) - - def as_ul(self): - if not self.data: - return '' - - return format_html( - '
          {}
        ', - self.error_class, - format_html_join('', '
      • {}
      • ', ((e,) for e in self)) - ) - - def as_text(self): - return '\n'.join('* %s' % e for e in self) - - def __str__(self): - return self.as_ul() + def get_context(self): + return { + 'errors': self, + 'error_class': self.error_class, + } def __repr__(self): return repr(list(self)) diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 23af7315dd..29af8cc7e2 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -57,6 +57,11 @@ details on these changes. * The ``django.contrib.gis.admin.GeoModelAdmin`` and ``OSMGeoAdmin`` classes will be removed. +* The undocumented ``BaseForm._html_output()`` method will be removed. + +* The ability to return a ``str``, rather than a ``SafeString``, when rendering + an ``ErrorDict`` and ``ErrorList`` will be removed. + .. _deprecation-removed-in-4.1: 4.1 diff --git a/docs/ref/forms/api.txt b/docs/ref/forms/api.txt index 9cdbc21800..9dcfbcfb09 100644 --- a/docs/ref/forms/api.txt +++ b/docs/ref/forms/api.txt @@ -520,13 +520,41 @@ Although ``
    • `` output is the default output style when you ``print`` a form, other output styles are available. Each style is available as a method on a form object, and each rendering method returns a string. +``template_name`` +----------------- + +.. versionadded:: 4.0 + +.. attribute:: Form.template_name + +The name of a template that is going to be rendered if the form is cast into a +string, e.g. via ``print(form)`` or in a template via ``{{ form }}``. By +default this template is ``'django/forms/default.html'``, which is a proxy for +``'django/forms/table.html'``. The template can be changed per form by +overriding the ``template_name`` attribute or more generally by overriding the +default template, see also :ref:`overriding-built-in-form-templates`. + +``template_name_label`` +----------------------- + +.. versionadded:: 4.0 + +.. attribute:: Form.template_name_label + +The template used to render a field's ``
      ``. This is -exactly the same as ``print``. In fact, when you ``print`` a form object, -it calls its ``as_table()`` method behind the scenes:: +Finally, ``as_table()`` renders the form using the template assigned to the +forms ``template_name_table`` attribute, by default this template is +``'django/forms/table.html'``. This template outputs the form as an HTML +``
      ``:: >>> f = ContactForm() >>> f.as_table() @@ -574,6 +605,37 @@ it calls its ``as_table()`` method behind the scenes:: +``get_context()`` +----------------- + +.. versionadded:: 4.0 + +.. method:: Form.get_context() + +Return context for form rendering in a template. + +The available context is: + +* ``form``: The bound form. +* ``fields``: All bound fields, except the hidden fields. +* ``hidden_fields``: All hidden bound fields. +* ``errors``: All non field related or hidden field related form errors. + +``render()`` +------------ + +.. versionadded:: 4.0 + +.. method:: Form.render(template_name=None, context=None, renderer=None) + +The render method is called by ``__str__`` as well as the +:meth:`.Form.as_table`, :meth:`.Form.as_p`, and :meth:`.Form.as_ul` methods. +All arguments are optional and default to: + +* ``template_name``: :attr:`.Form.template_name` +* ``context``: Value returned by :meth:`.Form.get_context` +* ``renderer``: Value returned by :attr:`.Form.default_renderer` + .. _ref-forms-api-styling-form-rows: Styling required or erroneous form rows @@ -834,25 +896,99 @@ method you're using:: Customizing the error list format --------------------------------- -By default, forms use ``django.forms.utils.ErrorList`` to format validation -errors. If you'd like to use an alternate class for displaying errors, you can -pass that in at construction time:: +.. class:: ErrorList(initlist=None, error_class=None, renderer=None) - >>> from django.forms.utils import ErrorList - >>> class DivErrorList(ErrorList): - ... def __str__(self): - ... return self.as_divs() - ... def as_divs(self): - ... if not self: return '' - ... return '
      %s
      ' % ''.join(['
      %s
      ' % e for e in self]) - >>> f = ContactForm(data, auto_id=False, error_class=DivErrorList) - >>> f.as_p() -
      This field is required.
      -

      Subject:

      -

      Message:

      -
      Enter a valid email address.
      -

      Sender:

      -

      Cc myself:

      + By default, forms use ``django.forms.utils.ErrorList`` to format validation + errors. ``ErrorList`` is a list like object where ``initlist`` is the + list of errors. In addition this class has the following attributes and + methods. + + .. attribute:: error_class + + The CSS classes to be used when rendering the error list. Any provided + classes are added to the default ``errorlist`` class. + + .. attribute:: renderer + + .. versionadded:: 4.0 + + Specifies the :doc:`renderer ` to use for ``ErrorList``. + Defaults to ``None`` which means to use the default renderer + specified by the :setting:`FORM_RENDERER` setting. + + .. attribute:: template_name + + .. versionadded:: 4.0 + + The name of the template used when calling ``__str__`` or + :meth:`render`. By default this is + ``'django/forms/errors/list/default.html'`` which is a proxy for the + ``'ul.html'`` template. + + .. attribute:: template_name_text + + .. versionadded:: 4.0 + + The name of the template used when calling :meth:`.as_text`. By default + this is ``'django/forms/errors/list/text.html'``. This template renders + the errors as a list of bullet points. + + .. attribute:: template_name_ul + + .. versionadded:: 4.0 + + The name of the template used when calling :meth:`.as_ul`. By default + this is ``'django/forms/errors/list/ul.html'``. This template renders + the errors in ``
    • `` tags with a wrapping ``
        `` with the CSS + classes as defined by :attr:`.error_class`. + + .. method:: get_context() + + .. versionadded:: 4.0 + + Return context for rendering of errors in a template. + + The available context is: + + * ``errors`` : A list of the errors. + * ``error_class`` : A string of CSS classes. + + .. method:: render(template_name=None, context=None, renderer=None) + + .. versionadded:: 4.0 + + The render method is called by ``__str__`` as well as by the + :meth:`.as_ul` method. + + All arguments are optional and will default to: + + * ``template_name``: Value returned by :attr:`.template_name` + * ``context``: Value returned by :meth:`.get_context` + * ``renderer``: Value returned by :attr:`.renderer` + + .. method:: as_text() + + Renders the error list using the template defined by + :attr:`.template_name_text`. + + .. method:: as_ul() + + Renders the error list using the template defined by + :attr:`.template_name_ul`. + + If you'd like to customize the rendering of errors this can be achieved by + overriding the :attr:`.template_name` attribute or more generally by + overriding the default template, see also + :ref:`overriding-built-in-form-templates`. + +.. versionchanged:: 4.0 + + Rendering of :class:`ErrorList` was moved to the template engine. + +.. deprecated:: 4.0 + + The ability to return a ``str`` when calling the ``__str__`` method is + deprecated. Use the template engine instead which returns a ``SafeString``. More granular output ==================== @@ -1086,12 +1222,16 @@ Methods of ``BoundField`` attributes for the ``
    • -The above ends up calling the ``as_table`` method on the formset class. +The above ends up calling the :meth:`BaseFormSet.render` method on the formset +class. This renders the formset using the template specified by the +:attr:`~BaseFormSet.template_name` attribute. Similar to forms, by default the +formset will be rendered ``as_table``, with other helper methods of ``as_p`` +and ``as_ul`` being available. The rendering of the formset can be customized +by specifying the ``template_name`` attribute, or more generally by +:ref:`overriding the default template `. + +.. versionchanged:: 4.0 + + Rendering of formsets was moved to the template engine. .. _manually-rendered-can-delete-and-can-order: diff --git a/docs/topics/forms/index.txt b/docs/topics/forms/index.txt index eed18a2cee..8ed99d5773 100644 --- a/docs/topics/forms/index.txt +++ b/docs/topics/forms/index.txt @@ -733,12 +733,17 @@ Reusable form templates If your site uses the same rendering logic for forms in multiple places, you can reduce duplication by saving the form's loop in a standalone template and -using the :ttag:`include` tag to reuse it in other templates: +overriding the forms :attr:`~django.forms.Form.template_name` attribute to +render the form using the custom template. The below example will result in +``{{ form }}`` being rendered as the output of the ``form_snippet.html`` +template. + +In your templates: .. code-block:: html+django - # In your form template: - {% include "form_snippet.html" %} + # In your template: + {{ form }} # In form_snippet.html: {% for field in form %} @@ -748,16 +753,15 @@ using the :ttag:`include` tag to reuse it in other templates: {% endfor %} -If the form object passed to a template has a different name within the -context, you can alias it using the ``with`` argument of the :ttag:`include` -tag: +In your form:: -.. code-block:: html+django + class MyForm(forms.Form): + template_name = 'form_snippet.html' + ... - {% include "form_snippet.html" with form=comment_form %} +.. versionchanged:: 4.0 -If you find yourself doing this often, you might consider creating a custom -:ref:`inclusion tag`. + Template rendering of forms was added. Further topics ============== diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 57da633d73..337b5469f7 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -6374,7 +6374,7 @@ class AdminViewOnSiteTests(TestCase): response, 'inline_admin_formset', 0, None, ['Children must share a family name with their parents in this contrived test case'] ) - msg = "The formset 'inline_admin_formset' in context 12 does not contain any non-form errors." + msg = "The formset 'inline_admin_formset' in context 22 does not contain any non-form errors." with self.assertRaisesMessage(AssertionError, msg): self.assertFormsetError(response, 'inline_admin_formset', None, None, ['Error']) diff --git a/tests/forms_tests/templates/forms_tests/error.html b/tests/forms_tests/templates/forms_tests/error.html new file mode 100644 index 0000000000..4981d32b70 --- /dev/null +++ b/tests/forms_tests/templates/forms_tests/error.html @@ -0,0 +1 @@ +{% if errors %}

      {% for error in errors %}
      {{ error }}
      {% endfor %}
      {% endif %} diff --git a/tests/forms_tests/templates/forms_tests/form_snippet.html b/tests/forms_tests/templates/forms_tests/form_snippet.html new file mode 100644 index 0000000000..90ab4f2981 --- /dev/null +++ b/tests/forms_tests/templates/forms_tests/form_snippet.html @@ -0,0 +1,6 @@ +{% for field in form %} +
      + {{ field.errors }} + {{ field.label_tag }} {{ field }} +
      +{% endfor %} diff --git a/tests/forms_tests/tests/__init__.py b/tests/forms_tests/tests/__init__.py index e69de29bb2..7fc0269ec5 100644 --- a/tests/forms_tests/tests/__init__.py +++ b/tests/forms_tests/tests/__init__.py @@ -0,0 +1,30 @@ +import inspect + +from django.test.utils import override_settings + +TEST_SETTINGS = [ + { + 'FORM_RENDERER': 'django.forms.renderers.DjangoTemplates', + 'TEMPLATES': {'BACKEND': 'django.template.backends.django.DjangoTemplates'}, + }, + { + 'FORM_RENDERER': 'django.forms.renderers.Jinja2', + 'TEMPLATES': {'BACKEND': 'django.template.backends.jinja2.Jinja2'}, + }, +] + + +def test_all_form_renderers(): + def wrapper(func): + def inner(*args, **kwargs): + for settings in TEST_SETTINGS: + with override_settings(**settings): + func(*args, **kwargs) + return inner + + def decorator(cls): + for name, func in inspect.getmembers(cls, inspect.isfunction): + if name.startswith('test_'): + setattr(cls, name, wrapper(func)) + return cls + return decorator diff --git a/tests/forms_tests/tests/test_deprecation_forms.py b/tests/forms_tests/tests/test_deprecation_forms.py new file mode 100644 index 0000000000..6bdb595602 --- /dev/null +++ b/tests/forms_tests/tests/test_deprecation_forms.py @@ -0,0 +1,183 @@ +# RemovedInDjango50 +from django.forms import CharField, EmailField, Form, HiddenInput +from django.forms.utils import ErrorList +from django.test import SimpleTestCase, ignore_warnings +from django.utils.deprecation import RemovedInDjango50Warning + +from .test_forms import Person + + +class DivErrorList(ErrorList): + def __str__(self): + return self.as_divs() + + def as_divs(self): + if not self: + return '' + return '
      %s
      ' % ''.join( + f'
      {error}
      ' for error in self + ) + + +class DeprecationTests(SimpleTestCase): + def test_deprecation_warning_html_output(self): + msg = ( + 'django.forms.BaseForm._html_output() is deprecated. Please use ' + '.render() and .get_context() instead.' + ) + with self.assertRaisesMessage(RemovedInDjango50Warning, msg): + form = Person() + form._html_output( + normal_row='

      ', + error_row='%s', + row_ender='

      ', + help_text_html=' %s', + errors_on_separate_row=True, + ) + + def test_deprecation_warning_error_list(self): + class EmailForm(Form): + email = EmailField() + comment = CharField() + + data = {'email': 'invalid'} + f = EmailForm(data, error_class=DivErrorList) + msg = ( + 'Returning a plain string from DivErrorList is deprecated. Please ' + 'customize via the template system instead.' + ) + with self.assertRaisesMessage(RemovedInDjango50Warning, msg): + f.as_p() + + +@ignore_warnings(category=RemovedInDjango50Warning) +class DeprecatedTests(SimpleTestCase): + def test_errorlist_override_str(self): + class CommentForm(Form): + name = CharField(max_length=50, required=False) + email = EmailField() + comment = CharField() + + data = {'email': 'invalid'} + f = CommentForm(data, auto_id=False, error_class=DivErrorList) + self.assertHTMLEqual( + f.as_p(), + '

      Name:

      ' + '
      ' + '
      Enter a valid email address.
      ' + '

      Email:

      ' + '
      ' + '
      This field is required.
      ' + '

      Comment:

      ', + ) + + def test_field_name(self): + """#5749 - `field_name` may be used as a key in _html_output().""" + class SomeForm(Form): + some_field = CharField() + + def as_p(self): + return self._html_output( + normal_row='

      ', + error_row='%s', + row_ender='

      ', + help_text_html=' %s', + errors_on_separate_row=True, + ) + + form = SomeForm() + self.assertHTMLEqual(form.as_p(), '

      ') + + def test_field_without_css_classes(self): + """ + `css_classes` may be used as a key in _html_output() (empty classes). + """ + class SomeForm(Form): + some_field = CharField() + + def as_p(self): + return self._html_output( + normal_row='

      ', + error_row='%s', + row_ender='

      ', + help_text_html=' %s', + errors_on_separate_row=True, + ) + + form = SomeForm() + self.assertHTMLEqual(form.as_p(), '

      ') + + def test_field_with_css_class(self): + """ + `css_classes` may be used as a key in _html_output() (class comes + from required_css_class in this case). + """ + class SomeForm(Form): + some_field = CharField() + required_css_class = 'foo' + + def as_p(self): + return self._html_output( + normal_row='

      ', + error_row='%s', + row_ender='

      ', + help_text_html=' %s', + errors_on_separate_row=True, + ) + + form = SomeForm() + self.assertHTMLEqual(form.as_p(), '

      ') + + def test_field_name_with_hidden_input(self): + """ + BaseForm._html_output() should merge all the hidden input fields and + put them in the last row. + """ + class SomeForm(Form): + hidden1 = CharField(widget=HiddenInput) + custom = CharField() + hidden2 = CharField(widget=HiddenInput) + + def as_p(self): + return self._html_output( + normal_row='%(field)s %(field_name)s

      ', + error_row='%s', + row_ender='

      ', + help_text_html=' %s', + errors_on_separate_row=True, + ) + + form = SomeForm() + self.assertHTMLEqual( + form.as_p(), + '

      custom' + '' + '

      ' + ) + + def test_field_name_with_hidden_input_and_non_matching_row_ender(self): + """ + BaseForm._html_output() should merge all the hidden input fields and + put them in the last row ended with the specific row ender. + """ + class SomeForm(Form): + hidden1 = CharField(widget=HiddenInput) + custom = CharField() + hidden2 = CharField(widget=HiddenInput) + + def as_p(self): + return self._html_output( + normal_row='%(field)s %(field_name)s

      ', + error_row='%s', + row_ender='

      ', + help_text_html=' %s', + errors_on_separate_row=True, + ) + + form = SomeForm() + self.assertHTMLEqual( + form.as_p(), + '

      custom

      \n' + '' + '

      ' + ) diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py index b713cb1321..8c08cd5ad4 100644 --- a/tests/forms_tests/tests/test_forms.py +++ b/tests/forms_tests/tests/test_forms.py @@ -23,6 +23,7 @@ from django.test import SimpleTestCase from django.test.utils import override_settings from django.utils.datastructures import MultiValueDict from django.utils.safestring import mark_safe +from tests.forms_tests.tests import test_all_form_renderers class FrameworkForm(Form): @@ -55,6 +56,7 @@ class MultiValueDictLike(dict): return [self[key]] +@test_all_form_renderers() class FormsTestCase(SimpleTestCase): # A Form is a collection of Fields. It knows how to validate a set of data and it # knows how to render itself in a couple of default ways (e.g., an HTML table). @@ -3077,117 +3079,6 @@ Password: self.assertHTMLEqual(boundfield.label_tag(label_suffix='$'), '') - def test_field_name(self): - """#5749 - `field_name` may be used as a key in _html_output().""" - class SomeForm(Form): - some_field = CharField() - - def as_p(self): - return self._html_output( - normal_row='

      ', - error_row='%s', - row_ender='

      ', - help_text_html=' %s', - errors_on_separate_row=True, - ) - - form = SomeForm() - self.assertHTMLEqual(form.as_p(), '

      ') - - def test_field_without_css_classes(self): - """ - `css_classes` may be used as a key in _html_output() (empty classes). - """ - class SomeForm(Form): - some_field = CharField() - - def as_p(self): - return self._html_output( - normal_row='

      ', - error_row='%s', - row_ender='

      ', - help_text_html=' %s', - errors_on_separate_row=True, - ) - - form = SomeForm() - self.assertHTMLEqual(form.as_p(), '

      ') - - def test_field_with_css_class(self): - """ - `css_classes` may be used as a key in _html_output() (class comes - from required_css_class in this case). - """ - class SomeForm(Form): - some_field = CharField() - required_css_class = 'foo' - - def as_p(self): - return self._html_output( - normal_row='

      ', - error_row='%s', - row_ender='

      ', - help_text_html=' %s', - errors_on_separate_row=True, - ) - - form = SomeForm() - self.assertHTMLEqual(form.as_p(), '

      ') - - def test_field_name_with_hidden_input(self): - """ - BaseForm._html_output() should merge all the hidden input fields and - put them in the last row. - """ - class SomeForm(Form): - hidden1 = CharField(widget=HiddenInput) - custom = CharField() - hidden2 = CharField(widget=HiddenInput) - - def as_p(self): - return self._html_output( - normal_row='%(field)s %(field_name)s

      ', - error_row='%s', - row_ender='

      ', - help_text_html=' %s', - errors_on_separate_row=True, - ) - - form = SomeForm() - self.assertHTMLEqual( - form.as_p(), - '

      custom' - '' - '

      ' - ) - - def test_field_name_with_hidden_input_and_non_matching_row_ender(self): - """ - BaseForm._html_output() should merge all the hidden input fields and - put them in the last row ended with the specific row ender. - """ - class SomeForm(Form): - hidden1 = CharField(widget=HiddenInput) - custom = CharField() - hidden2 = CharField(widget=HiddenInput) - - def as_p(self): - return self._html_output( - normal_row='%(field)s %(field_name)s

      ', - error_row='%s', - row_ender='

      ', - help_text_html=' %s', - errors_on_separate_row=True - ) - - form = SomeForm() - self.assertHTMLEqual( - form.as_p(), - '

      custom

      \n' - '' - '

      ' - ) - def test_error_dict(self): class MyForm(Form): foo = CharField() @@ -3377,30 +3268,6 @@ Password: """ ) - def test_errorlist_override(self): - class DivErrorList(ErrorList): - def __str__(self): - return self.as_divs() - - def as_divs(self): - if not self: - return '' - return '
      %s
      ' % ''.join( - '
      %s
      ' % e for e in self) - - class CommentForm(Form): - name = CharField(max_length=50, required=False) - email = EmailField() - comment = CharField() - - data = {'email': 'invalid'} - f = CommentForm(data, auto_id=False, error_class=DivErrorList) - self.assertHTMLEqual(f.as_p(), """

      Name:

      -
      Enter a valid email address.
      -

      Email:

      -
      This field is required.
      -

      Comment:

      """) - def test_error_escaping(self): class TestForm(Form): hidden = CharField(widget=HiddenInput(), required=False) @@ -4045,3 +3912,40 @@ class TemplateTests(SimpleTestCase): "VALID: [('password1', 'secret'), ('password2', 'secret'), " "('username', 'adrian')]", ) + + +class OverrideTests(SimpleTestCase): + def test_use_custom_template(self): + class Person(Form): + first_name = CharField() + template_name = 'forms_tests/form_snippet.html' + + t = Template('{{ form }}') + html = t.render(Context({'form': Person()})) + expected = """ +
      +
      + """ + self.assertHTMLEqual(html, expected) + + def test_errorlist_override(self): + class CustomErrorList(ErrorList): + template_name = 'forms_tests/error.html' + + class CommentForm(Form): + name = CharField(max_length=50, required=False) + email = EmailField() + comment = CharField() + + data = {'email': 'invalid'} + f = CommentForm(data, auto_id=False, error_class=CustomErrorList) + self.assertHTMLEqual( + f.as_p(), + '

      Name:

      ' + '
      ' + '
      Enter a valid email address.
      ' + '

      Email:

      ' + '
      ' + '
      This field is required.
      ' + '

      Comment:

      ', + ) diff --git a/tests/forms_tests/tests/test_formsets.py b/tests/forms_tests/tests/test_formsets.py index 06b61306fe..0d7d452d71 100644 --- a/tests/forms_tests/tests/test_formsets.py +++ b/tests/forms_tests/tests/test_formsets.py @@ -11,6 +11,7 @@ from django.forms.formsets import BaseFormSet, all_valid, formset_factory from django.forms.utils import ErrorList from django.forms.widgets import HiddenInput from django.test import SimpleTestCase +from tests.forms_tests.tests import test_all_form_renderers class Choice(Form): @@ -47,6 +48,7 @@ class CustomKwargForm(Form): super().__init__(*args, **kwargs) +@test_all_form_renderers() class FormsFormsetTestCase(SimpleTestCase): def make_choiceformset( @@ -1288,7 +1290,32 @@ class FormsFormsetTestCase(SimpleTestCase): self.assertIs(formset._should_delete_form(formset.forms[1]), False) self.assertIs(formset._should_delete_form(formset.forms[2]), False) + def test_custom_renderer(self): + """ + A custom renderer passed to a formset_factory() is passed to all forms + and ErrorList. + """ + from django.forms.renderers import Jinja2 + renderer = Jinja2() + data = { + 'choices-TOTAL_FORMS': '2', + 'choices-INITIAL_FORMS': '0', + 'choices-MIN_NUM_FORMS': '0', + 'choices-0-choice': 'Zero', + 'choices-0-votes': '', + 'choices-1-choice': 'One', + 'choices-1-votes': '', + } + ChoiceFormSet = formset_factory(Choice, renderer=renderer) + formset = ChoiceFormSet(data, auto_id=False, prefix='choices') + self.assertEqual(formset.renderer, renderer) + self.assertEqual(formset.forms[0].renderer, renderer) + self.assertEqual(formset.management_form.renderer, renderer) + self.assertEqual(formset.non_form_errors().renderer, renderer) + self.assertEqual(formset.empty_form.renderer, renderer) + +@test_all_form_renderers() class FormsetAsTagTests(SimpleTestCase): def setUp(self): data = { @@ -1345,6 +1372,7 @@ class ArticleForm(Form): ArticleFormSet = formset_factory(ArticleForm) +@test_all_form_renderers() class TestIsBoundBehavior(SimpleTestCase): def test_no_data_error(self): formset = ArticleFormSet({}) @@ -1359,7 +1387,7 @@ class TestIsBoundBehavior(SimpleTestCase): ) self.assertEqual(formset.errors, []) # Can still render the formset. - self.assertEqual( + self.assertHTMLEqual( str(formset), '' '
        ' @@ -1390,7 +1418,7 @@ class TestIsBoundBehavior(SimpleTestCase): ) self.assertEqual(formset.errors, []) # Can still render the formset. - self.assertEqual( + self.assertHTMLEqual( str(formset), '' '
          ' diff --git a/tests/forms_tests/tests/test_i18n.py b/tests/forms_tests/tests/test_i18n.py index d941902af2..fc5d59c480 100644 --- a/tests/forms_tests/tests/test_i18n.py +++ b/tests/forms_tests/tests/test_i18n.py @@ -4,8 +4,10 @@ from django.forms import ( from django.test import SimpleTestCase from django.utils import translation from django.utils.translation import gettext_lazy +from tests.forms_tests.tests import test_all_form_renderers +@test_all_form_renderers() class FormsI18nTests(SimpleTestCase): def test_lazy_labels(self): class SomeForm(Form): diff --git a/tests/forms_tests/tests/tests.py b/tests/forms_tests/tests/tests.py index 18b4c58b5e..ec08a5f9ef 100644 --- a/tests/forms_tests/tests/tests.py +++ b/tests/forms_tests/tests/tests.py @@ -5,6 +5,7 @@ from django.db import models from django.forms import CharField, FileField, Form, ModelForm from django.forms.models import ModelFormMetaclass from django.test import SimpleTestCase, TestCase +from tests.forms_tests.tests import test_all_form_renderers from ..models import ( BoundaryModel, ChoiceFieldModel, ChoiceModel, ChoiceOptionModel, Defaults, @@ -283,6 +284,7 @@ class ManyToManyExclusionTestCase(TestCase): self.assertEqual([obj.pk for obj in form.instance.multi_choice_int.all()], data['multi_choice_int']) +@test_all_form_renderers() class EmptyLabelTestCase(TestCase): def test_empty_field_char(self): f = EmptyCharLabelChoiceForm() diff --git a/tests/model_formsets/tests.py b/tests/model_formsets/tests.py index 98fd501ad6..49e7705c2f 100644 --- a/tests/model_formsets/tests.py +++ b/tests/model_formsets/tests.py @@ -1994,3 +1994,22 @@ class TestModelFormsetOverridesTroughFormMeta(TestCase): self.assertEqual(len(formset), 2) self.assertNotIn('DELETE', formset.forms[0].fields) self.assertNotIn('DELETE', formset.forms[1].fields) + + def test_inlineformset_factory_passes_renderer(self): + from django.forms.renderers import Jinja2 + renderer = Jinja2() + BookFormSet = inlineformset_factory( + Author, + Book, + fields='__all__', + renderer=renderer, + ) + formset = BookFormSet() + self.assertEqual(formset.renderer, renderer) + + def test_modelformset_factory_passes_renderer(self): + from django.forms.renderers import Jinja2 + renderer = Jinja2() + BookFormSet = modelformset_factory(Author, fields='__all__', renderer=renderer) + formset = BookFormSet() + self.assertEqual(formset.renderer, renderer)