Browse Source

Fixed #15667 -- Added template-based widget rendering.

Thanks Carl Meyer and Tim Graham for contributing to the patch.
Preston Timmons 8 years ago
parent
commit
b52c73008a
98 changed files with 1333 additions and 831 deletions
  1. 3 0
      django/conf/global_settings.py
  2. 6 0
      django/contrib/admin/templates/admin/widgets/clearable_file_input.html
  3. 1 0
      django/contrib/admin/templates/admin/widgets/foreign_key_raw_id.html
  4. 1 0
      django/contrib/admin/templates/admin/widgets/many_to_many_raw_id.html
  5. 1 0
      django/contrib/admin/templates/admin/widgets/radio.html
  6. 27 0
      django/contrib/admin/templates/admin/widgets/related_widget_wrapper.html
  7. 4 0
      django/contrib/admin/templates/admin/widgets/split_datetime.html
  8. 1 0
      django/contrib/admin/templates/admin/widgets/url.html
  9. 67 99
      django/contrib/admin/widgets.py
  10. 12 18
      django/contrib/auth/forms.py
  11. 3 0
      django/contrib/auth/templates/auth/widgets/read_only_password_hash.html
  12. 1 1
      django/contrib/gis/admin/options.py
  13. 2 3
      django/contrib/gis/admin/widgets.py
  14. 8 6
      django/contrib/gis/forms/widgets.py
  15. 2 2
      django/contrib/postgres/forms/array.py
  16. 66 2
      django/forms/boundfield.py
  17. 15 1
      django/forms/forms.py
  18. 1 0
      django/forms/jinja2/django/forms/widgets/attrs.html
  19. 1 0
      django/forms/jinja2/django/forms/widgets/checkbox.html
  20. 1 0
      django/forms/jinja2/django/forms/widgets/checkbox_option.html
  21. 1 0
      django/forms/jinja2/django/forms/widgets/checkbox_select.html
  22. 5 0
      django/forms/jinja2/django/forms/widgets/clearable_file_input.html
  23. 1 0
      django/forms/jinja2/django/forms/widgets/date.html
  24. 1 0
      django/forms/jinja2/django/forms/widgets/datetime.html
  25. 1 0
      django/forms/jinja2/django/forms/widgets/email.html
  26. 1 0
      django/forms/jinja2/django/forms/widgets/file.html
  27. 1 0
      django/forms/jinja2/django/forms/widgets/hidden.html
  28. 1 0
      django/forms/jinja2/django/forms/widgets/input.html
  29. 1 0
      django/forms/jinja2/django/forms/widgets/input_option.html
  30. 1 0
      django/forms/jinja2/django/forms/widgets/multiple_hidden.html
  31. 5 0
      django/forms/jinja2/django/forms/widgets/multiple_input.html
  32. 1 0
      django/forms/jinja2/django/forms/widgets/multiwidget.html
  33. 1 0
      django/forms/jinja2/django/forms/widgets/number.html
  34. 1 0
      django/forms/jinja2/django/forms/widgets/password.html
  35. 1 0
      django/forms/jinja2/django/forms/widgets/radio.html
  36. 1 0
      django/forms/jinja2/django/forms/widgets/radio_option.html
  37. 5 0
      django/forms/jinja2/django/forms/widgets/select.html
  38. 1 0
      django/forms/jinja2/django/forms/widgets/select_date.html
  39. 1 0
      django/forms/jinja2/django/forms/widgets/select_option.html
  40. 1 0
      django/forms/jinja2/django/forms/widgets/splitdatetime.html
  41. 1 0
      django/forms/jinja2/django/forms/widgets/splithiddendatetime.html
  42. 1 0
      django/forms/jinja2/django/forms/widgets/text.html
  43. 2 0
      django/forms/jinja2/django/forms/widgets/textarea.html
  44. 1 0
      django/forms/jinja2/django/forms/widgets/time.html
  45. 1 0
      django/forms/jinja2/django/forms/widgets/url.html
  46. 71 0
      django/forms/renderers.py
  47. 1 0
      django/forms/templates/django/forms/widgets/attrs.html
  48. 1 0
      django/forms/templates/django/forms/widgets/checkbox.html
  49. 1 0
      django/forms/templates/django/forms/widgets/checkbox_option.html
  50. 1 0
      django/forms/templates/django/forms/widgets/checkbox_select.html
  51. 5 0
      django/forms/templates/django/forms/widgets/clearable_file_input.html
  52. 1 0
      django/forms/templates/django/forms/widgets/date.html
  53. 1 0
      django/forms/templates/django/forms/widgets/datetime.html
  54. 1 0
      django/forms/templates/django/forms/widgets/email.html
  55. 1 0
      django/forms/templates/django/forms/widgets/file.html
  56. 1 0
      django/forms/templates/django/forms/widgets/hidden.html
  57. 1 0
      django/forms/templates/django/forms/widgets/input.html
  58. 1 0
      django/forms/templates/django/forms/widgets/input_option.html
  59. 1 0
      django/forms/templates/django/forms/widgets/multiple_hidden.html
  60. 5 0
      django/forms/templates/django/forms/widgets/multiple_input.html
  61. 1 0
      django/forms/templates/django/forms/widgets/multiwidget.html
  62. 1 0
      django/forms/templates/django/forms/widgets/number.html
  63. 1 0
      django/forms/templates/django/forms/widgets/password.html
  64. 1 0
      django/forms/templates/django/forms/widgets/radio.html
  65. 1 0
      django/forms/templates/django/forms/widgets/radio_option.html
  66. 5 0
      django/forms/templates/django/forms/widgets/select.html
  67. 1 0
      django/forms/templates/django/forms/widgets/select_date.html
  68. 1 0
      django/forms/templates/django/forms/widgets/select_option.html
  69. 1 0
      django/forms/templates/django/forms/widgets/splitdatetime.html
  70. 1 0
      django/forms/templates/django/forms/widgets/splithiddendatetime.html
  71. 1 0
      django/forms/templates/django/forms/widgets/text.html
  72. 2 0
      django/forms/templates/django/forms/widgets/textarea.html
  73. 1 0
      django/forms/templates/django/forms/widgets/time.html
  74. 1 0
      django/forms/templates/django/forms/widgets/url.html
  75. 366 393
      django/forms/widgets.py
  76. 1 1
      django/template/backends/jinja2.py
  77. 2 0
      django/test/signals.py
  78. 3 0
      docs/internals/deprecation.txt
  79. 23 0
      docs/ref/forms/api.txt
  80. 1 0
      docs/ref/forms/index.txt
  81. 131 0
      docs/ref/forms/renderers.txt
  82. 96 60
      docs/ref/forms/widgets.txt
  83. 16 0
      docs/ref/settings.txt
  84. 28 0
      docs/releases/1.11.txt
  85. 1 0
      docs/spelling_wordlist
  86. 14 12
      tests/admin_inlines/tests.py
  87. 1 1
      tests/admin_views/tests.py
  88. 41 21
      tests/admin_widgets/tests.py
  89. 3 0
      tests/forms_tests/field_tests/test_filepathfield.py
  90. 1 0
      tests/forms_tests/jinja2/forms_tests/custom_widget.html
  91. 1 0
      tests/forms_tests/templates/forms_tests/custom_widget.html
  92. 91 2
      tests/forms_tests/tests/test_forms.py
  93. 52 0
      tests/forms_tests/tests/test_renderers.py
  94. 1 200
      tests/forms_tests/tests/test_widgets.py
  95. 19 1
      tests/forms_tests/widget_tests/base.py
  96. 62 0
      tests/forms_tests/widget_tests/test_select.py
  97. 1 8
      tests/model_forms/tests.py
  98. 5 0
      tests/runtests.py

+ 3 - 0
django/conf/global_settings.py

@@ -216,6 +216,9 @@ INSTALLED_APPS = []
 
 TEMPLATES = []
 
+# Default form rendering class.
+FORM_RENDERER = 'django.forms.renderers.DjangoTemplates'
+
 # Default email address to use for various automated correspondence from
 # the site managers.
 DEFAULT_FROM_EMAIL = 'webmaster@localhost'

+ 6 - 0
django/contrib/admin/templates/admin/widgets/clearable_file_input.html

@@ -0,0 +1,6 @@
+{% if is_initial %}<p class="file-upload">{{ initial_text }}: <a href="{{ widget.value.url }}">{{ widget.value }}</a>{% if not widget.required %}
+<span class="clearable-file-input">
+<input type="checkbox" name="{{ checkbox_name }}" id="{{ checkbox_id }}" />
+<label for="{{ checkbox_id }}">{{ clear_checkbox_label }}</label>{% endif %}</span><br />
+{{ input_text }}:{% endif %}
+<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} />{% if is_initial %}</p>{% endif %}

+ 1 - 0
django/contrib/admin/templates/admin/widgets/foreign_key_raw_id.html

@@ -0,0 +1 @@
+{% include 'django/forms/widgets/input.html' %}{% if related_url %}<a href="{{ related_url }}" class="related-lookup" id="lookup_id_{{ widget.name }}" title="{{ link_title }}"></a>{% endif %}{% if link_label %}&nbsp;<strong>{% if link_url %}<a href="{{ link_url }}">{% endif %}{{ link_label }}{% if link_url %}</a>{% endif %}</strong>{% endif %}

+ 1 - 0
django/contrib/admin/templates/admin/widgets/many_to_many_raw_id.html

@@ -0,0 +1 @@
+{% include 'admin/widgets/foreign_key_raw_id.html' %}

+ 1 - 0
django/contrib/admin/templates/admin/widgets/radio.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/multiple_input.html" %}

+ 27 - 0
django/contrib/admin/templates/admin/widgets/related_widget_wrapper.html

@@ -0,0 +1,27 @@
+{% load i18n static %}
+<div class="related-widget-wrapper">
+    {% include widget.template_name %}
+    {% block links %}
+        {% if can_change_related %}
+        <a class="related-widget-wrapper-link change-related" id="change_id_{{ widget.name }}"
+            data-href-template="{{ change_related_template_url }}?{{ url_params }}"
+            title="{% blocktrans %}Change selected {{ model }}{% endblocktrans %}">
+            <img src="{% static 'admin/img/icon-changelink.svg' %}" width="10" height="10" alt="{% trans 'Change' %}"/>
+        </a>
+        {% endif %}
+        {% if can_add_related %}
+        <a class="related-widget-wrapper-link add-related" id="add_id_{{ widget.name }}"
+            href="{{ add_related_url }}?{{ url_params }}"
+            title="{% blocktrans %}Add another {{ model }}{% endblocktrans %}">
+            <img src="{% static 'admin/img/icon-addlink.svg' %}" width="10" height="10" alt="{% trans 'Add' %}"/>
+        </a>
+        {% endif %}
+        {% if can_delete_related %}
+        <a class="related-widget-wrapper-link delete-related" id="delete_id_{{ widget.name }}"
+            data-href-template="{{ delete_related_template_url }}?{{ url_params }}"
+            title="{% blocktrans %}Delete selected {{ model }}{% endblocktrans %}">
+            <img src="{% static 'admin/img/icon-deletelink.svg' %}" width="10" height="10" alt="{% trans 'Delete' %}"/>
+        </a>
+        {% endif %}
+    {% endblock %}
+</div>

+ 4 - 0
django/contrib/admin/templates/admin/widgets/split_datetime.html

@@ -0,0 +1,4 @@
+<p class="datetime">
+  {{ date_label }} {% with widget=widget.subwidgets.0 %}{% include widget.template_name %}{% endwith %}<br />
+  {{ time_label }} {% with widget=widget.subwidgets.1 %}{% include widget.template_name %}{% endwith %}
+</p>

+ 1 - 0
django/contrib/admin/templates/admin/widgets/url.html

@@ -0,0 +1 @@
+{% if widget.value %}<p class="url">{{ current_label }} <a href="{{ widget.href }}">{{ widget.value }}</a><br />{{ change_label }} {% endif %}{% include "django/forms/widgets/input.html" %}{% if widget.value %}</p>{% endif %}

+ 67 - 99
django/contrib/admin/widgets.py

@@ -7,14 +7,11 @@ import copy
 
 from django import forms
 from django.db.models.deletion import CASCADE
-from django.forms.utils import flatatt
-from django.forms.widgets import RadioFieldRenderer
-from django.template.loader import render_to_string
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
 from django.utils import six
 from django.utils.encoding import force_text
-from django.utils.html import format_html, format_html_join, smart_urlquote
+from django.utils.html import smart_urlquote
 from django.utils.safestring import mark_safe
 from django.utils.text import Truncator
 from django.utils.translation import ugettext as _
@@ -37,17 +34,14 @@ class FilteredSelectMultiple(forms.SelectMultiple):
         self.is_stacked = is_stacked
         super(FilteredSelectMultiple, self).__init__(attrs, choices)
 
