forked from Mapan/odoo17e
438 lines
23 KiB
Python
438 lines
23 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import datetime
|
|
import requests
|
|
import re
|
|
from werkzeug.urls import url_join
|
|
|
|
from odoo import _
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools.float_utils import float_round, float_is_zero, float_repr
|
|
|
|
API_BASE_URL = 'https://api.easypost.com/v2/'
|
|
NON_BLOCKING_MESSAGES = ['rate_message']
|
|
|
|
class EasypostRequest():
|
|
"Implementation of Easypost API"
|
|
|
|
def __init__(self, api_key, debug_logger):
|
|
self.api_key = api_key
|
|
self.debug_logger = debug_logger
|
|
self.is_domestic_shipping = None
|
|
|
|
def _make_api_request(self, endpoint, request_type='get', data=None):
|
|
"""make an api call, return response"""
|
|
access_url = url_join(API_BASE_URL, endpoint)
|
|
try:
|
|
self.debug_logger("%s\n%s\n%s" % (access_url, request_type, data if data else None), 'easypost_request_%s' % endpoint)
|
|
if request_type == 'get':
|
|
response = requests.get(access_url, auth=(self.api_key, ''), data=data)
|
|
else:
|
|
response = requests.post(access_url, auth=(self.api_key, ''), data=data)
|
|
self.debug_logger("%s\n%s" % (response.status_code, response.text), 'easypost_response_%s' % endpoint)
|
|
response = response.json()
|
|
# check for any error in response
|
|
if 'error' in response:
|
|
error_message = response['error'].get('message')
|
|
error_detail = response['error'].get('errors')
|
|
if error_detail:
|
|
error_message += ''.join(['\n - %s: %s' % (err.get('field', _('Unspecified field')), err.get('message', _('Unknown error'))) for err in error_detail])
|
|
raise UserError(_('Easypost returned an error: %s', error_message))
|
|
return response
|
|
except Exception as e:
|
|
raise e
|
|
|
|
def fetch_easypost_carrier(self):
|
|
""" Import all carrier account from easypost
|
|
https://www.easypost.com/docs/api.html#carrier-accounts
|
|
It returns a dict with carrier account name and it's
|
|
easypost id in order to generate shipments.
|
|
e.g: {'FeDex: ca_27839172aee03918a701'}
|
|
"""
|
|
carriers = self._make_api_request('carrier_accounts')
|
|
carriers = {c['readable']: c['id'] for c in carriers}
|
|
if carriers:
|
|
return carriers
|
|
else:
|
|
# The user need at least one carrier on its easypost account.
|
|
# https://www.easypost.com/account/carriers
|
|
raise UserError(_("You have no carrier linked to your Easypost Account.\
|
|
Please connect to Easypost, link your account to carriers and then retry."))
|
|
|
|
def fetch_easypost_user(self):
|
|
""" Import data about the current user
|
|
https://www.easypost.com/docs/api/curl#retrieve-a-user
|
|
It returns a dict of info regarding the current user,
|
|
such as its `id` or his related `insurance_fee_rate`.
|
|
"""
|
|
return self._make_api_request('users')
|
|
|
|
def _check_required_value(self, carrier, recipient, shipper, order=False, picking=False):
|
|
""" Check if the required value are present in order
|
|
to process an API call.
|
|
return True or an error if a value is missing.
|
|
"""
|
|
# check carrier credentials
|
|
if carrier.prod_environment and not carrier.sudo().easypost_production_api_key:
|
|
raise UserError(_("The %s carrier is missing (Missing field(s) :\n Production API Key)", carrier.name))
|
|
elif not carrier.sudo().easypost_test_api_key:
|
|
raise UserError(_("The %s carrier is missing (Missing field(s) :\n Test API Key)", carrier.name))
|
|
|
|
if not carrier.easypost_delivery_type:
|
|
raise UserError(_("The %s carrier is missing (Missing field(s) :\n Delivery Carrier Type)", carrier.name))
|
|
|
|
if not carrier.easypost_default_package_type_id:
|
|
raise UserError(_("The %s carrier is missing (Missing field(s) :\n Default Package Type)", carrier.name))
|
|
|
|
if not order and not picking:
|
|
raise UserError(_("Sale Order/Stock Picking is missing."))
|
|
|
|
# check required value for order
|
|
if order:
|
|
if not order.order_line:
|
|
raise UserError(_("Please provide at least one item to ship."))
|
|
error_lines = order.order_line.filtered(lambda line: not line.product_id.weight and line.product_qty != 0 and not line.is_delivery and line.product_id.type != 'service' and not line.display_type)
|
|
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')))
|
|
|
|
# check required value for picking
|
|
if picking:
|
|
if not picking.move_ids:
|
|
raise UserError(_("Please provide at least one item to ship."))
|
|
if picking.move_ids.filtered(lambda line: not line.weight and line.product_qty != 0):
|
|
raise UserError(_('The estimated price cannot be computed because the weight of your product is missing.'))
|
|
return True
|
|
|
|
def _prepare_address(self, addr_type, addr_obj):
|
|
""" Create a dictionary with list of available
|
|
value to easypost.
|
|
param string: addr_type: 'from_address' for shipper
|
|
or 'to_address' for recipient.
|
|
param addr_obj res.partner: partner linked to order/picking
|
|
in order to retrieve shipping information
|
|
return str: response address id of API request to create an address.
|
|
We do an extra API request since the address creation is free of charge.
|
|
If there is an error about address it will be raise before the rate
|
|
or shipment request.
|
|
"""
|
|
addr_fields = {
|
|
'street1': 'street', 'street2': 'street2',
|
|
'city': 'city', 'zip': 'zip', 'phone': 'phone',
|
|
'email': 'email'}
|
|
address = {'order[%s][%s]' % (addr_type, field_name): addr_obj[addr_obj_field]
|
|
for field_name, addr_obj_field in addr_fields.items()
|
|
if addr_obj[addr_obj_field]}
|
|
address['order[%s][name]' % addr_type] = (addr_obj.name or addr_obj.display_name)[:25]
|
|
if addr_obj.state_id:
|
|
address['order[%s][state]' % addr_type] = addr_obj.state_id.code
|
|
address['order[%s][country]' % addr_type] = addr_obj.country_id.code
|
|
if addr_obj.commercial_company_name:
|
|
address['order[%s][company]' % addr_type] = addr_obj.commercial_company_name[:25]
|
|
return address
|
|
|
|
def _prepare_shipments(self, carrier, packages, is_return=False):
|
|
""" Prepare easypost order's shipments with the real
|
|
value used in the picking.
|
|
It will iterates over multiple packages if they are used.
|
|
It returns a dict with the necessary shipments (containing
|
|
parcel/customs info used for each stock.move.line result package.
|
|
Move lines without package are considered to be lock together
|
|
in a single package.
|
|
"""
|
|
shipment = {}
|
|
for shp_id, pkg in enumerate(packages):
|
|
shipment.update(self._prepare_parcel(carrier, shp_id, pkg, carrier.easypost_label_file_type))
|
|
shipment.update(self._customs_info(carrier, shp_id, pkg.commodities, pkg.currency_id))
|
|
shipment.update(self._options(shp_id, carrier))
|
|
if is_return:
|
|
shipment.update({'order[is_return]': True})
|
|
return shipment
|
|
|
|
def _prepare_parcel(self, carrier, shipment_id, delivery_package, label_format='pdf'):
|
|
""" Prepare parcel for used package. (carrier default if it comes from
|
|
an order). https://www.easypost.com/docs/api.html#parcels
|
|
params:
|
|
- Shipment_id int: The current easypost shipement.
|
|
- delivery_package: custom 'DeliveryPackage' -> used package for shipement.
|
|
- Weight float(oz): Product's weight contained in package
|
|
- label_format str: Format for label to print.
|
|
return dict: a dict with necessary keys in order to create
|
|
a easypost parcel for the easypost shipement with shipment_id
|
|
"""
|
|
shipment = {
|
|
'order[shipments][%d][parcel][weight]' % shipment_id: carrier._easypost_convert_weight(delivery_package.weight),
|
|
'order[shipments][%d][options][label_format]' % shipment_id: label_format,
|
|
'order[shipments][%d][options][label_date]' % shipment_id: datetime.datetime.now().isoformat()
|
|
}
|
|
# If this is not an EasyPost predefined package, then we give the dimensions.
|
|
packages = carrier._easypost_get_services_and_package_types()[0]
|
|
if delivery_package.packaging_type and any(delivery_package.packaging_type in pkg_names for pkg_names in packages.values()):
|
|
shipment.update({
|
|
'order[shipments][%d][parcel][predefined_package]' % shipment_id: delivery_package.packaging_type
|
|
})
|
|
elif all(dim > 0 for dim in delivery_package.dimension.values()):
|
|
shipment.update({
|
|
'order[shipments][%d][parcel][%s]' % (shipment_id, dim): delivery_package.dimension[dim]
|
|
for dim in 'height width length'.split()
|
|
})
|
|
else:
|
|
raise UserError(_('Package type used in pack %s is not configured for easypost.', delivery_package.name))
|
|
return shipment
|
|
|
|
def _customs_info(self, carrier, shipment_id, commodities, currency):
|
|
""" generate a dict with customs info for each package.
|
|
https://www.easypost.com/customs-guide.html
|
|
Currently general customs info for all packages are not generate.
|
|
For each shipment add a customs items by move line containing
|
|
- Product description (care it crash if bracket are used)
|
|
- Quantity for this product in the current package
|
|
- Total Value (unit value * qty)
|
|
- Total Value currency
|
|
- Total weight in ounces.
|
|
- Original country code(warehouse)
|
|
"""
|
|
# Customs information should be given only for international deliveries
|
|
if self.is_domestic_shipping:
|
|
return {}
|
|
|
|
customs_info = {}
|
|
contents_explanation = ', '.join(["%s" % (re.sub(r'[\W_]+', ' ', commodity.product_id.name or '')) for commodity in commodities])[:255]
|
|
customs_info.update({'order[shipments][%d][customs_info][contents_explanation]' % (shipment_id) : contents_explanation})
|
|
for customs_item_id, commodity in enumerate(commodities):
|
|
customs_info.update({
|
|
'order[shipments][%d][customs_info][customs_items][%d][description]' % (shipment_id, customs_item_id): commodity.product_id.name,
|
|
'order[shipments][%d][customs_info][customs_items][%d][quantity]' % (shipment_id, customs_item_id): commodity.qty,
|
|
'order[shipments][%d][customs_info][customs_items][%d][value]' % (shipment_id, customs_item_id): commodity.monetary_value * commodity.qty,
|
|
'order[shipments][%d][customs_info][customs_items][%d][currency]' % (shipment_id, customs_item_id): currency.name,
|
|
'order[shipments][%d][customs_info][customs_items][%d][weight]' % (shipment_id, customs_item_id): carrier._easypost_convert_weight(commodity.product_id.weight * commodity.qty),
|
|
'order[shipments][%d][customs_info][customs_items][%d][origin_country]' % (shipment_id, customs_item_id): commodity.country_of_origin,
|
|
'order[shipments][%d][customs_info][customs_items][%d][hs_tariff_number]' % (shipment_id, customs_item_id): commodity.product_id.hs_code,
|
|
})
|
|
return customs_info
|
|
|
|
def _options(self, shipment_id, carrier):
|
|
options = {}
|
|
if carrier.easypost_default_service_id:
|
|
service_otpions = carrier.easypost_default_service_id._get_service_specific_options()
|
|
for option_name, option_value in service_otpions.items():
|
|
options['order[shipments][%d][options][%s]' % (shipment_id, option_name)] = option_value
|
|
return options
|
|
|
|
def rate_request(self, carrier, recipient, shipper, order=False, picking=False, is_return=False):
|
|
""" Create an easypost order in order to proccess
|
|
all package at once.
|
|
https://www.easypost.com/docs/api.html#orders
|
|
It will process in this order:
|
|
- recipient address (check _prepare_address for more info)
|
|
- shipper address (check _prepare_address for more info)
|
|
- prepare shipments (with parcel/customs info)
|
|
- Do the API request
|
|
If a service level is defined on the delivery carrier it will
|
|
returns the rate for this service or an error if there is no
|
|
rate for this service.
|
|
If there is no service level on the delivery carrier, it will
|
|
return the cheapest rate. this behavior could be override with
|
|
the method _sort_rates.
|
|
return
|
|
- an error if rates couldn't be found.
|
|
- API response with potential warning messages.
|
|
"""
|
|
self._check_required_value(carrier, recipient, shipper, order=order, picking=picking)
|
|
|
|
# Dict that will contains data in
|
|
# order to create an easypost object
|
|
order_payload = {}
|
|
|
|
# reference field to track Odoo customers that use easypost for postage/shipping.
|
|
order_payload['order[reference]'] = 'odoo'
|
|
|
|
# Add current carrier type
|
|
order_payload['order[carrier_accounts][id]'] = carrier.easypost_delivery_type_id
|
|
|
|
# Add addresses (recipient and shipper)
|
|
order_payload.update(self._prepare_address('to_address', recipient))
|
|
order_payload.update(self._prepare_address('from_address', shipper))
|
|
if carrier.easypost_default_service_id._require_residential_address():
|
|
order_payload['order[to_address][residential]'] = True
|
|
|
|
# The request differ depending on if it is a domestic shipping or an international one
|
|
self.is_domestic_shipping = order_payload["order[from_address][country]"] == order_payload["order[to_address][country]"]
|
|
|
|
# if picking then count total_weight of picking move lines, else count on order
|
|
# easypost always takes weight in ounces(oz)
|
|
if picking:
|
|
delivery_packages = carrier._get_packages_from_picking(picking, carrier.easypost_default_package_type_id)
|
|
else:
|
|
delivery_packages = carrier._get_packages_from_order(order, carrier.easypost_default_package_type_id)
|
|
order_payload.update(self._prepare_shipments(carrier, delivery_packages, is_return=is_return))
|
|
|
|
insured_amount = 0.0
|
|
if carrier.shipping_insurance:
|
|
for pkg in delivery_packages:
|
|
insured_amount += carrier._easypost_usd_insured_value(pkg.total_cost, pkg.currency_id)
|
|
insurance_cost = 0
|
|
if insured_amount:
|
|
insurance_cost = carrier._easypost_usd_estimated_insurance_cost(insured_amount)
|
|
|
|
# request for rate
|
|
response = self._make_api_request("orders", "post", data=order_payload)
|
|
error_message = False
|
|
warning_message = False
|
|
rate = False
|
|
|
|
# explicitly check response for any messages
|
|
# error message are catch during _make_api_request method
|
|
if response.get('messages'):
|
|
warning_message = ('\n'.join([x['carrier'] + ': ' + x['type'] + ' -- ' + x['message'] for x in response['messages']]))
|
|
response.update({'warning_message': warning_message})
|
|
|
|
# check response contains rate for particular service
|
|
rates = response.get('rates')
|
|
# When easypost returns a JSON without rates in probably
|
|
# means that some data are missing or inconsistent.
|
|
# However instead of returning a correct error message,
|
|
# it will return an empty JSON or a message asking to contact
|
|
# their support. In this case a good practice would be to check
|
|
# the order_payload sent and try to find missing or erroneous value.
|
|
# DON'T FORGET DEBUG MODE ON DELIVERY CARRIER.
|
|
if not rates:
|
|
error_message = _("It seems Easypost do not provide shipments for this order.\
|
|
We advise you to try with another package type or service level.")
|
|
elif rates and not carrier.easypost_default_service_id:
|
|
# Get cheapest rate.
|
|
rate = self._sort_rates(rates)[0]
|
|
# Complete service level on the delivery carrier.
|
|
carrier._generate_services(rates)
|
|
# If the user ask for a specific service level on its carrier.
|
|
elif rates and carrier.easypost_default_service_id:
|
|
rate = [rate for rate in rates if rate['service'] == carrier.easypost_default_service_id.name]
|
|
if not rate:
|
|
error_message = _("There is no rate available for the selected service level for one of your package. Please choose another service level.")
|
|
else:
|
|
rate = rate[0]
|
|
|
|
# warning_message could contains useful information
|
|
# in order to correct the delivery carrier or SO/picking.
|
|
if error_message and warning_message:
|
|
error_message += warning_message
|
|
|
|
response.update({
|
|
'error_message': error_message,
|
|
'rate': rate,
|
|
'insurance_cost': insurance_cost,
|
|
'insured_amount': insured_amount,
|
|
'shipment_ids': [shipment['id'] for shipment in response.get('shipments', [])],
|
|
})
|
|
|
|
return response
|
|
|
|
def send_shipping(self, carrier, recipient, shipper, picking, is_return=False):
|
|
""" In order to ship an easypost order:
|
|
- prepare an order by asking a rate request with correct parcel
|
|
and customs info.
|
|
https://www.easypost.com/docs/api.html#create-an-order
|
|
- then buy the order with selected provider and service level.
|
|
https://www.easypost.com/docs/api.html#buy-an-order
|
|
- collect label and tracking data from the order buy request's
|
|
response.
|
|
return a dict with:
|
|
- order data
|
|
- selected rate
|
|
- tracking label
|
|
- tracking URL
|
|
"""
|
|
# create an order
|
|
result = self.rate_request(carrier, recipient, shipper, picking=picking, is_return=is_return)
|
|
# check for error in result
|
|
if result.get('error_message'):
|
|
return result
|
|
|
|
# buy an order
|
|
buy_order_payload = {}
|
|
buy_order_payload['carrier'] = result['rate']['carrier']
|
|
buy_order_payload['service'] = result['rate']['service']
|
|
endpoint = "orders/%s/buy" % result['id']
|
|
response = self._make_api_request(endpoint, 'post', data=buy_order_payload)
|
|
response = self._post_process_ship_response(response, carrier=carrier, picking=picking)
|
|
# explicitly check response for any messages
|
|
messages = response.get('messages', [])
|
|
message_type = messages[0].get('type') if messages else None
|
|
if message_type and message_type not in NON_BLOCKING_MESSAGES:
|
|
raise UserError('\n'.join([x['carrier'] + ': ' + x['type'] + ' -- ' + x['message'] for x in response['messages']]))
|
|
|
|
# get tracking code and lable file url
|
|
result['track_shipments_url'] = {res['tracking_code']: res['tracker']['public_url'] for res in response['shipments'] if res['tracker']}
|
|
result['track_label_data'] = {res['tracking_code']: res['postage_label']['label_url'] for res in response['shipments'] if res['postage_label']}
|
|
|
|
# get commercial invoice + other forms
|
|
result['forms'] = {form['form_type']: form['form_url'] for res in response['shipments'] for form in res.get('forms', [])}
|
|
|
|
# buy insurance after successful order purchase
|
|
for shp_id in result.get('shipment_ids'):
|
|
insured_amount = result.get('insured_amount')
|
|
if not float_is_zero(insured_amount, precision_rounding=2):
|
|
endpoint = "shipments/%s/insure" % shp_id
|
|
response = self._make_api_request(endpoint, 'post', data={'amount': insured_amount})
|
|
return result
|
|
|
|
def get_tracking_link(self, order_id):
|
|
""" Retrieve the information on the order with id 'order_id'.
|
|
https://www.easypost.com/docs/api.html#retrieve-an-order
|
|
Return data relative to tracker.
|
|
"""
|
|
tracking_public_urls = []
|
|
endpoint = "orders/%s" % order_id
|
|
response = self._make_api_request(endpoint)
|
|
for shipment in response.get('shipments'):
|
|
tracking_public_urls.append([shipment['tracking_code'], shipment['tracker']['public_url']])
|
|
return tracking_public_urls
|
|
|
|
def get_tracking_link_from_code(self, code):
|
|
""" Retrieve the information from the tracking code entered manually.
|
|
https://www.easypost.com/docs/api#retrieve-a-list-of-trackers
|
|
Return data relative to tracker.
|
|
"""
|
|
tracking_public_urls = []
|
|
endpoint = "trackers"
|
|
response = self._make_api_request(endpoint, 'get', data={'tracking_code': code})
|
|
for tracker in response.get('trackers'):
|
|
tracking_public_urls.append([tracker['tracking_code'], tracker['public_url']])
|
|
return tracking_public_urls
|
|
|
|
def _sort_rates(self, rates):
|
|
""" Sort rates by price. This function
|
|
can be override in order to modify the default
|
|
rate behavior.
|
|
"""
|
|
return sorted(rates, key=lambda rate: float(rate.get('rate')))
|
|
|
|
def _post_process_ship_response(self, response, carrier=False, picking=False):
|
|
""" Easypost manage different carriers however they don't follow a
|
|
standard flow and some carriers could act a specific way compare to
|
|
other. The purpose of this method is to catch problematic behavior and
|
|
modify the returned response in order to make it standard compare to
|
|
other carrier.
|
|
"""
|
|
# With multiples shipments, some carrier will return a message explaining that
|
|
# the rates are on the first shipments and not on the next ones.
|
|
if response.get('messages') and carrier.easypost_delivery_type in ['Purolator', 'DPD UK', 'UPS'] and \
|
|
len(response.get('shipments', [])) > 1 and \
|
|
len(response.get('shipments')[0].get('rates', [])) > 0 and \
|
|
all(len(s.get('rates', [])) == 0 for s in response['shipments'][1:]):
|
|
if carrier.easypost_delivery_type == 'UPS' and not all(s.get('messages') for s in response['shipments'][1:]):
|
|
# UPS also send a message on following shipments explaining that their rates is in the
|
|
# first shipment (other carrier just return an empty list).
|
|
return response
|
|
if carrier.easypost_delivery_type in ['Purolator', 'DPD UK'] and (
|
|
len(response['messages']) != 1 or
|
|
response['messages'][0].get('type', '') != 'rate_error' or
|
|
"multi-shipment rate includes this shipment." not in response['messages'][0].get('message', '')):
|
|
# Purolator & DPD UK send a rate_error message for this situation.
|
|
return response
|
|
|
|
if picking:
|
|
picking.message_post(body=response.get('messages'))
|
|
response['messages'] = False
|
|
return response
|