mirror of
				https://github.com/encode/django-rest-framework.git
				synced 2025-11-01 00:17:40 +03:00 
			
		
		
		
	DurationField output format (#8532)
* Allow format duration as ISO-8601 * Update tests/test_fields.py Co-authored-by: Bruno Alla <browniebroke@users.noreply.github.com> * Update tests/test_fields.py Co-authored-by: Bruno Alla <browniebroke@users.noreply.github.com> * Add validation for DurationField format, add more tests for it and improve related docs * Add more precise validation check for duration field format and adjust docs * Adjust typo in duration field docs --------- Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> Co-authored-by: Bruno Alla <browniebroke@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									c73dddfada
								
							
						
					
					
						commit
						c8b6d3dcdf
					
				|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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): | ||||
|  |  | |||
|  | @ -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... | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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`. | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user