diff --git a/docs/api-guide/renderers.md b/docs/api-guide/renderers.md index f78d81b166..ee718886c4 100644 --- a/docs/api-guide/renderers.md +++ b/docs/api-guide/renderers.md @@ -104,9 +104,12 @@ Unlike other renderers, the data passed to the `Response` does not need to be se The TemplateHTMLRenderer will create a `RequestContext`, using the `response.data` as the context dict, and determine a template name to use to render the context. !!! note - When used with a view that makes use of a serializer the `Response` sent for rendering may not be a dictionary and will need to be wrapped in a dict before returning to allow the `TemplateHTMLRenderer` to render it. For example: + When used with a view that makes use of a serializer, the `Response` sent for rendering may be a list rather than a dictionary, for example with a list view. Lists are automatically wrapped in a dict and made available to the template under the `results` key, alongside a `status_code` key: - response.data = {'results': response.data} + {% for item in results %}{{ item }}{% endfor %} + +!!! warning + Up to version 3.17, lists were wrapped under a `details` key instead. `details` still works but is deprecated, and will be removed in 3.20. Update your templates to use `results`. The template name is determined by (in order of preference): @@ -376,7 +379,7 @@ Exceptions raised and handled by an HTML renderer will attempt to render using o * Load and render a template named `api_exception.html`. * Render the HTTP status code and text, for example "404 Not Found". -Templates will render with a `RequestContext` which includes the `status_code` and `details` keys. +Templates will render with a `RequestContext` which includes the `status_code` key alongside the error data, typically under a `detail` key. When the error data is a list, as is the case for a `ValidationError`, it is available under the `results` key instead. !!! note If `DEBUG=True`, Django's standard traceback error page will be displayed instead of rendering the HTTP status code and text. diff --git a/rest_framework/deprecation.py b/rest_framework/deprecation.py new file mode 100644 index 0000000000..e0eb208ccb --- /dev/null +++ b/rest_framework/deprecation.py @@ -0,0 +1,10 @@ +class RemovedInDRF319Warning(DeprecationWarning): + pass + + +class RemovedInDRF320Warning(PendingDeprecationWarning): + pass + + +RemovedInNextDRFVersionWarning = RemovedInDRF319Warning +RemovedAfterNextDRFVersionWarning = RemovedInDRF320Warning diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index ad1adbaeaa..509ad08293 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -10,6 +10,7 @@ import contextlib import datetime import sys +import warnings from django import forms from django.conf import settings @@ -24,6 +25,7 @@ from rest_framework.compat import ( INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, pygments_css, yaml ) +from rest_framework.deprecation import RemovedInDRF320Warning from rest_framework.exceptions import ParseError from rest_framework.request import is_form_media_type, override_method from rest_framework.settings import api_settings @@ -107,6 +109,46 @@ def render(self, data, accepted_media_type=None, renderer_context=None): return ret.encode() +class _DeprecatedResultsList(list): + """ + The list exposed under the legacy `details` template variable, warning on + first use to point users at `results` instead. + + The deprecation lives on the value rather than on the context dict, because + Django copies the context dict into a plain `dict` when building the + `RequestContext`, which would drop any `dict` subclass behavior. + + See `TemplateHTMLRenderer.get_template_context()`. + """ + _warned = False + + def _warn(self): + if not self._warned: + self._warned = True + warnings.warn( + 'The "details" template variable is deprecated, ' + 'update your templates to use "results" instead.', + RemovedInDRF320Warning, + stacklevel=3, + ) + + def __iter__(self): + self._warn() + return super().__iter__() + + def __len__(self): + self._warn() + return super().__len__() + + def __getitem__(self, item): + self._warn() + return super().__getitem__(item) + + def __repr__(self): + self._warn() + return super().__repr__() + + class TemplateHTMLRenderer(BaseRenderer): """ An HTML renderer for use with templates. @@ -168,10 +210,16 @@ def resolve_template(self, template_names): def get_template_context(self, data, renderer_context): response = renderer_context['response'] - # in case a ValidationError is caught the data parameter may be a list - # see rest_framework.views.exception_handler + # data may be a list when a list view is used or when a ValidationError + # is raised; wrap it in a dict so Django's template engine can accept it. + # Use 'results' to stay consistent with paginated response conventions. if isinstance(data, list): - return {'details': data, 'status_code': response.status_code} + return { + 'results': data, + # Deprecated alias for 'results', kept until DRF 3.20. + 'details': _DeprecatedResultsList(data), + 'status_code': response.status_code, + } if response.exception: data['status_code'] = response.status_code return data diff --git a/tests/test_htmlrenderer.py b/tests/test_htmlrenderer.py index aa0cfb19c8..f6aec2d4ca 100644 --- a/tests/test_htmlrenderer.py +++ b/tests/test_htmlrenderer.py @@ -8,6 +8,7 @@ from rest_framework import status from rest_framework.decorators import api_view, renderer_classes +from rest_framework.deprecation import RemovedInDRF320Warning from rest_framework.exceptions import ValidationError from rest_framework.renderers import TemplateHTMLRenderer from rest_framework.response import Response @@ -41,11 +42,27 @@ def validation_error(request): raise ValidationError('error') +@api_view(('GET',)) +@renderer_classes((TemplateHTMLRenderer,)) +def list_view(request): + data = [{'name': 'foo'}, {'name': 'bar'}] + return Response(data, template_name='list.html') + + +@api_view(('GET',)) +@renderer_classes((TemplateHTMLRenderer,)) +def deprecated_list_view(request): + data = [{'name': 'foo'}, {'name': 'bar'}] + return Response(data, template_name='deprecated_list.html') + + urlpatterns = [ path('', example), path('permission_denied', permission_denied), path('not_found', not_found), path('validation_error', validation_error), + path('list', list_view), + path('deprecated_list', deprecated_list_view), ] @@ -62,6 +79,7 @@ def _monkey_patch_get_template(self): Monkeypatch get_template """ self.get_template = django.template.loader.get_template + self.select_template = django.template.loader.select_template def get_template(template_name, dirs=None): if template_name == 'example.html': @@ -71,6 +89,14 @@ def get_template(template_name, dirs=None): def select_template(template_name_list, dirs=None, using=None): if template_name_list == ['example.html']: return engines['django'].from_string("example: {{ object }}") + if template_name_list == ['list.html']: + return engines['django'].from_string( + "{% for item in results %}{{ item.name }}{% endfor %}" + ) + if template_name_list == ['deprecated_list.html']: + return engines['django'].from_string( + "{% for item in details %}{{ item.name }}{% endfor %}" + ) raise TemplateDoesNotExist(template_name_list[0]) django.template.loader.get_template = get_template @@ -81,6 +107,7 @@ def tearDown(self): Revert monkeypatching """ django.template.loader.get_template = self.get_template + django.template.loader.select_template = self.select_template def test_simple_html_view(self): response = self.client.get('/') @@ -105,6 +132,38 @@ def test_validation_error_html_view(self): self.assertEqual(response.content, b"400 Bad Request") self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8') + def test_list_view_with_template_html_renderer(self): + response = self.client.get('/list') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertContains(response, 'foo') + self.assertContains(response, 'bar') + self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8') + + def test_list_view_with_deprecated_details_variable(self): + with pytest.warns(RemovedInDRF320Warning, match='use "results" instead'): + response = self.client.get('/deprecated_list') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertContains(response, 'foo') + self.assertContains(response, 'bar') + + def test_get_template_context_wraps_list_under_results_key(self): + renderer = TemplateHTMLRenderer() + + class MockResponse: + template_name = None + exception = False + status_code = 200 + + context = renderer.get_template_context( + [{'name': 'foo'}], {'response': MockResponse()} + ) + assert context['results'] == [{'name': 'foo'}] + assert context['status_code'] == 200 + + # Deprecated alias for 'results', removed in DRF 3.20 + with pytest.warns(RemovedInDRF320Warning, match='use "results" instead'): + assert list(context['details']) == [{'name': 'foo'}] + # 2 tests below are based on order of if statements in corresponding method # of TemplateHTMLRenderer def test_get_template_names_returns_own_template_name(self):