Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 3192842

Browse files
committedMar 20, 2025·
[IMP] delivery_postlogistics: refactor API of web service class to avoid loss when several unchained modules implements new behaviors in it
1 parent 1f3da3e commit 3192842

File tree

3 files changed

+257
-170
lines changed

3 files changed

+257
-170
lines changed
 

‎delivery_postlogistics/models/stock_picking.py

+135-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
# Copyright 2013 Camptocamp SA
22
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
33
import base64
4+
from io import BytesIO
45
from operator import attrgetter
56

67
import lxml.html
8+
from PIL import Image
9+
from typing_extensions import deprecated
710

811
from odoo import api, fields, models, tools
912
from odoo.exceptions import UserError
1013

11-
from ..postlogistics.web_service import PostlogisticsWebService
14+
from ..postlogistics.web_service import PostlogisticsWebService, sanitize_string
1215

1316

1417
class StockPicking(models.Model):
@@ -27,6 +30,14 @@ class StockPicking(models.Model):
2730
"Mobile", help="For notify delivery by telephone (ZAW3213)"
2831
)
2932

33+
@deprecated(
34+
"This method will be removed in version 18.0. \
35+
Please use _get_quant_packages_from_picking instead."
36+
)
37+
def _get_packages_from_picking(self):
38+
# TODO: remove this method in version > 18.0
39+
return self._get_quant_packages_from_picking()
40+
3041
def _get_quant_packages_from_picking(self):
3142
"""Get all the quant packages from the picking"""
3243
self.ensure_one()
@@ -235,3 +246,126 @@ def action_generate_carrier_label(self):
235246
if not self.carrier_id:
236247
raise UserError(self.env._("Please, set a carrier."))
237248
self.env["delivery.carrier"].postlogistics_send_shipping(self)
249+
250+
#
251+
# Postlogistics specific methods allowing proper override
252+
#
253+
254+
def get_package_number_hook(self, package):
255+
"""Hook method to customize the package number retrieval"""
256+
return None
257+
258+
def get_recipient_partner_hook(self):
259+
"""Hook method to customize the partner retrieval"""
260+
self.ensure_one()
261+
if self.picking_type_id.code != "outgoing":
262+
return (
263+
self.location_dest_id.company_id.partner_id
264+
or self.env.user.company_id.partner_id
265+
)
266+
return self.partner_id
267+
268+
def postlogistics_label_prepare_attributes(
269+
self, pack=None, pack_num=None, pack_total=None, pack_weight=None
270+
):
271+
"""This method aims to prepare a dictionary of attributes to be sent
272+
to the PostLogistics API"""
273+
self.ensure_one()
274+
package_type = (
275+
pack
276+
and pack.package_type_id
277+
or self.carrier_id.postlogistics_default_package_type_id
278+
)
279+
package_codes = package_type._get_shipper_package_code_list()
280+
281+
if pack_weight:
282+
total_weight = pack_weight
283+
else:
284+
total_weight = pack.shipping_weight if pack else self.shipping_weight
285+
total_weight *= 1000
286+
287+
if not package_codes:
288+
raise UserError(
289+
self.env._(
290+
"No PostLogistics packaging services found "
291+
"in package type {package_type_name}, for picking {picking_name}."
292+
).format(package_type_name=package_type.name, picking_name=self.name)
293+
)
294+
295+
# Activate phone notification ZAW3213
296+
# if phone call notification is set on partner
297+
if self.partner_id.postlogistics_notification == "phone":
298+
package_codes.append("ZAW3213")
299+
300+
attributes = {
301+
"weight": int(total_weight),
302+
}
303+
304+
# Remove the services if the delivery fixed date is not set
305+
if "ZAW3217" in package_codes:
306+
if self.delivery_fixed_date:
307+
attributes["deliveryDate"] = self.delivery_fixed_date
308+
else:
309+
package_codes.remove("ZAW3217")
310+
311+
# parcelNo / parcelTotal cannot be used if service ZAW3218 is not activated
312+
if "ZAW3218" in package_codes:
313+
if pack_total > 1:
314+
attributes.update(
315+
{"parcelTotal": pack_total - 1, "parcelNo": pack_num - 1}
316+
)
317+
else:
318+
package_codes.remove("ZAW3218")
319+
320+
if "ZAW3219" in package_codes and self.delivery_place:
321+
attributes["deliveryPlace"] = self.delivery_place
322+
if self.carrier_id.postlogistics_proclima_logo:
323+
attributes["proClima"] = True
324+
else:
325+
attributes["proClima"] = False
326+
327+
attributes["przl"] = package_codes
328+
329+
return attributes
330+
331+
def postlogistics_label_prepare_customer(self):
332+
"""Create a ns0:Customer as a dict from picking
333+
334+
This is the PostLogistics Customer, thus the sender
335+
336+
:param picking: picking browse record
337+
:return a dict containing data for ns0:Customer
338+
339+
"""
340+
self.ensure_one()
341+
company = self.company_id
342+
partner = company.partner_id
343+
if self.picking_type_id.code != "outgoing":
344+
partner = self.partner_id
345+
346+
partner_name = partner.name or partner.parent_id.name
347+
if not partner_name:
348+
raise UserError(self.env._("Customer name is required."))
349+
customer = {
350+
"name1": sanitize_string(partner_name)[:25],
351+
"street": sanitize_string(partner.street)[:25],
352+
"zip": sanitize_string(partner.zip)[:10],
353+
"city": sanitize_string(partner.city)[:25],
354+
"country": partner.country_id.code,
355+
"domicilePostOffice": self.carrier_id.postlogistics_office or None,
356+
}
357+
logo = self.carrier_id.postlogistics_logo
358+
if logo:
359+
logo_image = Image.open(BytesIO(base64.b64decode(logo)))
360+
logo_format = logo_image.format
361+
customer["logo"] = logo.decode()
362+
customer["logoFormat"] = logo_format
363+
return customer
364+
365+
def postlogistics_label_cash_on_delivery(self, package=None):
366+
amount = (package or self).postlogistics_cod_amount()
367+
amount = f"{amount:.2f}"
368+
return [{"Type": "NN_BETRAG", "Value": amount}]
369+
370+
def postlogistics_label_get_item_additional_data(self, package=None):
371+
return []

