From 28c5dadaa163c7b44d5a425354f30c7cc2fd0c3c Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 11 Sep 2024 14:34:15 +0200 Subject: [PATCH] Introduce new carrier format based on pydantic models. Implement mondialrelay get_label as an example. --- roulier/__init__.py | 1 + roulier/carriersv2/__init__.py | 1 + roulier/carriersv2/api.py | 86 ++++++++ roulier/carriersv2/helpers.py | 23 +++ roulier/carriersv2/mondialrelay/__init__.py | 0 roulier/carriersv2/mondialrelay/constants.py | 149 ++++++++++++++ roulier/carriersv2/mondialrelay/schema.py | 183 ++++++++++++++++++ .../carriersv2/mondialrelay/transporter.py | 33 ++++ roulier/carriersv2/schema.py | 81 ++++++++ setup.py | 3 + 10 files changed, 560 insertions(+) create mode 100644 roulier/carriersv2/__init__.py create mode 100644 roulier/carriersv2/api.py create mode 100644 roulier/carriersv2/helpers.py create mode 100644 roulier/carriersv2/mondialrelay/__init__.py create mode 100644 roulier/carriersv2/mondialrelay/constants.py create mode 100644 roulier/carriersv2/mondialrelay/schema.py create mode 100644 roulier/carriersv2/mondialrelay/transporter.py create mode 100644 roulier/carriersv2/schema.py diff --git a/roulier/__init__.py b/roulier/__init__.py index c0c3fc1..8759ca1 100755 --- a/roulier/__init__.py +++ b/roulier/__init__.py @@ -5,6 +5,7 @@ from . import transport from . import carrier_action from . import carriers +from . import carriersv2 import logging __all__ = [roulier] diff --git a/roulier/carriersv2/__init__.py b/roulier/carriersv2/__init__.py new file mode 100644 index 0000000..1a84411 --- /dev/null +++ b/roulier/carriersv2/__init__.py @@ -0,0 +1 @@ +from .mondialrelay import transporter diff --git a/roulier/carriersv2/api.py b/roulier/carriersv2/api.py new file mode 100644 index 0000000..4c636dd --- /dev/null +++ b/roulier/carriersv2/api.py @@ -0,0 +1,86 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from functools import wraps +import logging +import typing +from ..roulier import factory +from ..exception import InvalidApiInput + + +log = logging.getLogger(__name__) + + +class MetaTransporter(type): + """ + Metaclass for Transporter classes. + + Used to register transporter actions in the roulier factory. + """ + + def __new__(cls, name, bases, dct): + transporter = super().__new__(cls, name, bases, dct) + + name = getattr(transporter, "__key__", transporter.__name__.lower()) + + for key, value in dct.items(): + if getattr(value, "__action__", False): + log.debug(f"Registering {key} for {name}") + factory.register_builder(name, key, transporter) + + return transporter + + +class Transporter(metaclass=MetaTransporter): + """ + Base class for pydantic transporters. + """ + + def __init__(self, carrier_type, action, **kwargs): + """This is unused, but required by the factory.""" + self.carrier_type = carrier_type + self.action = action + + +def action(f): + """ + Decorator for transporter actions. Use it to register an action in the + factory and to validate input and output data. + + The decorated method must have an `input` argument decorated with a type hint + and a return type hint. + + Example: + ```python + @action + def get_label(self, input: TransporterLabelInput) -> TransporterLabelOutput: + return TransporterLabelOutput.from_response( + self.fetch(input.to_request()) + ) + ``` + """ + + @wraps(f) + def wrapper(self, carrier_type, action, data): + hints = typing.get_type_hints(f) + if "input" not in hints: + raise ValueError(f"Missing input argument or type hint for {f}") + if "return" not in hints: + raise ValueError(f"Missing return type hint for {f}") + + try: + input = hints["input"](**data) + except Exception as e: + raise InvalidApiInput(f"Invalid input data {data!r}\n\n{e!s}") from e + + rv = f(self, input) + + if isinstance(rv, hints["return"]): + return rv.dict() + + return rv + + # Mark the function as an action for the metaclass + wrapper.__action__ = True + return wrapper diff --git a/roulier/carriersv2/helpers.py b/roulier/carriersv2/helpers.py new file mode 100644 index 0000000..1cb45ee --- /dev/null +++ b/roulier/carriersv2/helpers.py @@ -0,0 +1,23 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import ClassVar + +REMOVED = ClassVar[None] # Hack to remove a field from inherited class + + +def prefix(data, prefix): + return {f"{prefix}{k}": v for k, v in data.items()} + + +def suffix(data, suffix): + return {f"{k}{suffix}": v for k, v in data.items()} + + +def clean_empty(data): + return {k: v for k, v in data.items() if v is not None and v != ""} + + +def none_as_empty(data): + return {k: v if v is not None else "" for k, v in data.items()} diff --git a/roulier/carriersv2/mondialrelay/__init__.py b/roulier/carriersv2/mondialrelay/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roulier/carriersv2/mondialrelay/constants.py b/roulier/carriersv2/mondialrelay/constants.py new file mode 100644 index 0000000..e61de07 --- /dev/null +++ b/roulier/carriersv2/mondialrelay/constants.py @@ -0,0 +1,149 @@ +STATUSES = { + 0: "Opération effectuée avec succès", + 1: "Enseigne invalide", + 2: "Numéro d'enseigne vide ou inexistant", + 3: "Numéro de compte enseigne invalide", + 4: "", + 5: "Numéro de dossier enseigne invalide", + 6: "", + 7: "Numéro de client enseigne invalide (champ NCLIENT)", + 8: "Mot de passe ou hachage invalide", + 9: "Ville non reconnu ou non unique", + 10: "Type de collecte invalide", + 11: "Numéro de Relais de Collecte invalide", + 12: "Pays de Relais de collecte invalide", + 13: "Type de livraison invalide", + 14: "Numéro de Relais de livraison invalide", + 15: "Pays de Relais de livraison invalide", + 16: "", + 17: "", + 18: "", + 19: "", + 20: "Poids du colis invalide", + 21: "Taille (Longueur + Hauteur) du colis invalide", + 22: "Taille du Colis invalide", + 23: "", + 24: "Numéro d'expédition ou de suivi invalide", + 25: "", + 26: "Temps de montage invalide", + 27: "Mode de collecte ou de livraison invalide", + 28: "Mode de collecte invalide", + 29: "Mode de livraison invalide", + 30: "Adresse (L1) invalide", + 31: "Adresse (L2) invalide", + 32: "", + 33: "Adresse (L3) invalide", + 34: "Adresse (L4) invalide", + 35: "Ville invalide", + 36: "Code postal invalide", + 37: "Pays invalide", + 38: "Numéro de téléphone invalide", + 39: "Adresse e-mail invalide", + 40: "Paramètres manquants", + 41: "", + 42: "Montant CRT invalide", + 43: "Devise CRT invalide", + 44: "Valeur du colis invalide", + 45: "Devise de la valeur du colis invalide", + 46: "Plage de numéro d'expédition épuisée", + 47: "Nombre de colis invalide", + 48: "Multi-Colis Relais Interdit", + 49: "Action invalide", + 50: "", + 51: "", + 52: "", + 53: "", + 54: "", + 55: "", + 56: "", + 57: "", + 58: "", + 59: "", + 60: "Champ texte libre invalide (Ce code erreur n'est pas invalidant)", + 61: "Top avisage invalide", + 62: "Instruction de livraison invalide", + 63: "Assurance invalide", + 64: "Temps de montage invalide", + 65: "Top rendez-vous invalide", + 66: "Top reprise invalide", + 67: "Latitude invalide", + 68: "Longitude invalide", + 69: "Code Enseigne invalide", + 70: "Numéro de Point Relais invalide", + 71: "Nature de point de vente non valide", + 72: "", + 73: "", + 74: "Langue invalide", + 75: "", + 76: "", + 77: "", + 78: "Pays de Collecte invalide", + 79: "Pays de Livraison invalide", + 80: "Code tracing : Colis enregistré", + 81: "Code tracing : Colis en traitement chez Mondial Relay", + 82: "Code tracing : Colis livré", + 83: "Code tracing : Anomalie", + 84: "(Réservé Code Tracing)", + 85: "(Réservé Code Tracing)", + 86: "(Réservé Code Tracing)", + 87: "(Réservé Code Tracing)", + 88: "(Réservé Code Tracing)", + 89: "(Réservé Code Tracing)", + 90: "", + 91: "", + 92: "Le code pays du destinataire et le code pays du Point Relais doivent être identiques ou solde insuffisant (comptes prépayés).", + 93: "Aucun élément retourné par le plan de tri. Si vous effectuez une collecte ou une livraison en Point Relais, vérifiez que les Point Relais sont bien disponibles. Si vous effectuez une livraison à domicile, il est probable que le code postal que vous avez indiqué n'existe pas.", + 94: "Colis Inexistant", + 95: "Compte Enseigne non activé", + 96: "Type d'enseigne incorrect en Base", + 97: "Clé de sécurité invalide", + 98: "Erreur générique (Paramètres invalides)", + 99: "Erreur générique du service", +} + +SORTED_KEYS = [ + "Enseigne", + "ModeCol", + "ModeLiv", + "NDossier", + "NClient", + "Expe_Langage", + "Expe_Ad1", + "Expe_Ad2", + "Expe_Ad3", + "Expe_Ad4", + "Expe_Ville", + "Expe_CP", + "Expe_Pays", + "Expe_Tel1", + "Expe_Tel2", + "Expe_Mail", + "Dest_Langage", + "Dest_Ad1", + "Dest_Ad2", + "Dest_Ad3", + "Dest_Ad4", + "Dest_Ville", + "Dest_CP", + "Dest_Pays", + "Dest_Tel1", + "Dest_Tel2", + "Dest_Mail", + "Poids", + "Longueur", + "Taille", + "NbColis", + "CRT_Valeur", + "CRT_Devise", + "Exp_Valeur", + "Exp_Devise", + "COL_Rel_Pays", + "COL_Rel", + "LIV_Rel_Pays", + "LIV_Rel", + "TAvisage", + "TReprise", + "Montage", + "Assurance", + "Instructions", +] diff --git a/roulier/carriersv2/mondialrelay/schema.py b/roulier/carriersv2/mondialrelay/schema.py new file mode 100644 index 0000000..8ba8251 --- /dev/null +++ b/roulier/carriersv2/mondialrelay/schema.py @@ -0,0 +1,183 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from ..helpers import prefix, clean_empty, REMOVED +from ..schema import ( + LabelInput, + Address, + LabelOutput, + Auth, + Service, + Parcel, + ParcelLabel, + Label, +) +from .constants import SORTED_KEYS +from hashlib import md5 + + +class MondialRelayAuth(Auth): + login: str + password: str + + def soap(self): + return { + "Enseigne": self.login, + } + + def sign(self, parameters): + m = md5() + m.update( + "".join( + [ + str(v) + for k in SORTED_KEYS + for v in [parameters.get(k)] + if v is not None + ] + + [self.password] + ).encode( + "iso-8859-1" + ) # And not utf-8, yes the doc says otherwise + ) + parameters["Security"] = m.hexdigest().upper() + return parameters + + +class MondialRelayService(Service): + pickupMode: str + cashOnDelivery: int = 0 + cashOnDeliveryCurrency: str = "EUR" + price: int | None = None + currency: str = "EUR" + pickupCountry: str | None = None + pickupSite: str | None = None + shippingCountry: str | None = None + shippingSite: str | None = None + notice: bool | None = None + takeBack: bool | None = None + assemblyTime: int | None = None + insurance: int | None = None + text: str | None = None + + shippingDate: REMOVED + + def french_boolean(self, value): + if value is None: + return None + return "O" if value else "N" + + def soap(self): + return { + "ModeCol": self.pickupMode, + "ModeLiv": self.product, + "NDossier": self.shippingId, + "NClient": self.customerId, + "CRT_Valeur": self.cashOnDelivery, + "CRT_Devise": self.cashOnDeliveryCurrency, + "Exp_Valeur": self.price, + "Exp_Devise": self.currency, + "COL_Rel_Pays": self.pickupCountry, + "COL_Rel": self.pickupSite, + "LIV_Rel_Pays": self.shippingCountry, + "LIV_Rel": self.shippingSite, + "TAvisage": self.french_boolean(self.notice), + "TReprise": self.french_boolean(self.takeBack), + "Montage": self.assemblyTime, + # "TRDV": self.TRDV, Unused + "Assurance": self.insurance, + "Instructions": self.instructions, + "Texte": self.text, + } + + +class MondialRelayParcel(Parcel): + length: int | None = None + height: str | None = None + count: int = 1 + + def soap(self): + return { + "Poids": int(self.weight * 1000), + "Longueur": self.length, + "Taille": self.height, + "NbColis": self.count, + } + + +class MondialRelayAddress(Address): + lang: str + country: str + zip: str + city: str + street1: str + phone2: str | None = None + + def soap(self): + return { + "Langage": self.lang, + "Ad1": self.name, + "Ad2": self.company, + "Ad3": self.street1, + "Ad4": self.street2, + "Ville": self.city, + "CP": self.zip, + "Pays": self.country, + "Tel1": self.phone, + "Tel2": self.phone2, + "Mail": self.email, + } + + +class MondialRelayFromAddress(MondialRelayAddress): + phone: str + + +class MondialRelayLabelInput(LabelInput): + auth: MondialRelayAuth + service: MondialRelayService + parcels: list[MondialRelayParcel] + from_address: MondialRelayFromAddress + to_address: MondialRelayAddress + + def soap(self): + return self.auth.sign( + clean_empty( + { + **self.auth.soap(), + **self.service.soap(), + **self.parcels[0].soap(), + **prefix(self.from_address.soap(), "Expe_"), + **prefix(self.to_address.soap(), "Dest_"), + } + ) + ) + + +class MondialRelayLabel(Label): + @classmethod + def from_soap(cls, result): + return cls.model_construct( + data=f"https://www.mondialrelay.com{result['URL_Etiquette']}", + name="label_url", + type="url", + ) + + +class MondialRelayParcelLabel(ParcelLabel): + label: MondialRelayLabel | None = None + + @classmethod + def from_soap(cls, result): + return cls.model_construct( + id=int(result["ExpeditionNum"]), + label=MondialRelayLabel.from_soap(result), + ) + + +class MondialRelayLabelOutput(LabelOutput): + parcels: list[MondialRelayParcelLabel] + + @classmethod + def from_soap(cls, result): + return cls.model_construct(parcels=[MondialRelayParcelLabel.from_soap(result)]) diff --git a/roulier/carriersv2/mondialrelay/transporter.py b/roulier/carriersv2/mondialrelay/transporter.py new file mode 100644 index 0000000..fd7d0b0 --- /dev/null +++ b/roulier/carriersv2/mondialrelay/transporter.py @@ -0,0 +1,33 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import zeep + +from ..api import Transporter, action +from ...exception import CarrierError +from .schema import MondialRelayLabelInput, MondialRelayLabelOutput +from .constants import STATUSES + + +class MondialRelay(Transporter): + __key__ = "mondialrelay2" + __url__ = "https://api.mondialrelay.com/Web_Services.asmx?WSDL" + __ns_prefix__ = "http://www.mondialrelay.fr/webservice/" + + @property + def client(self): + client = zeep.Client(wsdl=self.__url__) + client.set_ns_prefix(None, self.__ns_prefix__) + return client.service + + def raise_for_status(self, result): + if "STAT" not in result: + raise CarrierError(result, "No status returned") + if result["STAT"] != "0": + raise CarrierError(result, STATUSES[int(result["STAT"])]) + + @action + def get_label(self, input: MondialRelayLabelInput) -> MondialRelayLabelOutput: + result = self.client.WSI2_CreationEtiquette(**input.soap()) + self.raise_for_status(result) + return MondialRelayLabelOutput.from_soap(result) diff --git a/roulier/carriersv2/schema.py b/roulier/carriersv2/schema.py new file mode 100644 index 0000000..d8583ca --- /dev/null +++ b/roulier/carriersv2/schema.py @@ -0,0 +1,81 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import date +from pydantic import BaseModel + + +class Auth(BaseModel): + login: str | None = None + password: str | None = None + isTest: bool = False + + +class Service(BaseModel): + product: str | None = None + agencyId: str | None = None + customerId: str | None = None + shippingId: str | None = None + shippingDate: date + reference1: str | None = None + reference2: str | None = None + reference3: str | None = None + labelFormat: str | None = None + instructions: str | None = None + + +class Parcel(BaseModel): + weight: float + reference: str | None = None + + +class Address(BaseModel): + company: str | None = None + name: str + street1: str | None = None + street2: str | None = None + city: str | None = None + country: str | None = None + zip: str | None = None + phone: str | None = None + email: str | None = None + + +class ToAddress(Address): + street1: str + country: str + city: str + zip: str + delivery_instructions: str | None = None + + +class LabelInput(BaseModel): + auth: Auth + service: Service + parcels: list[Parcel] + from_address: Address + to_address: ToAddress + + +class Tracking(BaseModel): + number: str + url: str | None = None + partner: str | None = None + + +class Label(BaseModel): + data: str + name: str + type: str + + +class ParcelLabel(BaseModel): + id: int + reference: str | None = None + tracking: Tracking | None = None + label: Label | None = None + + +class LabelOutput(BaseModel): + parcels: list[ParcelLabel] + annexes: list[Label] = [] diff --git a/setup.py b/setup.py index afe92cf..9083957 100755 --- a/setup.py +++ b/setup.py @@ -19,6 +19,9 @@ "zplgrf", "unidecode", "pycountry", + # v2 + "pydantic", + "zeep", ], extras_requires={ "dev": ["ptpython", "pytest"],