diff --git a/cl_sii/extras/__init__.py b/cl_sii/extras/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cl_sii/extras/dj_model_fields.py b/cl_sii/extras/dj_model_fields.py new file mode 100644 index 00000000..f4eb8a70 --- /dev/null +++ b/cl_sii/extras/dj_model_fields.py @@ -0,0 +1,167 @@ +""" +cl_sii "extras" / Django model fields. + +""" +try: + import django +except ImportError as exc: # pragma: no cover + raise ImportError("Package 'Django' is required to use this module.") from exc + +from typing import Any, Optional, Tuple + +import django.core.exceptions +import django.db.models +import django.db.models.fields + +import cl_sii.rut.constants +from cl_sii.rut import Rut + + +class RutField(django.db.models.Field): + + """ + Django model field for RUT. + + * Python data type: :class:`cl_sii.rut.Rut` + * DB type: ``varchar``, the same one as the one for model field + :class:`django.db.models.CharField` + + It verifies only that the input is syntactically valid; it does NOT check + that the value is within boundaries deemed acceptable by the SII. + + The field performs some input value cleaning when it is an str; + for example ``' 1.111.111-k \t '`` is allowed and the resulting value + is ``Rut('1111111-K')``. + + .. seealso:: + :class:`.drf_fields.RutField` and :class:`.mm_fields.RutField` + + Implementation partially inspired in + :class:`django.db.models.fields.UUIDField`. + + """ + + # TODO: add option to validate that "digito verificador" is correct. + # TODO: implement method 'formfield'. Probably a copy of 'CharField.formfield' is fine. + + description = 'RUT for SII (Chile)' + default_error_messages = { + 'invalid': "'%(value)s' is not a syntactically valid RUT.", + 'invalid_dv': "\"digito verificador\" of RUT '%(value)s' is incorrect.", + } + empty_strings_allowed = False + + def __init__(self, *args: Any, **kwargs: Any) -> None: + # note: the value saved to the DB will always be in canonical format. + db_column_max_length = cl_sii.rut.constants.RUT_CANONICAL_MAX_LENGTH + + # note: for some reason, even though the kwarg 'max_length' was not set explicitly in + # a model, some Django magic caused it was set automatically (perhaps consecutive calls + # or something like that?). + if 'max_length' in kwargs and kwargs['max_length'] != db_column_max_length: + raise ValueError("This field does not allow customization of 'max_length'.") + + kwargs['max_length'] = db_column_max_length + super().__init__(*args, **kwargs) + + def deconstruct(self) -> Tuple[str, str, Any, Any]: + """ + Return a 4-tuple with enough information to recreate the field. + """ + # note: this override is necessary because we have a custom constructor. + + name, path, args, kwargs = super().deconstruct() + del kwargs['max_length'] + + return name, path, args, kwargs + + def get_internal_type(self) -> str: + # Emulate built-in model field type 'CharField' i.e. the underlying DB type is the same. + # https://docs.djangoproject.com/en/2.1/howto/custom-model-fields/#emulating-built-in-field-types + return 'CharField' + + def from_db_value( + self, + value: Optional[str], + expression: object, + connection: object, + ) -> Optional[Rut]: + """ + Convert a value as returned by the database to a Python object. + + > It is the reverse of :meth:`get_prep_value`. + + > If present for the field subclass, :meth:`from_db_value` will be + > called in all circumstances when the data is loaded from the + > database, including in aggregates and ``values()`` calls. + + It needs to be able to process ``None``. + + .. seealso:: + https://docs.djangoproject.com/en/2.1/howto/custom-model-fields/#converting-values-to-python-objects + https://docs.djangoproject.com/en/2.1/ref/models/fields/#django.db.models.Field.from_db_value + + """ + # note: there is no parent implementation, for performance reasons. + return self.to_python(value) + + def get_prep_value(self, value: Optional[Rut]) -> Optional[str]: + """ + Convert the model's attribute value to a format suitable for the DB. + + i.e. prepared for use as a parameter in a query. + It is the reverse of :meth:`from_db_value`. + + However, these are preliminary non-DB specific value checks and + conversions (otherwise customize :meth:`get_db_prep_value`). + + """ + value = super().get_prep_value(value) + return value if value is None else value.canonical + + def to_python(self, value: Optional[object]) -> Optional[Rut]: + """ + Convert the input value to the correct Python object (:class:`Rut`). + + > It acts as the reverse of :meth:`value_to_string`, and is also + called in :meth`clean`. + + It needs to be able to process ``None``. + + .. seealso:: + https://docs.djangoproject.com/en/2.1/howto/custom-model-fields/#converting-values-to-python-objects + https://docs.djangoproject.com/en/2.1/ref/models/fields/#django.db.models.Field.to_python + + :raises django.core.exceptions.ValidationError: + if the data can't be converted + + """ + if value is None or isinstance(value, Rut): + converted_value = value + else: + try: + converted_value = Rut(value, validate_dv=False) # type: ignore + except (AttributeError, TypeError, ValueError): + raise django.core.exceptions.ValidationError( + self.error_messages['invalid'], + code='invalid', + params={'value': value}, + ) + + return converted_value + + def value_to_string(self, obj: django.db.models.Model) -> str: + """ + Convert to a string the field value of model instance``obj``. + + Used to serialize the value of the field. + + .. seealso:: + https://docs.djangoproject.com/en/2.1/howto/custom-model-fields/#converting-field-data-for-serialization + + """ + # note: according to official docs, 'value_from_object' is the + # "best way to get the field's value prior to serialization". + value: Optional[Rut] = self.value_from_object(obj) + + return '' if value is None else value.canonical diff --git a/cl_sii/extras/drf_fields.py b/cl_sii/extras/drf_fields.py new file mode 100644 index 00000000..9cbe7488 --- /dev/null +++ b/cl_sii/extras/drf_fields.py @@ -0,0 +1,76 @@ +""" +cl_sii "extras" / Django REST Framework (DRF) fields. + +(for serializers) + +""" +try: + import rest_framework +except ImportError as exc: # pragma: no cover + raise ImportError("Package 'djangorestframework' is required to use this module.") from exc + +import rest_framework.fields + +from cl_sii.rut import Rut + + +class RutField(rest_framework.fields.CharField): + + """ + DRF field for RUT. + + Data types: + * native/primitive/internal/deserialized: :class:`cl_sii.rut.Rut` + * representation/serialized: str, same as for DRF field + :class:`rest_framework.fields.CharField` + + It verifies only that the input is syntactically valid; it does NOT check + that the value is within boundaries deemed acceptable by the SII. + + The field performs some input value cleaning when it is an str; + for example ``' 1.111.111-k \t '`` is allowed and the resulting value + is ``Rut('1111111-K')``. + + .. seealso:: + :class:`.dj_model_fields.RutField` and :class:`.mm_fields.RutField` + + Implementation partially inspired in + :class:`rest_framework.fields.UUIDField`. + + """ + + default_error_messages = { + 'invalid': "'{value}' is not a syntactically valid RUT.", + } + + def to_internal_value(self, data: object) -> Rut: + """ + Deserialize. + + > Restore a primitive datatype into its internal python representation. + + :raises rest_framework.exceptions.ValidationError: + if the data can't be converted + + """ + if isinstance(data, Rut): + converted_data = data + else: + try: + if isinstance(data, str): + converted_data = Rut(data, validate_dv=False) + else: + self.fail('invalid', value=data) + except (AttributeError, TypeError, ValueError): + self.fail('invalid', value=data) + + return converted_data + + def to_representation(self, value: Rut) -> str: + """ + Serialize. + + > Convert the initial datatype into a primitive, serializable datatype. + + """ + return value.canonical diff --git a/cl_sii/extras/mm_fields.py b/cl_sii/extras/mm_fields.py new file mode 100644 index 00000000..d5eefc75 --- /dev/null +++ b/cl_sii/extras/mm_fields.py @@ -0,0 +1,64 @@ +""" +cl_sii "extras" / Marshmallow fields. + +(for serializers) + +""" +try: + import marshmallow +except ImportError as exc: # pragma: no cover + raise ImportError("Package 'marshmallow' is required to use this module.") from exc + +from typing import Optional + +import marshmallow.fields + +from cl_sii.rut import Rut + + +class RutField(marshmallow.fields.Field): + + """ + Marshmallow field for RUT. + + Data types: + * native/primitive/internal/deserialized: :class:`cl_sii.rut.Rut` + * representation/serialized: str, same as for Marshmallow field + :class:`marshmallow.fields.String` + + It verifies only that the input is syntactically valid; it does NOT check + that the value is within boundaries deemed acceptable by the SII. + + The field performs some input value cleaning when it is an str; + for example ``' 1.111.111-k \t '`` is allowed and the resulting value + is ``Rut('1111111-K')``. + + .. seealso:: + :class:`.dj_model_fields.RutField` and :class:`.drf_fields.RutField` + + Implementation partially inspired in :class:`marshmallow.fields.UUID`. + + """ + + default_error_messages = { + 'invalid': 'Not a syntactically valid RUT.' + } + + def _serialize(self, value: Optional[object], attr: str, obj: object) -> Optional[str]: + validated = self._validated(value) + return validated.canonical if validated is not None else None + + def _deserialize(self, value: str, attr: str, data: dict) -> Optional[Rut]: + return self._validated(value) + + def _validated(self, value: Optional[object]) -> Optional[Rut]: + if value is None or isinstance(value, Rut): + validated = value + else: + try: + validated = Rut(value, validate_dv=False) # type: ignore + except TypeError: + self.fail('type') + except ValueError: + self.fail('invalid') + return validated diff --git a/requirements/base.txt b/requirements/base.txt index 1601bfc4..f74d5c86 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -2,7 +2,7 @@ # note: it is mandatory to register all dependencies of the required packages. # Required packages: -#none +marshmallow==2.16.3 # Packages dependencies: #none diff --git a/requirements/extras.txt b/requirements/extras.txt index 7cb924e2..d0e3ad4f 100644 --- a/requirements/extras.txt +++ b/requirements/extras.txt @@ -1,4 +1,5 @@ # note: it is NOT mandatory to register all dependencies of the required packages. # Required packages: -#none +Django<2.2 +djangorestframework<3.9 diff --git a/setup.cfg b/setup.cfg index ddb01ab7..5eeb1deb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,15 @@ disallow_untyped_defs = True check_untyped_defs = True warn_return_any = True +[mypy-django.*] +ignore_missing_imports = True + +[mypy-marshmallow.*] +ignore_missing_imports = True + +[mypy-rest_framework.*] +ignore_missing_imports = True + [flake8] ignore = # W503 line break before binary operator diff --git a/setup.py b/setup.py index 95b4c42a..bc23ba05 100755 --- a/setup.py +++ b/setup.py @@ -21,10 +21,14 @@ def get_version(*file_paths: Sequence[str]) -> str: readme = open('README.rst').read() history = open('HISTORY.rst').read().replace('.. :changelog:', '') +# TODO: add reasonable upper-bound for some of these packages? requirements = [ + 'marshmallow>=2.16.3', ] extras_requirements = { + 'django': ['Django>=2.1'], + 'djangorestframework': ['djangorestframework>=3.8.2'], } setup_requirements = [ diff --git a/tests/test_extras_dj_model_fields.py b/tests/test_extras_dj_model_fields.py new file mode 100644 index 00000000..f3970295 --- /dev/null +++ b/tests/test_extras_dj_model_fields.py @@ -0,0 +1,12 @@ +import unittest + +import django.db.models # noqa: F401 + +from cl_sii.extras.dj_model_fields import Rut, RutField # noqa: F401 + + +class RutFieldTest(unittest.TestCase): + + # TODO: implement! + + pass diff --git a/tests/test_extras_drf_fields.py b/tests/test_extras_drf_fields.py new file mode 100644 index 00000000..93210832 --- /dev/null +++ b/tests/test_extras_drf_fields.py @@ -0,0 +1,14 @@ +import unittest + +import rest_framework # noqa: F401 + +# TODO: create a test setup that at least makes it possible to run the following imports +# (underlying `import rest_framework.fields` raises Django's 'ImproperlyConfigured'): +# from cl_sii.extras.drf_fields import Rut, RutField + + +class RutFieldTest(unittest.TestCase): + + # TODO: implement! + + pass diff --git a/tests/test_extras_mm_fields.py b/tests/test_extras_mm_fields.py new file mode 100644 index 00000000..a6266ec2 --- /dev/null +++ b/tests/test_extras_mm_fields.py @@ -0,0 +1,146 @@ +import unittest + +import marshmallow + +from cl_sii.extras.mm_fields import Rut, RutField + + +class RutFieldTest(unittest.TestCase): + + def setUp(self) -> None: + + class MyObj: + def __init__(self, emisor_rut: Rut, other_field: int = None) -> None: + self.emisor_rut = emisor_rut + self.other_field = other_field + + class MyBadObj: + def __init__(self, some_field: int) -> None: + self.some_field = some_field + + class MyMmSchema(marshmallow.Schema): + + class Meta: + strict = False + + emisor_rut = RutField( + required=True, + load_from='RUT of Emisor', + ) + other_field = marshmallow.fields.Integer( + required=False, + ) + + class MyMmSchemaStrict(marshmallow.Schema): + + class Meta: + strict = True + + emisor_rut = RutField( + required=True, + load_from='RUT of Emisor', + ) + other_field = marshmallow.fields.Integer( + required=False, + ) + + self.MyObj = MyObj + self.MyBadObj = MyBadObj + self.MyMmSchema = MyMmSchema + self.MyMmSchemaStrict = MyMmSchemaStrict + + def test_load_ok_valid(self) -> None: + schema = self.MyMmSchema() + + data_valid_1 = {'RUT of Emisor': '1-1'} + data_valid_2 = {'RUT of Emisor': Rut('1-1')} + data_valid_3 = {'RUT of Emisor': ' 1.111.111-k \t '} + + result = schema.load(data_valid_1) + self.assertDictEqual(dict(result.data), {'emisor_rut': Rut('1-1')}) + self.assertDictEqual(dict(result.errors), {}) + + result = schema.load(data_valid_2) + self.assertDictEqual(dict(result.data), {'emisor_rut': Rut('1-1')}) + self.assertDictEqual(dict(result.errors), {}) + + result = schema.load(data_valid_3) + self.assertDictEqual(dict(result.data), {'emisor_rut': Rut('1111111-K')}) + self.assertDictEqual(dict(result.errors), {}) + + def test_dump_ok_valid(self) -> None: + schema = self.MyMmSchema() + + obj_valid_1 = self.MyObj(emisor_rut=Rut('1-1')) + obj_valid_2 = self.MyObj(emisor_rut=None) + + data, errors = schema.dump(obj_valid_1) + self.assertDictEqual(data, {'emisor_rut': '1-1', 'other_field': None}) + self.assertDictEqual(errors, {}) + + data, errors = schema.dump(obj_valid_2) + self.assertDictEqual(data, {'emisor_rut': None, 'other_field': None}) + self.assertDictEqual(errors, {}) + + def test_dump_ok_strange(self) -> None: + # If the class of the object to be dumped has attributes that do not match at all the + # fields of the schema, there are no errors! Even if the schema has `strict = True` set. + + schema = self.MyMmSchema() + schema_strict = self.MyMmSchemaStrict() + + obj_valid_1 = self.MyBadObj(some_field=123) + obj_valid_2 = self.MyBadObj(some_field=None) + + data, errors = schema.dump(obj_valid_1) + self.assertEqual((data, errors), ({}, {})) + + data, errors = schema_strict.dump(obj_valid_1) + self.assertEqual((data, errors), ({}, {})) + + data, errors = schema.dump(obj_valid_2) + self.assertEqual((data, errors), ({}, {})) + + data, errors = schema_strict.dump(obj_valid_2) + self.assertEqual((data, errors), ({}, {})) + + def test_load_fail(self) -> None: + + schema = self.MyMmSchema() + + data_invalid_1 = {'RUT of Emisor': '123123123123'} + data_invalid_2 = {'RUT of Emisor': 123} + data_invalid_3 = {'RUT of Emisor': None} + data_invalid_4 = {} + + result = schema.load(data_invalid_1) + self.assertDictEqual(dict(result.data), {}) + self.assertDictEqual(dict(result.errors), {'RUT of Emisor': ['Not a syntactically valid RUT.']}) # noqa: E501 + + result = schema.load(data_invalid_2) + self.assertDictEqual(dict(result.data), {}) + self.assertDictEqual(dict(result.errors), {'RUT of Emisor': ['Invalid input type.']}) + + result = schema.load(data_invalid_3) + self.assertDictEqual(dict(result.data), {}) + self.assertDictEqual(dict(result.errors), {'RUT of Emisor': ['Field may not be null.']}) + + result = schema.load(data_invalid_4) + self.assertDictEqual(dict(result.data), {}) + self.assertDictEqual(dict(result.errors), {'RUT of Emisor': ['Missing data for required field.']}) # noqa: E501 + + def test_dump_fail(self) -> None: + schema = self.MyMmSchema() + + obj_invalid_1 = self.MyObj(emisor_rut=20) + obj_invalid_2 = self.MyObj(emisor_rut='123123123123') + obj_invalid_3 = self.MyObj(emisor_rut='') + + data, errors = schema.dump(obj_invalid_1) + self.assertDictEqual(errors, {'emisor_rut': ['Invalid input type.']}) + + data, errors = schema.dump(obj_invalid_2) + self.assertDictEqual(errors, {'emisor_rut': ['Not a syntactically valid RUT.']}) + + data, errors = schema.dump(obj_invalid_3) + self.assertDictEqual(errors, {'emisor_rut': ['Not a syntactically valid RUT.']})