Skip to content

Commit dcbefa9

Browse files
committed
Add validation for DurationField format, add more tests for it and improve related docs
1 parent 4377b08 commit dcbefa9

File tree

5 files changed

+60
-14
lines changed

5 files changed

+60
-14
lines changed

docs/api-guide/fields.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,12 +380,12 @@ The `validated_data` for these fields will contain a `datetime.timedelta` instan
380380

381381
**Signature:** `DurationField(format=api_settings.DURATION_FORMAT, max_value=None, min_value=None)`
382382

383-
* `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.
383+
* `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.
384384
* `max_value` Validate that the duration provided is no greater than this value.
385385
* `min_value` Validate that the duration provided is no less than this value.
386386

387-
#### `DurationField` format strings
388-
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'`).
387+
#### `DurationField` formats
388+
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'`).
389389

390390
---
391391

rest_framework/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
# Default datetime input and output formats
2323
ISO_8601 = 'iso-8601'
24-
STD_DURATION = 'standard'
24+
DJANGO_DURATION_FORMAT = 'django'
2525

2626

2727
class RemovedInDRF317Warning(PendingDeprecationWarning):

rest_framework/fields.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
except ImportError:
3636
pytz = None
3737

38-
from rest_framework import ISO_8601
38+
from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601
3939
from rest_framework.compat import ip_address_validators
4040
from rest_framework.exceptions import ErrorDetail, ValidationError
4141
from rest_framework.settings import api_settings
@@ -1351,11 +1351,17 @@ class DurationField(Field):
13511351
'overflow': _('The number of days must be between {min_days} and {max_days}.'),
13521352
}
13531353

1354-
def __init__(self, format=empty, **kwargs):
1354+
def __init__(self, *, format=empty, **kwargs):
13551355
self.max_value = kwargs.pop('max_value', None)
13561356
self.min_value = kwargs.pop('min_value', None)
13571357
if format is not empty:
1358-
self.format = format
1358+
if format is None or (isinstance(format, str) and format.lower() in (ISO_8601, DJANGO_DURATION_FORMAT)):
1359+
self.format = format
1360+
else:
1361+
raise ValueError(
1362+
f"Unknown duration format provided, got '{format}'"
1363+
" while expecting 'django', 'iso-8601' or `None`."
1364+
)
13591365
super().__init__(**kwargs)
13601366
if self.max_value is not None:
13611367
message = lazy_format(self.error_messages['max_value'], max_value=self.max_value)
@@ -1380,12 +1386,20 @@ def to_internal_value(self, value):
13801386
def to_representation(self, value):
13811387
output_format = getattr(self, 'format', api_settings.DURATION_FORMAT)
13821388

1383-
if output_format is None or isinstance(value, str):
1389+
if output_format is None:
13841390
return value
13851391

1386-
if output_format.lower() == ISO_8601:
1387-
return duration_iso_string(value)
1388-
return duration_string(value)
1392+
if isinstance(output_format, str):
1393+
if output_format.lower() == ISO_8601:
1394+
return duration_iso_string(value)
1395+
1396+
if output_format.lower() == DJANGO_DURATION_FORMAT:
1397+
return duration_string(value)
1398+
1399+
raise ValueError(
1400+
f"Unknown duration format provided, got '{output_format}'"
1401+
" while expecting 'django', 'iso-8601' or `None`."
1402+
)
13891403

13901404

13911405
# Choice types...

rest_framework/settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from django.core.signals import setting_changed
2525
from django.utils.module_loading import import_string
2626

27-
from rest_framework import ISO_8601, STD_DURATION
27+
from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601
2828

2929
DEFAULTS = {
3030
# Base API policies
@@ -109,7 +109,7 @@
109109
'TIME_FORMAT': ISO_8601,
110110
'TIME_INPUT_FORMATS': [ISO_8601],
111111

112-
'DURATION_FORMAT': STD_DURATION,
112+
'DURATION_FORMAT': DJANGO_DURATION_FORMAT,
113113

114114
# Encoding
115115
'UNICODE_JSON': True,

tests/test_fields.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,32 @@ class TestDurationField(FieldValues):
17721772
}
17731773
field = serializers.DurationField()
17741774

1775+
def test_invalid_format(self):
1776+
with pytest.raises(ValueError) as exc_info:
1777+
serializers.DurationField(format='unknown')
1778+
assert str(exc_info.value) == (
1779+
"Unknown duration format provided, got 'unknown'"
1780+
" while expecting 'django', 'iso-8601' or `None`."
1781+
)
1782+
with pytest.raises(ValueError) as exc_info:
1783+
serializers.DurationField(format=123)
1784+
assert str(exc_info.value) == (
1785+
"Unknown duration format provided, got '123'"
1786+
" while expecting 'django', 'iso-8601' or `None`."
1787+
)
1788+
1789+
@override_settings(REST_FRAMEWORK={'DURATION_FORMAT': 'unknown'})
1790+
def test_invalid_format_in_config(self):
1791+
field = serializers.DurationField()
1792+
1793+
with pytest.raises(ValueError) as exc_info:
1794+
field.to_representation(datetime.timedelta(days=1))
1795+
1796+
assert str(exc_info.value) == (
1797+
"Unknown duration format provided, got 'unknown'"
1798+
" while expecting 'django', 'iso-8601' or `None`."
1799+
)
1800+
17751801

17761802
class TestNoOutputFormatDurationField(FieldValues):
17771803
"""
@@ -1789,7 +1815,13 @@ class TestISOOutputFormatDurationField(FieldValues):
17891815
"""
17901816
Values for `DurationField` with a custom output format.
17911817
"""
1792-
valid_inputs = {}
1818+
valid_inputs = {
1819+
'13': datetime.timedelta(seconds=13),
1820+
'P3DT08H32M01.000123S': datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123),
1821+
'PT8H1M': datetime.timedelta(minutes=8, seconds=1),
1822+
'-P999999999D': datetime.timedelta(days=-999999999),
1823+
'P999999999D': datetime.timedelta
1824+
}
17931825
invalid_inputs = {}
17941826
outputs = {
17951827
datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123): 'P3DT08H32M01.000123S'

0 commit comments

Comments
 (0)