From 5048d25df2b830ca2fedd73ee2e2fa398825e9c1 Mon Sep 17 00:00:00 2001 From: Harald Welte Date: Sun, 14 Feb 2021 19:18:25 +0100 Subject: [PATCH] WIP: initial checkin --- __init__.py | 1 + __openerp__.py | 19 +++ models/__init__.py | 1 + models/res_config.py | 31 ++++ models/shipcloud.py | 202 ++++++++++++++++++++++++++ models/shipcloud_delivery_carrier.py | 204 +++++++++++++++++++++++++++ models/shipcloud_shipping_service.py | 13 ++ views/res_config.xml | 53 +++++++ 8 files changed, 524 insertions(+) create mode 100644 __init__.py create mode 100644 __openerp__.py create mode 100644 models/__init__.py create mode 100644 models/res_config.py create mode 100644 models/shipcloud.py create mode 100644 models/shipcloud_delivery_carrier.py create mode 100644 models/shipcloud_shipping_service.py create mode 100644 views/res_config.xml diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__openerp__.py b/__openerp__.py new file mode 100644 index 0000000..61cdf4e --- /dev/null +++ b/__openerp__.py @@ -0,0 +1,19 @@ +{ + 'name': 'shipcloud.io Shipping Integration', + 'category': 'Website/Shipping Logistics', + 'summary': 'Integrate shipping via shipcloud.io directly within Odoo', + 'website': 'https://sysmocom.de/', + 'version': '0.1', + 'description':""" + """, + 'author': 'Harald Welte', + 'depends': ['odoo_shipping_service_apps', 'shipment_packaging'], + 'data': [ + 'views/res_config.xml', + ], + 'installable': True, + 'application': True, + 'external_dependencies': { + #'python': ['inema'] + }, +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..d1ec237 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1 @@ +import res_config, shipcloud, shipcloud_delivery_carrier, shipcloud_shipping_service diff --git a/models/res_config.py b/models/res_config.py new file mode 100644 index 0000000..c0563e1 --- /dev/null +++ b/models/res_config.py @@ -0,0 +1,31 @@ +from openerp import fields, models, api +import logging + +_logger = logging.getLogger(__name__) + +class website_config_settings(models.Model): + _inherit = 'website.config.settings' + _name = 'website.sc.config.settings' + + sc_api_key_sandbox = fields.Char('shipcloud Sandbox API key') + sc_api_key_prod = fields.Char('shipcloud Production API key', required=1) + sc_api_use_prod = fields.Boolean('use shipcloud Production API key') + + @api.model + def get_default_sc_values(self, fields): + ir_values = self.env['ir.values'] + sc_config_values_list_tuples = ir_values.get_defaults('delivery.carrier') + sc_config_values = {} + for item in sc_config_values_list_tuples: + sc_config_values.update({item[1]:item[2]}) + return sc_config_values + + @api.one + def set_sc_values(self): + ir_values = self.env['ir.values'] + for config in self: + ir_values.set_default('delivery.carrier', 'sc_api_key_sandbox', config.sc_api_key_sandbox or '') + ir_values.set_default('delivery.carrier', 'sc_api_key_prod', config.sc_api_key_prod or '') + ir_values.set_default('delivery.carrier', 'sc_api_use_prod', config.sc_api_use_prod or False) + + return True diff --git a/models/shipcloud.py b/models/shipcloud.py new file mode 100644 index 0000000..ff4313e --- /dev/null +++ b/models/shipcloud.py @@ -0,0 +1,202 @@ +# +# Python module implementing shipcloud.io REST API +# (C) 2021 by Harald Welte +# +# SPDX-License-Identifier: MIT + + +import sys +import logging +import requests +from requests.auth import HTTPBasicAuth + +class ApiError(Exception): + """Exception raised in case of a HTTP/REST API Error. + + Attributes: + method -- HTTP method of the request causing the error + url -- URL of the HTTP request causing the error + sandbox -- request made in sandbox mode or not? + req_body -- json-serializable dict of request body causing the error + status -- HTTP status returned by REST API + errors -- list of string error messages returned by REST API + resp_body -- raw response body of failed rquest + """ + def __init__(self, method, url, sandbox, req_body, status, errors=[], resp_body=None): + self.method = method + self.url = url + self.sandbox = sandbox + self.req_body = req_body + self.status = status + self.errors = errors + self.resp_body = resp_body + def __str__(self): + sandbox_str = ' SANDBOX' if self.sandbox else '' + return "%s %s%s -> %s: %s" % (self.method, self.url, sandbox_str, self.status, self.errors) + +class transport(object): + def __init__(self, api_key, api_key_sandbox=None, logger=logging.getLogger(__name__)): + self._api_key = api_key + self._auth = HTTPBasicAuth(self._api_key, '') + self._api_key_sandbox = api_key_sandbox or None + self._auth_sandbox = HTTPBasicAuth(self._api_key_sandbox, '') if self._api_key_sandbox else None + self._server_host = 'api.shipcloud.io' + self._server_port = 443 + self._base_path= "/v1" + self._logger = logger + + def _get_auth(self, sandbox=False): + if sandbox: + return self._auth_sandbox + else: + return self._auth + + def _build_url(self, suffix): + return "https://%s:%u%s%s" % (self._server_host, self._server_port, self._base_path, suffix) + + def rest_http(self, method, suffix, js = None, sandbox = False): + url = self._build_url(suffix) + sandbox_str = ' SANDBOX' if sandbox else '' + self._logger.debug("%s %s (%s)%s" % (method, url, str(js), sandbox_str)) + resp = requests.request(method, url, json=js, auth=self._get_auth(sandbox)) + self._logger.debug("-> %s - %s" % (resp, resp.text)) + if not resp.ok: + self._logger.error("%s %s (%s)%s failed: %s - %s" % (method, url, str(js), sandbox_str, resp, resp.text)) + errors = [] + try: + resp_json = resp.json() + if 'errors' in resp_json: + errors = resp_json['errors'] + except ValueError: + self._logger.error("response contains no valid json: %s" % (resp.text)) + raise ApiError(method, url, sandbox, js, resp.status_code, errors, resp.text) + return resp.json() + + def rest_post(self, suffix, js=None, sandbox=False): + return self.rest_http('POST', suffix, js, sandbox) + + def rest_get(self, suffix, js=None, sandbox=False): + return self.rest_http('GET', suffix, js, sandbox) + + def rest_delete(self, suffix, js=None, sandbox=False): + return self.rest_http('DELETE', suffix, js, sandbox) + +class api(object): + def __init__(self, api_key, api_key_sandbox=None, logger=logging.getLogger(__name__)): + self._transport = transport(api_key, api_key_sandbox, logger) + + def get_shipment_quote(self, shipment): + # quote request cannot be issued against sandbox, so always use production + res = self._transport.rest_post('/shipment_quotes', gen_quote_req(shipment)) + return res + + def create_shipment(self, shipment, gen_label=False): + # Assume if the user passed a sandbox API key, we use it for create shipment requests + sandbox = True if self._transport._auth_sandbox else False + sh = shipment.copy() + sh['create_shipping_label'] = gen_label + res = self._transport.rest_post('/shipments', sh, sandbox) + return res + + +def gen_customs_item(origin, desc, hts, qty, value, net_weight): + """Generate a dict for a customs_declaration.item in accordance with + https://developers.shipcloud.io/reference/shipments_request_schema.html""" + customs_item = { + 'origin_country': origin, + 'description': desc, + 'hs_tariff_number': str(hts), + 'quantity': qty, + 'value_amount': value, + 'net_weight': net_weight, + #'gross_weight': , + } + return customs_item + +def gen_customs_decl(currency, invoice_nr, net_total, items, importer_ref=None, exporter_ref=None): + """Generate a dict for a customs_declaration in accordance with + https://developers.shipcloud.io/reference/shipments_request_schema.html""" + customs_decl = { + 'contents_type': 'commercial_goods', + #'contents_explanation': , + 'currency' : currency, + 'invoice_number': str(invoice_nr), + 'total_value_amount': net_total, + 'items': customs_items, + } + if importer_ref: + customs_decl['importer_reference'] = str(importer_ref) + if exporter_ref: + customs_decl['exporter_reference'] = str(exporter_ref) + return customs_decl + + +def gen_package(width_cm, length_cm, height_cm, weight_kgs, value=None, currency=None): + """Generate a dict for a package in accordance with + https://developers.shipcloud.io/reference/shipments_request_schema.html""" + package = { + 'width': int(width_cm), + 'length': int(length_cm), + 'height': int(height_cm), + 'weight': weight_kgs, + 'type': 'parcel', + } + if value: + if currency == None: + currency = 'EUR' + package['declared_value'] = { + 'amount': value, + 'currency': currency, + } + return package + + +def gen_shipment(from_addr, to_addr, pkg, ref, descr=None, customs_decl=None, incoterm='dap'): + """Generate a dict for a shipment in accordance with + https://developers.shipcloud.io/reference/shipments_request_schema.html""" + shipment = { + 'from': from_addr, + 'to': to_addr, + 'carrier': 'ups', + 'service': 'one_day', + 'package' : pkg, + 'reference_number': ref, + 'label': { + 'format': 'pdf_a5', + }, + 'notification_mail': 'hwelte@sysmocom.de', + 'incoterm': incoterm, + 'create_shipping_label': False, + } + if descr: + shipment['description'] = descr + if customs_decl: + shipment['customs_declaration'] = customs_decl + return shipment + + +def _filter_dict(indict, permitted_keys): + """Filter an input dictionary; keep only those keys listed in permitted_keys""" + outdict = {} + for k in permitted_keys: + if k in indict: + outdict[k] = indict[k] + return outdict + +def gen_quote_req(shipment): + """Generate a dict in accordance with + https://developers.shipcloud.io/reference/shipment_quotes_request_schema.html""" + # for some weird reason, the ShipmentQuoteRequest schema doesn't permit all + # the keys that are permitted when generating a label, making this more complicated + # than it should + permitted_sh_keys = [ 'carrier', 'service', 'to', 'from', 'package' ] + permitted_pkg_keys = [ 'width', 'height', 'length', 'weight', 'type' ] + permitted_addr_keys = [ 'street', 'street_no', 'city', 'zip_code', 'country' ] + + # create a copy so we don't modify the input data + sh = shipment.copy() + sh['from'] = _filter_dict(sh['from'], permitted_addr_keys) + sh['to'] = _filter_dict(sh['to'], permitted_addr_keys) + sh['package'] = _filter_dict(sh['package'], permitted_pkg_keys) + + return _filter_dict(sh, permitted_sh_keys) diff --git a/models/shipcloud_delivery_carrier.py b/models/shipcloud_delivery_carrier.py new file mode 100644 index 0000000..7fc54c0 --- /dev/null +++ b/models/shipcloud_delivery_carrier.py @@ -0,0 +1,204 @@ +from openerp import api, fields, models +import logging +from openerp.exceptions import Warning +import pycountry + +import shipcloud + +# FIXME: unify with odoo-internetmarke +# split the last word of a string containing stree name + house number +def split_street_house(streethouse): + # first try to split at last space + r = streethouse.rsplit(' ', 1) + # if that fails, try to split at last dot + if len(r) < 2: + r = streethouse.rsplit('.', 1) + # if that also fails, return empty house number + if len(r) < 2: + return (streethouse, '') + return (r[0], r[1]) + +def split_first_lastname(name): + # try to split at last space + r = name.rsplit(' ', 1) + # if this fails, simply claim everything is the last name + if len(r) < 2: + return ("", name) + return (r[0], r[1]) + + +class SCDeliveryCarrier(models.Model): + _inherit = 'delivery.carrier' + + def build_sc_addr(self, partner): + """Convert an Odoo partner object into a shipcloud address.""" + addr = {} + (street, house) = split_street_house(partner.street) + addr['street'] = street + addr['street_no'] = house + if partner.street2: + addr['care_of'] = partner.street2 + addr['zip_code'] = partner.zip + addr['city'] = partner.city + if partner.state_id and partner.state_id.code: + addr['state'] = partner.state_id.code + addr['country'] = partner.country_id.code + + if partner.is_company: + addr['company'] = partner.name + else: + if partner.parent_id.name: + addr['company'] = partner.parent_id.name + if partner.name: + (first, last) = split_first_lastname(partner.name) + addr['first_name'] = first + addr['last_name'] = last + if partner.email: + addr['email'] = partner.email + if partner.mobile: + addr['phone'] = partner.mobile + elif partner.phone: + addr['phone'] = partner.phone + return addr + + @staticmethod + def estimate_dimensions(weight_kg, density_kg_per_dm3): + """Estimate the dimensions of a given package, given its weight and mass density, + assuming a 3:2:1 ration between length:width:height""" + def cbrt(x): + """Return cubic root of 'x'""" + return x**(1.0/3) + volume_dm3 = float(weight_kg) / float(density_kg_per_dm3) + volume_cm3 = 1000 * volume_dm3 + # assuming l=3x, w=2x, h=1x -> x=6 + x = cbrt(volume_cm3 / 6) + return (3.0*x, 2.0*x, x) + + def build_sc_pkg(self, order=None, picking=None): + """Convert an Odoo stock.picking or sale.order into a shipcloud package""" + pkg = {} + pkg['type'] = 'parcel' + pkg['weight'] = self._get_weight(order, picking) + if picking: + pkg['length'] = picking.packaging_length + pkg['width'] = picking.packaging_width + pkg['height'] = picking.packaging_height + else: + # we assume an average mass density of 0.5kg per dm3 (litre) + est = self.estimate_dimensions(pkg['weight'], 0.5) + pkg['length'] = est[0] + pkg['width'] = est[1] + pkg['height'] = est[2] + return pkg + + def build_sc_customs_item(self, line): + """Generate a shipcloud customs_item from a stock.move (line of a picking)""" + product_uom_obj = self.env['product.uom'] + q = product_uom_obj._compute_qty_obj(self._get_default_uom(), line.product_oum_qty, self.uom_id) + product = line.product_id + if product: + if product.x_sysmo_customs_code: + hts = product.x_sysmo_customs_code + else: + raise Warning('Product Variant %s has no HTS defined' % (product.name)) + orig = product.x_country_of_origin + weight = product.weight + elif line.product_tmpl_id: + ptmpl = line.product_tmpl_id + if ptempl.x_sysmo_default_customs_code: + hts = ptempl.x_sysmo_default_customs_code + else: + raise Warning('Product %s has no HTS defined' % (ptempl.name)) + orig = ptempl.x_default_country_of_origin + weight = ptempl.weight + res = { + 'origin_country': orig, + 'description': line.name, + 'hs_tariff_number': hts, + 'quantity': q, + 'value_amount': line.price_unit, + 'net_weight': weight, + } + + def build_sc_customs_decl(self, picking): + items = [build_sc_customs_item(x) for x in picking.move_lines] + total = 0.0 + for i in items: + total += i['value_amount'] + customs = { + 'contents_type': 'commercial_goods', + 'currency': currency, + 'invoice_number': picking.name, + 'total_value_amount': total, + 'items': items + } + return customs + + def _shipcloud_api(self): + config = self._get_config() + api_key = config['sc_api_key'] + sandbox_api_key = None if config['sc_api_use_prod'] else config['sc_api_key_sandbox'] + return shipcloud.api(api_key, sandbox_api_key) + + # 'public' methods used by delivery_carrier + + def sc_get_shipping_price_from_so(self, order): + """Obtain a shipping quote for the given sale.order""" + recipient = order.partner_shipping_id if order.partner_shipping_id else order.partner_id + warehouse = order.warehouse_id.partner_id + + # build individual sub-objects of the shipment + from_addr = self.build_sc_addr(warehouse) + to_addr = self.build_sc_addr(recipient) + pkg = self.build_sc_pkg(order=order) + # build the actual shipment object + shp = shipcloud.gen_shipment(from_addr, to_addr, pkg, order.name) + # convert shipment to quote object + api = self._shipcloud_api() + try: + result = api.get_shipment_quote(shp) + except shipcloud.ApiError as err: + raise Warning(err) + # { "shipment_quote": { "price": 42.12 } } + return result['shipment_quote']['price'] + + + def sc_send_shipping(self, pickings): + """Generate a shipping label from the given stock.picking""" + order = self.env['sale.order'].search([('name','=',pickings.origin)]) + recipient = pickings.partner_id + warehouse = pickings.picking_type_id.warehouse_id.partner_id + + # build individual sub-objects of the shipment + from_addr = self.build_sc_addr(warehouse) + to_addr = self.build_sc_addr(recipient) + pkg = self.build_sc_pkg(pickings=pickings) + customs = self.build_sc_customs_decl(pickings) + # build the actual shipment object + shp = shipcloud.gen_shipment(from_addr, to_addr, pkg, picking.name, customs_decl=customs) + api = self._shipcloud_api() + try: + result = api.create_shipment(shp) + except shipcloud.ApiError as err: + raise Warning(err) + + # result = ["id", "carrier_tracking_no", "tracking_url", "label_url", "price"] + self.update({'sc_shipment_id': result['id'], + 'sc_tracking_url': result['tracking_url']}) + # TODO: download label from label_url so it can be returned as attachment + + res = {'exact_price': result['price'], + 'weight': pkg['weight'], + 'tracking_number': result['carrier_tracking_no'], + 'attachments': [(filename, label.pdf_bin)]} + return res + + + def sc_cancel_shipment(self, pickings): + """Cancel a shipping label""" + # TODO: use sc_shipment_id to issue a cancel request in the API + # DELETE /v1/shipments/:id -> 204 on success + + def sc_get_tracking_link(self, pickings): + """Return a tracking link for the given picking""" + return pickings.sc_tracking_url diff --git a/models/shipcloud_shipping_service.py b/models/shipcloud_shipping_service.py new file mode 100644 index 0000000..7785a07 --- /dev/null +++ b/models/shipcloud_shipping_service.py @@ -0,0 +1,13 @@ +from openerp import api, fields, models + +# extend deliver.carrier with shipcloud +class SMCShippingShipcloud(models.Model): + _inherit = 'delivery.carrier' + delivery_type = fields.Selection(selection_add=[('sc', 'shipcloud')]) + + +# extend stock.picking with fields related to shipcloud +class SMCStockPickingShipclodu(models.Model): + _inherit = 'stock.picking' + sc_shipment_id = fields.Char(string='shipcloud shipment ID') + sc_tracking_url = fields.Char(string='shipcloud tracking URL') diff --git a/views/res_config.xml b/views/res_config.xml new file mode 100644 index 0000000..2a8e2c7 --- /dev/null +++ b/views/res_config.xml @@ -0,0 +1,53 @@ + + + + website.sc.config.settings + website.sc.config.settings + +
+ + + + + + + +
+
+
+
+
+ + + shipcloud Configuration + website.sc.config.settings + form + new + + + + website.sc.inherited.config.settings + website.config.settings + + + + + + + + + + 15 + automatic + + +
+