-    def render(self, name, value, attrs=None):
-        if attrs is None:
-            attrs = {}
-        attrs['class'] = 'selectfilter'
+    def get_context(self, name, value, attrs=None):
+        context = super(FilteredSelectMultiple, self).get_context(name, value, attrs)
+        context['widget']['attrs']['class'] = 'selectfilter'
         if self.is_stacked:
-            attrs['class'] += 'stacked'
-
-        attrs['data-field-name'] = self.verbose_name
-        attrs['data-is-stacked'] = int(self.is_stacked)
-        output = super(FilteredSelectMultiple, self).render(name, value, attrs)
-        return mark_safe(output)
+            context['widget']['attrs']['class'] += 'stacked'
+        context['widget']['attrs']['data-field-name'] = self.verbose_name
+        context['widget']['attrs']['data-is-stacked'] = int(self.is_stacked)
+        return context
 
 
 class AdminDateWidget(forms.DateInput):
@@ -80,38 +74,27 @@ class AdminSplitDateTime(forms.SplitDateTimeWidget):
     """
     A SplitDateTime Widget that has some admin-specific styling.
     """
+    template_name = 'admin/widgets/split_datetime.html'
+
     def __init__(self, attrs=None):
         widgets = [AdminDateWidget, AdminTimeWidget]
         # Note that we're calling MultiWidget, not SplitDateTimeWidget, because
         # we want to define widgets.
         forms.MultiWidget.__init__(self, widgets, attrs)
 
-    def format_output(self, rendered_widgets):
-        return format_html('<p class="datetime">{} {}<br />{} {}</p>',
-                           _('Date:'), rendered_widgets[0],
-                           _('Time:'), rendered_widgets[1])
-
-
-class AdminRadioFieldRenderer(RadioFieldRenderer):
-    def render(self):
-        """Outputs a <ul> for this set of radio fields."""
-        return format_html('<ul{}>\n{}\n</ul>',
-                           flatatt(self.attrs),
-                           format_html_join('\n', '<li>{}</li>',
-                                            ((force_text(w),) for w in self)))
+    def get_context(self, name, value, attrs):
+        context = super(AdminSplitDateTime, self).get_context(name, value, attrs)
+        context['date_label'] = _('Date:')
+        context['time_label'] = _('Time:')
+        return context
 
 
 class AdminRadioSelect(forms.RadioSelect):
-    renderer = AdminRadioFieldRenderer
+    template_name = 'admin/widgets/radio.html'
 
 
 class AdminFileWidget(forms.ClearableFileInput):
-    template_with_initial = (
-        '<p class="file-upload">%s</p>' % forms.ClearableFileInput.template_with_initial
-    )
-    template_with_clear = (
-        '<span class="clearable-file-input">%s</span>' % forms.ClearableFileInput.template_with_clear
-    )
+    template_name = 'admin/widgets/clearable_file_input.html'
 
 
 def url_params_from_lookup_dict(lookups):
@@ -141,17 +124,17 @@ class ForeignKeyRawIdWidget(forms.TextInput):
     A Widget for displaying ForeignKeys in the "raw_id" interface rather than
     in a <select> box.
     """
+    template_name = 'admin/widgets/foreign_key_raw_id.html'
+
     def __init__(self, rel, admin_site, attrs=None, using=None):
         self.rel = rel
         self.admin_site = admin_site
         self.db = using
         super(ForeignKeyRawIdWidget, self).__init__(attrs)
 
-    def render(self, name, value, attrs=None):
+    def get_context(self, name, value, attrs=None):
+        context = super(ForeignKeyRawIdWidget, self).get_context(name, value, attrs)
         rel_to = self.rel.model
-        if attrs is None:
-            attrs = {}
-        extra = []
         if rel_to in self.admin_site._registry:
             # The related object is registered with the same AdminSite
             related_url = reverse(
@@ -164,21 +147,16 @@ class ForeignKeyRawIdWidget(forms.TextInput):
 
             params = self.url_parameters()
             if params:
-                url = '?' + '&amp;'.join('%s=%s' % (k, v) for k, v in params.items())
-            else:
-                url = ''
-            if "class" not in attrs:
-                attrs['class'] = 'vForeignKeyRawIdAdminField'  # The JavaScript code looks for this hook.
-            # TODO: "lookup_id_" is hard-coded here. This should instead use
-            # the correct API to determine the ID dynamically.
-            extra.append(
-                '<a href="%s%s" class="related-lookup" id="lookup_id_%s" title="%s"></a>'
-                % (related_url, url, name, _('Lookup'))
-            )
-        output = [super(ForeignKeyRawIdWidget, self).render(name, value, attrs)] + extra
-        if value:
-            output.append(self.label_for_value(value))
-        return mark_safe(''.join(output))
+                related_url += '?' + '&amp;'.join(
+                    '%s=%s' % (k, v) for k, v in params.items(),
+                )
+            context['related_url'] = mark_safe(related_url)
+            context['link_title'] = _('Lookup')
+            # The JavaScript code looks for this class.
+            context['widget']['attrs'].setdefault('class', 'vForeignKeyRawIdAdminField')
+        if context['widget']['value']:
+            context['link_label'], context['link_url'] = self.label_and_url_for_value(value)
+        return context
 
     def base_url_parameters(self):
         limit_choices_to = self.rel.limit_choices_to
@@ -192,17 +170,15 @@ class ForeignKeyRawIdWidget(forms.TextInput):
         params.update({TO_FIELD_VAR: self.rel.get_related_field().name})
         return params
 
-    def label_for_value(self, value):
+    def label_and_url_for_value(self, value):
         key = self.rel.get_related_field().name
         try:
             obj = self.rel.model._default_manager.using(self.db).get(**{key: value})
         except (ValueError, self.rel.model.DoesNotExist):
-            return ''
+            return '', ''
 
-        label = '&nbsp;<strong>{}</strong>'
-        text = Truncator(obj).words(14, truncate='...')
         try:
-            change_url = reverse(
+            url = reverse(
                 '%s:%s_%s_change' % (
                     self.admin_site.name,
                     obj._meta.app_label,
@@ -211,11 +187,9 @@ class ForeignKeyRawIdWidget(forms.TextInput):
                 args=(obj.pk,)
             )
         except NoReverseMatch:
-            pass  # Admin not registered for target model.
-        else:
-            text = format_html('<a href="{}">{}</a>', change_url, text)
+            url = ''  # Admin not registered for target model.
 
-        return format_html(label, text)
+        return Truncator(obj).words(14, truncate='...'), url
 
 
 class ManyToManyRawIdWidget(ForeignKeyRawIdWidget):
@@ -223,36 +197,36 @@ class ManyToManyRawIdWidget(ForeignKeyRawIdWidget):
     A Widget for displaying ManyToMany ids in the "raw_id" interface rather than
     in a <select multiple> box.
     """
-    def render(self, name, value, attrs=None):
-        if attrs is None:
-            attrs = {}
+    template_name = 'admin/widgets/many_to_many_raw_id.html'
+
+    def get_context(self, name, value, attrs=None):
+        context = super(ManyToManyRawIdWidget, self).get_context(name, value, attrs)
         if self.rel.model in self.admin_site._registry:
             # The related object is registered with the same AdminSite
-            attrs['class'] = 'vManyToManyRawIdAdminField'
-        if value:
-            value = ','.join(force_text(v) for v in value)
-        else:
-            value = ''
-        return super(ManyToManyRawIdWidget, self).render(name, value, attrs)
+            context['widget']['attrs']['class'] = 'vManyToManyRawIdAdminField'
+        return context
 
     def url_parameters(self):
         return self.base_url_parameters()
 
-    def label_for_value(self, value):
-        return ''
+    def label_and_url_for_value(self, value):
+        return '', ''
 
     def value_from_datadict(self, data, files, name):
         value = data.get(name)
         if value:
             return value.split(',')
 
+    def format_value(self, value):
+        return ','.join(force_text(v) for v in value) if value else ''
+
 
 class RelatedFieldWidgetWrapper(forms.Widget):
     """
     This class is a wrapper to a given widget to add the add icon for the
     admin interface.
     """
-    template = 'admin/related_widget_wrapper.html'
+    template_name = 'admin/widgets/related_widget_wrapper.html'
 
     def __init__(self, widget, rel, admin_site, can_add_related=None,
                  can_change_related=False, can_delete_related=False):
@@ -294,21 +268,19 @@ class RelatedFieldWidgetWrapper(forms.Widget):
         return reverse("admin:%s_%s_%s" % (info + (action,)),
                        current_app=self.admin_site.name, args=args)
 
-    def render(self, name, value, *args, **kwargs):
+    def get_context(self, name, value, attrs=None):
+        with self.widget.override_choices(self.choices):
+            context = self.widget.get_context(name, value, attrs)
+
         from django.contrib.admin.views.main import IS_POPUP_VAR, TO_FIELD_VAR
         rel_opts = self.rel.model._meta
         info = (rel_opts.app_label, rel_opts.model_name)
-        self.widget.choices = self.choices
         url_params = '&'.join("%s=%s" % param for param in [
             (TO_FIELD_VAR, self.rel.get_related_field().name),
             (IS_POPUP_VAR, 1),
         ])
-        context = {
-            'widget': self.widget.render(name, value, *args, **kwargs),
-            'name': name,
-            'url_params': url_params,
-            'model': rel_opts.verbose_name,
-        }
+        context['url_params'] = url_params
+        context['model'] = rel_opts.verbose_name
         if self.can_change_related:
             change_related_template_url = self.get_related_url(info, 'change', '__fk__')
             context.update(
@@ -327,12 +299,7 @@ class RelatedFieldWidgetWrapper(forms.Widget):
                 can_delete_related=True,
                 delete_related_template_url=delete_related_template_url,
             )
-        return mark_safe(render_to_string(self.template, context))
-
-    def build_attrs(self, extra_attrs=None, **kwargs):
-        "Helper function for building an attribute dictionary."
-        self.attrs = self.widget.build_attrs(extra_attrs=None, **kwargs)
-        return self.attrs
+        return context
 
     def value_from_datadict(self, data, files, name):
         return self.widget.value_from_datadict(data, files, name)
@@ -366,23 +333,24 @@ class AdminEmailInputWidget(forms.EmailInput):
 
 
 class AdminURLFieldWidget(forms.URLInput):
+    template_name = 'admin/widgets/url.html'
+
     def __init__(self, attrs=None):
         final_attrs = {'class': 'vURLField'}
         if attrs is not None:
             final_attrs.update(attrs)
         super(AdminURLFieldWidget, self).__init__(attrs=final_attrs)
 
-    def render(self, name, value, attrs=None):
-        html = super(AdminURLFieldWidget, self).render(name, value, attrs)
-        if value:
-            value = force_text(self.format_value(value))
-            final_attrs = {'href': smart_urlquote(value)}
-            html = format_html(
-                '<p class="url">{} <a{}>{}</a><br />{} {}</p>',
-                _('Currently:'), flatatt(final_attrs), value,
-                _('Change:'), html
-            )
-        return html
+    def get_context(self, name, value, attrs):
+        context = super(AdminURLFieldWidget, self).get_context(name, value, attrs)
+        context['current_label'] = _('Currently:')
+        context['change_label'] = _('Change:')
+        context['widget']['href'] = smart_urlquote(context['widget']['value'])
+        return context
+
+    def format_value(self, value):
+        value = super(AdminURLFieldWidget, self).format_value(value)
+        return force_text(value)
 
 
 class AdminIntegerFieldWidget(forms.NumberInput):

+ 12 - 18
django/contrib/auth/forms.py

@@ -13,12 +13,9 @@ from django.contrib.auth.models import User
 from django.contrib.auth.tokens import default_token_generator
 from django.contrib.sites.shortcuts import get_current_site
 from django.core.mail import EmailMultiAlternatives
-from django.forms.utils import flatatt
 from django.template import loader
 from django.utils.encoding import force_bytes
-from django.utils.html import format_html, format_html_join
 from django.utils.http import urlsafe_base64_encode
-from django.utils.safestring import mark_safe
 from django.utils.text import capfirst
 from django.utils.translation import ugettext, ugettext_lazy as _
 
@@ -26,26 +23,23 @@ UserModel = get_user_model()
 
 
 class ReadOnlyPasswordHashWidget(forms.Widget):
-    def render(self, name, value, attrs):
-        encoded = value
-        final_attrs = self.build_attrs(attrs)
+    template_name = 'auth/widgets/read_only_password_hash.html'
 
-        if not encoded or encoded.startswith(UNUSABLE_PASSWORD_PREFIX):
-            summary = mark_safe("<strong>%s</strong>" % ugettext("No password set."))
+    def get_context(self, name, value, attrs):
+        context = super(ReadOnlyPasswordHashWidget, self).get_context(name, value, attrs)
+        summary = []
+        if not value or value.startswith(UNUSABLE_PASSWORD_PREFIX):
+            summary.append({'label': ugettext("No password set.")})
         else:
             try:
