Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Relax some validations for trusted inputs #703

Merged
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
61 changes: 55 additions & 6 deletions src/cl_sii/dte/data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from __future__ import annotations

import dataclasses
import logging
from datetime import date, datetime
from typing import Mapping, Optional, Sequence

Expand All @@ -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``.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
24 changes: 20 additions & 4 deletions src/cl_sii/rtc/parse_aec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions src/tests/test_dte_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import dataclasses
import unittest
from datetime import date, datetime
from typing import Mapping

import pydantic

Expand All @@ -13,6 +14,7 @@
TipoDte,
)
from cl_sii.dte.data_models import ( # noqa: F401
VALIDATION_CONTEXT_TRUST_INPUT,
DteDataL0,
DteDataL1,
DteDataL2,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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' == <TipoDte.FACTURA_ELECTRONICA: 33>,"
" '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(
Expand Down Expand Up @@ -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(
Expand Down