‎delivery_postlogistics/postlogistics/web_service.py

+120-165
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@
88
import threading
99
import urllib.parse
1010
from datetime import datetime, timedelta
11-
from io import BytesIO
1211
from json import JSONDecodeError
1312

1413
import requests
15-
from PIL import Image
14+
from typing_extensions import deprecated
1615

1716
from odoo.exceptions import UserError
1817

@@ -33,6 +32,21 @@
3332
}
3433

3534

35+
def sanitize_string(value, mapping=None):
36+
"""Remove disallowed characters ("|", "\", "<", ">", "’", "‘") from a string
37+
38+
:param value: string to sanitize
39+
:param mapping: dict of disallowed characters to remove
40+
:return: sanitized string
41+
42+
"""
43+
mapping = mapping or DISALLOWED_CHARS_MAPPING
44+
value = value or ""
45+
for char, repl in mapping.items():
46+
value = value.replace(char, repl)
47+
return value
48+
49+
3650
class PostlogisticsWebService:
3751
"""Connector with PostLogistics for labels using post.ch API
3852
@@ -71,25 +85,21 @@ def _get_language(self, lang):
7185
return lang_code
7286
return "en"
7387

74-
def _prepare_recipient(self, picking):
88+
def _prepare_recipient(self, picking, sanitize_mapping=None):
7589
"""Create a ns0:Recipient as a dict from a partner
7690
7791
:param partner: partner browse record
7892
:return a dict containing data for ns0:Recipient
7993
8094
"""
81-
partner = picking.partner_id
82-
if picking.picking_type_id.code != "outgoing":
83-
location_dest = picking.location_dest_id
84-
partner = (
85-
location_dest.company_id.partner_id
86-
or self.env.user.company_id.partner_id
87-
)
95+
partner = picking.get_recipient_partner_hook()
8896

89-
partner_mobile = self._sanitize_string(
90-
picking.delivery_mobile or partner.mobile
97+
partner_mobile = sanitize_string(
98+
picking.delivery_mobile or partner.mobile, sanitize_mapping
99+
)
100+
partner_phone = sanitize_string(
101+
picking.delivery_phone or partner.phone, sanitize_mapping
91102
)
92-
partner_phone = self._sanitize_string(picking.delivery_phone or partner.phone)
93103

94104
if partner.postlogistics_notification == "email" and not partner.email:
95105
raise UserError(picking.env._("Email is required for notification."))
@@ -115,10 +125,10 @@ def _prepare_recipient(self, picking):
115125
raise UserError(picking.env._("Partner city is required."))
116126

117127
partner_name = partner.name or partner.parent_id.name
118-
sanitized_partner_name = self._sanitize_string(partner_name)
119-
partner_street = self._sanitize_string(partner.street)
120-
partner_zip = self._sanitize_string(partner.zip)
121-
partner_city = self._sanitize_string(partner.city)
128+
sanitized_partner_name = sanitize_string(partner_name, sanitize_mapping)
129+
partner_street = sanitize_string(partner.street, sanitize_mapping)
130+
partner_zip = sanitize_string(partner.zip, sanitize_mapping)
131+
partner_city = sanitize_string(partner.city, sanitize_mapping)
122132
recipient = {
123133
"name1": sanitized_partner_name[:35],
124134
"street": partner_street[:35],
@@ -127,66 +137,37 @@ def _prepare_recipient(self, picking):
127137
}
128138

129139
if partner.country_id.code:
130-
country_code = self._sanitize_string(partner.country_id.code.upper())
140+
country_code = sanitize_string(
141+
partner.country_id.code.upper(), sanitize_mapping
142+
)
131143
recipient["country"] = country_code
132144

133145
if partner.street2:
134146
# addressSuffix is shown before street on label
135147
recipient["addressSuffix"] = recipient["street"]
136-
recipient["street"] = self._sanitize_string(partner.street2[:35])
148+
recipient["street"] = sanitize_string(
149+
partner.street2[:35], sanitize_mapping
150+
)
137151

138152
company_partner_name = partner.commercial_company_name
139153
if company_partner_name and company_partner_name != partner_name:
140-
parent_name = self._sanitize_string(partner.parent_id.name)
154+
parent_name = sanitize_string(partner.parent_id.name, sanitize_mapping)
141155
recipient["name2"] = parent_name[:35]
142156
recipient["personallyAddressed"] = False
143157

144158
# Phone and / or mobile should only be displayed if instruction to
145159
# Notify delivery by telephone is set
146160
if partner.postlogistics_notification == "email":
147-
recipient["email"] = self._sanitize_string(partner.email)
161+
recipient["email"] = sanitize_string(partner.email, sanitize_mapping)
148162
elif partner.postlogistics_notification == "phone":
149-
recipient["phone"] = self._sanitize_string(partner_phone)
163+
recipient["phone"] = sanitize_string(partner_phone, sanitize_mapping)
150164
if partner_mobile:
151165
recipient["mobile"] = partner_mobile
152166
elif partner.postlogistics_notification == "sms":
153167
recipient["mobile"] = partner_mobile
154168

155169
return recipient
156170

157-
def _prepare_customer(self, picking):
158-
"""Create a ns0:Customer as a dict from picking
159-
160-
This is the PostLogistics Customer, thus the sender
161-
162-
:param picking: picking browse record
163-
:return a dict containing data for ns0:Customer
164-
165-
"""
166-
company = picking.company_id
167-
partner = company.partner_id
168-
if picking.picking_type_id.code != "outgoing":
169-
partner = picking.partner_id
170-
171-
partner_name = partner.name or partner.parent_id.name
172-
if not partner_name:
173-
raise UserError(picking.env._("Customer name is required."))
174-
customer = {
175-
"name1": self._sanitize_string(partner_name)[:25],
176-
"street": self._sanitize_string(partner.street)[:25],
177-
"zip": self._sanitize_string(partner.zip)[:10],
178-
"city": self._sanitize_string(partner.city)[:25],
179-
"country": partner.country_id.code,
180-
"domicilePostOffice": picking.carrier_id.postlogistics_office or None,
181-
}
182-
logo = picking.carrier_id.postlogistics_logo
183-
if logo:
184-
logo_image = Image.open(BytesIO(base64.b64decode(logo)))
185-
logo_format = logo_image.format
186-
customer["logo"] = logo.decode()
187-
customer["logoFormat"] = logo_format
188-
return customer
189-
190171
def _get_label_layout(self, picking):
191172
"""
192173
Get Label layout define in carrier
@@ -215,66 +196,6 @@ def _get_license(self, picking):
215196
franking_license = picking.carrier_id.postlogistics_license_id
216197
return franking_license.number
217198