-                hasher = identify_hasher(encoded)
+                hasher = identify_hasher(value)
             except ValueError:
-                summary = mark_safe("<strong>%s</strong>" % ugettext(
-                    "Invalid password format or unknown hashing algorithm."
-                ))
+                summary.append({'label': ugettext("Invalid password format or unknown hashing algorithm.")})
             else:
-                summary = format_html_join(
-                    '', '<strong>{}</strong>: {} ',
-                    ((ugettext(key), value) for key, value in hasher.safe_summary(encoded).items())
-                )
-
-        return format_html("<div{}>{}</div>", flatatt(final_attrs), summary)
+                for key, value_ in hasher.safe_summary(value).items():
+                    summary.append({'label': ugettext(key), 'value': value_})
+        context['summary'] = summary
+        return context
 
 
 class ReadOnlyPasswordHashField(forms.Field):

+ 3 - 0
django/contrib/auth/templates/auth/widgets/read_only_password_hash.html

@@ -0,0 +1,3 @@
+{% for entry in summary %}
+<div{% include 'django/forms/widgets/attrs.html' %}><strong>{{ entry.label }}</strong>{% if entry.value %}: {{ entry.value }}{% endif %}
+{% endfor %}

+ 1 - 1
django/contrib/gis/admin/options.py

@@ -80,7 +80,7 @@ class GeoModelAdmin(ModelAdmin):
             collection_type = 'None'
 
         class OLMap(self.widget):
-            template = self.map_template
+            template_name = self.map_template
             geom_type = db_field.geom_type
 
             wms_options = ''

+ 2 - 3
django/contrib/gis/admin/widgets.py

@@ -3,7 +3,6 @@ import logging
 from django.contrib.gis.gdal import GDALException
 from django.contrib.gis.geos import GEOSException, GEOSGeometry
 from django.forms.widgets import Textarea
-from django.template import loader
 from django.utils import six, translation
 
 # Creating a template context that contains Django settings
@@ -16,7 +15,7 @@ class OpenLayersWidget(Textarea):
     """
     Renders an OpenLayers map using the WKT of the geometry.
     """
-    def render(self, name, value, attrs=None):
+    def get_context(self, name, value, attrs=None):
         # Update the template parameters with any attributes passed in.
         if attrs:
             self.params.update(attrs)
@@ -77,7 +76,7 @@ class OpenLayersWidget(Textarea):
             self.params['wkt'] = wkt
 
         self.params.update(geo_context)
-        return loader.render_to_string(self.template, self.params)
+        return self.params
 
     def map_options(self):
         "Builds the map options hash for the OpenLayers template."

+ 8 - 6
django/contrib/gis/forms/widgets.py

@@ -6,7 +6,6 @@ from django.conf import settings
 from django.contrib.gis import gdal
 from django.contrib.gis.geos import GEOSException, GEOSGeometry
 from django.forms.widgets import Widget
-from django.template import loader
 from django.utils import six, translation
 
 logger = logging.getLogger('django.contrib.gis')
@@ -43,7 +42,7 @@ class BaseGeometryWidget(Widget):
             logger.error("Error creating geometry from value '%s' (%s)", value, err)
         return None
 
-    def render(self, name, value, attrs=None):
+    def get_context(self, name, value, attrs=None):
         # If a string reaches here (via a validation error on another
         # field) then just reconstruct the Geometry.
         if value and isinstance(value, six.string_types):
@@ -62,16 +61,19 @@ class BaseGeometryWidget(Widget):
                         value.srid, self.map_srid, err
                     )
 
-        context = self.build_attrs(
-            attrs,
+        if attrs is None:
+            attrs = {}
+
+        context = self.build_attrs(self.attrs, dict(
             name=name,
             module='geodjango_%s' % name.replace('-', '_'),  # JS-safe
             serialized=self.serialize(value),
             geom_type=gdal.OGRGeomType(self.attrs['geom_type']),
             STATIC_URL=settings.STATIC_URL,
             LANGUAGE_BIDI=translation.get_language_bidi(),
-        )
-        return loader.render_to_string(self.template_name, context)
+            **attrs
+        ))
+        return context
 
 
 class OpenLayersWidget(BaseGeometryWidget):

+ 2 - 2
django/contrib/postgres/forms/array.py

@@ -117,7 +117,7 @@ class SplitArrayWidget(forms.Widget):
             id_ += '_0'
         return id_
 
-    def render(self, name, value, attrs=None):
+    def render(self, name, value, attrs=None, renderer=None):
         if self.is_localized:
             self.widget.is_localized = self.is_localized
         value = value or []
@@ -131,7 +131,7 @@ class SplitArrayWidget(forms.Widget):
                 widget_value = None
             if id_:
                 final_attrs = dict(final_attrs, id='%s_%s' % (id_, i))
-            output.append(self.widget.render(name + '_%s' % i, widget_value, final_attrs))
+            output.append(self.widget.render(name + '_%s' % i, widget_value, final_attrs, renderer))
         return mark_safe(self.format_output(output))
 
     def format_output(self, rendered_widgets):

+ 66 - 2
django/forms/boundfield.py

@@ -1,13 +1,16 @@
 from __future__ import unicode_literals
 
 import datetime
+import warnings
 
 from django.forms.utils import flatatt, pretty_name
 from django.forms.widgets import Textarea, TextInput
 from django.utils import six
+from django.utils.deprecation import RemovedInDjango21Warning
 from django.utils.encoding import force_text, python_2_unicode_compatible
 from django.utils.functional import cached_property
 from django.utils.html import conditional_escape, format_html, html_safe
+from django.utils.inspect import func_supports_parameter
 from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext_lazy as _
 
@@ -49,7 +52,10 @@ class BoundField(object):
         id_ = self.field.widget.attrs.get('id') or self.auto_id
         attrs = {'id': id_} if id_ else {}
         attrs = self.build_widget_attrs(attrs)
-        return list(self.field.widget.subwidgets(self.html_name, self.value(), attrs))
+        return list(
+            BoundWidget(self.field.widget, widget, self.form.renderer)
+            for widget in self.field.widget.subwidgets(self.html_name, self.value(), attrs=attrs)
+        )
 
     def __iter__(self):
         return iter(self.subwidgets)
@@ -97,7 +103,23 @@ class BoundField(object):
             name = self.html_name
         else:
             name = self.html_initial_name
-        return force_text(widget.render(name, self.value(), attrs=attrs))
+
+        kwargs = {}
+        if func_supports_parameter(widget.render, 'renderer'):
+            kwargs['renderer'] = self.form.renderer
+        else:
+            warnings.warn(
+                'Add the `renderer` argument to the render() method of %s. '
+                'It will be mandatory in Django 2.1.' % widget.__class__,
+                RemovedInDjango21Warning, stacklevel=2,
+            )
+        html = widget.render(
+            name=name,
+            value=self.value(),
+            attrs=attrs,
+            **kwargs
+        )
+        return force_text(html)
 
     def as_text(self, attrs=None, **kwargs):
         """
@@ -230,3 +252,45 @@ class BoundField(object):
         if self.field.disabled:
             attrs['disabled'] = True
         return attrs
+
+
+@html_safe
+@python_2_unicode_compatible
+class BoundWidget(object):
+    """
+    A container class used for iterating over widgets. This is useful for
+    widgets that have choices. For example, the following can be used in a
+    template:
+
+    {% for radio in myform.beatles %}
+      <label for="{{ radio.id_for_label }}">
+        {{ radio.choice_label }}
+        <span class="radio">{{ radio.tag }}</span>
+      </label>
+    {% endfor %}
+    """
+    def __init__(self, parent_widget, data, renderer):
+        self.parent_widget = parent_widget
+        self.data = data
+        self.renderer = renderer
+
+    def __str__(self):
+        return self.tag(wrap_label=True)
+
+    def tag(self, wrap_label=False):
+        context = {'widget': self.data, 'wrap_label': wrap_label}
+        return self.parent_widget._render(self.template_name, context, self.renderer)
+
+    @property
+    def template_name(self):
+        if 'template_name' in self.data:
+            return self.data['template_name']
+        return self.parent_widget.template_name
+
+    @property
+    def id_for_label(self):
+        return 'id_%s_%s' % (self.data['name'], self.data['index'])
+
+    @property
+    def choice_label(self):
+        return self.data['label']

+ 15 - 1
django/forms/forms.py

@@ -21,6 +21,8 @@ from django.utils.html import conditional_escape, html_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext as _
 
+from .renderers import get_default_renderer
+
 __all__ = ('BaseForm', 'Form')
 
 
@@ -65,13 +67,14 @@ class BaseForm(object):
     # class is different than Form. See the comments by the Form class for more
     # information. Any improvements to the form API should be made to *this*
     # class, not to the Form class.
+    default_renderer = None
     field_order = None
     prefix = None
     use_required_attribute = True
 
     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):
+                 empty_permitted=False, field_order=None, use_required_attribute=None, renderer=None):
         self.is_bound = data is not None or files is not None
         self.data = data or {}
         self.files = files or {}
@@ -97,6 +100,17 @@ class BaseForm(object):
         if use_required_attribute is not None:
             self.use_required_attribute = use_required_attribute
 
+        # Initialize form renderer. Use a global default if not specified
+        # either as an argument or as self.default_renderer.
+        if renderer is None:
+            if self.default_renderer is None:
+                renderer = get_default_renderer()
+            else:
+                renderer = self.default_renderer
+                if isinstance(self.default_renderer, type):
+                    renderer = renderer()
+        self.renderer = renderer
+
     def order_fields(self, field_order):
         """
         Rearranges the fields according to field_order.

+ 1 - 0
django/forms/jinja2/django/forms/widgets/attrs.html

@@ -0,0 +1 @@
+{% for name, value in widget.attrs.items() %} {{ name }}{% if not value is sameas True %}="{{ value }}"{% endif %}{% endfor %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/checkbox.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/checkbox_option.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input_option.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/checkbox_select.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/multiple_input.html" %}

+ 5 - 0
django/forms/jinja2/django/forms/widgets/clearable_file_input.html

@@ -0,0 +1,5 @@
+{% if is_initial %}{{ initial_text }}: <a href="{{ widget.value.url }}">{{ widget.value }}</a>{% if not widget.required %}
+<input type="checkbox" name="{{ checkbox_name }}" id="{{ checkbox_id }}" />
+<label for="{{ checkbox_id }}">{{ clear_checkbox_label }}</label>{% endif %}<br />
+{{ input_text }}:{% endif %}
+<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} />

+ 1 - 0
django/forms/jinja2/django/forms/widgets/date.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/datetime.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/email.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/file.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/hidden.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/input.html

@@ -0,0 +1 @@
+<input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None and widget.value != "" %} value="{{ widget.value }}"{% endif %}{% include "django/forms/widgets/attrs.html" %} />

+ 1 - 0
django/forms/jinja2/django/forms/widgets/input_option.html

