Added RangeField

This commit is contained in:
Aron Podrigal 2016-07-17 03:21:12 +03:00
parent 91aad9299b
commit 72d88678e5
5 changed files with 148 additions and 3 deletions

View File

@ -3,3 +3,4 @@ markdown==2.6.4
django-guardian==1.4.3 django-guardian==1.4.3
django-filter==0.13.0 django-filter==0.13.0
coreapi==1.32.0 coreapi==1.32.0
psycopg2==2.6.2

View File

@ -1575,6 +1575,98 @@ class JSONField(Field):
return value return value
class RangeField(Field):
initial = {}
default_error_messages = {
'not_a_dict': _('Expected a dictionary of items but got type "{input_type}".'),
'unexpected_keys': _('Got unexpected keys "{unexpected}".'),
'invalid_bounds': _('Bounds flags "{bounds}" not valid. Valid bounds are "{valid_bounds}".'),
'empty': _('Range may not be empty.'),
}
valid_bounds = ('[)', '(]', '()', '[]')
def __init__(self, range_type, **kwargs):
self.child = kwargs.pop('child')
self.range_type = range_type
self.allow_empty = kwargs.pop('allow_empty', True)
assert not inspect.isclass(self.child), '`child` has not been instantiated.'
assert self.child.source is None, (
"The `source` argument is not meaningful when applied to a `child=` field. "
"Remove `source=` from the field declaration."
)
super(RangeField, self).__init__(**kwargs)
self.child.bind(field_name='', parent=self)
def _valid_empty_range(self, data):
if not data.pop('empty', False):
return False
if not self.allow_empty:
self.fail('empty')
return True
def _validate_bounds(self, data):
try:
bounds = data.pop('bounds')
except KeyError:
return
if bounds not in self.valid_bounds:
self.fail('invalid_bounds', bounds=bounds, valid_bounds=', '.join(self.valid_bounds))
return bounds
def _validate_ranges(self, data):
errors, validated_data = {}, {}
for key in ('lower', 'upper'):
try:
value = data.pop(key)
except KeyError:
continue
else:
try:
validated_data[key] = self.child.run_validation(value)
except ValidationError as e:
errors[key] = e.detail
if errors:
raise ValidationError(errors)
return validated_data
def to_internal_value(self, data):
if isinstance(data, self.range_type):
return data
if not isinstance(data, dict):
self.fail('not_a_dict', input_type=type(data).__name__)
if self._valid_empty_range(data):
return self.range_type(empty=True)
validated_data = self._validate_ranges(data)
bounds = self._validate_bounds(data)
if bounds:
validated_data['bounds'] = bounds
if data:
self.fail('unexpected_keys', unexpected=', '.join(map(str, data.keys())))
return self.range_type(**validated_data)
def to_representation(self, value):
if value is None:
return value
lower = self.child.to_representation(value.lower) if value.lower is not None else None
upper = self.child.to_representation(value.upper) if value.upper is not None else None
if value.isempty:
return {'empty': True}
return {'lower': lower, 'upper': upper, 'bounds': value._bounds}
# Miscellaneous field types... # Miscellaneous field types...
class ReadOnlyField(Field): class ReadOnlyField(Field):

View File

@ -1138,7 +1138,7 @@ class ModelSerializer(Serializer):
# `allow_blank` is only valid for textual fields. # `allow_blank` is only valid for textual fields.
field_kwargs.pop('allow_blank', None) field_kwargs.pop('allow_blank', None)
if postgres_fields and isinstance(model_field, postgres_fields.ArrayField): if postgres_fields and isinstance(model_field, (postgres_fields.ArrayField, postgres_fields.RangeField)):
# Populate the `child` argument on `ListField` instances generated # Populate the `child` argument on `ListField` instances generated
# for the PostgrSQL specfic `ArrayField`. # for the PostgrSQL specfic `ArrayField`.
child_model_field = model_field.base_field child_model_field = model_field.base_field
@ -1147,6 +1147,9 @@ class ModelSerializer(Serializer):
) )
field_kwargs['child'] = child_field_class(**child_field_kwargs) field_kwargs['child'] = child_field_class(**child_field_kwargs)
if isinstance(model_field, postgres_fields.RangeField):
field_kwargs['range_type'] = model_field.range_type
return field_class, field_kwargs return field_class, field_kwargs
def build_relational_field(self, field_name, relation_info): def build_relational_field(self, field_name, relation_info):
@ -1469,6 +1472,7 @@ if postgres_fields:
ModelSerializer.serializer_field_mapping[postgres_fields.HStoreField] = CharMappingField ModelSerializer.serializer_field_mapping[postgres_fields.HStoreField] = CharMappingField
ModelSerializer.serializer_field_mapping[postgres_fields.ArrayField] = ListField ModelSerializer.serializer_field_mapping[postgres_fields.ArrayField] = ListField
ModelSerializer.serializer_field_mapping[postgres_fields.RangeField] = RangeField
class HyperlinkedModelSerializer(ModelSerializer): class HyperlinkedModelSerializer(ModelSerializer):

