WIP: initial checkin

This commit is contained in:
Harald Welte 2021-02-14 19:18:25 +01:00
commit 5048d25df2
8 changed files with 524 additions and 0 deletions

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

19
__openerp__.py Normal file
View File

@ -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']
},
}

1
models/__init__.py Normal file
View File

@ -0,0 +1 @@
import res_config, shipcloud, shipcloud_delivery_carrier, shipcloud_shipping_service

31
models/res_config.py Normal file
View File

@ -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

202
models/shipcloud.py Normal file
View File

@ -0,0 +1,202 @@
#
# Python module implementing shipcloud.io REST API
# (C) 2021 by Harald Welte <laforge@gnumonks.org>
#
# 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)

View File

@ -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

View File

@ -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')

53
views/res_config.xml Normal file
View File

@ -0,0 +1,53 @@
<openerp>
<data>
<record id="view_website_shipcloud_config_setting" model="ir.ui.view">
<field name="name">website.sc.config.settings</field>
<field name="model">website.sc.config.settings</field>
<field name="arch" type="xml">
<form string="shipcloud Settings" class="oe_form_configuration">
<sheet>
<group string="shipcloud Credentials">
<field name="sc_api_key_sandbox" class="oe_inline"/>
<field name="sc_api_key_prod" class="oe_inline"/>
<field name="sc_api_use_prod" class="oe_inline"/>
</group>
</sheet>
<footer>
<button string="Apply" type="object" name="execute" class="oe_highlight"/>
<button string="Cancel" class="oe_link" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_module_website_shipcloud_configuration" model="ir.actions.act_window">
<field name="name">shipcloud Configuration</field>
<field name="res_model">website.sc.config.settings</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<record id="wk_inherit_website_access_sc" model="ir.ui.view">
<field name="name">website.sc.inherited.config.settings</field>
<field name="model">website.config.settings</field>
<field name="inherit_id" ref="odoo_shipping_service_apps.wk_inherit_website_acess"/>
<field name="arch" type="xml">
<xpath expr="//group[@string='Shipping Service']" position="inside">
<label for="module_sc_delivery_carrier" string=""/>
<div name="module_sc_delivery_carrier" string="">
<div class="oe_inline">
<button type="action" name="%(odoo_shipcloud.action_module_website_shipcloud_configuration)d" string="shipcloud Configuration" class="oe_link oe_inline"/>
</div>
</div>
</xpath>
</field>
</record>
<record id="shipcloud_configuration_installer_todo" model="ir.actions.todo">
<field name="action_id" ref="action_module_website_shipcloud_configuration"/>
<field name="sequence">15</field>
<field name="type">automatic</field>
</record>
</data>
</openerp>