Files
enterprise-addons/delivery_usps_rest/models/usps_request.py
T

261 lines
13 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import re
import requests
from json.decoder import JSONDecodeError
from requests.exceptions import RequestException
from odoo import _
from odoo.exceptions import UserError
TEST_BASE_URL = 'https://apis-tem.usps.com/'
PROD_BASE_URL = 'https://apis.usps.com/'
TOKEN_TYPE = 'Bearer'
API_VERSION = 'v3'
# This re should match postcodes like 12345 and 12345-6789
ZIP_ZIP4 = re.compile('^[0-9]{5}(-[0-9]{4})?$')
def split_zip(zipcode):
'''If zipcode is a ZIP+4, split it into two parts.
Else leave it unchanged '''
if ZIP_ZIP4.match(zipcode) and '-' in zipcode:
return zipcode.split('-')
else:
return [zipcode, '']
class USPSRequest:
def __init__(self, carrier):
super_carrier = carrier.sudo()
self.base_url = PROD_BASE_URL if carrier.prod_environment else TEST_BASE_URL
self.logger = carrier.log_xml
self.client_id = super_carrier.usps_api_key
self.client_secret = super_carrier.usps_api_secret
self.eps_account_number = super_carrier.usps_eps_account_number
self.crid = super_carrier.usps_crid
self.mid = super_carrier.usps_mid
self.manifest_mid = super_carrier.usps_manifest_mid
self.access_token = super_carrier.usps_access_token
self.payment_token = super_carrier.usps_payment_token
self.carrier = carrier
self.session = requests.Session()
def _send_request(self, url, method='GET', data=None, json=None, headers=None, attach_payment_token=False, auth=None):
url = f'{self.base_url}{url}'
def _request_call(req_headers):
if self.access_token and not req_headers:
req_headers = {
'Authorization': f'{TOKEN_TYPE} {self.access_token}'
}
if attach_payment_token:
if not req_headers:
req_headers = {}
req_headers['X-Payment-Authorization-Token'] = self.payment_token
self.logger(f'{url}\n{method}\n{req_headers}\n{data}\n{json}', f'usps request {url}')
try:
self.session.cookies.clear()
res = self.session.request(method=method, url=url, data=data, json=json, headers=req_headers, auth=auth, cookies=None)
self.logger(f'{res.status_code}\n{res.text}', f'usps response {url}')
except RequestException as e:
self.logger(str(e), f'usps request error {url}')
raise UserError(str(e))
return res
if attach_payment_token:
self.payment_token = None
self.payment_token = self._get_new_payment_token()
self.carrier.sudo().usps_payment_token = self.payment_token
res = _request_call(headers)
if res.status_code == 401 and auth is None and 'oauth' not in url:
self.access_token = None
self.access_token = self._get_new_access_token()
self.carrier.sudo().usps_access_token = self.access_token
res = _request_call(None)
elif res.status_code == 401 and 'oauth' in url:
raise UserError(_("Your USPS API Key and Secret are invalid."))
return res
def _process_errors(self, res_body):
err_msgs = []
if 'error' in res_body:
err_msgs.append(res_body.get('error', {}).get('message'))
errors = res_body.get('error', {}).get('errors', [])
for error in errors:
msg = f"{error.get('code', '')} {error.get('title', '')}: {error.get('message', '') or error.get('detail', '')}. {error.get('source', '')}"
err_msgs.append(msg)
return '\n'.join(err_msgs)
def _process_authorization_errors(self, res_body):
return f"{res_body.get('fault', {}).get('detail', {}).get('errorcode', {})} {res_body.get('fault', {}).get('faultstring', {})}"
def _get_new_access_token(self):
if not self.client_id or not self.client_secret:
raise UserError(_("You need to set your USPS API Key and Secret to use this feature."))
url = 'oauth2/v3/token'
body = {
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret,
}
res = self._send_request(url, method='POST', json=body)
try:
res_body = res.json()
except JSONDecodeError as err:
self.logger(str(err), 'usps response decoding error')
raise UserError(_("Couldn't decode the response from USPS."))
if not res.ok:
raise UserError(self._process_authorization_errors(res_body))
return res_body.get('access_token')
def _get_new_payment_token(self):
if not self.crid or not self.mid or not self.manifest_mid or not self.eps_account_number:
raise UserError(_("You need to set your USPS CRID, MID, Manifest MID and EPS Account Number to use this feature."))
url = 'payments/v3/payment-authorization'
body = {
'roles': [
{
'roleName': 'PAYER',
'CRID': self.crid,
'MID': self.mid,
'manifestMID': self.manifest_mid,
'accountType': 'EPS',
'accountNumber': self.eps_account_number,
},
{
'roleName': 'LABEL_OWNER',
'CRID': self.crid,
'MID': self.mid,
'manifestMID': self.manifest_mid,
'accountType': 'EPS',
'accountNumber': self.eps_account_number,
}
]
}
self.carrier._usps_add_extra_data_to_request(body, 'payment_token')
res = self._send_request(url, method='POST', json=body)
try:
res_body = res.json()
except JSONDecodeError as err:
self.logger(str(err), 'usps response decoding error')
raise UserError(_("Couldn't decode the response from USPS."))
if not res.ok:
raise UserError(self._process_errors(res_body))
return res_body.get('paymentAuthorizationToken')
def check_required_value(self, recipient, delivery_nature, shipper, order=False, picking=False):
recipient_required_field = ['city', 'zip', 'country_id']
# The street isn't required if we compute the rate with a partial delivery address in the
# express checkout flow.
if not recipient.street and not recipient.street2 and not recipient.env.context.get(
'express_checkout_partial_delivery_address', False
):
recipient_required_field.append('street')
shipper_required_field = ['city', 'zip', 'state_id', 'country_id']
if not shipper.street and not shipper.street2:
shipper_required_field.append('street')
res = [field for field in shipper_required_field if not shipper[field]]
if res:
return _("The address of your company is missing or wrong (Missing field(s) : \n %s)", ", ".join(res).replace("_id", ""))
if shipper.country_id.code != 'US':
return _("Please set country USA in your company address, Service is only available for USA")
if not ZIP_ZIP4.match(shipper.zip):
return _("Please enter a valid ZIP code in your Company address")
if not self._convert_phone_number(shipper.phone):
return _("Company phone number is invalid. Please insert a US phone number.")
res = [field for field in recipient_required_field if not recipient[field]]
if res:
return _("The recipient address is missing or wrong (Missing field(s) : \n %s)", ", ".join(res).replace("_id", ""))
if delivery_nature == 'domestic' and not ZIP_ZIP4.match(recipient.zip):
return _("Please enter a valid ZIP code in recipient address")
if recipient.country_id.code == "US" and delivery_nature == 'international':
return _("USPS International is used only to ship outside of the USA. Please change the delivery method into USPS Domestic.")
if recipient.country_id.code != "US" and delivery_nature == 'domestic':
return _("USPS Domestic is used only to ship inside of the USA. Please change the delivery method into USPS International.")
if order:
if not order.order_line:
return _("Please provide at least one item to ship.")
error_lines = order.order_line._get_invalid_delivery_weight_lines()
if error_lines:
return _("The estimated shipping price cannot be computed because the weight is missing for the following product(s): \n %s", ", ".join(error_lines.product_id.mapped('name')))
return False
def _get_rates(self, request_body):
url_first_token = 'prices' if self.carrier.usps_delivery_nature == 'domestic' else 'international-prices'
url = f'{url_first_token}/{API_VERSION}/base-rates-list/search'
res = self._send_request(url, method='POST', json=request_body)
try:
res_body = res.json()
except JSONDecodeError as err:
self.logger(str(err), 'usps response decoding error')
raise UserError(_("Couldn't decode the response from USPS."))
if not res.ok:
raise UserError(self._process_errors(res_body))
return res_body
def _get_shipping_label(self, shipping_data):
url = 'labels/v3/label' if self.carrier.usps_delivery_nature == 'domestic' else 'international-labels/v3/international-label'
res = self._send_request(url, method='POST', json=shipping_data, attach_payment_token=True)
if not res.ok:
try:
error_body = res.json()
raise UserError(self._process_errors(error_body))
except JSONDecodeError as err:
self.logger(str(err), 'usps response decoding error')
raise UserError(_("Couldn't decode the response from USPS.\n%s", res.text))
multipart_boundary = res.headers['Content-Type'].split('boundary=')[1]
parts = res.text.split(f'--{multipart_boundary}')
res_body = {}
for part in parts:
if 'name="labelMetadata"' in part:
label_metadata = part.split('\r\n\r\n')[1]
res_body['labelMetadata'] = json.loads(label_metadata)
elif 'name="returnLabelMetadata"' in part:
return_label_metadata = part.split('\r\n\r\n')[1]
res_body['returnLabelMetadata'] = json.loads(return_label_metadata)
elif 'name="labelImage"' in part:
label_image = part.split('\r\n\r\n')[1]
res_body['labelImage'] = label_image
elif 'name="returnLabelImage"' in part:
return_label_image = part.split('\r\n\r\n')[1]
res_body['returnLabelImage'] = return_label_image
elif 'name="receiptImage"' in part:
receipt_image = part.split('\r\n\r\n')[1]
res_body['receiptImage'] = receipt_image
return res_body
def _cancel_label(self, tracking_number):
url = f'labels/v3/label/{tracking_number}' if self.carrier.usps_delivery_nature == 'domestic' else f'international-labels/v3/international-label/{tracking_number}'
res = self._send_request(url, method='DELETE', attach_payment_token=True)
if not res.ok:
try:
error_body = res.json()
raise UserError(self._process_errors(error_body))
except JSONDecodeError as err:
self.logger(str(err), 'usps response decoding error')
raise UserError(_("Couldn't decode the response from USPS.\n%s", res.text))
return True
def _convert_phone_number(self, phone):
phone_pattern = re.compile(r'''
# don't match beginning of string, number can start anywhere
(\d{3}) # area code is 3 digits (e.g. '800')
\D* # optional separator is any number of non-digits
(\d{3}) # trunk is 3 digits (e.g. '555')
\D* # optional separator
(\d{4}) # rest of number is 4 digits (e.g. '1212')
\D* # optional separator
(\d*) # extension is optional and can be any number of digits
$ # end of string
''', re.VERBOSE)
match = phone_pattern.search(phone)
if match:
return ''.join(str(digits_number) for digits_number in match.groups())
else:
return False