Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/api-guide/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/api-guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ May be a list including the string `'iso-8601'` or Python [strftime format][strf

Default: `['iso-8601']`


#### DURATION_FORMAT

A format string that should be used by default 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 `'standard'` (the format accepted by `django.utils.dateparse.parse_duration`).

Default: `'standard'`

---

## Encodings
Expand Down
1 change: 1 addition & 0 deletions rest_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

# Default datetime input and output formats
ISO_8601 = 'iso-8601'
DJANGO_DURATION_FORMAT = 'django'


class RemovedInDRF317Warning(PendingDeprecationWarning):
Expand Down
31 changes: 27 additions & 4 deletions rest_framework/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
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
Expand All @@ -35,7 +35,7 @@
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
Expand Down Expand Up @@ -1351,9 +1351,17 @@ 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
else:
raise ValueError(
f"Unknown duration format provided, got '{format}'"
" while expecting 'django', 'iso-8601' or `None`."
)
super().__init__(**kwargs)
if self.max_value is not None:
message = lazy_format(self.error_messages['max_value'], max_value=self.max_value)
Expand All @@ -1376,7 +1384,22 @@ def to_internal_value(self, value):
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`."
)


# Choice types...
Expand Down
4 changes: 3 additions & 1 deletion rest_framework/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
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
Expand Down Expand Up @@ -109,6 +109,8 @@
'TIME_FORMAT': ISO_8601,
'TIME_INPUT_FORMATS': [ISO_8601],

'DURATION_FORMAT': DJANGO_DURATION_FORMAT,

# Encoding
'UNICODE_JSON': True,
'COMPACT_JSON': True,
Expand Down
57 changes: 56 additions & 1 deletion tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -1772,9 +1772,64 @@ 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(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`."
)

# Choice types...
@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):
"""
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`.
Expand Down
Loading