From 01659c075a9dedb6cb6d29ba23012d447aff0c8b Mon Sep 17 00:00:00 2001 From: Marcelo Galigniana Date: Thu, 30 Oct 2025 05:23:54 -0500 Subject: [PATCH] Fixed #5363 -- HTML5 datetime-local valid format HTMLFormRenderer (#9365) * Fixed #5363 -- HTML5 datetime-local valid format HTMLFormRenderer Co-authored-by: Peter Thomassen * Add condition to make code cleanable by pyupgrade --------- Co-authored-by: Bruno Alla --- rest_framework/renderers.py | 30 ++++++++++++-- tests/test_renderers.py | 81 +++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index b81f9ab46..6d7218bbc 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -10,6 +10,7 @@ REST framework also provides an HTML renderer that renders the browsable API. import base64 import contextlib import datetime +import sys from urllib import parse from django import forms @@ -22,7 +23,7 @@ from django.utils.html import mark_safe from django.utils.http import parse_header_parameters from django.utils.safestring import SafeString -from rest_framework import VERSION, exceptions, serializers, status +from rest_framework import ISO_8601, VERSION, exceptions, serializers, status from rest_framework.compat import ( INDENT_SEPARATORS, LONG_SEPARATORS, SHORT_SEPARATORS, coreapi, coreschema, pygments_css, yaml @@ -339,11 +340,32 @@ class HTMLFormRenderer(BaseRenderer): style['template_pack'] = parent_style.get('template_pack', self.template_pack) style['renderer'] = self - # Get a clone of the field with text-only value representation. + # Get a clone of the field with text-only value representation ('' if None or False). field = field.as_form_field() - if style.get('input_type') == 'datetime-local' and isinstance(field.value, str): - field.value = field.value.rstrip('Z') + if style.get('input_type') == 'datetime-local': + try: + format_ = field._field.format + except AttributeError: + format_ = api_settings.DATETIME_FORMAT + + if format_ is not None: + # field.value is expected to be a string + # https://www.django-rest-framework.org/api-guide/fields/#datetimefield + field_value = field.value + if format_ == ISO_8601 and sys.version_info < (3, 11): + # We can drop this branch once we drop support for Python < 3.11 + # https://docs.python.org/3/whatsnew/3.11.html#datetime + field_value = field_value.rstrip('Z') + field.value = ( + datetime.datetime.fromisoformat(field_value) if format_ == ISO_8601 + else datetime.datetime.strptime(field_value, format_) + ) + + # The format of an input type="datetime-local" is "yyyy-MM-ddThh:mm" + # followed by optional ":ss" or ":ss.SSS", so keep only the first three + # digits of milliseconds to avoid browser console error. + field.value = field.value.replace(tzinfo=None).isoformat(timespec="milliseconds") if 'template' in style: template_name = style['template'] diff --git a/tests/test_renderers.py b/tests/test_renderers.py index 1b396575d..e5c33e6b4 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -1,5 +1,7 @@ import re from collections.abc import MutableMapping +from datetime import datetime +from zoneinfo import ZoneInfo import pytest from django.core.cache import cache @@ -488,6 +490,85 @@ class TestHiddenFieldHTMLFormRenderer(TestCase): assert rendered == '' +class TestDateTimeFieldHTMLFormRender(TestCase): + """ + Default USE_TZ is True. + Default TIME_ZONE is 'America/Chicago'. + """ + + def _assert_datetime_rendering(self, appointment, expected, datetimefield_kwargs=None): + datetimefield_kwargs = datetimefield_kwargs or {} + + class TestSerializer(serializers.Serializer): + appointment = serializers.DateTimeField(**datetimefield_kwargs) + + serializer = TestSerializer(data={"appointment": appointment}) + serializer.is_valid() + renderer = HTMLFormRenderer() + field = serializer['appointment'] + rendered = renderer.render_field(field, {}) + expected_html = ( + '' + ) + + self.assertInHTML(expected_html, rendered) + + def test_datetime_field_rendering_milliseconds(self): + self._assert_datetime_rendering( + datetime(2024, 12, 24, 0, 55, 30, 345678), "2024-12-24T00:55:30.345" + ) + + def test_datetime_field_rendering_no_seconds_and_no_milliseconds(self): + self._assert_datetime_rendering( + datetime(2024, 12, 24, 0, 55, 0, 0), "2024-12-24T00:55:00.000" + ) + + def test_datetime_field_rendering_with_format_as_none(self): + self._assert_datetime_rendering( + datetime(2024, 12, 24, 0, 55, 30, 345678), + "2024-12-24T00:55:30.345", + {"format": None} + ) + + def test_datetime_field_rendering_with_format(self): + self._assert_datetime_rendering( + datetime(2024, 12, 24, 0, 55, 30, 345678), + "2024-12-24T00:55:00.000", + {"format": "%a %d %b %Y, %I:%M%p"} + ) + + # New project templates default to 'UTC'. + @override_settings(TIME_ZONE='UTC') + def test_datetime_field_rendering_utc(self): + self._assert_datetime_rendering( + datetime(2024, 12, 24, 0, 55, 30, 345678), + "2024-12-24T00:55:30.345" + ) + + @override_settings(REST_FRAMEWORK={'DATETIME_FORMAT': '%a %d %b %Y, %I:%M%p'}) + def test_datetime_field_rendering_with_custom_datetime_format(self): + self._assert_datetime_rendering( + datetime(2024, 12, 24, 0, 55, 30, 345678), + "2024-12-24T00:55:00.000" + ) + + @override_settings(REST_FRAMEWORK={'DATETIME_FORMAT': None}) + def test_datetime_field_rendering_datetime_format_is_none(self): + self._assert_datetime_rendering( + datetime(2024, 12, 24, 0, 55, 30, 345678), + "2024-12-24T00:55:30.345" + ) + + # Enforce it in True because in Django versions under 4.2 was False by default. + @override_settings(USE_TZ=True) + def test_datetime_field_rendering_timezone_aware_datetime(self): + self._assert_datetime_rendering( + datetime(2024, 12, 24, 0, 55, 30, 345678, tzinfo=ZoneInfo('Asia/Tokyo')), # +09:00 + "2024-12-23T09:55:30.345" # Rendered in -06:00 + ) + + class TestHTMLFormRenderer(TestCase): def setUp(self): class TestSerializer(serializers.Serializer):