Add validation for DurationField format, add more tests for it and improve related docs

This commit is contained in:
sevdog 2025-08-07 12:42:39 +02:00
parent 4377b08ce6
commit a3096b9989
No known key found for this signature in database
GPG Key ID: D939AF7A93A9C178
5 changed files with 60 additions and 14 deletions

View File

@ -380,12 +380,12 @@ The `validated_data` for these fields will contain a `datetime.timedelta` instan
**Signature:** `DurationField(format=api_settings.DURATION_FORMAT, max_value=None, min_value=None)` **Signature:** `DurationField(format=api_settings.DURATION_FORMAT, max_value=None, min_value=None)`
* `format` - A string representing the output format. If not specified, this defaults to the same value as the `DURATION_FORMAT` settings key, which will be `'standard'` unless set. Setting to a format string indicates that `to_representation` return values should be coerced to string output. Format strings are described below. Setting this value to `None` indicates that Python `timedelta` objects should be returned by `to_representation`. In this case the date encoding will be determined by the renderer. * `format` - A string representing the output format. If not specified, this defaults to the same value as the `DURATION_FORMAT` settings key, which will be `'django'` unless set. Formats are described below. Setting this value to `None` indicates that Python `timedelta` objects should be returned by `to_representation`. In this case the date encoding will be determined by the renderer.
* `max_value` Validate that the duration provided is no greater than this value. * `max_value` Validate that the duration provided is no greater than this value.
* `min_value` Validate that the duration provided is no less than this value. * `min_value` Validate that the duration provided is no less than this value.
#### `DurationField` format strings #### `DurationField` formats
Format strings may either be the special string `'iso-8601'`, which indicates that [ISO 8601][iso8601] style intervals should be used (eg `'P4DT1H15M20S'`), or the special string `'standard'`, which indicates that Django interval format `'[DD] [HH:[MM:]]ss[.uuuuuu]'` should be used (eg: `'4 1:15:20'`). Format may either be the special string `'iso-8601'`, which indicates that [ISO 8601][iso8601] style intervals should be used (eg `'P4DT1H15M20S'`), or `'django'` which indicates that Django interval format `'[DD] [HH:[MM:]]ss[.uuuuuu]'` should be used (eg: `'4 1:15:20'`).
--- ---

View File

@ -21,7 +21,7 @@ HTTP_HEADER_ENCODING = 'iso-8859-1'
# Default datetime input and output formats # Default datetime input and output formats
ISO_8601 = 'iso-8601' ISO_8601 = 'iso-8601'
STD_DURATION = 'standard' DJANGO_DURATION_FORMAT = 'django'
class RemovedInDRF317Warning(PendingDeprecationWarning): class RemovedInDRF317Warning(PendingDeprecationWarning):

View File

