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

Add ciblex carrier with api v2 #180

Draft
wants to merge 1 commit into
base: pydantic
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions roulier/carriers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
from . import geodis_fr
from . import mondialrelay
from . import mondialrelay_fr
from . import ciblex
1 change: 1 addition & 0 deletions roulier/carriers/ciblex/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import carrier
153 changes: 153 additions & 0 deletions roulier/carriers/ciblex/carrier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Copyright 2024 Akretion (http://www.akretion.com).
# @author Florian Mounier <[email protected]>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import base64
import logging
import requests
from lxml.html import fromstring
from ...carrier import Carrier, action
from ...exception import CarrierError
from .schema import CiblexLabelInput, CiblexLabelOutput

_logger = logging.getLogger(__name__)


class Ciblex(Carrier):
__key__ = "ciblex"
__url__ = "https://secure.extranet.ciblex.fr/extranet/client"

def _xpath(self, response, xpath):
root = fromstring(response.text)
return root.xpath(xpath)

def _xpath_to_text(self, response, xpath):
nodes = self._xpath(response, xpath)
if nodes:
return "\n".join([e.text_content() for e in nodes])

Check warning on line 26 in roulier/carriers/ciblex/carrier.py

View check run for this annotation

Codecov / codecov/patch

roulier/carriers/ciblex/carrier.py#L26

Added line #L26 was not covered by tests

def _auth(self, auth):
response = requests.post(f"{self.__url__}/index.php", data=auth.params())
error = self._xpath_to_text(response, '//td[@class="f_erreur_small"]')
if error:
raise CarrierError(response, error)

Check warning on line 32 in roulier/carriers/ciblex/carrier.py

View check run for this annotation

Codecov / codecov/patch

roulier/carriers/ciblex/carrier.py#L32

Added line #L32 was not covered by tests

return response.cookies

def _validate(self, auth, params):
# 1) Validate
response = requests.get(
f"{self.__url__}/corps.php",
params={"action": "Valider", **params},
cookies=auth,
)

# Handle approximative city
cp_dest = self._xpath(response, '//select[@name="cp_dest"]')
if cp_dest:
good_city = cp_dest[0].getchildren()[0].text.split(" ", 1)[1]
if params["dest_ville"] == good_city:
raise CarrierError(response, "City not found")

Check warning on line 49 in roulier/carriers/ciblex/carrier.py

View check run for this annotation

Codecov / codecov/patch

roulier/carriers/ciblex/carrier.py#L49

Added line #L49 was not covered by tests
_logger.warning(f"Replacing {params['dest_ville']} by {good_city}")
params["dest_ville"] = good_city.encode("latin-1")
return self._validate(auth, params)

error = self._xpath_to_text(response, '//p[@class="f_erreur"]')
if error:
raise CarrierError(response, error)

Check warning on line 56 in roulier/carriers/ciblex/carrier.py

View check run for this annotation

Codecov / codecov/patch

roulier/carriers/ciblex/carrier.py#L56

Added line #L56 was not covered by tests

def _print(self, auth, params, format="PDF"):
# 2) Print
response = requests.get(
f"{self.__url__}/corps.php",
params={
"action": (
"Imprimer(PDF)" if format == "PDF" else "Imprimer(Thermique)"
),
**params,
},
cookies=auth,
)

labels = self._xpath(response, '//input[@name="liste_cmd"]')
if not labels:
raise CarrierError(response, "No label found")

Check warning on line 73 in roulier/carriers/ciblex/carrier.py

View check run for this annotation

Codecov / codecov/patch

roulier/carriers/ciblex/carrier.py#L73

Added line #L73 was not covered by tests
if len(labels) > 1:
raise CarrierError(response, "Multiple labels found")

Check warning on line 75 in roulier/carriers/ciblex/carrier.py

View check run for this annotation

Codecov / codecov/patch

roulier/carriers/ciblex/carrier.py#L75

Added line #L75 was not covered by tests
label = labels[0]
order = label.attrib["value"]
return {
"order": order,
"format": format,
}

def _download(self, auth, order):
# 3) Get label
response = requests.get(
f"{self.__url__}/label_ool.php",
params={
"origine": "OOL",
"output": order["format"],
"url_retour": f"{self.__url__}/corps.php?module=cmdjou",
"liste_cmd": order["order"],
},
cookies=auth,
)
return base64.b64encode(response.content)

def _get_tracking(self, auth, order, label, input):
# 4) Get tracking
response = requests.get(
f"{self.__url__}/corps.php",
params={
"codecli": "tous",
"date1": input.service.shippingDate.strftime("%d/%m/%Y"),
"date2": input.service.shippingDate.strftime("%d/%m/%Y"),
"etat": 0,
"cmdsui": "Rechercher",
"module": "cmdsui",
"action": "rechercher",
},
cookies=auth,
)
# Order format is like "04282,17,1,1" : customerId, order, parcel count, ?
customer_id, order_id, count, _ = order["order"].split(",")

count = int(count)
assert count == len(input.parcels), "Parcel count mismatch"

order_ref = f"{customer_id}-{order_id.zfill(6)}"
orders = self._xpath(response, '//tr[@class="t_liste_ligne"]')
order = next(
filter(lambda o: o.getchildren()[0].text == order_ref, orders), None
)
if order is None or not len(order):
raise CarrierError(response, f"Order {order_ref} not found")

Check warning on line 124 in roulier/carriers/ciblex/carrier.py

View check run for this annotation

Codecov / codecov/patch

roulier/carriers/ciblex/carrier.py#L124

Added line #L124 was not covered by tests

trackings = [a.text for a in order.getchildren()[4].findall("a")]
return [
{
"id": f"{order_ref}_{i+1}",
"reference": input.parcels[i].reference,
"format": "PDF",
"label": label, # TODO: Label contain all parcels, split it?
"tracking": trackings[i],
}
for i in range(count)
]

@action
def get_label(self, input: CiblexLabelInput) -> CiblexLabelOutput:
auth = self._auth(input.auth)
format = input.service.labelFormat or "PDF"
if format != "PDF":
# Website also use "PRINTER" but this can't work here
raise CarrierError(None, "Only PDF format is supported")

Check warning on line 144 in roulier/carriers/ciblex/carrier.py

View check run for this annotation

Codecov / codecov/patch

roulier/carriers/ciblex/carrier.py#L144

Added line #L144 was not covered by tests

# requests send all params as utf-8, but Ciblex expect latin-1
params = input.params()
self._validate(auth, params)
order = self._print(auth, params, format)
label = self._download(auth, order)
results = self._get_tracking(auth, order, label, input)

return CiblexLabelOutput.from_params(results)
164 changes: 164 additions & 0 deletions roulier/carriers/ciblex/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Copyright 2024 Akretion (http://www.akretion.com).
# @author Florian Mounier <[email protected]>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from ...helpers import prefix, suffix, none_as_empty, unaccent
from ...schema import (
LabelInput,
Address,
LabelOutput,
Auth,
Service,
Parcel,
ParcelLabel,
Label,
Tracking,
)


class CiblexAuth(Auth):
login: str
password: str

def params(self):
return {
"USER_COMPTE": self.login,
"USER_PASSWORD": self.password,
"lang": "fr",
"LOGIN": "Connexion sécurisée",
}


class CiblexService(Service):
customerId: str
product: str
imperative_time: str | None = None # 08:00, 09:00
opt_ssm: bool | None = None

def params(self):
return {
"expediteur": self.customerId,
"prestation": self.product,
"date_cmd": self.shippingDate.strftime("%d/%m/%Y"),
"imperatif": self.imperative_time,
"opt_ssm": self.opt_ssm,
}


class CiblexParcel(Parcel):
reference2: str | None = None
reference3: str | None = None
delivery_versus: float | None = None
check_payable_to: str | None = None
# ad_valorem_types: 1 : standand, 2 : sensible, 4 : international
ad_valorem_type: int | None = None
ad_valorem: float | None = None
ad_valorem_agreed: bool | None = None

def params(self):
return {
"poids": self.weight,
"ref1": self.reference,
"ref2": self.reference2,
"ref3": self.reference3,
"cpa": self.delivery_versus,
"ordre_chq": self.check_payable_to,
"opt_adv": self.ad_valorem_type,
"adv": self.ad_valorem,
"adv_cond": self.ad_valorem_agreed,
}


class CiblexAddress(Address):
zip: str
city: str
country: str # FR ou MC, enum?
street3: str | None = None
street4: str | None = None

def params(self):
return {
"raison": ", ".join([part for part in (self.name, self.company) if part]),
"adr1": self.street1,
"adr2": self.street2,
"adr3": self.street3,
"adr4": self.street4,
"cp": self.zip,
"ville": self.city,
"pays": self.country,
"tel": self.phone,
"email": self.email,
}


class CiblexLabelInput(LabelInput):
auth: CiblexAuth
service: CiblexService
parcels: list[CiblexParcel]
to_address: CiblexAddress
from_address: CiblexAddress

def params(self):
return unaccent(
none_as_empty(
{
"module": "cmdsai",
"commande": None,
**self.service.params(),
**prefix(self.from_address.params(), "exp_"),
**prefix(self.to_address.params(), "dest_"),
"nb_colis": len(self.parcels),
**{
k: v
for i, parcel in enumerate(self.parcels)
for k, v in suffix(parcel.params(), f"_{i+1}").items()
},
}
)
)


class CiblexTracking(Tracking):
@classmethod
def from_params(cls, result):
return cls.model_construct(
number=result["tracking"],
url=(
"https://secure.extranet.ciblex.fr/extranet/client/"
"corps.php?module=colis&colis=%s" % result["tracking"]
),
)


class CiblexLabel(Label):
@classmethod
def from_params(cls, result):
return cls.model_construct(
data=result["label"].decode("utf-8"),
name="label",
type=result["format"],
)


class CiblexParcelLabel(ParcelLabel):
id: str
label: CiblexLabel | None = None
tracking: CiblexTracking | None = None

@classmethod
def from_params(cls, result):
return cls.model_construct(
id=result["id"],
reference=result["reference"],
label=CiblexLabel.from_params(result),
tracking=CiblexTracking.from_params(result),
)


class CiblexLabelOutput(LabelOutput):
parcels: list[CiblexParcelLabel]

@classmethod
def from_params(cls, results):
return cls.model_construct(
parcels=[CiblexParcelLabel.from_params(result) for result in results],
)
Empty file.
Loading
Loading