diff --git a/django_api_forms/fields.py b/django_api_forms/fields.py index e5ea5af..03e61f2 100644 --- a/django_api_forms/fields.py +++ b/django_api_forms/fields.py @@ -40,16 +40,20 @@ def has_changed(self, initial, data): class FieldList(Field): default_error_messages = { + 'max_length': _('Ensure this list has at most %(max)d values (it has %(length)d).'), + 'min_length': _('Ensure this list has at least %(max)d values (it has %(length)d).'), 'not_field': _('Invalid Field type passed into FieldList!'), 'not_list': _('This field needs to be a list of objects!'), } - def __init__(self, field, **kwargs): + def __init__(self, field, min_length=None, max_length=None, **kwargs): super().__init__(**kwargs) if not isinstance(field, Field): raise RuntimeError(self.error_messages['not_field']) + self._min_length = min_length + self._max_length = max_length self._field = field def to_python(self, value) -> typing.List: @@ -59,6 +63,14 @@ def to_python(self, value) -> typing.List: if not isinstance(value, list): raise ValidationError(self.error_messages['not_list'], code='not_list') + if self._min_length is not None and len(value) < self._min_length: + params = {'max': self._min_length, 'length': len(value)} + raise ValidationError(self.error_messages['min_length'], code='min_length', params=params) + + if self._max_length is not None and len(value) > self._max_length: + params = {'max': self._max_length, 'length': len(value)} + raise ValidationError(self.error_messages['max_length'], code='max_length', params=params) + result = [] errors = [] @@ -97,7 +109,14 @@ def to_python(self, value) -> typing.Union[typing.Dict, None]: class FormFieldList(FormField, IgnoreFillMixin): + def __init__(self, form: typing.Type, min_length=None, max_length=None, **kwargs): + self._min_length = min_length + self._max_length = max_length + super().__init__(form, **kwargs) + default_error_messages = { + 'max_length': _('Ensure this list has at most %(max)d values (it has %(length)d).'), + 'min_length': _('Ensure this list has at least %(max)d values (it has %(length)d).'), 'not_list': _('This field needs to be a list of objects!') } @@ -108,6 +127,14 @@ def to_python(self, value): if not isinstance(value, list): raise ValidationError(self.error_messages['not_list'], code='not_list') + if self._min_length is not None and len(value) < self._min_length: + params = {'max': self._min_length, 'length': len(value)} + raise ValidationError(self.error_messages['min_length'], code='min_length', params=params) + + if self._max_length is not None and len(value) > self._max_length: + params = {'max': self._max_length, 'length': len(value)} + raise ValidationError(self.error_messages['max_length'], code='max_length', params=params) + result = [] errors = [] diff --git a/docs/fields.md b/docs/fields.md index a788e73..197b2c6 100644 --- a/docs/fields.md +++ b/docs/fields.md @@ -59,9 +59,11 @@ This field is used to parse list of primitive values (like strings or numbers). check `FormFieldList`. - Normalizes to: A Python list -- Error message keys: `not_field`, `not_list` +- Error message keys: `not_field`, `not_list`, `min_length`, `max_length` - Required arguments: - `field`: Instance of a form field representing children + - `min_length`: Minimum length of field size in integer (optional) + - `max_length`: Maximum length of field size in integer (optional) **JSON example** @@ -130,9 +132,11 @@ class AlbumForm(Form): Field used for embedded objects represented as another API form. - Normalizes to: A Python list of dictionaries -- Error message keys: `not_list` +- Error message keys: `not_list`, `min_length`, `max_length` - Required arguments: - `form`: Type of a nested form + - `min_length`: Minimum length of field size in integer (optional) + - `max_length`: Maximum length of field size in integer (optional) **JSON example** diff --git a/tests/test_fields.py b/tests/test_fields.py index e5ff1af..7828a3e 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -140,6 +140,30 @@ def test_fieldlist_required_false(self): for empty_val in EMPTY_VALUES: self.assertEqual([], field_list.clean(empty_val)) + def test_min_length(self): + form_field_list = FieldList(field=fields.IntegerField(), min_length=2) + + # TEST: valid input + valid_val = [1, 2] + self.assertEqual(valid_val, form_field_list.clean(valid_val)) + + # TEST: invalid input (values more than the defined max length) + valid_val = [1] + with self.assertRaises(ValidationError): + form_field_list.clean(valid_val) + + def test_max_length(self): + form_field_list = FieldList(field=fields.IntegerField(), max_length=3) + + # TEST: valid input + valid_val = [1, 2, 3] + self.assertEqual(valid_val, form_field_list.clean(valid_val)) + + # TEST: invalid input (values more than the defined max length) + valid_val = [1, 2, 3, 4] + with self.assertRaises(ValidationError): + form_field_list.clean(valid_val) + class FormFieldTests(SimpleTestCase): class TestFormWithRequiredField(Form): @@ -272,6 +296,30 @@ def test_formfieldlist_required_false(self): # TEST: required=False - [] allowed self.assertEqual([], form_field_list.clean([])) + def test_min_length(self): + form_field_list = FormFieldList(form=self.TestFormWithRequiredField, min_length=2) + + # TEST: valid input + valid_val = [{'number': 1}, {'number': 2}] + self.assertEqual(valid_val, form_field_list.clean(valid_val)) + + # TEST: invalid input (values more than the defined max length) + valid_val = [{'number': 1}] + with self.assertRaises(ValidationError): + form_field_list.clean(valid_val) + + def test_max_length(self): + form_field_list = FormFieldList(form=self.TestFormWithRequiredField, max_length=3) + + # TEST: valid input + valid_val = [{'number': 1}, {'number': 2}, {'number': 0}] + self.assertEqual(valid_val, form_field_list.clean(valid_val)) + + # TEST: invalid input (values more than the defined max length) + valid_val = [{'number': 1}, {'number': 2}, {'number': 0}, {'number': 4}] + with self.assertRaises(ValidationError): + form_field_list.clean(valid_val) + class EnumFieldTests(SimpleTestCase): class Color(Enum):