@ -35,7 +35,7 @@ try:
except ImportError: except ImportError:
pytz = None pytz = None
from rest_framework import ISO_8601 from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601
from rest_framework.compat import ip_address_validators from rest_framework.compat import ip_address_validators
from rest_framework.exceptions import ErrorDetail, ValidationError from rest_framework.exceptions import ErrorDetail, ValidationError
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
@ -1351,11 +1351,17 @@ class DurationField(Field):
'overflow': _('The number of days must be between {min_days} and {max_days}.'), 'overflow': _('The number of days must be between {min_days} and {max_days}.'),
} }
def __init__(self, format=empty, **kwargs): def __init__(self, *, format=empty, **kwargs):
self.max_value = kwargs.pop('max_value', None) self.max_value = kwargs.pop('max_value', None)
self.min_value = kwargs.pop('min_value', None) self.min_value = kwargs.pop('min_value', None)
if format is not empty: if format is not empty:
self.format = format if format is None or (isinstance(format, str) and format.lower() in (ISO_8601, DJANGO_DURATION_FORMAT)):
self.format = format
else:
raise ValueError(
f"Unknown duration format provided, got '{format}'"
" while expecting 'django', 'iso-8601' or `None`."
)
super().__init__(**kwargs) super().__init__(**kwargs)
if self.max_value is not None: if self.max_value is not None:
message = lazy_format(self.error_messages['max_value'], max_value=self.max_value) message = lazy_format(self.error_messages['max_value'], max_value=self.max_value)
@ -1380,12 +1386,20 @@ class DurationField(Field):
def to_representation(self, value): def to_representation(self, value):
output_format = getattr(self, 'format', api_settings.DURATION_FORMAT) output_format = getattr(self, 'format', api_settings.DURATION_FORMAT)
if output_format is None or isinstance(value, str): if output_format is None:
return value return value
if output_format.lower() == ISO_8601: if isinstance(output_format, str):
return duration_iso_string(value) if output_format.lower() == ISO_8601:
return duration_string(value) return duration_iso_string(value)
if output_format.lower() == DJANGO_DURATION_FORMAT:
return duration_string(value)
raise ValueError(
f"Unknown duration format provided, got '{output_format}'"
" while expecting 'django', 'iso-8601' or `None`."
)
# Choice types... # Choice types...

View File

@ -24,7 +24,7 @@ from django.conf import settings
from django.core.signals import setting_changed from django.core.signals import setting_changed
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from rest_framework import ISO_8601, STD_DURATION from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601
DEFAULTS = { DEFAULTS = {
# Base API policies # Base API policies
@ -109,7 +109,7 @@ DEFAULTS = {
'TIME_FORMAT': ISO_8601, 'TIME_FORMAT': ISO_8601,
'TIME_INPUT_FORMATS': [ISO_8601], 'TIME_INPUT_FORMATS': [ISO_8601],
'DURATION_FORMAT': STD_DURATION, 'DURATION_FORMAT': DJANGO_DURATION_FORMAT,
# Encoding # Encoding
'UNICODE_JSON': True, 'UNICODE_JSON': True,

View File

@ -1772,6 +1772,32 @@ class TestDurationField(FieldValues):
} }
field = serializers.DurationField() field = serializers.DurationField()
def test_invalid_format(self):
with pytest.raises(ValueError) as exc_info:
serializers.DurationField(format='unknown')
assert str(exc_info.value) == (
"Unknown duration format provided, got 'unknown'"
" while expecting 'django', 'iso-8601' or `None`."
)
with pytest.raises(ValueError) as exc_info:
serializers.DurationField(format=123)
assert str(exc_info.value) == (
"Unknown duration format provided, got '123'"
" while expecting 'django', 'iso-8601' or `None`."
)
@override_settings(REST_FRAMEWORK={'DURATION_FORMAT': 'unknown'})
def test_invalid_format_in_config(self):
field = serializers.DurationField()
with pytest.raises(ValueError) as exc_info:
field.to_representation(datetime.timedelta(days=1))
assert str(exc_info.value) == (
"Unknown duration format provided, got 'unknown'"
" while expecting 'django', 'iso-8601' or `None`."
)
class TestNoOutputFormatDurationField(FieldValues): class TestNoOutputFormatDurationField(FieldValues):
""" """
@ -1789,7 +1815,13 @@ class TestISOOutputFormatDurationField(FieldValues):
""" """
Values for `DurationField` with a custom output format. Values for `DurationField` with a custom output format.
""" """
valid_inputs = {} valid_inputs = {
'13': datetime.timedelta(seconds=13),
'P3DT08H32M01.000123S': datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123),
'PT8H1M': datetime.timedelta(hours=8, minutes=1),
'-P999999999D': datetime.timedelta(days=-999999999),
'P999999999D': datetime.timedelta(days=999999999)
}
invalid_inputs = {} invalid_inputs = {}
outputs = { outputs = {
datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123): 'P3DT08H32M01.000123S' datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123): 'P3DT08H32M01.000123S'