@@ -0,0 +1 @@
+{% if wrap_label %}<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% endif %}{% include "django/forms/widgets/input.html" %}{% if wrap_label %} {{ widget.label }}</label>{% endif %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/multiple_hidden.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/multiwidget.html" %}

+ 5 - 0
django/forms/jinja2/django/forms/widgets/multiple_input.html

@@ -0,0 +1,5 @@
+{% set id = widget.attrs.id %}<ul{% if id %} id="{{ id }}"{% endif %}>{% for group, options, index in widget.optgroups %}{% if group %}
+  <li>{{ group }}<ul{% if id %} id="{{ id }}_{{ index }}{% endif %}">{% endif %}{% for widget in options %}
+    <li>{% include widget.template_name %}</li>{% endfor %}{% if group %}
+  </ul></li>{% endif %}{% endfor %}
+</ul>

+ 1 - 0
django/forms/jinja2/django/forms/widgets/multiwidget.html

@@ -0,0 +1 @@
+{% for widget in widget.subwidgets %}{% include widget.template_name %}{% endfor %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/number.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/password.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/radio.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/multiple_input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/radio_option.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input_option.html" %}

+ 5 - 0
django/forms/jinja2/django/forms/widgets/select.html

@@ -0,0 +1,5 @@
+<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
+  <optgroup label="{{ group_name }}">{% endif %}{% for widget in group_choices %}
+  {% include widget.template_name %}{% endfor %}{% if group_name %}
+  </optgroup>{% endif %}{% endfor %}
+</select>

+ 1 - 0
django/forms/jinja2/django/forms/widgets/select_date.html

@@ -0,0 +1 @@
+{% include 'django/forms/widgets/multiwidget.html' %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/select_option.html

@@ -0,0 +1 @@
+<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %}>{{ widget.label }}</option>

+ 1 - 0
django/forms/jinja2/django/forms/widgets/splitdatetime.html

@@ -0,0 +1 @@
+{% include 'django/forms/widgets/multiwidget.html' %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/splithiddendatetime.html

@@ -0,0 +1 @@
+{% include 'django/forms/widgets/multiwidget.html' %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/text.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 2 - 0
django/forms/jinja2/django/forms/widgets/textarea.html

@@ -0,0 +1,2 @@
+<textarea name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
+{% if widget.value %}{{ widget.value }}{% endif %}</textarea>

+ 1 - 0
django/forms/jinja2/django/forms/widgets/time.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/jinja2/django/forms/widgets/url.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 71 - 0
django/forms/renderers.py

@@ -0,0 +1,71 @@
+import os
+
+from django.conf import settings
+from django.template.backends.django import DjangoTemplates
+from django.template.loader import get_template
+from django.utils import lru_cache
+from django.utils._os import upath
+from django.utils.functional import cached_property
+from django.utils.module_loading import import_string
+
+try:
+    from django.template.backends.jinja2 import Jinja2
+except ImportError:
+    def Jinja2(params):
+        raise ImportError("jinja2 isn't installed")
+
+ROOT = upath(os.path.dirname(__file__))
+
+
+@lru_cache.lru_cache()
+def get_default_renderer():
+    renderer_class = import_string(settings.FORM_RENDERER)
+    return renderer_class()
+
+
+class BaseRenderer(object):
+    def get_template(self, template_name):
+        raise NotImplementedError('subclasses must implement get_template()')
+
+    def render(self, template_name, context, request=None):
+        template = self.get_template(template_name)
+        return template.render(context, request=request).strip()
+
+
+class EngineMixin(object):
+    def get_template(self, template_name):
+        return self.engine.get_template(template_name)
+
+    @cached_property
+    def engine(self):
+        return self.backend({
+            'APP_DIRS': True,
+            'DIRS': [os.path.join(ROOT, self.backend.app_dirname)],
+            'NAME': 'djangoforms',
+            'OPTIONS': {},
+        })
+
+
+class DjangoTemplates(EngineMixin, BaseRenderer):
+    """
+    Load Django templates from the built-in widget templates in
+    django/forms/templates and from apps' 'templates' directory.
+    """
+    backend = DjangoTemplates
+
+
+class Jinja2(EngineMixin, BaseRenderer):
+    """
+    Load Jinja2 templates from the built-in widget templates in
+    django/forms/jinja2 and from apps' 'jinja2' directory.
+    """
+    backend = Jinja2
+
+
+class TemplatesSetting(BaseRenderer):
+    """
+    Load templates using template.loader.get_template() which is configured
+    based on settings.TEMPLATES.
+    """
+    def get_template(self, template_name):
+        return get_template(template_name)

+ 1 - 0
django/forms/templates/django/forms/widgets/attrs.html

@@ -0,0 +1 @@
+{% for name, value in widget.attrs.items %} {{ name }}{% if not value is True %}="{{ value }}"{% endif %}{% endfor %}

+ 1 - 0
django/forms/templates/django/forms/widgets/checkbox.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/checkbox_option.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input_option.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/checkbox_select.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/multiple_input.html" %}

+ 5 - 0
django/forms/templates/django/forms/widgets/clearable_file_input.html

@@ -0,0 +1,5 @@
+{% if is_initial %}{{ initial_text }}: <a href="{{ widget.value.url }}">{{ widget.value }}</a>{% if not widget.required %}
+<input type="checkbox" name="{{ checkbox_name }}" id="{{ checkbox_id }}" />
+<label for="{{ checkbox_id }}">{{ clear_checkbox_label }}</label>{% endif %}<br />
+{{ input_text }}:{% endif %}
+<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} />

+ 1 - 0
django/forms/templates/django/forms/widgets/date.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/datetime.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/email.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/file.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/hidden.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/input.html

@@ -0,0 +1 @@
+<input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None and widget.value != "" %} value="{{ widget.value }}"{% endif %}{% include "django/forms/widgets/attrs.html" %} />

+ 1 - 0
django/forms/templates/django/forms/widgets/input_option.html

@@ -0,0 +1 @@
+{% if wrap_label %}<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% endif %}{% include "django/forms/widgets/input.html" %}{% if wrap_label %} {{ widget.label }}</label>{% endif %}

+ 1 - 0
django/forms/templates/django/forms/widgets/multiple_hidden.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/multiwidget.html" %}

+ 5 - 0
django/forms/templates/django/forms/widgets/multiple_input.html

@@ -0,0 +1,5 @@
+{% with id=widget.attrs.id %}<ul{% if id %} id="{{ id }}"{% endif %}>{% for group, options, index in widget.optgroups %}{% if group %}
+  <li>{{ group }}<ul{% if id %} id="{{ id }}_{{ index }}{% endif %}">{% endif %}{% for option in options %}
+    <li>{% include option.template_name with widget=option %}</li>{% endfor %}{% if group %}
+  </ul></li>{% endif %}{% endfor %}
+</ul>{% endwith %}

+ 1 - 0
django/forms/templates/django/forms/widgets/multiwidget.html

@@ -0,0 +1 @@
+{% for widget in widget.subwidgets %}{% include widget.template_name %}{% endfor %}

+ 1 - 0
django/forms/templates/django/forms/widgets/number.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/password.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/radio.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/multiple_input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/radio_option.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input_option.html" %}

+ 5 - 0
django/forms/templates/django/forms/widgets/select.html

@@ -0,0 +1,5 @@
+<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
+  <optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
+  {% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
+  </optgroup>{% endif %}{% endfor %}
+</select>

+ 1 - 0
django/forms/templates/django/forms/widgets/select_date.html

@@ -0,0 +1 @@
+{% include 'django/forms/widgets/multiwidget.html' %}

+ 1 - 0
django/forms/templates/django/forms/widgets/select_option.html

@@ -0,0 +1 @@
+<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %}>{{ widget.label }}</option>

+ 1 - 0
django/forms/templates/django/forms/widgets/splitdatetime.html

@@ -0,0 +1 @@
+{% include 'django/forms/widgets/multiwidget.html' %}

+ 1 - 0
django/forms/templates/django/forms/widgets/splithiddendatetime.html

@@ -0,0 +1 @@
+{% include 'django/forms/widgets/multiwidget.html' %}

+ 1 - 0
django/forms/templates/django/forms/widgets/text.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 2 - 0
django/forms/templates/django/forms/widgets/textarea.html

@@ -0,0 +1,2 @@
+<textarea name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
+{% if widget.value %}{{ widget.value }}{% endif %}</textarea>

+ 1 - 0
django/forms/templates/django/forms/widgets/time.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

+ 1 - 0
django/forms/templates/django/forms/widgets/url.html

@@ -0,0 +1 @@
+{% include "django/forms/widgets/input.html" %}

File diff suppressed because it is too large
+ 366 - 393
django/forms/widgets.py


+ 1 - 1
django/template/backends/jinja2.py

@@ -12,7 +12,6 @@ from django.utils.functional import cached_property
 from django.utils.module_loading import import_string
 
 from .base import BaseEngine
-from .utils import csrf_input_lazy, csrf_token_lazy
 
 
 class Jinja2(BaseEngine):
@@ -70,6 +69,7 @@ class Template(object):
         )
 
     def render(self, context=None, request=None):
+        from .utils import csrf_input_lazy, csrf_token_lazy
         if context is None:
             context = {}
         if request is not None:

+ 2 - 0
django/test/signals.py

@@ -97,6 +97,8 @@ def reset_template_engines(**kwargs):
         engines._engines = {}
         from django.template.engine import Engine
         Engine.get_default.cache_clear()
+        from django.forms.renderers import get_default_renderer
+        get_default_renderer.cache_clear()
 
 
 @receiver(setting_changed)

+ 3 - 0
docs/internals/deprecation.txt

@@ -51,6 +51,9 @@ details on these changes.
 * Support for regular expression groups with ``iLmsu#`` in ``url()`` will be
   removed.
 
+* Support for ``Widget.render()`` methods without the ``renderer`` argument
+  will be removed.
+
 .. _deprecation-removed-in-2.0:
 
 2.0

+ 23 - 0
docs/ref/forms/api.txt

@@ -720,6 +720,29 @@ 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.
 
+Configuring the rendering of a form's widgets
+---------------------------------------------
+
+.. attribute:: Form.default_renderer
+
+.. versionadded:: 1.11
+
+Specifies the :doc:`renderer <renderers>` to use for the form. Defaults to
+``None`` which means to use the default renderer specified by the
+:setting:`FORM_RENDERER` setting.
+
+You can set this as a class attribute when declaring your form or use the
+``renderer`` argument to ``Form.__init__()``. For example::
+
+    from django import forms
+
+    class MyForm(forms.Form):
+        default_renderer = MyRenderer()
+
+or::
+
+    form = MyForm(renderer=MyRenderer())
+
 Notes on field ordering
 -----------------------
 

+ 1 - 0
docs/ref/forms/index.txt

@@ -12,5 +12,6 @@ Detailed form API reference. For introductory material, see the
    fields
    models
    formsets
+   renderers
    widgets
    validation

+ 131 - 0
docs/ref/forms/renderers.txt

