Repository: cordada/lib-cl-sii-python (language: python)

Issue:
Release v0.31.0
## Changes

- (PR #661, 2024-07-17) extras: Add `RutFilter` for Django views and DRF views
- (PR #662, 2024-07-17) extras: Add Django URL path converter for RUT and Tipo DTE
- (PR #663, 2024-07-17) extras: Add mapping of Django model fields to DRF serializer fields
- (PR #664, 2024-07-17) extras: Reformat source code of `.dj_filters`


Discussion / hints:

Fix patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 1cf4ad37..bcf97b41 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.30.0
+current_version = 0.31.0
 commit = True
 tag = False
 message = chore: Bump version from {current_version} to {new_version}
diff --git a/HISTORY.md b/HISTORY.md
index b2f2c8fe..f588b139 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,5 +1,12 @@
 # History
 
+## 0.31.0 (2024-07-17)
+
+- (PR #661, 2024-07-17) extras: Add `RutFilter` for Django views and DRF views
+- (PR #662, 2024-07-17) extras: Add Django URL path converter for RUT and Tipo DTE
+- (PR #663, 2024-07-17) extras: Add mapping of Django model fields to DRF serializer fields
+- (PR #664, 2024-07-17) extras: Reformat source code of `.dj_filters`
+
 ## 0.30.0 (2024-07-11)
 
 - (PR #640, 2024-05-20) chore: Bump the production-dependencies group across 1 directory with 5 updates
diff --git a/mypy.ini b/mypy.ini
index 94f65c4a..4a64dbc1 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -29,6 +29,9 @@ ignore_missing_imports = True
 [mypy-django.*]
 ignore_missing_imports = True
 
+[mypy-django_filters.*]
+ignore_missing_imports = True
+
 [mypy-lxml.*]
 ignore_missing_imports = True
 
diff --git a/requirements.in b/requirements.in
index e4c2266c..01ed5af9 100644
--- a/requirements.in
+++ b/requirements.in
@@ -8,6 +8,7 @@
 backports-zoneinfo==0.2.1 ; python_version < "3.9" # Used by `djangorestframework`.
 cryptography==42.0.8
 defusedxml==0.7.1
+django-filter>=24.2
 Django>=2.2.24
 djangorestframework>=3.10.3,<3.16
 importlib-metadata==7.1.0
diff --git a/requirements.txt b/requirements.txt
index 851fb122..0785636d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -31,7 +31,10 @@ defusedxml==0.7.1
 django==4.2.14
     # via
     #   -r requirements.in
+    #   django-filter
     #   djangorestframework
+django-filter==24.2
+    # via -r requirements.in
 djangorestframework==3.15.2
     # via -r requirements.in
 importlib-metadata==7.1.0
diff --git a/setup.py b/setup.py
index cbd5c760..d39ae3e5 100644
--- a/setup.py
+++ b/setup.py
@@ -34,6 +34,7 @@ def get_version(*file_paths: str) -> str:
 
 extras_requirements = {
     'django': ['Django>=2.2.24'],
+    'django-filter': ['django-filter>=24.2'],
     'djangorestframework': ['djangorestframework>=3.10.3,<3.16'],
 }
 
diff --git a/src/cl_sii/__init__.py b/src/cl_sii/__init__.py
index 43751ea9..67546f89 100644
--- a/src/cl_sii/__init__.py
+++ b/src/cl_sii/__init__.py
@@ -4,4 +4,4 @@
 
 """
 
-__version__ = '0.30.0'
+__version__ = '0.31.0'
diff --git a/src/cl_sii/extras/dj_filters.py b/src/cl_sii/extras/dj_filters.py
new file mode 100644
index 00000000..20ec8f36
--- /dev/null
+++ b/src/cl_sii/extras/dj_filters.py
@@ -0,0 +1,79 @@
+"""
+cl_sii "extras" / Django-Filter.
+
+(for Django views and DRF views)
+"""
+
+from __future__ import annotations
+
+
+try:
+    import django_filters
+except ImportError as exc:  # pragma: no cover
+    raise ImportError("Package 'django-filter' is required to use this module.") from exc
+
+from copy import deepcopy
+from typing import ClassVar, Mapping, Tuple, Type
+
+import django.db.models
+import django.forms
+
+import cl_sii.extras.dj_form_fields
+import cl_sii.extras.dj_model_fields
+
+
+FILTER_FOR_DBFIELD_DEFAULTS: Mapping[Type[django.db.models.Field], Mapping[str, object]]
+FILTER_FOR_DBFIELD_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS)
+
+
+class RutFilter(django_filters.filters.CharFilter):
+    """
+    Matches on a RUT.
+
+    Used with :class:`cl_sii.extras.dj_form_fields.RutField` by default.
+
+    .. seealso::
+        - https://django-filter.readthedocs.io/en/stable/ref/filters.html
+        - https://github.com/carltongibson/django-filter/blob/24.2/docs/ref/filters.txt
+    """
+
+    field_class: ClassVar[Type[django.forms.Field]]
+    field_class = cl_sii.extras.dj_form_fields.RutField
+
+
+FILTER_FOR_DBFIELD_DEFAULTS = {
+    **FILTER_FOR_DBFIELD_DEFAULTS,
+    cl_sii.extras.dj_model_fields.RutField: {'filter_class': RutFilter},
+}
+
+
+class SiiFilterSet(django_filters.filterset.FilterSet):
+    """
+    Custom filterset with extra database field mappings.
+
+    This class serves as a base class for filtersets that additionally need to
+    support filtering one of the following database fields:
+      - :class:`cl_sii.extras.dj_model_fields.RutField`
+
+    .. seealso::
+        - https://django-filter.readthedocs.io/en/main/ref/filterset.html
+        - https://github.com/carltongibson/django-filter/blob/24.2/docs/ref/filterset.txt
+    """
+
+    FILTER_DEFAULTS: ClassVar[Mapping[Type[django.db.models.Field], Mapping[str, object]]]
+    FILTER_DEFAULTS = FILTER_FOR_DBFIELD_DEFAULTS
+
+    @classmethod
+    def filter_for_lookup(
+        cls, field: django.db.models.Field, lookup_type: str
+    ) -> Tuple[Type[django_filters.filters.Filter], Mapping[str, object]]:
+        filter_class, params = super().filter_for_lookup(field, lookup_type)
+
+        # Override RUT containment lookups.
+        if isinstance(field, cl_sii.extras.dj_model_fields.RutField) and lookup_type in (
+            'contains',
+            'icontains',
+        ):
+            filter_class, params = django_filters.filters.CharFilter, {}
+
+        return filter_class, params
diff --git a/src/cl_sii/extras/dj_url_converters.py b/src/cl_sii/extras/dj_url_converters.py
new file mode 100644
index 00000000..3afee132
--- /dev/null
+++ b/src/cl_sii/extras/dj_url_converters.py
@@ -0,0 +1,58 @@
+"""
+cl_sii "extras" / Django URL converters.
+"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+import cl_sii.dte.constants
+import cl_sii.rut
+
+
+class RutConverter:
+    """
+    Django URL path converter for Chilean RUT.
+
+    Thousands separators are not supported.
+
+    Example:
+
+    >>> from django.urls import path, register_converter
+    >>> register_converter(RutConverter, 'cl_sii_rut')
+    >>> urlpatterns = [path('example/<cl_sii_rut:emisor_rut>/', ...)]
+
+    .. seealso::
+        https://docs.djangoproject.com/en/4.2/topics/http/urls/#registering-custom-path-converters
+    """
+
+    regex: ClassVar[str] = r'\d{1,8}-[\dKk]'
+
+    def to_python(self, value: str) -> cl_sii.rut.Rut:
+        return cl_sii.rut.Rut(value)
+
+    def to_url(self, value: cl_sii.rut.Rut) -> str:
+        return str(value)
+
+
+class TipoDteConverter:
+    """
+    Django URL path converter for `Tipo DTE` object.
+
+    Example:
+
+    >>> from django.urls import path, register_converter
+    >>> register_converter(TipoDteConverter, 'cl_sii_tipo_dte')
+    >>> urlpatterns = [path('example/<cl_sii_tipo_dte:tipo_dte>/', ...)]
+
+    .. seealso::
+        https://docs.djangoproject.com/en/4.2/topics/http/urls/#registering-custom-path-converters
+    """
+
+    regex: ClassVar[str] = r'\d{2,3}'
+
+    def to_python(self, value: str) -> cl_sii.dte.constants.TipoDte:
+        return cl_sii.dte.constants.TipoDte(int(value))
+
+    def to_url(self, value: cl_sii.dte.constants.TipoDte) -> str:
+        return str(value.value)
diff --git a/src/cl_sii/extras/drf_serializers.py b/src/cl_sii/extras/drf_serializers.py
new file mode 100644
index 00000000..3473d02b
--- /dev/null
+++ b/src/cl_sii/extras/drf_serializers.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+
+try:
+    import rest_framework
+except ImportError as exc:  # pragma: no cover
+    raise ImportError("Package 'djangorestframework' is required to use this module.") from exc
+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 Mapping, Type
+
+import django.db.models
+import rest_framework.serializers
+
+import cl_sii.extras.dj_model_fields
+import cl_sii.extras.drf_fields
+
+
+model_serializer_field_mapping: Mapping[
+    Type[django.db.models.Field], Type[rest_framework.serializers.Field]
+]
+"""
+Mapping of Django model fields to DRF serializer fields.
+
+Use this to extend DRF serializers that inherit from :class:`ModelSerializer` so
+that Django model fields from :mod:`cl_sii.extras.dj_model_fields` do not have
+to be explicitly defined in the serializer.
+
+Usage example:
+
+>>> class ExampleSerializer(rest_framework.serializers.ModelSerializer):
+...     serializer_field_mapping = {
+...         **rest_framework.serializers.ModelSerializer.serializer_field_mapping,
+...         **model_serializer_field_mapping,
+...     }
+"""
+model_serializer_field_mapping = {
+    cl_sii.extras.dj_model_fields.RutField: cl_sii.extras.drf_fields.RutField,
+}


Test patch:
diff --git a/src/tests/test_extras_dj_filters.py b/src/tests/test_extras_dj_filters.py
new file mode 100644
index 00000000..1f83e209
--- /dev/null
+++ b/src/tests/test_extras_dj_filters.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+import unittest
+
+import django_filters
+
+from cl_sii.extras.dj_filters import RutFilter, SiiFilterSet
+
+
+class RutFilterTest(unittest.TestCase):
+    """
+    Tests for :class:`cl_sii.extras.dj_filters.RutFilter`.
+    """
+
+    @unittest.expectedFailure
+    def test_new_instance(self) -> None:
+        try:
+            filter = RutFilter()
+        except Exception as exc:
+            self.fail(f'{exc.__class__.__name__} raised')
+
+        self.assertIsInstance(filter, RutFilter)
+        self.assertIsInstance(filter, django_filters.filters.Filter)
+
+    # TODO: Add tests.
+
+
+class SiiFilterSetTest(unittest.TestCase):
+    """
+    Tests for :class:`cl_sii.extras.dj_filters.SiiFilterSet`.
+    """
+
+    @unittest.skip("TODO: Implement for 'filter_for_lookup'.")
+    def test_filter_for_lookup(self) -> None:
+        assert SiiFilterSet.filter_for_lookup()
+
+    # TODO: Add tests.
diff --git a/src/tests/test_extras_dj_url_converters.py b/src/tests/test_extras_dj_url_converters.py
new file mode 100644
index 00000000..affe2e71
--- /dev/null
+++ b/src/tests/test_extras_dj_url_converters.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import unittest
+from typing import Sequence, Tuple
+
+from cl_sii.dte.constants import TipoDte
+from cl_sii.extras.dj_url_converters import RutConverter, TipoDteConverter
+from cl_sii.rut import Rut
+
+
+class RutConverterTest(unittest.TestCase):
+    """
+    Tests for :class:`RutConverter`.
+    """
+
+    def test_regex(self) -> None:
+        for rut_str in (
+            '60805000-0',
+            '78773510-K',
+            '123456-0',
+            '123-6',
+            '1-9',
+            '6-K',
+        ):
+            self.assertRegex(rut_str, RutConverter.regex)
+
+    def test_regex_matches_leading_zeroes(self) -> None:
+        for rut_str in ('001-9', '000006-K', '0123456-0'):
+            self.assertRegex(rut_str, RutConverter.regex)
+
+    def test_regex_matches_uppercase_and_lowercase(self) -> None:
+        self.assertRegex('6-K', RutConverter.regex)
+        self.assertRegex('6-k', RutConverter.regex)
+
+    def test_to_python(self) -> None:
+        obj = RutConverter()
+
+        test_values: Sequence[Tuple[str, Rut]] = [
+            ('60805000-0', Rut('60805000-0')),
+            ('78773510-K', Rut('78773510-K')),
+            ('123456-0', Rut('123456-0')),
+            ('1-9', Rut('1-9')),
+            ('01-9', Rut('1-9')),
+            ('6-k', Rut('6-K')),
+        ]
+
+        for string_test_value, python_test_value in test_values:
+            self.assertEqual(obj.to_python(string_test_value), python_test_value)
+
+    def test_to_url(self) -> None:
+        obj = RutConverter()
+
+        test_values: Sequence[Tuple[Rut, str]] = [
+            (Rut('60805000-0'), '60805000-0'),
+            (Rut('78773510-K'), '78773510-K'),
+            (Rut('123456-0'), '123456-0'),
+            (Rut('1-9'), '1-9'),
+        ]
+
+        for python_test_value, string_test_value in test_values:
+            self.assertEqual(obj.to_url(python_test_value), string_test_value)
+
+
+class TipoDteConverterTest(unittest.TestCase):
+    """
+    Tests for :class:`TipoDteConverter`.
+    """
+
+    def test_regex(self) -> None:
+        for tipo_dte_str in (
+            '33',
+            '34',
+            '43',
+            '46',
+            '52',
+            '56',
+            '61',
+            '110',
+        ):
+            self.assertRegex(tipo_dte_str, TipoDteConverter.regex)
+
+    def test_to_python(self) -> None:
+        obj = TipoDteConverter()
+
+        test_values: Sequence[Tuple[str, TipoDte]] = [
+            ('33', TipoDte(33)),
+            ('34', TipoDte(34)),
+            ('43', TipoDte(43)),
+            ('46', TipoDte(46)),
+            ('52', TipoDte(52)),
+            ('56', TipoDte(56)),
+            ('61', TipoDte(61)),
+            # warning: `110` is a valid value, but `TipoDte` doesn't support it yet.
+            # ('110', TipoDte(110)),
+        ]
+
+        for string_test_value, python_test_value in test_values:
+            self.assertEqual(obj.to_python(string_test_value), python_test_value)
+
+    def test_to_url(self) -> None:
+        obj = TipoDteConverter()
+
+        test_values: Sequence[Tuple[TipoDte, str]] = [
+            (TipoDte(33), '33'),
+            (TipoDte(34), '34'),
+            (TipoDte(43), '43'),
+            (TipoDte(46), '46'),
+            (TipoDte(52), '52'),
+            (TipoDte(56), '56'),
+            (TipoDte(61), '61'),
+            # warning: `110` is a valid value, but `TipoDte` doesn't support it yet.
+            # (TipoDte(110), '110'),
+        ]
+
+        for python_test_value, string_test_value in test_values:
+            self.assertEqual(obj.to_url(python_test_value), string_test_value)
diff --git a/src/tests/test_extras_drf_serializers.py b/src/tests/test_extras_drf_serializers.py
new file mode 100644
index 00000000..2a26aeb1
--- /dev/null
+++ b/src/tests/test_extras_drf_serializers.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+import unittest
+
+import django.db.models
+import rest_framework.serializers
+
+import cl_sii.extras.drf_serializers
+
+
+class ModelSerializerFieldMappingTestCase(unittest.TestCase):
+    """
+    Tests for :attr:`model_serializer_field_mapping`.
+    """
+
+    def test_types(self) -> None:
+        serializer_field_mapping = {
+            **rest_framework.serializers.ModelSerializer.serializer_field_mapping,
+            **cl_sii.extras.drf_serializers.model_serializer_field_mapping,
+        }
+
+        for k, v in serializer_field_mapping.items():
+            self.assertTrue(issubclass(k, django.db.models.Field))
+            self.assertTrue(issubclass(v, rest_framework.serializers.Field))