Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Empty file added cl_sii/extras/__init__.py
Empty file.
167 changes: 167 additions & 0 deletions cl_sii/extras/dj_model_fields.py
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions cl_sii/extras/drf_fields.py
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions cl_sii/extras/mm_fields.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion requirements/extras.txt
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
12 changes: 12 additions & 0 deletions tests/test_extras_dj_model_fields.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions tests/test_extras_drf_fields.py
Original file line number Diff line number Diff line change
@@ -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
Loading