218-
def _prepare_attributes(
219-
self, picking, pack=None, pack_num=None, pack_total=None, pack_weight=None
220-
):
221-
package_type = (
222-
pack
223-
and pack.package_type_id
224-
or picking.carrier_id.postlogistics_default_package_type_id
225-
)
226-
package_codes = package_type._get_shipper_package_code_list()
227-
228-
if pack_weight:
229-
total_weight = pack_weight
230-
else:
231-
total_weight = pack.shipping_weight if pack else picking.shipping_weight
232-
total_weight *= 1000
233-
234-
if not package_codes:
235-
raise UserError(
236-
picking.env._(
237-
"No PostLogistics packaging services found "
238-
"in package type {package_type_name}, for picking {picking_name}."
239-
).format(package_type_name=package_type.name, picking_name=picking.name)
240-
)
241-
242-
# Activate phone notification ZAW3213
243-
# if phone call notification is set on partner
244-
if picking.partner_id.postlogistics_notification == "phone":
245-
package_codes.append("ZAW3213")
246-
247-
attributes = {
248-
"weight": int(total_weight),
249-
}
250-
251-
# Remove the services if the delivery fixed date is not set
252-
if "ZAW3217" in package_codes:
253-
if picking.delivery_fixed_date:
254-
attributes["deliveryDate"] = picking.delivery_fixed_date
255-
else:
256-
package_codes.remove("ZAW3217")
257-
258-
# parcelNo / parcelTotal cannot be used if service ZAW3218 is not activated
259-
if "ZAW3218" in package_codes:
260-
if pack_total > 1:
261-
attributes.update(
262-
{"parcelTotal": pack_total - 1, "parcelNo": pack_num - 1}
263-
)
264-
else:
265-
package_codes.remove("ZAW3218")
266-
267-
if "ZAW3219" in package_codes and picking.delivery_place:
268-
attributes["deliveryPlace"] = picking.delivery_place
269-
if picking.carrier_id.postlogistics_proclima_logo:
270-
attributes["proClima"] = True
271-
else:
272-
attributes["proClima"] = False
273-
274-
attributes["przl"] = package_codes
275-
276-
return attributes
277-
278199
def _get_itemid(self, picking, package):
279200
"""Allowed characters are alphanumeric plus `+`, `-` and `_`
280201
Last `+` separates picking name and package number (if any)
@@ -290,28 +211,21 @@ def _get_itemid(self, picking, package):
290211
codes = [name, pack_no]
291212
return "+".join(c for c in codes if c)
292213

293-
def _cash_on_delivery(self, picking, package=None):
294-
amount = (package or picking).postlogistics_cod_amount()
295-
amount = f"{amount:.2f}"
296-
return [{"Type": "NN_BETRAG", "Value": amount}]
297-
298-
def _get_item_additional_data(self, picking, package=None):
299-
if package and not package.package_type_id:
300-
raise UserError(
301-
picking.env._("The package %s must have a package type.") % package.name
302-
)
303-
304-
result = []
305-
packaging_codes = (
306-
package and package.package_type_id._get_shipper_package_code_list() or []
307-
)
308-
309-
if set(packaging_codes) & {"BLN", "N"}:
310-
cod_attributes = self._cash_on_delivery(picking, package=package)
311-
result += cod_attributes
312-
return result
214+
def _prepare_data(
215+
self, lang, frankingLicense, post_customer, labelDefinition, item
216+
):
217+
return {
218+
"language": lang.upper(),
219+
"frankingLicense": frankingLicense,
220+
"ppFranking": False,
221+
"customer": post_customer,
222+
"customerSystem": None,
223+
"labelDefinition": labelDefinition,
224+
"sendingID": None,
225+
"item": item,
226+
}
313227

314-
def _get_item_number(self, picking, package):
228+
def _get_item_number(self, picking, package, index=1):
315229
"""Generate the tracking reference for the last 8 digits
316230
of tracking number of the label.
317231
@@ -321,15 +235,17 @@ def _get_item_number(self, picking, package):
321235
e.g. 03000042 for 3rd pack of picking OUT/19000042
322236
"""
323237
picking_num = _compile_itemnum.sub("", picking.name)
324-
package_number = self.get_package_number_hook(package)
238+
package_number = picking.get_package_number_hook(package)
239+
if not package_number:
240+
package_number = index
325241
return "%02d%s" % (package_number, picking_num[-6:].zfill(6))
326242

327243
def _prepare_item_list(self, picking, recipient, packages):
328244
"""Return a list of item made from the pickings"""
329245
carrier = picking.carrier_id
330246
item_list = []
331247

332-
def add_item(package_number=1, package=None):
248+
def add_item(index=1, package=None):
333249
assert picking or package
334250
itemid = self._get_itemid(picking, package)
335251
item = {
@@ -344,7 +260,7 @@ def add_item(package_number=1, package=None):
344260
picking_num = _compile_itemnum.sub("", picking.name)
345261
item_number = f"9{picking_num[-7:].zfill(7)}"
346262
else:
347-
item_number = self._get_item_number(picking, package_number)
263+
item_number = self._get_item_number(picking, package, index)
348264
item["itemNumber"] = item_number
349265

350266
additional_data = self._get_item_additional_data(picking, package=package)
@@ -354,8 +270,10 @@ def add_item(package_number=1, package=None):
354270
item_list.append(item)
355271

356272
total_packages = len(packages)
357-
for package in packages:
358-
package_number = self.get_package_number_hook(package)
273+
for index, package in enumerate(packages):
274+
package_number = picking.get_package_number_hook(package)
275+
if not package_number:
276+
package_number = index + 1
359277
attributes = self._prepare_attributes(
360278
picking, package, package_number, total_packages
361279
)
@@ -403,20 +321,6 @@ def _prepare_label_definition(self, picking):
403321
"printPreview": False,
404322
}
405323

406-
def _prepare_data(
407-
self, lang, frankingLicense, post_customer, labelDefinition, item
408-
):
409-
return {
410-
"language": lang.upper(),
411-
"frankingLicense": frankingLicense,
412-
"ppFranking": False,
413-
"customer": post_customer,
414-
"customerSystem": None,
415-
"labelDefinition": labelDefinition,
416-
"sendingID": None,
417-
"item": item,
418-
}
419-
420324
@classmethod
421325
def _request_access_token(cls, delivery_carrier):
422326
if not delivery_carrier.postlogistics_endpoint_url:
@@ -500,13 +404,6 @@ def get_access_token(cls, picking_carrier):
500404
cls.access_token_expiry = now + timedelta(seconds=response["expires_in"])
501405
return cls.access_token
502406

503-
def _sanitize_string(self, value):
504-
"""Removes disallowed chars ("|", "\", "<", ">", "’", "‘") from strings."""
505-
value = value or ""
506-
for char, repl in DISALLOWED_CHARS_MAPPING.items():
507-
value = value.replace(char, repl)
508-
return value
509-
510407
def generate_label(self, picking, packages):
511408
"""Generate a label for a picking
512409
@@ -601,6 +498,64 @@ def generate_label(self, picking, packages):
601498
results.append(res)
602499
return results
603500

604-
def get_package_number_hook(self, package):
605-
"""Hook method to customize the package number retrieval"""
606-
return package
501+
# These methods could be overridden in a custom module, thus if several
502+
# modules are installed but not chained properly, the last one will be used.
503+
# Meaning if you doesn't inherit from the last module, this module override
504+
# will not be used. This is a big issue in a modular environment, thus we
505+
# need to provide a better way to allow to override these methods by
506+
# implementing them in the picking model.
507+
508+
@deprecated(
509+
"This method will be removed in version > 18.0. Please use \
510+
`stock.picking::postlogistics_label_cash_on_delivery` instead."
511+
)
512+
def _cash_on_delivery(self, picking, package=None):
513+
picking.postlogistics_label_cash_on_delivery(package=package)
514+
515+
@deprecated(
516+
"This method will be removed in version > 18.0. Please use \
517+
`stock.picking::postlogistics_label_get_item_additional_data` instead."
518+
)
519+
def _get_item_additional_data(self, picking, package=None):
520+
# TODO: remove this method in versions > 18.0 and reimplement current
521+
# behavior inside stock.picking::postlogistics_label_get_item_additional_data
522+
if package and not package.package_type_id:
523+
raise UserError(
524+
self.env._("The package %s must have a package type.") % package.name
525+
)
526+
result = picking.postlogistics_label_get_item_additional_data(package=package)
527+
packaging_codes = (
528+
package and package.package_type_id._get_shipper_package_code_list() or []
529+
)
530+
if set(packaging_codes) & {"BLN", "N"}:
531+
cod_attributes = self._cash_on_delivery(picking, package=package)
532+
result += cod_attributes
533+
return result
534+
535+
@deprecated(
536+
"This method will be removed in version > 18.0. \
537+
Please use global `sanitize_string` instead."
538+
)
539+
def _sanitize_string(self, value, mapping=None):
540+
# TODO: remove this method in versions > 18.0
541+
return sanitize_string(value, mapping)
542+
543+
@deprecated(
544+
"This method will be removed in version > 18.0. Please use \
545+
`stock.picking::postlogistics_label_prepare_attributes` instead."
546+
)
547+
def _prepare_attributes(
548+
self, picking, pack=None, pack_num=None, pack_total=None, pack_weight=None
549+
):
550+
# TODO: remove this method in versions > 18.0
551+
return picking.postlogistics_label_prepare_attributes(
552+
pack=pack, pack_num=pack_num, pack_total=pack_total, pack_weight=pack_weight
553+
)
554+
555+
@deprecated(
556+
"This method will be removed in version > 18.0. Please use \
557+
`stock.picking::postlogistics_label_prepare_recipient` instead."
558+
)
559+
def _prepare_customer(self, picking):
560+
# TODO: remove this method in versions > 18.0
561+
return picking.postlogistics_label_prepare_customer()

‎delivery_postlogistics/tests/test_sanitize_values.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def check_strings_in_list(self, values):
4646
)
4747

4848
def test_sanitize(self):
49-
customer = self.service_class._prepare_customer(self.picking)
49+
customer = self.picking.postlogistics_label_prepare_customer()
5050
self.check_strings_in_dict(customer)
5151
recipient = self.service_class._prepare_recipient(self.picking)
5252
self.check_strings_in_dict(recipient)
@@ -55,9 +55,7 @@ def test_sanitize(self):
5555
self.picking, recipient, packages
5656
)
5757
self.check_strings_in_list(item_list)
58-
attributes = self.service_class._prepare_attributes(
59-
self.picking, packages, 1, 1
60-
)
58+
attributes = self.picking.postlogistics_label_prepare_attributes(packages, 1, 1)
6159
self.check_strings_in_dict(attributes)
6260

6361
def test_cleanup_error_message(self):

0 commit comments

Comments
 (0)
Please sign in to comment.