@@ -0,0 +1,131 @@
+======================
+The form rendering API
+======================
+
+.. module:: django.forms.renderers
+   :synopsis: Built-in form renderers.
+
+.. versionadded:: 1.11
+
+    In older versions, widgets are rendered using Python. All APIs described
+    in this document are new.
+
+Django's form widgets are rendered using Django's :doc:`template engines
+system </topics/templates>`.
+
+The form rendering process can be customized at several levels:
+
+* Widgets can specify custom template names.
+* Forms and widgets can specify custom renderer classes.
+* A widget's template can be overridden by a project. (Reusable applications
+  typically shouldn't override built-in templates because they might conflict
+  with a project's custom templates.)
+
+.. _low-level-widget-render-api:
+
+The low-level render API
+========================
+
+The rendering of form templates is controlled by a customizable renderer class.
+A custom renderer can be specified by updating the :setting:`FORM_RENDERER`
+setting. It defaults to
+``'``:class:`django.forms.renderers.DjangoTemplates`\ ``'``.
+
+You can also provide a custom renderer by setting the
+:attr:`.Form.default_renderer` attribute or by using the ``renderer`` argument
+of :meth:`.Widget.render`.
+
+Use one of the :ref:`built-in template form renderers
+<built-in-template-form-renderers>` or implement your own. Custom renderers
+must implement a ``render(template_name, context, request=None)`` method. It
+should return a rendered templates (as a string) or raise
+:exc:`~django.template.TemplateDoesNotExist`.
+
+.. _built-in-template-form-renderers:
+
+Built-in-template form renderers
+================================
+
+``DjangoTemplates``
+-------------------
+
+.. class:: DjangoTemplates
+
+This renderer uses a standalone
+:class:`~django.template.backends.django.DjangoTemplates`
+engine (unconnected to what you might have configured in the
+:setting:`TEMPLATES` setting). It loads templates first from the built-in form
+templates directory in ``django/forms/templates`` and then from the installed
+apps' templates directories using the :class:`app_directories
+<django.template.loaders.app_directories.Loader>` loader.
+
+If you want to render templates with customizations from your
+:setting:`TEMPLATES` setting, such as context processors for example, use the
+:class:`TemplatesSetting` renderer.
+
+``Jinja2``
+----------
+
+.. class:: Jinja2
+
+This renderer is the same as the :class:`DjangoTemplates` renderer except that
+it uses a :class:`~django.template.backends.jinja2.Jinja2` backend. Templates
+for the built-in widgets are located in ``django/forms/jinja2`` and installed
+apps can provide templates in a ``jinja2`` directory.
+
+To use this backend, all the widgets in your project and its third-party apps
+must have Jinja2 templates. Unless you provide your own Jinja2 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.
+
+``TemplatesSetting``
+--------------------
+
+.. class:: TemplatesSetting
+
+This renderer gives you complete control of how widget templates are sourced.
+It uses :func:`~django.template.loader.get_template` to find widget
+templates based on what's configured in the :setting:`TEMPLATES` setting.
+
+Using this renderer along with the built-in widget templates requires either:
+
+#. ``'django.forms'`` in :setting:`INSTALLED_APPS` and at least one engine
+   with :setting:`APP_DIRS=True <TEMPLATES-APP_DIRS>`.
+
+#. Adding the built-in widgets templates directory (``django/forms/templates``
+   or ``django/forms/jinja2``) in :setting:`DIRS <TEMPLATES-DIRS>` of one of
+   your template engines.
+
+Using this renderer requires you to make sure the form templates your project
+needs can be located.
+
+Context available in widget templates
+=====================================
+
+Widget templates receive a context from :meth:`.Widget.get_context`. By
+default, widgets receive a single value in the context, ``widget``. This is a
+dictionary that contains values like:
+
+* ``name``
+* ``value``
+* ``attrs``
+* ``is_hidden``
+* ``template_name``
+
+Some widgets add further information to the context. For instance, all widgets
+that subclass ``Input`` defines ``widget['type']`` and :class:`.MultiWidget`
+defines ``widget['subwidgets']`` for looping purposes.
+
+Overriding built-in widget templates
+====================================
+
+Each widget has a ``template_name`` attribute with a value such as
+``input.html``. Built-in widget templates are stored in the
+``django/forms/widgets`` path. You can provide a custom template for
+``input.html`` by defining ``django/forms/widgets/input.html``, for example.
+See :ref:`built-in widgets` for the name of each widget's template.
+
+If you use the :class:`TemplatesSetting` renderer, overriding widget templates
+works the same as overriding any other template in your project. You can't
+override built-in widget templates using the other built-in renderers.

+ 96 - 60
docs/ref/forms/widgets.txt

@@ -241,6 +241,28 @@ foundation for custom widgets.
             In older versions, this method is a private API named
             ``_format_value()``. The old name will work until Django 2.0.
 
+    .. method:: get_context(name, value, attrs=None)
+
+        .. versionadded:: 1.11
+
+        Returns a dictionary of values to use when rendering the widget
+        template. By default, the dictionary contains a single key,
+        ``'widget'``, which is a dictionary representation of the widget
+        containing the following keys:
+
+        * ``'name'``: The name of the field from the ``name`` argument.
+        * ``'is_hidden'``: A boolean indicating whether or not this widget is
+          hidden.
+        * ``'required'``: A boolean indicating whether or not the field for
+          this widget is required.
+        * ``'value'``: The value as returned by :meth:`format_value`.
+        * ``'attrs'``: HTML attributes to be set on the rendered widget. The
+          combination of the :attr:`attrs` attribute and the ``attrs`` argument.
+        * ``'template_name'``: The value of ``self.template_name``.
+
+        ``Widget`` subclasses can provide custom context values by overriding
+        this method.
+
     .. method:: id_for_label(self, id_)
 
         Returns the HTML ID attribute of this widget for use by a ``<label>``,
@@ -251,14 +273,16 @@ foundation for custom widgets.
         return an ID value that corresponds to the first ID in the widget's
         tags.
 
-    .. method:: render(name, value, attrs=None)
+    .. method:: render(name, value, attrs=None, renderer=None)
 
-        Returns HTML for the widget, as a Unicode string. This method must be
-        implemented by the subclass, otherwise ``NotImplementedError`` will be
-        raised.
+        Renders a widget to HTML using the given renderer. If ``renderer`` is
+        ``None``, the renderer from the :setting:`FORM_RENDERER` setting is
+        used.
 
-        The 'value' given is not guaranteed to be valid input, therefore
-        subclass implementations should program defensively.
+        .. versionchanged:: 1.11
+
+            The ``renderer`` argument was added. Support for subclasses that
+            don't accept it will be removed in Django 2.1.
 
     .. method:: value_from_datadict(data, files, name)
 
@@ -360,40 +384,21 @@ foundation for custom widgets.
             with the opposite responsibility - to combine cleaned values of
             all member fields into one.
 
-    Other methods that may be useful to override include:
-
-    .. method:: render(name, value, attrs=None)
-
-        Argument ``value`` is handled differently in this method from the
-        subclasses of :class:`~Widget` because it has to figure out how to
-        split a single value for display in multiple widgets.
-
-        The ``value`` argument used when rendering can be one of two things:
-
-        * A ``list``.
-        * A single value (e.g., a string) that is the "compressed" representation
-          of a ``list`` of values.
+    It provides some custom context:
 
-        If ``value`` is a list, the output of :meth:`~MultiWidget.render` will
-        be a concatenation of rendered child widgets. If ``value`` is not a
-        list, it will first be processed by the method
-        :meth:`~MultiWidget.decompress()` to create the list and then rendered.
+    .. method:: get_context(name, value, attrs=None)
 
-        When ``render()`` executes its HTML rendering, each value in the list
-        is rendered with the corresponding widget -- the first value is
-        rendered in the first widget, the second value is rendered in the
-        second widget, etc.
+        In addition to the ``'widget'`` key described in
+        :meth:`Widget.get_context`, ``MultiValueWidget`` adds a
+        ``widget['subwidgets']`` key.
 
-        Unlike in the single value widgets, method :meth:`~MultiWidget.render`
-        need not be implemented in the subclasses.
+        These can be looped over in the widget template:
 
-    .. method:: format_output(rendered_widgets)
+        .. code-block:: html+django
 
-        Given a list of rendered widgets (as strings), returns a Unicode string
-        representing the HTML for the whole lot.
-
-        This hook allows you to format the HTML design of the widgets any way
-        you'd like.
+            {% for subwidget in widget.subwidgets %}
+                {% include widget.template_name with widget=subwidget %}
+            {% endfor %}
 
     Here's an example widget which subclasses :class:`MultiWidget` to display
     a date with the day, month, and year in different select boxes. This widget
@@ -421,9 +426,6 @@ foundation for custom widgets.
                     return [value.day, value.month, value.year]
                 return [None, None, None]
 
-            def format_output(self, rendered_widgets):
-                return ''.join(rendered_widgets)
-
             def value_from_datadict(self, data, files, name):
                 datelist = [
                     widget.value_from_datadict(data, files, name + '_%s' % i)
@@ -442,11 +444,6 @@ foundation for custom widgets.
     The constructor creates several :class:`Select` widgets in a tuple. The
     ``super`` class uses this tuple to setup the widget.
 
-    The :meth:`~MultiWidget.format_output` method is fairly vanilla here (in
-    fact, it's the same as what's been implemented as the default for
-    ``MultiWidget``), but the idea is that you could add custom HTML between
-    the widgets should you wish.
-
     The required method :meth:`~MultiWidget.decompress` breaks up a
     ``datetime.date`` value into the day, month, and year values corresponding
     to each widget. Note how the method handles the case where ``value`` is
@@ -485,14 +482,18 @@ These widgets make use of the HTML elements ``input`` and ``textarea``.
 
 .. class:: TextInput
 
-    Text input: ``<input type="text" ...>``
+    * ``input_type``: ``'text'``
+    * ``template_name``: ``'django/forms/widgets/text.html'``
+    * Renders as: ``<input type="text" ...>``
 
 ``NumberInput``
 ~~~~~~~~~~~~~~~
 
 .. class:: NumberInput
 
-    Text input: ``<input type="number" ...>``
+    * ``input_type``: ``'number'``
+    * ``template_name``: ``'django/forms/widgets/number.html'``
+    * Renders as: ``<input type="number" ...>``
 
     Beware that not all browsers support entering localized numbers in
     ``number`` input types. Django itself avoids using them for fields having
@@ -503,21 +504,27 @@ These widgets make use of the HTML elements ``input`` and ``textarea``.
 
 .. class:: EmailInput
 
-    Text input: ``<input type="email" ...>``
+    * ``input_type``: ``'email'``
+    * ``template_name``: ``'django/forms/widgets/email.html'``
+    * Renders as: ``<input type="email" ...>``
 
 ``URLInput``
 ~~~~~~~~~~~~
 
 .. class:: URLInput
 
-    Text input: ``<input type="url" ...>``
+    * ``input_type``: ``'url'``
+    * ``template_name``: ``'django/forms/widgets/url.html'``
+    * Renders as: ``<input type="url" ...>``
 
 ``PasswordInput``
 ~~~~~~~~~~~~~~~~~
 
 .. class:: PasswordInput
 
-    Password input: ``<input type='password' ...>``
+    * ``input_type``: ``'password'``
+    * ``template_name``: ``'django/forms/widgets/password.html'``
+    * Renders as: ``<input type='password' ...>``
 
     Takes one optional argument:
 
@@ -531,7 +538,9 @@ These widgets make use of the HTML elements ``input`` and ``textarea``.
 
 .. class:: HiddenInput
 
-    Hidden input: ``<input type='hidden' ...>``
+    * ``input_type``: ``'hidden'``
+    * ``template_name``: ``'django/forms/widgets/hidden.html'``
+    * Renders as: ``<input type='hidden' ...>``
 
     Note that there also is a :class:`MultipleHiddenInput` widget that
     encapsulates a set of hidden input elements.
@@ -541,7 +550,9 @@ These widgets make use of the HTML elements ``input`` and ``textarea``.
 
 .. class:: DateInput
 
-    Date input as a simple text box: ``<input type='text' ...>``
+    * ``input_type``: ``'text'``
+    * ``template_name``: ``'django/forms/widgets/date.html'``
+    * Renders as: ``<input type='text' ...>``
 
     Takes same arguments as :class:`TextInput`, with one more optional argument:
 
@@ -558,7 +569,9 @@ These widgets make use of the HTML elements ``input`` and ``textarea``.
 
 .. class:: DateTimeInput
 
-    Date/time input as a simple text box: ``<input type='text' ...>``
+    * ``input_type``: ``'text'``
+    * ``template_name``: ``'django/forms/widgets/datetime.html'``
+    * Renders as: ``<input type='text' ...>``
 
     Takes same arguments as :class:`TextInput`, with one more optional argument:
 
@@ -579,7 +592,9 @@ These widgets make use of the HTML elements ``input`` and ``textarea``.
 
 .. class:: TimeInput
 
-    Time input as a simple text box: ``<input type='text' ...>``
+    * ``input_type``: ``'text'``
+    * ``template_name``: ``'django/forms/widgets/time.html'``
+    * Renders as: ``<input type='text' ...>``
 
     Takes same arguments as :class:`TextInput`, with one more optional argument:
 
@@ -598,7 +613,8 @@ These widgets make use of the HTML elements ``input`` and ``textarea``.
 
 .. class:: Textarea
 
-    Text area: ``<textarea>...</textarea>``
+    * ``template_name``: ``'django/forms/widgets/textarea.html'``
+    * Renders as: ``<textarea>...</textarea>``
 
 .. _selector-widgets:
 
@@ -610,7 +626,9 @@ Selector and checkbox widgets
 
 .. class:: CheckboxInput
 
-    Checkbox: ``<input type='checkbox' ...>``
+    * ``input_type``: ``'checkbox'``
+    * ``template_name``: ``'django/forms/widgets/checkbox.html'``
+    * Renders as: ``<input type='checkbox' ...>``
 
     Takes one optional argument:
 
@@ -624,7 +642,8 @@ Selector and checkbox widgets
 
 .. class:: Select
 
-    Select widget: ``<select><option ...>...</select>``
+    * ``template_name``: ``'django/forms/widgets/select.html'``
+    * Renders as: ``<select><option ...>...</select>``
 
     .. attribute:: Select.choices
 
@@ -637,6 +656,8 @@ Selector and checkbox widgets
 
 .. class:: NullBooleanSelect
 
+    * ``template_name``: ``'django/forms/widgets/select.html'``
+
     Select widget with options 'Unknown', 'Yes' and 'No'
 
 ``SelectMultiple``
@@ -644,6 +665,8 @@ Selector and checkbox widgets
 
 .. class:: SelectMultiple
 
+    * ``template_name``: ``'django/forms/widgets/select.html'``
+
     Similar to :class:`Select`, but allows multiple selection:
     ``<select multiple='multiple'>...</select>``
 
@@ -652,6 +675,8 @@ Selector and checkbox widgets
 
 .. class:: RadioSelect
 
+    * ``template_name``: ``'django/forms/widgets/radio.html'``
+
     Similar to :class:`Select`, but rendered as a list of radio buttons within
     ``<li>`` tags:
 
@@ -744,6 +769,8 @@ Selector and checkbox widgets
 
 .. class:: CheckboxSelectMultiple
 
+    * ``template_name``: ``'django/forms/widgets/checkbox_select.html'``
+
     Similar to :class:`SelectMultiple`, but rendered as a list of check
     buttons:
 
@@ -776,16 +803,18 @@ File upload widgets
 
 .. class:: FileInput
 
-    File upload input: ``<input type='file' ...>``
+    * ``template_name``: ``'django/forms/widgets/file.html'``
+    * Renders as: ``<input type='file' ...>``
 
 ``ClearableFileInput``
 ~~~~~~~~~~~~~~~~~~~~~~
 
 .. class:: ClearableFileInput
 
-    File upload input: ``<input type='file' ...>``, with an additional checkbox
-    input to clear the field's value, if the field is not required and has
-    initial data.
+    * ``template_name``: ``'django/forms/widgets/clearable_file_input.html'``
+    * Renders as: ``<input type='file' ...>`` with an additional checkbox
+      input to clear the field's value, if the field is not required and has
+      initial data.
 
 .. _composite-widgets:
 
@@ -797,7 +826,8 @@ Composite widgets
 
 .. class:: MultipleHiddenInput
 
-    Multiple ``<input type='hidden' ...>`` widgets.
+    * ``template_name``: ``'django/forms/widgets/multiple_hidden.html'``
+    * Renders as: multiple ``<input type='hidden' ...>`` tags
 
     A widget that handles multiple hidden widgets for fields that have a list
     of values.
@@ -813,6 +843,8 @@ Composite widgets
 
 .. class:: SplitDateTimeWidget
 
+    * ``template_name``: ``'django/forms/widgets/splitdatetime.html'``
+
     Wrapper (using :class:`MultiWidget`) around two widgets: :class:`DateInput`
     for the date, and :class:`TimeInput` for the time. Must be used with
     :class:`SplitDateTimeField` rather than :class:`DateTimeField`.
@@ -832,6 +864,8 @@ Composite widgets
 
 .. class:: SplitHiddenDateTimeWidget
 
+    * ``template_name``: ``'django/forms/widgets/splithiddendatetime.html'``
+
     Similar to :class:`SplitDateTimeWidget`, but uses :class:`HiddenInput` for
     both date and time.
 
@@ -840,6 +874,8 @@ Composite widgets
 
 .. class:: SelectDateWidget
 
+    * ``template_name``: ``'django/forms/widgets/select_date.html'``
+
     Wrapper around three :class:`~django.forms.Select` widgets: one each for
     month, day, and year.
 

+ 16 - 0
docs/ref/settings.txt

@@ -1517,6 +1517,18 @@ generate correct URLs when ``SCRIPT_NAME`` is not ``/``.
 
     The setting's use in :func:`django.setup()` was added.
 
+.. setting:: FORM_RENDERER
+
+``FORM_RENDERER``
+-----------------
+
+.. versionadded:: 1.11
+
+Default: ``'``:class:`django.forms.renderers.DjangoTemplates`\ ``'``
+
+The class that renders form widgets. It must implement :ref:`the low-level
+render API <low-level-widget-render-api>`.
+
 .. setting:: FORMAT_MODULE_PATH
 
 ``FORMAT_MODULE_PATH``
@@ -3351,6 +3363,10 @@ File uploads
 * :setting:`MEDIA_ROOT`
 * :setting:`MEDIA_URL`
 
+Forms
+-----
+* :setting:`FORM_RENDERER`
+
 Globalization (``i18n``/``l10n``)
 ---------------------------------
 * :setting:`DATE_FORMAT`

+ 28 - 0
docs/releases/1.11.txt

@@ -61,6 +61,15 @@ It can be subclassed to support different index types, such as
 :class:`~django.contrib.postgres.indexes.GinIndex`. It also allows defining the
 order (ASC/DESC) for the columns of the index.
 
+Template-based widget rendering
+-------------------------------
+
+To ease customizing widgets, form widget rendering is now done using the
+template system rather than in Python. See :doc:`/ref/forms/renderers`.
+
+You may need to adjust any custom widgets that you've written for a few
+:ref:`backwards incompatible changes <template-widget-incompatibilities-1-11>`.
+
 Minor features
 --------------
 
@@ -551,6 +560,21 @@ inside help text.
 Read-only fields are wrapped in ``<div class="readonly">...</div>`` instead of
 ``<p>...</p>`` to allow any kind of HTML as the field's content.
 
+.. _template-widget-incompatibilities-1-11:
+
+Changes due to the introduction of template-based widget rendering
+------------------------------------------------------------------
+
+Some undocumented classes in ``django.forms.widgets`` are removed:
+
+* ``SubWidget``
+* ``RendererMixin``, ``ChoiceFieldRenderer``, ``RadioFieldRenderer``,
+  ``CheckboxFieldRenderer``
+* ``ChoiceInput``, ``RadioChoiceInput``, ``CheckboxChoiceInput``
+
+The ``Widget.format_output()`` method is removed. Use a custom widget template
+instead.
+
 Miscellaneous
 -------------
 
@@ -754,3 +778,7 @@ Miscellaneous
   entries for search engines, for example. An alternative solution could be to
   create a :data:`~django.conf.urls.handler404` that looks for uppercase
   characters in the URL and redirects to a lowercase equivalent.
+
+* The ``renderer`` argument is added to the :meth:`Widget.render()
+  <django.forms.Widget.render>` method. Methods that don't accept that argument
+  will work through a deprecation period.

+ 1 - 0
docs/spelling_wordlist

@@ -673,6 +673,7 @@ releasers
 reloader
 removetags
 renderer
+renderers
 repo
 reportable
 reprojection

+ 14 - 12
tests/admin_inlines/tests.py

@@ -184,9 +184,11 @@ class TestInline(TestDataMixin, TestCase):
         SomeChildModel.objects.create(name='c', position='1', parent=parent)
         response = self.client.get(reverse('admin:admin_inlines_someparentmodel_change', args=(parent.pk,)))
         self.assertNotContains(response, '<td class="field-position">')
-        self.assertContains(response, (
+        self.assertInHTML(
             '<input id="id_somechildmodel_set-1-position" '
-            'name="somechildmodel_set-1-position" type="hidden" value="1" />'))
+            'name="somechildmodel_set-1-position" type="hidden" value="1" />',
+            response.rendered_content,
+        )
 
     def test_non_related_name_inline(self):
         """
@@ -273,12 +275,12 @@ class TestInline(TestDataMixin, TestCase):
             'name="binarytree_set-TOTAL_FORMS" type="hidden" value="2" />'
         )
         response = self.client.get(reverse('admin:admin_inlines_binarytree_add'))
-        self.assertContains(response, max_forms_input % 3)
-        self.assertContains(response, total_forms_hidden)
+        self.assertInHTML(max_forms_input % 3, response.rendered_content)
+        self.assertInHTML(total_forms_hidden, response.rendered_content)
 
         response = self.client.get(reverse('admin:admin_inlines_binarytree_change', args=(bt_head.id,)))
-        self.assertContains(response, max_forms_input % 2)
-        self.assertContains(response, total_forms_hidden)
+        self.assertInHTML(max_forms_input % 2, response.rendered_content)
+        self.assertInHTML(total_forms_hidden, response.rendered_content)
 
     def test_min_num(self):
         """
@@ -302,8 +304,8 @@ class TestInline(TestDataMixin, TestCase):
         request = self.factory.get(reverse('admin:admin_inlines_binarytree_add'))
         request.user = User(username='super', is_superuser=True)
         response = modeladmin.changeform_view(request)
-        self.assertContains(response, min_forms)
-        self.assertContains(response, total_forms)
+        self.assertInHTML(min_forms, response.rendered_content)
+        self.assertInHTML(total_forms, response.rendered_content)
 
     def test_custom_min_num(self):
         bt_head = BinaryTree.objects.create(name="Tree Head")
@@ -331,14 +333,14 @@ class TestInline(TestDataMixin, TestCase):
         request = self.factory.get(reverse('admin:admin_inlines_binarytree_add'))
         request.user = User(username='super', is_superuser=True)
         response = modeladmin.changeform_view(request)
-        self.assertContains(response, min_forms % 2)
-        self.assertContains(response, total_forms % 5)
+        self.assertInHTML(min_forms % 2, response.rendered_content)
+        self.assertInHTML(total_forms % 5, response.rendered_content)
 
         request = self.factory.get(reverse('admin:admin_inlines_binarytree_change', args=(bt_head.id,)))
         request.user = User(username='super', is_superuser=True)
         response = modeladmin.changeform_view(request, object_id=str(bt_head.id))
-        self.assertContains(response, min_forms % 5)
-        self.assertContains(response, total_forms % 8)
+        self.assertInHTML(min_forms % 5, response.rendered_content)
+        self.assertInHTML(total_forms % 8, response.rendered_content)
 
     def test_inline_nonauto_noneditable_pk(self):
         response = self.client.get(reverse('admin:admin_inlines_author_add'))

+ 1 - 1
tests/admin_views/tests.py

@@ -5980,7 +5980,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 4 does not contain any non-form errors."
+        msg = "The formset 'inline_admin_formset' in context 10 does not contain any non-form errors."
         with self.assertRaisesMessage(AssertionError, msg):
             self.assertFormsetError(response, 'inline_admin_formset', None, None, ['Error'])
 

+ 41 - 21
tests/admin_widgets/tests.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 
 import gettext
 import os
+import re
 from datetime import datetime, timedelta
 from importlib import import_module
 
@@ -354,34 +355,53 @@ class AdminURLWidgetTest(SimpleTestCase):
         )
 
     def test_render_quoting(self):
-        # WARNING: Don't use assertHTMLEqual in that testcase!
-        # assertHTMLEqual will get rid of some escapes which are tested here!
+        """
+        WARNING: This test doesn't use assertHTMLEqual since it will get rid
+        of some escapes which are tested here!
+        """
+        HREF_RE = re.compile('href="([^"]+)"')
+        VALUE_RE = re.compile('value="([^"]+)"')
+        TEXT_RE = re.compile('<a[^>]+>([^>]+)</a>')
         w = widgets.AdminURLFieldWidget()
+        output = w.render('test', 'http://example.com/<sometag>some text</sometag>')
+        self.assertEqual(
+            HREF_RE.search(output).groups()[0],
+            'http://example.com/%3Csometag%3Esome%20text%3C/sometag%3E',
+        )
+        self.assertEqual(
+            TEXT_RE.search(output).groups()[0],
+            'http://example.com/&lt;sometag&gt;some text&lt;/sometag&gt;',
+        )
+        self.assertEqual(
+            VALUE_RE.search(output).groups()[0],
+            'http://example.com/&lt;sometag&gt;some text&lt;/sometag&gt;',
+        )
+        output = w.render('test', 'http://example-äüö.com/<sometag>some text</sometag>')
+        self.assertEqual(
+            HREF_RE.search(output).groups()[0],
+            'http://xn--example--7za4pnc.com/%3Csometag%3Esome%20text%3C/sometag%3E',
+        )
         self.assertEqual(
-            w.render('test', 'http://example.com/<sometag>some text</sometag>'),
-            '<p class="url">Currently: '
-            '<a href="http://example.com/%3Csometag%3Esome%20text%3C/sometag%3E">'
-            'http://example.com/&lt;sometag&gt;some text&lt;/sometag&gt;</a><br />'
-            'Change: <input class="vURLField" name="test" type="url" '
-            'value="http://example.com/&lt;sometag&gt;some text&lt;/sometag&gt;" /></p>'
+            TEXT_RE.search(output).groups()[0],
+            'http://example-äüö.com/&lt;sometag&gt;some text&lt;/sometag&gt;',
         )
         self.assertEqual(
-            w.render('test', 'http://example-äüö.com/<sometag>some text</sometag>'),
-            '<p class="url">Currently: '
-            '<a href="http://xn--example--7za4pnc.com/%3Csometag%3Esome%20text%3C/sometag%3E">'
-            'http://example-äüö.com/&lt;sometag&gt;some text&lt;/sometag&gt;</a><br />'
-            'Change: <input class="vURLField" name="test" type="url" '
-            'value="http://example-äüö.com/&lt;sometag&gt;some text&lt;/sometag&gt;" /></p>'
+            VALUE_RE.search(output).groups()[0],
+            'http://example-äüö.com/&lt;sometag&gt;some text&lt;/sometag&gt;',
         )
+        output = w.render('test', 'http://www.example.com/%C3%A4"><script>alert("XSS!")</script>"')
         self.assertEqual(
-            w.render('test', 'http://www.example.com/%C3%A4"><script>alert("XSS!")</script>"'),
-            '<p class="url">Currently: '
-            '<a href="http://www.example.com/%C3%A4%22%3E%3Cscript%3Ealert(%22XSS!%22)%3C/script%3E%22">'
+            HREF_RE.search(output).groups()[0],
+            'http://www.example.com/%C3%A4%22%3E%3Cscript%3Ealert(%22XSS!%22)%3C/script%3E%22',
+        )
+        self.assertEqual(
+            TEXT_RE.search(output).groups()[0],
             'http://www.example.com/%C3%A4&quot;&gt;&lt;script&gt;'
-            'alert(&quot;XSS!&quot;)&lt;/script&gt;&quot;</a><br />'
-            'Change: <input class="vURLField" name="test" type="url" '
-            'value="http://www.example.com/%C3%A4&quot;&gt;&lt;script&gt;'
-            'alert(&quot;XSS!&quot;)&lt;/script&gt;&quot;" /></p>'
+            'alert(&quot;XSS!&quot;)&lt;/script&gt;&quot;'
+        )
+        self.assertEqual(
+            VALUE_RE.search(output).groups()[0],
+            'http://www.example.com/%C3%A4&quot;&gt;&lt;script&gt;alert(&quot;XSS!&quot;)&lt;/script&gt;&quot;',
         )
 
 

+ 3 - 0
tests/forms_tests/field_tests/test_filepathfield.py

@@ -39,6 +39,7 @@ class FilePathFieldTest(SimpleTestCase):
             ('/django/forms/forms.py', 'forms.py'),
             ('/django/forms/formsets.py', 'formsets.py'),
             ('/django/forms/models.py', 'models.py'),
+            ('/django/forms/renderers.py', 'renderers.py'),
             ('/django/forms/utils.py', 'utils.py'),
             ('/django/forms/widgets.py', 'widgets.py')
         ]
@@ -62,6 +63,7 @@ class FilePathFieldTest(SimpleTestCase):
             ('/django/forms/forms.py', 'forms.py'),
             ('/django/forms/formsets.py', 'formsets.py'),
             ('/django/forms/models.py', 'models.py'),
+            ('/django/forms/renderers.py', 'renderers.py'),
             ('/django/forms/utils.py', 'utils.py'),
             ('/django/forms/widgets.py', 'widgets.py')
         ]
@@ -83,6 +85,7 @@ class FilePathFieldTest(SimpleTestCase):
             ('/django/forms/forms.py', 'forms.py'),
             ('/django/forms/formsets.py', 'formsets.py'),
             ('/django/forms/models.py', 'models.py'),
+            ('/django/forms/renderers.py', 'renderers.py'),
             ('/django/forms/utils.py', 'utils.py'),
             ('/django/forms/widgets.py', 'widgets.py')
         ]

+ 1 - 0
tests/forms_tests/jinja2/forms_tests/custom_widget.html

@@ -0,0 +1 @@
+<input type="text" name="custom">

+ 1 - 0
tests/forms_tests/templates/forms_tests/custom_widget.html

@@ -0,0 +1 @@
+<input type="text" name="custom">

+ 91 - 2
tests/forms_tests/tests/test_forms.py

@@ -17,6 +17,7 @@ from django.forms import (
     SplitDateTimeField, SplitHiddenDateTimeWidget, Textarea, TextInput,
     TimeField, ValidationError, forms,
 )
+from django.forms.renderers import DjangoTemplates, get_default_renderer
 from django.forms.utils import ErrorList
 from django.http import QueryDict
 from django.template import Context, Template
@@ -678,6 +679,50 @@ Java</label></li>
 <div><label><input type="radio" name="name" value="ringo" required /> Ringo</label></div>"""
         )
 
+    def test_form_with_iterable_boundfield_id(self):
+        class BeatleForm(Form):
+            name = ChoiceField(
+                choices=[('john', 'John'), ('paul', 'Paul'), ('george', 'George'), ('ringo', 'Ringo')],
+                widget=RadioSelect,
+            )
+        fields = list(BeatleForm()['name'])
+        self.assertEqual(len(fields), 4)
+
+        self.assertEqual(fields[0].id_for_label, 'id_name_0')
+        self.assertEqual(fields[0].choice_label, 'John')
+        self.assertHTMLEqual(
+            fields[0].tag(),
+            '<input type="radio" name="name" value="john" id="id_name_0" required />'
+        )
+        self.assertHTMLEqual(
+            str(fields[0]),
+            '<label for="id_name_0"><input type="radio" name="name" '
+            'value="john" id="id_name_0" required /> John</label>'
+        )
+
+        self.assertEqual(fields[1].id_for_label, 'id_name_1')
+        self.assertEqual(fields[1].choice_label, 'Paul')
+        self.assertHTMLEqual(
+            fields[1].tag(),
+            '<input type="radio" name="name" value="paul" id="id_name_1" required />'
+        )
+        self.assertHTMLEqual(
+            str(fields[1]),
+            '<label for="id_name_1"><input type="radio" name="name" '
+            'value="paul" id="id_name_1" required /> Paul</label>'
+        )
+
+    def test_iterable_boundfield_select(self):
+        class BeatleForm(Form):
+            name = ChoiceField(choices=[('john', 'John'), ('paul', 'Paul'), ('george', 'George'), ('ringo', 'Ringo')])
+        fields = list(BeatleForm(auto_id=False)['name'])
+        self.assertEqual(len(fields), 4)
+
+        self.assertEqual(fields[0].id_for_label, 'id_name_0')
+        self.assertEqual(fields[0].choice_label, 'John')
+        self.assertHTMLEqual(fields[0].tag(), '<option value="john">John</option>')
+        self.assertHTMLEqual(str(fields[0]), '<option value="john">John</option>')
+
     def test_form_with_noniterable_boundfield(self):
         # You can iterate over any BoundField, not just those with widget=RadioSelect.
         class BeatleForm(Form):
@@ -1993,8 +2038,9 @@ Password: <input type="password" name="password" required /></li>
         doesn't lose it's safe string status (#22950).
         """
         class CustomWidget(TextInput):
-            def render(self, name, value, attrs=None):
-                return format_html(str('<input{} required />'), ' id=custom')
+            def render(self, name, value, attrs=None, choices=None,
+                       renderer=None, extra_context=None):
+                return format_html(str('<input{} />'), ' id=custom')
 
         class SampleForm(Form):
             name = CharField(widget=CustomWidget)
@@ -3573,3 +3619,46 @@ Good luck picking a username that doesn&#39;t already exist.</p>
         f = DataForm({'data': 'xyzzy'})
         self.assertTrue(f.is_valid())
         self.assertEqual(f.cleaned_data, {'data': 'xyzzy'})
+
+
+class CustomRenderer(DjangoTemplates):
+    pass
+
+
+class RendererTests(SimpleTestCase):
+
+    def test_default(self):
+        form = Form()
+        self.assertEqual(form.renderer, get_default_renderer())
+
+    def test_kwarg_instance(self):
+        custom = CustomRenderer()
+        form = Form(renderer=custom)
+        self.assertEqual(form.renderer, custom)
+
+    def test_kwarg_class(self):
+        custom = CustomRenderer()
+        form = Form(renderer=custom)
+        self.assertEqual(form.renderer, custom)
+
+    def test_attribute_instance(self):
+        class CustomForm(Form):
+            default_renderer = DjangoTemplates()
+
+        form = CustomForm()
+        self.assertEqual(form.renderer, CustomForm.default_renderer)
+
+    def test_attribute_class(self):
+        class CustomForm(Form):
+            default_renderer = CustomRenderer
+
+        form = CustomForm()
+        self.assertTrue(isinstance(form.renderer, CustomForm.default_renderer))
+
+    def test_attribute_override(self):
+        class CustomForm(Form):
+            default_renderer = DjangoTemplates()
+
+        custom = CustomRenderer()
+        form = CustomForm(renderer=custom)
+        self.assertEqual(form.renderer, custom)

+ 52 - 0
tests/forms_tests/tests/test_renderers.py

@@ -0,0 +1,52 @@
+import os
+import unittest
+
+from django.forms.renderers import (
+    BaseRenderer, DjangoTemplates, Jinja2, TemplatesSetting,
+)
+from django.test import SimpleTestCase
+from django.utils._os import upath
+
+try:
+    import jinja2
+except ImportError:
+    jinja2 = None
+
+
+class SharedTests(object):
+    expected_widget_dir = 'templates'
+
+    def test_installed_apps_template_found(self):
+        """Can find a custom template in INSTALLED_APPS."""
+        renderer = self.renderer()
+        # Found because forms_tests is .
+        tpl = renderer.get_template('forms_tests/custom_widget.html')
+        expected_path = os.path.abspath(
+            os.path.join(
+                upath(os.path.dirname(__file__)),
+                '..',
+                self.expected_widget_dir + '/forms_tests/custom_widget.html',
+            )
+        )
+        self.assertEqual(tpl.origin.name, expected_path)
+
+
+class BaseTemplateRendererTests(SimpleTestCase):
+
+    def test_get_renderer(self):
+        with self.assertRaisesMessage(NotImplementedError, 'subclasses must implement get_template()'):
+            BaseRenderer().get_template('')
+
+
+class DjangoTemplatesTests(SharedTests, SimpleTestCase):
+    renderer = DjangoTemplates
+
+
+@unittest.skipIf(jinja2 is None, 'jinja2 required')
+class Jinja2Tests(SharedTests, SimpleTestCase):
+    renderer = Jinja2
+    expected_widget_dir = 'jinja2'
+
+
+class TemplatesSettingTests(SharedTests, SimpleTestCase):
+    renderer = TemplatesSetting

+ 1 - 200
tests/forms_tests/tests/test_widgets.py

@@ -1,182 +1,12 @@
-# -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
 from django.contrib.admin.tests import AdminSeleniumTestCase
-from django.forms import (
-    CheckboxSelectMultiple, ClearableFileInput, RadioSelect, TextInput,
-)
-from django.forms.widgets import (
-    ChoiceFieldRenderer, ChoiceInput, RadioFieldRenderer,
-)
-from django.test import SimpleTestCase, override_settings
+from django.test import override_settings
 from django.urls import reverse
-from django.utils import six
-from django.utils.encoding import force_text, python_2_unicode_compatible
-from django.utils.safestring import SafeData
 
 from ..models import Article
 
 
-class FormsWidgetTests(SimpleTestCase):
-
-    def test_radiofieldrenderer(self):
-        # RadioSelect uses a RadioFieldRenderer to render the individual radio inputs.
-        # You can manipulate that object directly to customize the way the RadioSelect
-        # is rendered.
-        w = RadioSelect(choices=(('J', 'John'), ('P', 'Paul'), ('G', 'George'), ('R', 'Ringo')))
-        r = w.get_renderer('beatle', 'J')
-        inp_set1 = []
-        inp_set2 = []
-        inp_set3 = []
-        inp_set4 = []
-
-        for inp in r:
-            inp_set1.append(str(inp))
-            inp_set2.append('%s<br />' % inp)
-            inp_set3.append('<p>%s %s</p>' % (inp.tag(), inp.choice_label))
-            inp_set4.append(
-                '%s %s %s %s %s' % (
-                    inp.name,
-                    inp.value,
-                    inp.choice_value,
-                    inp.choice_label,
-                    inp.is_checked(),
-                )
-            )
-
-        self.assertHTMLEqual('\n'.join(inp_set1), """<label><input checked type="radio" name="beatle" value="J" /> John</label>
-<label><input type="radio" name="beatle" value="P" /> Paul</label>
-<label><input type="radio" name="beatle" value="G" /> George</label>
-<label><input type="radio" name="beatle" value="R" /> Ringo</label>""")
-        self.assertHTMLEqual('\n'.join(inp_set2), """<label><input checked type="radio" name="beatle" value="J" /> John</label><br />
-<label><input type="radio" name="beatle" value="P" /> Paul</label><br />
-<label><input type="radio" name="beatle" value="G" /> George</label><br />
-<label><input type="radio" name="beatle" value="R" /> Ringo</label><br />""")
-        self.assertHTMLEqual('\n'.join(inp_set3), """<p><input checked type="radio" name="beatle" value="J" /> John</p>
-<p><input type="radio" name="beatle" value="P" /> Paul</p>
-<p><input type="radio" name="beatle" value="G" /> George</p>
-<p><input type="radio" name="beatle" value="R" /> Ringo</p>""")
-        self.assertHTMLEqual('\n'.join(inp_set4), """beatle J J John True
-beatle J P Paul False
-beatle J G George False
-beatle J R Ringo False""")
-
-        # A RadioFieldRenderer object also allows index access to individual RadioChoiceInput
-        w = RadioSelect(choices=(('J', 'John'), ('P', 'Paul'), ('G', 'George'), ('R', 'Ringo')))
-        r = w.get_renderer('beatle', 'J')
-        self.assertHTMLEqual(str(r[1]), '<label><input type="radio" name="beatle" value="P" /> Paul</label>')
-        self.assertHTMLEqual(
-            str(r[0]),
-            '<label><input checked type="radio" name="beatle" value="J" /> John</label>'
-        )
-        self.assertTrue(r[0].is_checked())
-        self.assertFalse(r[1].is_checked())
-        self.assertEqual((r[1].name, r[1].value, r[1].choice_value, r[1].choice_label), ('beatle', 'J', 'P', 'Paul'))
-
-        # These individual widgets can accept extra attributes if manually rendered.
-        self.assertHTMLEqual(
-            r[1].render(attrs={'extra': 'value'}),
-            '<label><input type="radio" extra="value" name="beatle" value="P" /> Paul</label>'
-        )
-
-        with self.assertRaises(IndexError):
-            r[10]
-
-        # You can create your own custom renderers for RadioSelect to use.
-        class MyRenderer(RadioFieldRenderer):
-            def render(self):
-                return '<br />\n'.join(six.text_type(choice) for choice in self)
-        w = RadioSelect(choices=(('J', 'John'), ('P', 'Paul'), ('G', 'George'), ('R', 'Ringo')), renderer=MyRenderer)
-        self.assertHTMLEqual(
-            w.render('beatle', 'G'),
-            """<label><input type="radio" name="beatle" value="J" /> John</label><br />
-<label><input type="radio" name="beatle" value="P" /> Paul</label><br />
-<label><input checked type="radio" name="beatle" value="G" /> George</label><br />
-<label><input type="radio" name="beatle" value="R" /> Ringo</label>"""
-        )
-
-        # Or you can use custom RadioSelect fields that use your custom renderer.
-        class CustomRadioSelect(RadioSelect):
-            renderer = MyRenderer
-        w = CustomRadioSelect(choices=(('J', 'John'), ('P', 'Paul'), ('G', 'George'), ('R', 'Ringo')))
-        self.assertHTMLEqual(
-            w.render('beatle', 'G'),
-            """<label><input type="radio" name="beatle" value="J" /> John</label><br />
-<label><input type="radio" name="beatle" value="P" /> Paul</label><br />
-<label><input checked type="radio" name="beatle" value="G" /> George</label><br />
-<label><input type="radio" name="beatle" value="R" /> Ringo</label>"""
-        )
-
-        # You can customize rendering with outer_html/inner_html renderer variables (#22950)
-        class MyRenderer(RadioFieldRenderer):
-            # str is just to test some Python 2 issue with bytestrings
-            outer_html = str('<div{id_attr}>{content}</div>')
-            inner_html = '<p>{choice_value}{sub_widgets}</p>'
-        w = RadioSelect(choices=(('J', 'John'), ('P', 'Paul'), ('G', 'George'), ('R', 'Ringo')), renderer=MyRenderer)
-        output = w.render('beatle', 'J', attrs={'id': 'bar'})
-        self.assertIsInstance(output, SafeData)
-        self.assertHTMLEqual(
-            output,
-            """<div id="bar">
-<p><label for="bar_0"><input checked type="radio" id="bar_0" value="J" name="beatle" /> John</label></p>
-<p><label for="bar_1"><input type="radio" id="bar_1" value="P" name="beatle" /> Paul</label></p>
-<p><label for="bar_2"><input type="radio" id="bar_2" value="G" name="beatle" /> George</label></p>
-<p><label for="bar_3"><input type="radio" id="bar_3" value="R" name="beatle" /> Ringo</label></p>
-</div>""")
-
-    def test_subwidget(self):
-        # Each subwidget tag gets a separate ID when the widget has an ID specified
-        self.assertHTMLEqual(
-            "\n".join(
-                c.tag() for c in CheckboxSelectMultiple(
-                    attrs={'id': 'abc'},
-                    choices=zip('abc', 'ABC')
-                ).subwidgets('letters', list('ac'))
-            ),
-            """<input checked type="checkbox" name="letters" value="a" id="abc_0" />
-<input type="checkbox" name="letters" value="b" id="abc_1" />
-<input checked type="checkbox" name="letters" value="c" id="abc_2" />""")
-
-        # Each subwidget tag does not get an ID if the widget does not have an ID specified
-        self.assertHTMLEqual(
-            "\n".join(c.tag() for c in CheckboxSelectMultiple(
-                choices=zip('abc', 'ABC'),
-            ).subwidgets('letters', list('ac'))),
-            """<input checked type="checkbox" name="letters" value="a" />
-<input type="checkbox" name="letters" value="b" />
-<input checked type="checkbox" name="letters" value="c" />""")
-
-        # The id_for_label property of the subwidget should return the ID that is used on the subwidget's tag
-        self.assertHTMLEqual(
-            "\n".join(
-                '<input type="checkbox" name="letters" value="%s" id="%s" />'
-                % (c.choice_value, c.id_for_label) for c in CheckboxSelectMultiple(
-                    attrs={'id': 'abc'},
-                    choices=zip('abc', 'ABC'),
-                ).subwidgets('letters', [])
-            ),
-            """<input type="checkbox" name="letters" value="a" id="abc_0" />
-<input type="checkbox" name="letters" value="b" id="abc_1" />
-<input type="checkbox" name="letters" value="c" id="abc_2" />""")
-
-    def test_sub_widget_html_safe(self):
-        widget = TextInput()
-        subwidget = next(widget.subwidgets('username', 'John Doe'))
-        self.assertTrue(hasattr(subwidget, '__html__'))
-        self.assertEqual(force_text(subwidget), subwidget.__html__())
-
-    def test_choice_input_html_safe(self):
-        widget = ChoiceInput('choices', 'CHOICE1', {}, ('CHOICE1', 'first choice'), 0)
-        self.assertTrue(hasattr(ChoiceInput, '__html__'))
-        self.assertEqual(force_text(widget), widget.__html__())
-
-    def test_choice_field_renderer_html_safe(self):
-        renderer = ChoiceFieldRenderer('choices', 'CHOICE1', {}, [('CHOICE1', 'first_choice')])
-        renderer.choice_input_class = lambda *args: args
-        self.assertTrue(hasattr(ChoiceFieldRenderer, '__html__'))
-        self.assertEqual(force_text(renderer), renderer.__html__())
-
-
 @override_settings(ROOT_URLCONF='forms_tests.urls')
 class LiveWidgetTests(AdminSeleniumTestCase):
 
@@ -190,33 +20,4 @@ class LiveWidgetTests(AdminSeleniumTestCase):
         self.selenium.get(self.live_server_url + reverse('article_form', args=[article.pk]))
         self.selenium.find_element_by_id('submit').submit()
         article = Article.objects.get(pk=article.pk)
-        # Should be "\nTst\n" after #19251 is fixed
         self.assertEqual(article.content, "\r\nTst\r\n")
-
-
-@python_2_unicode_compatible
-class FakeFieldFile(object):
-    """
-    Quacks like a FieldFile (has a .url and unicode representation), but
-    doesn't require us to care about storages etc.
-    """
-    url = 'something'
-
-    def __str__(self):
-        return self.url
-
-
-class ClearableFileInputTests(SimpleTestCase):
-
-    def test_render_custom_template(self):
-        widget = ClearableFileInput()
-        widget.template_with_initial = (
-            '%(initial_text)s: <img src="%(initial_url)s" alt="%(initial)s" /> '
-            '%(clear_template)s<br />%(input_text)s: %(input)s'
-        )
-        self.assertHTMLEqual(
-            widget.render('myfile', FakeFieldFile()),
-            'Currently: <img src="something" alt="something" /> '
-            '<input type="checkbox" name="myfile-clear" id="myfile-clear_id" /> '
-            '<label for="myfile-clear_id">Clear</label><br />Change: <input type="file" name="myfile" />'
-        )

+ 19 - 1
tests/forms_tests/widget_tests/base.py

@@ -1,9 +1,27 @@
+from django.forms.renderers import DjangoTemplates, Jinja2
 from django.test import SimpleTestCase
 
+try:
+    import jinja2
+except ImportError:
+    jinja2 = None
+
 
 class WidgetTest(SimpleTestCase):
     beatles = (('J', 'John'), ('P', 'Paul'), ('G', 'George'), ('R', 'Ringo'))
 
+    @classmethod
+    def setUpClass(cls):
+        cls.django_renderer = DjangoTemplates()
+        cls.jinja2_renderer = Jinja2() if jinja2 else None
+        cls.renderers = [cls.django_renderer] + ([cls.jinja2_renderer] if cls.jinja2_renderer else [])
+        super(WidgetTest, cls).setUpClass()
+
     def check_html(self, widget, name, value, html='', attrs=None, **kwargs):
-        output = widget.render(name, value, attrs=attrs, **kwargs)
+        if self.jinja2_renderer:
+            output = widget.render(name, value, attrs=attrs, renderer=self.jinja2_renderer, **kwargs)
+            # Django escapes quotes with '&quot;' while Jinja2 uses '&#34;'.
+            self.assertHTMLEqual(output.replace('&#34;', '&quot;'), html)
+
+        output = widget.render(name, value, attrs=attrs, renderer=self.django_renderer, **kwargs)
         self.assertHTMLEqual(output, html)

+ 62 - 0
tests/forms_tests/widget_tests/test_select.py

@@ -221,6 +221,68 @@ class SelectTest(WidgetTest):
             </select>"""
         ))
 
+    def test_options(self):
+        options = list(self.widget(choices=self.beatles).options(
+            'name', ['J'], attrs={'class': 'super'},
+        ))
+        self.assertEqual(len(options), 4)
+        self.assertEqual(options[0]['name'], 'name')
+        self.assertEqual(options[0]['value'], 'J')
+        self.assertEqual(options[0]['label'], 'John')
+        self.assertEqual(options[0]['index'], '0')
+        self.assertEqual(options[0]['selected'], True)
+        # Template-related attributes
+        self.assertEqual(options[1]['name'], 'name')
+        self.assertEqual(options[1]['value'], 'P')
+        self.assertEqual(options[1]['label'], 'Paul')
+        self.assertEqual(options[1]['index'], '1')
+        self.assertEqual(options[1]['selected'], False)
+
+    def test_optgroups(self):
+        choices = [
+            ('Audio', [
+                ('vinyl', 'Vinyl'),
+                ('cd', 'CD'),
+            ]),
+            ('Video', [
+                ('vhs', 'VHS Tape'),
+                ('dvd', 'DVD'),
+            ]),
+            ('unknown', 'Unknown'),
+        ]
+        groups = list(self.widget(choices=choices).optgroups(
+            'name', ['vhs'], attrs={'class': 'super'},
+        ))
+        self.assertEqual(len(groups), 3)
+        self.assertEqual(groups[0][0], None)
+        self.assertEqual(groups[0][2], 0)
+        self.assertEqual(len(groups[0][1]), 1)
+        options = groups[0][1]
+        self.assertEqual(options[0]['name'], 'name')
+        self.assertEqual(options[0]['value'], 'unknown')
+        self.assertEqual(options[0]['label'], 'Unknown')
+        self.assertEqual(options[0]['index'], '0')
+        self.assertEqual(options[0]['selected'], False)
+        self.assertEqual(groups[1][0], 'Audio')
+        self.assertEqual(groups[1][2], 1)
+        self.assertEqual(len(groups[1][1]), 2)
+        options = groups[1][1]
+        self.assertEqual(options[0]['name'], 'name')
+        self.assertEqual(options[0]['value'], 'vinyl')
+        self.assertEqual(options[0]['label'], 'Vinyl')
+        self.assertEqual(options[0]['index'], '1_0')
+        self.assertEqual(options[1]['index'], '1_1')
+        self.assertEqual(groups[2][0], 'Video')
+        self.assertEqual(groups[2][2], 2)
+        self.assertEqual(len(groups[2][1]), 2)
+        options = groups[2][1]
+        self.assertEqual(options[0]['name'], 'name')
+        self.assertEqual(options[0]['value'], 'vhs')
+        self.assertEqual(options[0]['label'], 'VHS Tape')
+        self.assertEqual(options[0]['index'], '2_0')
+        self.assertEqual(options[0]['selected'], True)
+        self.assertEqual(options[1]['index'], '2_1')
+
     def test_deepcopy(self):
         """
         __deepcopy__() should copy all attributes properly (#25085).

+ 1 - 8
tests/model_forms/tests.py

@@ -1651,13 +1651,6 @@ class ModelChoiceFieldTests(TestCase):
         with self.assertNumQueries(1):
             template.render(Context({'field': field}))
 
-    def test_modelchoicefield_index_renderer(self):
-        field = forms.ModelChoiceField(Category.objects.all(), widget=forms.RadioSelect)
-        self.assertEqual(
-            str(field.widget.get_renderer('foo', [])[0]),
-            '<label><input name="foo" type="radio" value="" /> ---------</label>'
-        )
-
     def test_disabled_modelchoicefield(self):
         class ModelChoiceForm(forms.ModelForm):
             author = forms.ModelChoiceField(Author.objects.all(), disabled=True)
@@ -2115,7 +2108,7 @@ class FileAndImageFieldTests(TestCase):
 
         doc = Document.objects.create()
         form = DocumentForm(instance=doc)
-        self.assertEqual(
+        self.assertHTMLEqual(
             str(form['myfile']),
             '<input id="id_myfile" name="myfile" type="file" />'
         )

+ 5 - 0
tests/runtests.py

@@ -169,6 +169,11 @@ def setup(verbosity, test_labels, parallel):
         'The GeoManager class is deprecated.',
         RemovedInDjango20Warning
     )
+    warnings.filterwarnings(
+        'ignore',
+        'django.forms.extras is deprecated.',
+        RemovedInDjango20Warning
+    )
 
     # Load all the ALWAYS_INSTALLED_APPS.
     django.setup()

Some files were not shown because too many files changed in this diff