diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index 888996eec..8278e2a2f 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -377,13 +377,16 @@ A Duration representation. Corresponds to `django.db.models.fields.DurationField` The `validated_data` for these fields will contain a `datetime.timedelta` instance. -The representation is a string following this format `'[DD] [HH:[MM:]]ss[.uuuuuu]'`. -**Signature:** `DurationField(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 `'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. * `min_value` Validate that the duration provided is no less than this value. +#### `DurationField` formats +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'`). + --- # Choice selection fields diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md index 7bee3166d..2a070b77e 100644 --- a/docs/api-guide/settings.md +++ b/docs/api-guide/settings.md @@ -314,6 +314,15 @@ May be a list including the string `'iso-8601'` or Python [strftime format][strf Default: `['iso-8601']` + +#### DURATION_FORMAT + +Indicates the default format that should be used for rendering the output of `DurationField` serializer fields. If `None`, then `DurationField` serializer fields will return Python `timedelta` objects, and the duration encoding will be determined by the renderer. + +May be any of `None`, `'iso-8601'` or `'django'` (the format accepted by `django.utils.dateparse.parse_duration`). + +Default: `'django'` + --- ## Encodings diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index 9b9bb6eda..413f32606 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -21,6 +21,7 @@ HTTP_HEADER_ENCODING = 'iso-8859-1' # Default datetime input and output formats ISO_8601 = 'iso-8601' +DJANGO_DURATION_FORMAT = 'django' class RemovedInDRF317Warning(PendingDeprecationWarning): diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 89c0a714c..847ee7b19 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -24,7 +24,7 @@ from django.utils import timezone from django.utils.dateparse import ( parse_date, parse_datetime, parse_duration, parse_time ) -from django.utils.duration import duration_string +from django.utils.duration import duration_iso_string, duration_string from django.utils.encoding import is_protected_type, smart_str from django.utils.formats import localize_input, sanitize_separators from django.utils.ipv6 import clean_ipv6_address @@ -35,7 +35,7 @@ try: except ImportError: 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.exceptions import ErrorDetail, ValidationError from rest_framework.settings import api_settings @@ -1351,9 +1351,22 @@ class DurationField(Field): 'overflow': _('The number of days must be between {min_days} and {max_days}.'), } - def __init__(self, **kwargs): + def __init__(self, *, format=empty, **kwargs): self.max_value = kwargs.pop('max_value', None) self.min_value = kwargs.pop('min_value', None) + if format is not empty: + if format is None or (isinstance(format, str) and format.lower() in (ISO_8601, DJANGO_DURATION_FORMAT)): + self.format = format + elif isinstance(format, str): + raise ValueError( + f"Unknown duration format provided, got '{format}'" + " while expecting 'django', 'iso-8601' or `None`." + ) + else: + raise TypeError( + "duration format must be either str or `None`," + f" not {type(format).__name__}" + ) super().__init__(**kwargs) if self.max_value is not None: message = lazy_format(self.error_messages['max_value'], max_value=self.max_value) @@ -1376,7 +1389,26 @@ class DurationField(Field): self.fail('invalid', format='[DD] [HH:[MM:]]ss[.uuuuuu]') def to_representation(self, value): - return duration_string(value) + output_format = getattr(self, 'format', api_settings.DURATION_FORMAT) + + if output_format is None: + return value + + if isinstance(output_format, str): + if output_format.lower() == ISO_8601: + 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`." + ) + raise TypeError( + "duration format must be either str or `None`," + f" not {type(output_format).__name__}" + ) # Choice types... diff --git a/rest_framework/settings.py b/rest_framework/settings.py index b0d7bacec..50e3ad40e 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -24,7 +24,7 @@ from django.conf import settings from django.core.signals import setting_changed from django.utils.module_loading import import_string -from rest_framework import ISO_8601 +from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601 DEFAULTS = { # Base API policies @@ -109,6 +109,8 @@ DEFAULTS = { 'TIME_FORMAT': ISO_8601, 'TIME_INPUT_FORMATS': [ISO_8601], + 'DURATION_FORMAT': DJANGO_DURATION_FORMAT, + # Encoding 'UNICODE_JSON': True, 'COMPACT_JSON': True, diff --git a/tests/test_fields.py b/tests/test_fields.py index 56693ed7a..b52442a2c 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1770,9 +1770,69 @@ class TestDurationField(FieldValues): } 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(TypeError) as exc_info: + serializers.DurationField(format=123) + assert str(exc_info.value) == ( + "duration format must be either str or `None`, not int" + ) + + def test_invalid_format_in_config(self): + field = serializers.DurationField() + + with override_settings(REST_FRAMEWORK={'DURATION_FORMAT': 'unknown'}): + 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`." + ) + with override_settings(REST_FRAMEWORK={'DURATION_FORMAT': 123}): + with pytest.raises(TypeError) as exc_info: + field.to_representation(datetime.timedelta(days=1)) + assert str(exc_info.value) == ( + "duration format must be either str or `None`, not int" + ) + + +class TestNoOutputFormatDurationField(FieldValues): + """ + Values for `DurationField` with a no output format. + """ + valid_inputs = {} + invalid_inputs = {} + outputs = { + datetime.timedelta(1): datetime.timedelta(1) + } + field = serializers.DurationField(format=None) + + +class TestISOOutputFormatDurationField(FieldValues): + """ + Values for `DurationField` with a custom output format. + """ + 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 = {} + outputs = { + datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123): 'P3DT08H32M01.000123S' + } + field = serializers.DurationField(format='iso-8601') + # Choice types... - class TestChoiceField(FieldValues): """ Valid and invalid values for `ChoiceField`.