Add 'wpint' module for Warenpost International ReST-API
This is a new module conforming to revision 1.03 of the above-mentioned ReST-API.
This commit is contained in:
parent
640baf1dbc
commit
c812926610
|
@ -2,3 +2,4 @@
|
|||
from .inema import Internetmarke
|
||||
from .inema import ProductInformation
|
||||
from .inema import __version__
|
||||
from .wpint import WarenpostInt
|
||||
|
|
|
@ -0,0 +1,247 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Implementation of the "Warenpost International ReST-API"
|
||||
#
|
||||
# This is a completely different interface that Deutsche Post came up many years
|
||||
# after the "1C4A" Internetmarke API. For some strange reason they didn't
|
||||
# extend the old Internetmarke API to add support for the harmonized label and
|
||||
# the electronic customs declaration. Instead, they decided to implement a
|
||||
# completely different API with different standards (REST vs. SOAP) with
|
||||
# literally nothing in common to the old Internetmarke API
|
||||
|
||||
from datetime import datetime, date
|
||||
from pytz import timezone
|
||||
import requests
|
||||
from lxml import etree
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class WarenpostInt(object):
|
||||
def __init__(self, partner_id, key, ekp, pk_email, pk_passwd, key_phase="1", sandbox = False):
|
||||
self.sandbox = sandbox
|
||||
self.partner_id = 'DP_LT' if sandbox else partner_id
|
||||
self.key = key
|
||||
self.ekp = ekp
|
||||
self.key_phase = '1' if sandbox else key_phase
|
||||
self.pk_email = pk_email
|
||||
self.pk_passwd = pk_passwd
|
||||
if sandbox:
|
||||
self.auth_url = 'https://api-qa.deutschepost.com/v1'
|
||||
self.url = 'https://api-qa.deutschepost.com/dpi/shipping/v1'
|
||||
else:
|
||||
self.auth_url = 'https://api.deutschepost.com/v1'
|
||||
self.url = 'https://api.deutschepost.com/dpi/shipping/v1'
|
||||
self.user_token = None
|
||||
self.wallet_balance = None
|
||||
|
||||
# FIXME: merge with inema?
|
||||
@staticmethod
|
||||
def compute_1c4a_hash(partner_id, req_ts, key_phase, key):
|
||||
""" Compute 1C4A request hash accordig to Section 4 of service description. """
|
||||
# trim leading and trailing spaces of each argument
|
||||
partner_id = partner_id.strip()
|
||||
req_ts = req_ts.strip()
|
||||
key_phase = key_phase.strip()
|
||||
key = key.strip()
|
||||
# concatenate with "::" separator
|
||||
inp = "%s::%s::%s::%s" % (partner_id, req_ts, key_phase, key)
|
||||
# compute MD5 hash as 32 hex nibbles
|
||||
md5_hex = hashlib.md5(inp.encode('utf8')).hexdigest()
|
||||
# return the first 8 characters
|
||||
return md5_hex[:8]
|
||||
|
||||
# FIXME: merge with inema?
|
||||
@staticmethod
|
||||
def gen_timestamp():
|
||||
de_zone = timezone("Europe/Berlin")
|
||||
de_time = datetime.now(de_zone)
|
||||
return de_time.strftime("%d%m%Y-%H%M%S")
|
||||
|
||||
def gen_headers(self):
|
||||
"""Generate the HTTP headers required for the API."""
|
||||
ret = {
|
||||
'KEY_PHASE': self.key_phase,
|
||||
'PARTNER_ID': self.partner_id,
|
||||
'Authorization': 'Bearer %s' % self.user_token
|
||||
}
|
||||
if self.sandbox:
|
||||
ret['REQUEST_TIMESTAMP'] = '16082018-122210'
|
||||
ret['PARTNER_SIGNATURE'] = '9d7c35be'
|
||||
else:
|
||||
timestamp = self.gen_timestamp()
|
||||
sig = self.compute_1c4a_hash(self.partner_id, timestamp, self.key_phase, self.key)
|
||||
ret['REQUEST_TIMESTAMP'] = timestamp
|
||||
ret['PARTNER_SIGNATURE'] = sig
|
||||
return ret
|
||||
|
||||
def get_token(self):
|
||||
"""Get an Access Token for further API requests."""
|
||||
url = "%s/%s" % (self.auth_url, 'auth/accesstoken')
|
||||
auth = requests.auth.HTTPBasicAuth(self.pk_email, self.pk_passwd)
|
||||
ret = requests.request('GET', url, headers=self.gen_headers(), auth=auth)
|
||||
et = etree.XML(ret.content)
|
||||
e_userToken = et.find(".//{http://oneclickforapp.dpag.de/V3}userToken")
|
||||
e_walletBalance = et.find(".//{http://oneclickforapp.dpag.de/V3}walletBalance")
|
||||
# update status + return token
|
||||
self.user_token = e_userToken.text
|
||||
self.wallet_balance = e_walletBalance.text
|
||||
_logger.debug("User Token: %s" % (self.user_token))
|
||||
_logger.info("Wallet balance: %s" % (self.wallet_balance))
|
||||
return e_userToken.text
|
||||
|
||||
def request(self, method, suffix, json=None, headers={}):
|
||||
"""Wrapper for issuing HTTP requests against the API.
|
||||
This internally generates all required headers, including Authorization."""
|
||||
url = "%s/%s" % (self.url, suffix)
|
||||
# FIXME: automatically ensure we have a [current] user_token
|
||||
h = headers.copy()
|
||||
h.update(self.gen_headers())
|
||||
_logger.debug("HTTP Request: %s %s: HDR: %s JSON: %s", method, url, h, json)
|
||||
r = requests.request(method, url, json=json, headers=h)
|
||||
_logger.debug("HTTP Response: %s", r.content)
|
||||
return r
|
||||
|
||||
class Address(object):
|
||||
"""Common Representation of a postal address. In their infinite cluelessness,
|
||||
the developes of the Warenpost International API decided it's a good idea to
|
||||
use a flat, non-hierarchical structure with different names of fields for
|
||||
sender and recipient."""
|
||||
def __init__(self, name, addr_lines, city, country_code, postal_code='', state=None,
|
||||
phone=None, fax=None, email=None):
|
||||
if len(addr_lines) > 3:
|
||||
raise ValueError('Maximum number of 3 Address Lines supported')
|
||||
if len(name) > 30:
|
||||
raise ValueError('Maximum length of name is 30 chars')
|
||||
if phone and len(phone) > 15:
|
||||
raise ValueError('Maximum length of phone number is 15 chars')
|
||||
if fax and len(fax) > 15:
|
||||
raise ValueError('Maximum length of fax number is 15 chars')
|
||||
if email and len(email) > 50:
|
||||
raise ValueError('Maximum length of email address is 50 chars')
|
||||
for l in addr_lines:
|
||||
if len(l) > 40:
|
||||
raise ValueError('Maximum length of address lines is 40 chars')
|
||||
self.name = name
|
||||
self.addr_lines = addr_lines
|
||||
self.city = city
|
||||
self.postal_code = postal_code
|
||||
self.country_code = country_code
|
||||
self.state = state
|
||||
self.phone = phone
|
||||
self.fax = fax
|
||||
self.email = email
|
||||
|
||||
def as_sender(self):
|
||||
"""Represent an Address object as JSON fields of a sender."""
|
||||
if len(self.addr_lines) > 2:
|
||||
raise ValueError('Maximum number of 2 Address Lines supported')
|
||||
for l in self.addr_lines:
|
||||
if len(l) > 30:
|
||||
raise ValueError('Maximum length of address lines is 30 chars')
|
||||
ret = {
|
||||
'senderName': self.name,
|
||||
'senderAddressLine1': self.addr_lines[0],
|
||||
'senderAddressLine2': self.addr_lines[1] if len(self.addr_lines) > 1 else '',
|
||||
'senderCity': self.city,
|
||||
'senderPostalCode': self.postal_code,
|
||||
'senderCountry': self.country_code,
|
||||
}
|
||||
if self.phone:
|
||||
ret['senderPhone'] = self.phone
|
||||
if self.email:
|
||||
ret['senderEmail'] = self.email
|
||||
return ret
|
||||
|
||||
def as_recipient(self):
|
||||
"""Represent an Address object as JSON fields of a sender."""
|
||||
ret = {
|
||||
'recipient': self.name,
|
||||
'addressLine1': self.addr_lines[0],
|
||||
'city': self.city,
|
||||
'postalCode': self.postal_code,
|
||||
'destinationCountry': self.country_code,
|
||||
}
|
||||
if len(self.addr_lines) > 1:
|
||||
ret['addressLine2'] = self.addr_lines[1]
|
||||
if len(self.addr_lines) > 2:
|
||||
ret['addressLine3'] = self.addr_lines[2]
|
||||
if self.state:
|
||||
ret['state'] = self.state
|
||||
if self.phone:
|
||||
ret['recipientPhone'] = self.phone
|
||||
if self.fax:
|
||||
ret['recipientFax'] = self.fax
|
||||
if self.email:
|
||||
ret['recipientEmail'] = self.email
|
||||
return ret
|
||||
|
||||
def build_content_item(self, line_weight_g, line_value, qty, hs_code=None, origin_cc=None, desc=None):
|
||||
"""Build an 'content item' in the language of the WaPoInt API. Represents one
|
||||
line on the customs form."""
|
||||
ret = {
|
||||
'contentPieceNetweight': line_weight_g,
|
||||
'contentPieceValue': str(int(line_value)),
|
||||
'contentPieceAmount': int(qty),
|
||||
}
|
||||
if hs_code:
|
||||
ret['contentPieceHsCode'] = str(hs_code)
|
||||
if desc:
|
||||
ret['contentPieceDescription'] = str(desc)
|
||||
if origin_cc:
|
||||
ret['contentPieceOrigin'] = origin_cc
|
||||
return ret
|
||||
|
||||
def build_item(self, product, sender, receiver, weight_grams, amount=0, currency='EUR',
|
||||
contents=[]):
|
||||
"""Build an 'item' in the language of the WaPoInt API. Represents one shipment."""
|
||||
ret = {
|
||||
'product': str(product),
|
||||
'serviceLevel': 'STANDARD',
|
||||
'shipmentAmount': int(amount),
|
||||
'shipmentCurrency': currency,
|
||||
'shipmentGrossWeight': int(weight_grams),
|
||||
'shipmentNaturetype': 'SALE_GOODS',
|
||||
}
|
||||
# merge in the sender and recipient fields
|
||||
ret.update(sender.as_sender())
|
||||
ret.update(recipient.as_recipient())
|
||||
if len(contents):
|
||||
ret['contents'] = contents
|
||||
return ret
|
||||
|
||||
def build_order(self, items, contactName, orderStatus='FINALIZE'):
|
||||
"""Build an 'order' in the language of the WaPoInt API. Consists of multiple shipments."""
|
||||
ret = {
|
||||
'customerEkp': self.ekp,
|
||||
'orderStatus': orderStatus,
|
||||
'paperwork': {
|
||||
'contactName': contactName,
|
||||
'awbCopyCount': 1,
|
||||
},
|
||||
'items': items
|
||||
}
|
||||
return ret
|
||||
|
||||
def api_create_order(self, items, contactName, orderStatus='FINALIZE'):
|
||||
"""Issue an API request to create an order consisting of items."""
|
||||
order = self.build_order(items, contactName=contactName, orderStatus=orderStatus)
|
||||
_logger.info("Order: %s", order)
|
||||
r = self.request('POST', 'orders', json = order)
|
||||
# TODO: figure out the AWB and the (item, barcode, voucherId, ...) for the items
|
||||
json_resp = r.json()
|
||||
#print(json.dumps(json_resp, indent=4))
|
||||
_logger.info("Response: %s", json_resp)
|
||||
# TODO: download the PDF for each AWB
|
||||
return json_resp
|
||||
|
||||
def api_get_item_label(self, item_id, accept='application/pdf'):
|
||||
"""Download the label for a given item. Returns PDF as 'bytes'"""
|
||||
r = self.request('GET', 'items/%s/label' % item_id, headers={'Accept': accept})
|
||||
return r.content
|
||||
|
||||
def api_get_item_labels(self, awb, accept='application/pdf'):
|
||||
"""Download the labels for all items in a given AWB. Returns PDF as 'bytes'"""
|
||||
r = self.request('GET', 'shipments/%s/itemlabels' % awb, headers={'Accept': accept})
|
||||
return r.content
|
Loading…
Reference in New Issue