From e972c2bfafbbede07463aa4f526fa6abf9cbacd5 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 21 May 2026 06:04:26 -0600 Subject: [PATCH 1/4] Use 'results' key instead of 'details' when wrapping list data in TemplateHTMLRenderer When TemplateHTMLRenderer.get_template_context() receives list data (from a list view or a ValidationError), it wraps it in a dict so Django's template engine can accept it. The key 'results' is more consistent with DRF's paginated response convention than 'details'. Fixes #5236 --- rest_framework/renderers.py | 7 ++++--- tests/test_htmlrenderer.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index ad1adbaeaa..8a64d7f499 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -168,10 +168,11 @@ 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, '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..004da63056 100644 --- a/tests/test_htmlrenderer.py +++ b/tests/test_htmlrenderer.py @@ -41,11 +41,19 @@ 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') + + urlpatterns = [ path('', example), path('permission_denied', permission_denied), path('not_found', not_found), path('validation_error', validation_error), + path('list', list_view), ] @@ -71,6 +79,10 @@ 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 %}" + ) raise TemplateDoesNotExist(template_name_list[0]) django.template.loader.get_template = get_template @@ -105,6 +117,28 @@ 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_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()} + ) + self.assertIn('results', context) + self.assertEqual(context['results'], [{'name': 'foo'}]) + self.assertNotIn('details', context) + # 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): From 95e6d3aa1fe9fb196fabd43267b0561c329089d9 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Sat, 25 Jul 2026 21:42:24 +0100 Subject: [PATCH 2/4] Add backwards compatibility layer for details -> results --- rest_framework/deprecation.py | 10 ++++++++++ rest_framework/renderers.py | 27 ++++++++++++++++++++++++++- tests/test_htmlrenderer.py | 5 ++++- 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 rest_framework/deprecation.py 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 8a64d7f499..b1a885736f 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,29 @@ def render(self, data, accepted_media_type=None, renderer_context=None): return ret.encode() +class _ResultDetailsFallback(dict): + """Backwards compatibility dict subclass to """ + def __getitem__(self, item): + if item == 'details': + warnings.warn( + 'The "detail" key is deprecated, update your templates to use "results" instead.', + RemovedInDRF320Warning, + stacklevel=2, + ) + return super().__getitem__('results') + return super().__getitem__(item) + + def __contains__(self, item): + if item == 'details': + warnings.warn( + 'The "detail" key is deprecated, update your templates to use "results" instead.', + RemovedInDRF320Warning, + stacklevel=2, + ) + return super().__contains__('results') + return super().__contains__(item) + + class TemplateHTMLRenderer(BaseRenderer): """ An HTML renderer for use with templates. @@ -172,7 +197,7 @@ def get_template_context(self, data, renderer_context): # 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 {'results': data, 'status_code': response.status_code} + return _ResultDetailsFallback(results=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 004da63056..66eaa8b2b3 100644 --- a/tests/test_htmlrenderer.py +++ b/tests/test_htmlrenderer.py @@ -137,7 +137,10 @@ class MockResponse: ) self.assertIn('results', context) self.assertEqual(context['results'], [{'name': 'foo'}]) - self.assertNotIn('details', context) + + # Backwards compatibility until DRF 3.20 + self.assertIn('details', context) + self.assertEqual(context['details'], [{'name': 'foo'}]) # 2 tests below are based on order of if statements in corresponding method # of TemplateHTMLRenderer From 351d7fc7fc72f2696fa3c39819c14ad94f936d85 Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Sat, 25 Jul 2026 23:51:28 +0100 Subject: [PATCH 3/4] Fix deprecation warning emitting in backwards compatibility wrapper --- rest_framework/renderers.py | 56 ++++++++++++++++++++++++++----------- tests/test_htmlrenderer.py | 32 +++++++++++++++++---- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index b1a885736f..509ad08293 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -109,27 +109,44 @@ def render(self, data, accepted_media_type=None, renderer_context=None): return ret.encode() -class _ResultDetailsFallback(dict): - """Backwards compatibility dict subclass to """ - def __getitem__(self, item): - if item == 'details': +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 "detail" key is deprecated, update your templates to use "results" instead.', + 'The "details" template variable is deprecated, ' + 'update your templates to use "results" instead.', RemovedInDRF320Warning, - stacklevel=2, + stacklevel=3, ) - return super().__getitem__('results') + + 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 __contains__(self, item): - if item == 'details': - warnings.warn( - 'The "detail" key is deprecated, update your templates to use "results" instead.', - RemovedInDRF320Warning, - stacklevel=2, - ) - return super().__contains__('results') - return super().__contains__(item) + def __repr__(self): + self._warn() + return super().__repr__() class TemplateHTMLRenderer(BaseRenderer): @@ -197,7 +214,12 @@ def get_template_context(self, data, renderer_context): # 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 _ResultDetailsFallback(results=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 66eaa8b2b3..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 @@ -48,12 +49,20 @@ def list_view(request): 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), ] @@ -70,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': @@ -83,6 +93,10 @@ def select_template(template_name_list, dirs=None, using=None): 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 @@ -93,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('/') @@ -124,6 +139,13 @@ def test_list_view_with_template_html_renderer(self): 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() @@ -135,12 +157,12 @@ class MockResponse: context = renderer.get_template_context( [{'name': 'foo'}], {'response': MockResponse()} ) - self.assertIn('results', context) - self.assertEqual(context['results'], [{'name': 'foo'}]) + assert context['results'] == [{'name': 'foo'}] + assert context['status_code'] == 200 - # Backwards compatibility until DRF 3.20 - self.assertIn('details', context) - self.assertEqual(context['details'], [{'name': 'foo'}]) + # 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 From 2aa4463b9598a27a118df05297ff27cd7064083c Mon Sep 17 00:00:00 2001 From: Bruno Alla Date: Sat, 25 Jul 2026 23:52:14 +0100 Subject: [PATCH 4/4] Update documentation --- docs/api-guide/renderers.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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.