From 86dfa7140551c179131f02647ab5526bbe9cbac8 Mon Sep 17 00:00:00 2001 From: Jose Tomas Robles Hahn Date: Thu, 26 Sep 2024 13:59:50 -0300 Subject: [PATCH 1/2] feat(dte): Relax some DteXmlData validations for trusted inputs Ref: https://app.shortcut.com/cordada/story/9837 --- src/cl_sii/dte/data_models.py | 61 ++++++++++++++++++--- src/tests/test_dte_data_models.py | 88 +++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/src/cl_sii/dte/data_models.py b/src/cl_sii/dte/data_models.py index c3a32a4e..73f1b9a4 100644 --- a/src/cl_sii/dte/data_models.py +++ b/src/cl_sii/dte/data_models.py @@ -19,6 +19,7 @@ from __future__ import annotations import dataclasses +import logging from datetime import date, datetime from typing import Mapping, Optional, Sequence @@ -33,6 +34,9 @@ from .constants import CodigoReferencia, TipoDte +logger = logging.getLogger(__name__) + + def validate_dte_folio(value: int) -> None: """ Validate value for DTE field ``folio``. @@ -99,6 +103,39 @@ def validate_non_empty_bytes(value: bytes) -> None: raise ValueError("Bytes value length is 0.") +VALIDATION_CONTEXT_TRUST_INPUT: str = 'trust_input' +""" +Key for the validation context to indicate that the input data is trusted. +""" + + +def is_input_trusted_according_to_validation_context( + validation_context: Optional[Mapping[str, object]] +) -> bool: + """ + Return whether the input data is trusted according to the validation context. + + :param validation_context: + The validation context of a Pydantic model. + Get it from ``pydantic.ValidationInfo.context``. + + Example for data classes: + + >>> dte_xml_data_instance_kwargs: Mapping[str, object] = dict( + ... emisor_rut=Rut('60910000-1'), # ... + ... ) + >>> dte_xml_data_adapter = pydantic.TypeAdapter(DteXmlData) + >>> dte_xml_data_instance: DteXmlData = dte_xml_data_adapter.validate_python( + ... dte_xml_data_instance_kwargs, + ... context={VALIDATION_CONTEXT_TRUST_INPUT: True} + ... ) + """ + if validation_context is None: + return False + else: + return validation_context.get(VALIDATION_CONTEXT_TRUST_INPUT) is True + + @pydantic.dataclasses.dataclass( frozen=True, config=pydantic.ConfigDict( @@ -815,7 +852,9 @@ def validate_referencias_numero_linea_ref_order(cls, v: object) -> object: return v @pydantic.model_validator(mode='after') - def validate_referencias_rut_otro_is_consistent_with_tipo_dte(self) -> DteXmlData: + def validate_referencias_rut_otro_is_consistent_with_tipo_dte( + self, info: pydantic.ValidationInfo + ) -> DteXmlData: referencias = self.referencias tipo_dte = self.tipo_dte @@ -826,27 +865,37 @@ def validate_referencias_rut_otro_is_consistent_with_tipo_dte(self) -> DteXmlDat ): for referencia in referencias: if referencia.rut_otro: - raise ValueError( + message: str = ( f"Setting a 'rut_otro' is not a valid option for this 'tipo_dte':" f" 'tipo_dte' == {tipo_dte!r}," - f" 'Referencia' number {referencia.numero_linea_ref}.", + f" 'Referencia' number {referencia.numero_linea_ref}." ) + if is_input_trusted_according_to_validation_context(info.context): + logger.warning('Validation failed but input is trusted: %s', message) + else: + raise ValueError(message) return self @pydantic.model_validator(mode='after') - def validate_referencias_rut_otro_is_consistent_with_emisor_rut(self) -> DteXmlData: + def validate_referencias_rut_otro_is_consistent_with_emisor_rut( + self, info: pydantic.ValidationInfo + ) -> DteXmlData: referencias = self.referencias emisor_rut = self.emisor_rut if isinstance(referencias, Sequence) and isinstance(emisor_rut, Rut): for referencia in referencias: if referencia.rut_otro and referencia.rut_otro == emisor_rut: - raise ValueError( + message: str = ( f"'rut_otro' must be different from 'emisor_rut':" f" {referencia.rut_otro!r} == {emisor_rut!r}," - f" 'Referencia' number {referencia.numero_linea_ref}.", + f" 'Referencia' number {referencia.numero_linea_ref}." ) + if is_input_trusted_according_to_validation_context(info.context): + logger.warning('Validation failed but input is trusted: %s', message) + else: + raise ValueError(message) return self diff --git a/src/tests/test_dte_data_models.py b/src/tests/test_dte_data_models.py index b37b1091..94f39fd2 100644 --- a/src/tests/test_dte_data_models.py +++ b/src/tests/test_dte_data_models.py @@ -2,6 +2,7 @@ import dataclasses import unittest from datetime import date, datetime +from typing import Mapping import pydantic @@ -13,6 +14,7 @@ TipoDte, ) from cl_sii.dte.data_models import ( # noqa: F401 + VALIDATION_CONTEXT_TRUST_INPUT, DteDataL0, DteDataL1, DteDataL2, @@ -1060,6 +1062,8 @@ def setUpClass(cls) -> None: 'test_data/sii-crypto/DTE--96670340-7--61--110616-cert.der' ) + cls.dte_xml_data_pydantic_type_adapter = pydantic.TypeAdapter(DteXmlData) + def setUp(self) -> None: super().setUp() @@ -1761,6 +1765,46 @@ def test_validate_referencias_rut_otro_is_consistent_with_tipo_dte(self) -> None self.assertEqual(len(validation_errors), len(expected_validation_errors)) self.assertEqual(validation_errors, expected_validation_errors) + def test_validate_referencias_rut_otro_is_consistent_with_tipo_dte_for_trusted_input( + self, + ) -> None: + obj = self.dte_xml_data_2 + obj_referencia = DteXmlReferencia( + numero_linea_ref=1, + tipo_documento_ref="801", + folio_ref="1", + fecha_ref=date(2019, 3, 28), + ind_global=None, + rut_otro=Rut('76354771-K'), + codigo_ref=None, + razon_ref=None, + ) + + expected_log_msg = ( + "Validation failed but input is trusted: " + "Setting a 'rut_otro' is not a valid option for this 'tipo_dte':" + " 'tipo_dte' == ," + " 'Referencia' number 1." + ) + + invalid_but_trusted_obj: Mapping[str, object] = { + **self.dte_xml_data_pydantic_type_adapter.dump_python(obj), + **dict( + referencias=[obj_referencia], + ), + } + validation_context = {VALIDATION_CONTEXT_TRUST_INPUT: True} + + try: + with self.assertLogs('cl_sii.dte.data_models', level='WARNING') as assert_logs_cm: + self.dte_xml_data_pydantic_type_adapter.validate_python( + invalid_but_trusted_obj, context=validation_context + ) + except pydantic.ValidationError as exc: + self.fail(f'{exc.__class__.__name__} raised') + + self.assertEqual(assert_logs_cm.records[0].getMessage(), expected_log_msg) + def test_validate_referencias_rut_otro_is_consistent_with_emisor_rut(self) -> None: obj = self.dte_xml_data_2 obj = dataclasses.replace( @@ -1805,6 +1849,50 @@ def test_validate_referencias_rut_otro_is_consistent_with_emisor_rut(self) -> No self.assertEqual(len(validation_errors), len(expected_validation_errors)) self.assertEqual(validation_errors, expected_validation_errors) + def test_validate_referencias_rut_otro_is_consistent_with_emisor_rut_for_trusted_input( + self, + ) -> None: + obj = self.dte_xml_data_2 + obj = dataclasses.replace( + obj, + tipo_dte=TipoDte.FACTURA_COMPRA_ELECTRONICA, + ) + obj_referencia = DteXmlReferencia( + numero_linea_ref=1, + tipo_documento_ref="801", + folio_ref="1", + fecha_ref=date(2019, 3, 28), + ind_global=None, + rut_otro=Rut('60910000-1'), + codigo_ref=None, + razon_ref=None, + ) + + expected_log_msg = ( + "Validation failed but input is trusted: " + "'rut_otro' must be different from 'emisor_rut':" + " Rut('60910000-1') == Rut('60910000-1')," + " 'Referencia' number 1." + ) + + invalid_but_trusted_obj: Mapping[str, object] = { + **self.dte_xml_data_pydantic_type_adapter.dump_python(obj), + **dict( + referencias=[obj_referencia], + ), + } + validation_context = {VALIDATION_CONTEXT_TRUST_INPUT: True} + + try: + with self.assertLogs('cl_sii.dte.data_models', level='WARNING') as assert_logs_cm: + self.dte_xml_data_pydantic_type_adapter.validate_python( + invalid_but_trusted_obj, context=validation_context + ) + except pydantic.ValidationError as exc: + self.fail(f'{exc.__class__.__name__} raised') + + self.assertEqual(assert_logs_cm.records[0].getMessage(), expected_log_msg) + def test_validate_referencias_codigo_ref_is_consistent_with_tipo_dte(self) -> None: obj = self.dte_xml_data_3 obj_referencia = dataclasses.replace( From 36c374b062bee6cd9ca3e467f4e68f6f83545ac9 Mon Sep 17 00:00:00 2001 From: Jose Tomas Robles Hahn Date: Thu, 26 Sep 2024 14:30:34 -0300 Subject: [PATCH 2/2] feat(rtc): Add option to trust the input when parsing AEC XML documents Ref: https://app.shortcut.com/cordada/story/9837 --- src/cl_sii/rtc/parse_aec.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/cl_sii/rtc/parse_aec.py b/src/cl_sii/rtc/parse_aec.py index d1098886..54ee4f96 100644 --- a/src/cl_sii/rtc/parse_aec.py +++ b/src/cl_sii/rtc/parse_aec.py @@ -69,14 +69,25 @@ def validate_aec_xml(xml_doc: XmlElement) -> None: xml_utils.validate_xml_doc(AEC_XML_SCHEMA_OBJ, xml_doc) -def parse_aec_xml(xml_doc: XmlElement) -> data_models_aec.AecXml: +def parse_aec_xml(xml_doc: XmlElement, trust_input: bool = False) -> data_models_aec.AecXml: """ Parse data from a "cesión"'s AEC XML doc. .. warning:: It is assumed that ``xml_doc`` is an ``{http://www.sii.cl/SiiDte}/AEC`` XML element. + + :param xml_doc: + AEC XML document. + :param trust_input: + If ``True``, the input data is trusted to be valid and + some validation errors are replaced by warnings. + + .. warning:: + Use this option *only* if you obtained the AEC XML document + from the SII *and* you need to work around some validation errors + that the SII should have caught, but let through. """ - aec_struct = _Aec.parse_xml(xml_doc) + aec_struct = _Aec.parse_xml(xml_doc, trust_input=trust_input) return aec_struct.as_aec_xml() @@ -889,9 +900,14 @@ class _Aec(pydantic.BaseModel): ########################################################################### @classmethod - def parse_xml(cls, xml_doc: XmlElement) -> _Aec: + def parse_xml(cls, xml_doc: XmlElement, trust_input: bool = False) -> _Aec: aec_dict = cls.parse_xml_to_dict(xml_doc) - return cls.model_validate(aec_dict) + return cls.model_validate( + aec_dict, + context={ + cl_sii.dte.data_models.VALIDATION_CONTEXT_TRUST_INPUT: trust_input, + }, + ) def as_aec_xml(self) -> data_models_aec.AecXml: doc_aec_struct = self.documento_aec