View File

@ -9,7 +9,7 @@ from django.test import TestCase, override_settings
from django.utils import six, timezone from django.utils import six, timezone
import rest_framework import rest_framework
from rest_framework import serializers from rest_framework import compat, serializers
# Tests for field keyword arguments and core functionality. # Tests for field keyword arguments and core functionality.
@ -1642,6 +1642,37 @@ class TestBinaryJSONField(FieldValues):
field = serializers.JSONField(binary=True) field = serializers.JSONField(binary=True)
if compat.postgres_fields:
from psycopg2.extras import DateTimeTZRange
class TestRangeField(FieldValues):
"""
Values for `ListField` with no `child` argument.
"""
valid_inputs = [
({'lower': '2016-01-01T00:30:01', 'upper': '2016-01-01T01:00', 'bounds': '[]', 'empty': False},
DateTimeTZRange(datetime.datetime(2016, 1, 1, 0, 30, 1), datetime.datetime(2016, 1, 1, 1, 0), '[]')),
({'lower': '2016-01-01T00:30:01'}, DateTimeTZRange(datetime.datetime(2016, 1, 1, 0, 30, 1), None, '[)')),
({'upper': '2016-01-01T00:00'}, DateTimeTZRange(None, datetime.datetime(2016, 1, 1, 0, 0, 0), '[)')),
({'empty': True}, DateTimeTZRange(empty=True)),
(DateTimeTZRange(None, datetime.datetime(2016, 1, 1, 0, 0, 0), '[)'), DateTimeTZRange(None, datetime.datetime(2016, 1, 1, 0, 0, 0), '[)'))
]
invalid_inputs = [
('not a dict', ['Expected a dictionary of items but got type "str".']),
(['not a dict'], ['Expected a dictionary of items but got type "list".']),
({'lower': 0}, {'lower': ['Datetime has wrong format. Use one of these formats instead: YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].']}),
({'bounds': '['}, ['Bounds flags "[" not valid. Valid bounds are "[), (], (), []".']),
({'unexpected': '[]'}, ['Got unexpected keys "unexpected".']),
]
outputs = [
(DateTimeTZRange(datetime.datetime(2016, 1, 1, 0, 30, 1), datetime.datetime(2016, 1, 1, 1, 0), '[]'),
{'lower': '2016-01-01T00:30:01', 'upper': '2016-01-01T01:00:00', 'bounds': '[]'}),
(DateTimeTZRange(empty=True), {'empty': True}),
(None, None),
]
field = serializers.RangeField(range_type=DateTimeTZRange, child=serializers.DateTimeField())
# Tests for FieldField. # Tests for FieldField.
# --------------------- # ---------------------

View File

@ -19,7 +19,7 @@ from django.db.models import DurationField as ModelDurationField
from django.test import TestCase from django.test import TestCase
from django.utils import six from django.utils import six
from rest_framework import serializers from rest_framework import compat, serializers
from rest_framework.compat import unicode_repr from rest_framework.compat import unicode_repr
@ -382,6 +382,23 @@ class TestGenericIPAddressFieldValidation(TestCase):
'{0}'.format(s.errors)) '{0}'.format(s.errors))
if compat.postgres_fields:
class TestRangeFieldMapping(TestCase):
def test_date_range_field(self):
class DateRangeFieldModel(models.Model):
timestamps = compat.postgres_fields.DateTimeRangeField()
class TestSerializer(serializers.ModelSerializer):
class Meta:
model = DateRangeFieldModel
fields = serializers.ALL_FIELDS
extra_kwargs = {'timestamps': {'allow_empty': False}}
s = TestSerializer(data={'timestamps': {'empty': True}})
self.assertFalse(s.is_valid())
self.assertEqual(s.errors['timestamps'], ['Range may not be empty.'])
# Tests for relational field mappings. # Tests for relational field mappings.
# ------------------------------------ # ------------------------------------