Merge branch 'imp/11.0/walmart_oauth' into '11.0-test'

TEST: imp/11.0/walmart_oauth

See merge request hibou-io/hibou-odoo/suite!26
This commit is contained in:
Jared Kipe
2019-08-22 20:09:59 +00:00
9 changed files with 423 additions and 285 deletions

View File

@@ -3,7 +3,7 @@
{
'name': 'Walmart Connector',
'version': '11.0.1.1.0',
'version': '11.0.1.2.0',
'category': 'Connector',
'depends': [
'account',

View File

@@ -1 +1,7 @@
from . import walmart
"""python-walmart - Walmart Marketplace API"""
__version__ = '0.0.6'
__author__ = 'Fulfil.IO Inc. <hello@fulfil.io>'
__all__ = []
from .walmart import Walmart # noqa

View File

@@ -0,0 +1,21 @@
class BaseException(Exception):
"""
Base Exception which implements message attr on exceptions
Required for: Python 3
"""
def __init__(self, message=None, *args, **kwargs):
self.message = message
super(BaseException, self).__init__(
self.message, *args, **kwargs
)
def __str__(self):
return self.message or self.__class__.__name__
class WalmartException(BaseException):
pass
class WalmartAuthenticationError(WalmartException):
pass

View File

@@ -30,39 +30,61 @@
# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
# OF THE POSSIBILITY OF SUCH DAMAGE.
# © 2017 Hibou Corp. - Extended and converted to v3/JSON
import requests
import base64
import time
from uuid import uuid4
# from lxml import etree
# from lxml.builder import E, ElementMaker
from json import dumps, loads
import uuid
import csv
import io
import zipfile
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
from Crypto.Signature import PKCS1_v1_5
from datetime import datetime
from requests.auth import HTTPBasicAuth
from lxml import etree
from lxml.builder import E, ElementMaker
try:
from urllib.parse import urlencode
except ImportError:
from urllib import urlencode
from .exceptions import WalmartAuthenticationError
def epoch_milliseconds(dt):
"Walmart accepts timestamps as epoch time in milliseconds"
epoch = datetime.utcfromtimestamp(0)
return int((dt - epoch).total_seconds() * 1000.0)
class Walmart(object):
def __init__(self, consumer_id, channel_type, private_key):
self.base_url = 'https://marketplace.walmartapis.com/v3/%s'
self.consumer_id = consumer_id
self.channel_type = channel_type
self.private_key = private_key
self.session = requests.Session()
self.session.headers['Accept'] = 'application/json'
self.session.headers['WM_SVC.NAME'] = 'Walmart Marketplace'
self.session.headers['WM_CONSUMER.ID'] = self.consumer_id
self.session.headers['WM_CONSUMER.CHANNEL.TYPE'] = self.channel_type
def __init__(self, client_id, client_secret):
"""To get client_id and client_secret for your Walmart Marketplace
visit: https://developer.walmart.com/#/generateKey
"""
self.client_id = client_id
self.client_secret = client_secret
self.token = None
self.token_expires_in = None
self.base_url = "https://marketplace.walmartapis.com/v3"
session = requests.Session()
session.headers.update({
"WM_SVC.NAME": "Walmart Marketplace",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
})
session.auth = HTTPBasicAuth(self.client_id, self.client_secret)
self.session = session
# Get the token required for API requests
self.authenticate()
def authenticate(self):
data = self.send_request(
"POST", "{}/token".format(self.base_url),
body={
"grant_type": "client_credentials",
},
)
self.token = data["access_token"]
self.token_expires_in = data["expires_in"]
self.session.headers["WM_SEC.ACCESS_TOKEN"] = self.token
@property
def items(self):
@@ -80,42 +102,70 @@ class Walmart(object):
def orders(self):
return Orders(connection=self)
def get_sign(self, url, method, timestamp):
return self.sign_data(
'\n'.join([self.consumer_id, url, method, timestamp]) + '\n'
)
@property
def report(self):
return Report(connection=self)
def sign_data(self, data):
rsakey = RSA.importKey(base64.b64decode(self.private_key))
signer = PKCS1_v1_5.new(rsakey)
digest = SHA256.new()
digest.update(data.encode('utf-8'))
sign = signer.sign(digest)
return base64.b64encode(sign)
@property
def feed(self):
return Feed(connection=self)
def get_headers(self, url, method):
timestamp = str(int(round(time.time() * 1000)))
def send_request(
self, method, url, params=None, body=None, json=None,
request_headers=None
):
# A unique ID which identifies each API call and used to track
# and debug issues; use a random generated GUID for this ID
headers = {
'WM_SEC.AUTH_SIGNATURE': self.get_sign(url, method, timestamp),
'WM_SEC.TIMESTAMP': timestamp,
'WM_QOS.CORRELATION_ID': str(uuid4()),
"WM_QOS.CORRELATION_ID": uuid.uuid4().hex,
}
if method in ('POST', ):
headers['Content-Type'] = 'application/json'
return headers
if request_headers:
headers.update(request_headers)
def send_request(self, method, url, params=None, body=None):
encoded_url = url
if params:
encoded_url += '?%s' % urlencode(params)
headers = self.get_headers(encoded_url, method)
response = None
if method == "GET":
response = self.session.get(url, params=params, headers=headers)
elif method == "PUT":
response = self.session.put(
url, params=params, headers=headers, data=body
)
elif method == "POST":
request_params = {
"params": params,
"headers": headers,
}
if json is not None:
request_params["json"] = json
else:
request_params["data"] = body
response = self.session.post(url, **request_params)
if method == 'GET':
return loads(self.session.get(url, params=params, headers=headers).text)
elif method == 'PUT':
return loads(self.session.put(url, params=params, headers=headers).text)
elif method == 'POST':
return loads(self.session.post(url, data=body, headers=headers).text)
if response is not None:
try:
response.raise_for_status()
except requests.exceptions.HTTPError:
if response.status_code == 401:
raise WalmartAuthenticationError((
"Invalid client_id or client_secret. Please verify "
"your credentials from https://developer.walmart."
"com/#/generateKey"
))
elif response.status_code == 400:
data = response.json()
if data["error"][0]["code"] == \
"INVALID_TOKEN.GMP_GATEWAY_API":
# Refresh the token as the current token has expired
self.authenticate()
return self.send_request(
method, url, params, body, request_headers
)
raise
try:
return response.json()
except ValueError:
# In case of reports, there is no JSON response, so return the
# content instead which contains the actual report
return response.content
class Resource(object):
@@ -128,24 +178,21 @@ class Resource(object):
@property
def url(self):
return self.connection.base_url % self.path
return "{}/{}".format(self.connection.base_url, self.path)
def all(self, **kwargs):
return self.connection.send_request(
method='GET', url=self.url, params=kwargs)
method="GET", url=self.url, params=kwargs
)
def get(self, id):
url = self.url + '/%s' % id
return self.connection.send_request(method='GET', url=url)
url = "{}/{}".format(self.url, id)
return self.connection.send_request(method="GET", url=url)
def update(self, **kwargs):
return self.connection.send_request(
method='PUT', url=self.url, params=kwargs)
# def bulk_update(self, items):
# url = self.connection.base_url % 'feeds?feedType=%s' % self.feedType
# return self.connection.send_request(
# method='POST', url=url, data=self.get_payload(items))
method="PUT", url=self.url, params=kwargs
)
class Items(Resource):
@@ -155,6 +202,14 @@ class Items(Resource):
path = 'items'
def get_items(self):
"Get all the items from the Item Report"
response = self.connection.report.all(type="item")
zf = zipfile.ZipFile(io.BytesIO(response), "r")
product_report = zf.read(zf.infolist()[0]).decode("utf-8")
return list(csv.DictReader(io.StringIO(product_report)))
class Inventory(Resource):
"""
@@ -164,6 +219,74 @@ class Inventory(Resource):
path = 'inventory'
feedType = 'inventory'
def bulk_update(self, items):
"""Updates the inventory for multiple items at once by creating the
feed on Walmart.
:param items: Items for which the inventory needs to be updated in
the format of:
[{
"sku": "XXXXXXXXX",
"availability_code": "AC",
"quantity": "10",
"uom": "EACH",
"fulfillment_lag_time": "1",
}]
"""
inventory_data = []
for item in items:
data = {
"sku": item["sku"],
"quantity": {
"amount": item["quantity"],
"unit": item.get("uom", "EACH"),
},
"fulfillmentLagTime": item.get("fulfillment_lag_time"),
}
if item.get("availability_code"):
data["availabilityCode"] = item["availability_code"]
inventory_data.append(data)
body = {
"InventoryHeader": {
"version": "1.4",
},
"Inventory": inventory_data,
}
return self.connection.feed.create(resource="inventory", content=body)
def update_inventory(self, sku, quantity):
headers = {
'Content-Type': "application/xml"
}
return self.connection.send_request(
method='PUT',
url=self.url,
params={'sku': sku},
body=self.get_inventory_payload(sku, quantity),
request_headers=headers
)
def get_inventory_payload(self, sku, quantity):
element = ElementMaker(
namespace='http://walmart.com/',
nsmap={
'wm': 'http://walmart.com/',
}
)
return etree.tostring(
element(
'inventory',
element('sku', sku),
element(
'quantity',
element('unit', 'EACH'),
element('amount', str(quantity)),
),
element('fulfillmentLagTime', '4'),
), xml_declaration=True, encoding='utf-8'
)
def get_payload(self, items):
return etree.tostring(
E.InventoryFeed(
@@ -191,55 +314,53 @@ class Prices(Resource):
feedType = 'price'
def get_payload(self, items):
# root = ElementMaker(
# nsmap={'gmp': 'http://walmart.com/'}
# )
# return etree.tostring(
# root.PriceFeed(
# E.PriceHeader(E('version', '1.5')),
# *[E.Price(
# E(
# 'itemIdentifier',
# E('sku', item['sku'])
# ),
# E(
# 'pricingList',
# E(
# 'pricing',
# E(
# 'currentPrice',
# E(
# 'value',
# **{
# 'currency': item['currenctCurrency'],
# 'amount': item['currenctPrice']
# }
# )
# ),
# E('currentPriceType', item['priceType']),
# E(
# 'comparisonPrice',
# E(
# 'value',
# **{
# 'currency': item['comparisonCurrency'],
# 'amount': item['comparisonPrice']
# }
# )
# ),
# E(
# 'priceDisplayCode',
# **{
# 'submapType': item['displayCode']
# }
# ),
# )
# )
# ) for item in items]
# ), xml_declaration=True, encoding='utf-8'
# )
payload = {}
return
root = ElementMaker(
nsmap={'gmp': 'http://walmart.com/'}
)
return etree.tostring(
root.PriceFeed(
E.PriceHeader(E('version', '1.5')),
*[E.Price(
E(
'itemIdentifier',
E('sku', item['sku'])
),
E(
'pricingList',
E(
'pricing',
E(
'currentPrice',
E(
'value',
**{
'currency': item['currenctCurrency'],
'amount': item['currenctPrice']
}
)
),
E('currentPriceType', item['priceType']),
E(
'comparisonPrice',
E(
'value',
**{
'currency': item['comparisonCurrency'],
'amount': item['comparisonPrice']
}
)
),
E(
'priceDisplayCode',
**{
'submapType': item['displayCode']
}
),
)
)
) for item in items]
), xml_declaration=True, encoding='utf-8'
)
class Orders(Resource):
@@ -250,158 +371,165 @@ class Orders(Resource):
path = 'orders'
def all(self, **kwargs):
next_cursor = kwargs.pop('nextCursor', '')
return self.connection.send_request(
method='GET', url=self.url + next_cursor, params=kwargs)
def released(self, **kwargs):
next_cursor = kwargs.pop('nextCursor', '')
url = self.url + '/released'
return self.connection.send_request(
method='GET', url=url + next_cursor, params=kwargs)
try:
return super(Orders, self).all(**kwargs)
except requests.exceptions.HTTPError as exc:
if exc.response.status_code == 404:
# If no orders are there on walmart matching the query
# filters, it throws 404. In this case return an empty
# list to make the API consistent
return {
"list": {
"elements": {
"order": [],
}
}
}
raise
def acknowledge(self, id):
url = self.url + '/%s/acknowledge' % id
return self.connection.send_request(method='POST', url=url)
return self.send_request(method='POST', url=url)
def cancel(self, id, lines):
url = self.url + '/%s/cancel' % id
return self.connection.send_request(
method='POST', url=url, body=self.get_cancel_payload(lines))
return self.send_request(
method='POST', url=url, data=self.get_cancel_payload(lines))
def get_cancel_payload(self, lines):
"""
{
"orderCancellation": {
"orderLines": {
"orderLine": [
{
"lineNumber": "1",
"orderLineStatuses": {
"orderLineStatus": [
{
"status": "Cancelled",
"cancellationReason": "CUSTOMER_REQUESTED_SELLER_TO_CANCEL",
"statusQuantity": {
"unitOfMeasurement": "EA",
"amount": "1"
}
}
]
}
}
]
element = ElementMaker(
namespace='http://walmart.com/mp/orders',
nsmap={
'ns2': 'http://walmart.com/mp/orders',
'ns3': 'http://walmart.com/'
}
}
}
:param lines:
:return: string
"""
payload = {
'orderCancellation': {
'orderLines': [{
'lineNumber': line['number'],
'orderLineStatuses': {
'orderLineStatus': [
{
'status': 'Cancelled',
'cancellationReason': 'CUSTOMER_REQUESTED_SELLER_TO_CANCEL',
'statusQuantity': {
'unitOfMeasurement': 'EA',
'amount': line['amount'],
}
}
]
}
} for line in lines]
}
}
return dumps(payload)
def ship(self, id, lines):
url = self.url + '/%s/shipping' % id
return self.connection.send_request(
method='POST',
url=url,
body=self.get_ship_payload(lines)
)
return etree.tostring(
element(
'orderCancellation',
element(
'orderLines',
*[element(
'orderLine',
element('lineNumber', line),
element(
'orderLineStatuses',
element(
'orderLineStatus',
element('status', 'Cancelled'),
element(
'cancellationReason', 'CANCEL_BY_SELLER'),
element(
'statusQuantity',
element('unitOfMeasurement', 'EACH'),
element('amount', '1')
)
)
)
) for line in lines]
)
), xml_declaration=True, encoding='utf-8'
)
def get_ship_payload(self, lines):
"""
def create_shipment(self, order_id, lines):
"""Send shipping updates to Walmart
:param lines: list[ dict(number, amount, carrier, methodCode, trackingNumber, trackingUrl) ]
:return:
:param order_id: Purchase order ID of an order
:param lines: Order lines to be fulfilled in the format:
[{
"line_number": "123",
"uom": "EACH",
"quantity": 3,
"ship_time": datetime(2019, 04, 04, 12, 00, 00),
"other_carrier": None,
"carrier": "USPS",
"carrier_service": "Standard",
"tracking_number": "34567890567890678",
"tracking_url": "www.fedex.com",
}]
"""
"""
{
"orderShipment": {
"orderLines": {
"orderLine": [
{
"lineNumber": "1",
"orderLineStatuses": {
"orderLineStatus": [
{
url = self.url + "/{}/shipping".format(order_id)
order_lines = []
for line in lines:
ship_time = line.get("ship_time", "")
if ship_time:
ship_time = epoch_milliseconds(ship_time)
order_lines.append({
"lineNumber": line["line_number"],
"orderLineStatuses": {
"orderLineStatus": [{
"status": "Shipped",
"statusQuantity": {
"unitOfMeasurement": "EA",
"amount": "1"
"unitOfMeasurement": line.get("uom", "EACH"),
"amount": str(int(line["quantity"])),
},
"trackingInfo": {
"shipDateTime": 1488480443000,
"carrierName": {
"otherCarrier": null,
"carrier": "UPS"
},
"methodCode": "Express",
"trackingNumber": "12345",
"trackingURL": "www.fedex.com"
"shipDateTime": ship_time,
"carrierName": {
"otherCarrier": line.get("other_carrier"),
"carrier": line["carrier"],
},
"methodCode": line.get("carrier_service", ""),
"trackingNumber": line["tracking_number"],
"trackingURL": line.get("tracking_url", "")
}
}
]
}
}],
}
]
}
}
}
:param lines:
:return:
"""
})
payload = {
body = {
"orderShipment": {
"orderLines": {
"orderLine": [
{
"lineNumber": str(line['number']),
"orderLineStatuses": {
"orderLineStatus": [
{
"status": "Shipped",
"statusQuantity": {
"unitOfMeasurement": "EA",
"amount": str(line['amount'])
},
"trackingInfo": {
"shipDateTime": line['shipDateTime'],
"carrierName": {
"otherCarrier": None,
"carrier": line['carrier']
},
"methodCode": line['methodCode'],
"trackingNumber": line['trackingNumber'],
"trackingURL": line['trackingUrl']
}
}
]
}
}
for line in lines]
"orderLine": order_lines,
}
}
}
return self.connection.send_request(
method="POST",
url=url,
json=body,
)
return dumps(payload)
class Report(Resource):
"""
Get report
"""
path = 'getReport'
class Feed(Resource):
path = "feeds"
def create(self, resource, content):
"""Creates the feed on Walmart for respective resource
Once you upload the Feed, you can use the Feed ID returned in the
response to track the status of the feed and the status of the
item within that Feed.
:param resource: The resource for which the feed needs to be created.
:param content: The content needed to create the Feed.
"""
return self.connection.send_request(
method="POST",
url=self.url,
params={
"feedType": resource,
},
json=content,
)
def get_status(self, feed_id, offset=0, limit=1000):
"Returns the feed and item status for a specified Feed ID"
return self.connection.send_request(
method="GET",
url="{}/{}".format(self.url, feed_id),
params={
"includeDetails": "true",
"limit": limit,
"offset": offset,
},
)

View File

@@ -199,10 +199,10 @@ class SaleOrderAdapter(Component):
""" Returns the order after ack
:rtype: dict
"""
_logger.warn('BEFORE ACK ' + str(id))
_logger.info('BEFORE ACK ' + str(id))
api_instance = self.api_instance
record = api_instance.orders.acknowledge(id)
_logger.warn('AFTER ACK RECORD: ' + str(record))
_logger.info('AFTER ACK RECORD: ' + str(record))
if 'order' in record:
return record['order']
raise RetryableJobError('Acknowledge Order "' + str(id) + '" did not return an order response.')

View File

@@ -51,9 +51,9 @@ class StockPickingAdapter(Component):
def create(self, id, lines):
api_instance = self.api_instance
_logger.warn('BEFORE SHIPPING %s list: %s' % (str(id), str(lines)))
record = api_instance.orders.ship(id, lines)
_logger.warn('AFTER SHIPPING RECORD: ' + str(record))
_logger.info('BEFORE SHIPPING %s list: %s' % (str(id), str(lines)))
record = api_instance.orders.create_shipment(id, lines)
_logger.info('AFTER SHIPPING RECORD: ' + str(record))
if 'order' in record:
return record['order']
raise RetryableJobError('Shipping Order %s did not return an order response. (lines: %s)' % (str(id), str(lines)))

View File

@@ -23,11 +23,11 @@ class WalmartPickingExporter(Component):
"""
Normalizes picking line data into the format to export to Walmart.
:param binding: walmart.stock.picking
:return: list[ dict(number, amount, carrier, methodCode, trackingNumber, trackingUrl=None) ]
:return: list[ dict(line_number, uom="EACH", quantity, ship_time, carrier, carrier_service, tracking_number, tracking_url=None) ]
"""
ship_date = binding.date_done
# in ms
ship_date_time = int(fields.Datetime.from_string(ship_date).strftime('%s')) * 1000
ship_date_time = fields.Datetime.from_string(ship_date)
lines = []
for line in binding.move_lines:
sale_line = line.sale_line_id
@@ -43,19 +43,17 @@ class WalmartPickingExporter(Component):
continue
number = walmart_sale_line.walmart_number
amount = 1 if line.product_qty > 0 else 0
amount = 1 if line.product_qty > 0 else 0 # potentially because of EACH?
carrier = binding.carrier_id.walmart_carrier_code
methodCode = binding.walmart_order_id.shipping_method_code
trackingNumber = binding.carrier_tracking_ref
trackingUrl = None
carrier_service = binding.walmart_order_id.shipping_method_code
tracking_number = binding.carrier_tracking_ref
lines.append(dict(
shipDateTime=ship_date_time,
number=number,
amount=amount,
line_number=number,
quantity=amount,
ship_time=ship_date_time,
carrier=carrier,
methodCode=methodCode,
trackingNumber=trackingNumber,
trackingUrl=trackingUrl,
carrier_service=carrier_service,
tracking_number=tracking_number,
))
return lines

View File

@@ -20,21 +20,8 @@ class WalmartBackend(models.Model):
_inherit = 'connector.backend'
name = fields.Char(string='Name')
consumer_id = fields.Char(
string='Consumer ID',
required=True,
help='Walmart Consumer ID',
)
channel_type = fields.Char(
string='Channel Type',
required=True,
help='Walmart Channel Type',
)
private_key = fields.Char(
string='Private Key',
required=True,
help='Walmart Private Key'
)
client_id = fields.Char(string='Client ID')
client_secret = fields.Char(string='Client Secret')
warehouse_id = fields.Many2one(
comodel_name='stock.warehouse',
string='Warehouse',
@@ -89,7 +76,7 @@ class WalmartBackend(models.Model):
@api.multi
def work_on(self, model_name, **kwargs):
self.ensure_one()
walmart_api = Walmart(self.consumer_id, self.channel_type, self.private_key)
walmart_api = Walmart(self.client_id, self.client_secret)
_super = super(WalmartBackend, self)
with _super.work_on(model_name, walmart_api=walmart_api, **kwargs) as work:
yield work
@@ -98,9 +85,8 @@ class WalmartBackend(models.Model):
def _scheduler_import_sale_orders(self):
# potential hook for customization (e.g. pad from date or provide its own)
backends = self.search([
('consumer_id', '!=', False),
('channel_type', '!=', False),
('private_key', '!=', False),
('client_id', '!=', False),
('client_secret', '!=', False),
('import_orders_from_date', '!=', False),
])
return backends.import_sale_orders()

View File

@@ -17,9 +17,8 @@
<notebook>
<page string="API" name="api">
<group colspan="4" col="4">
<field name="consumer_id"/>
<field name="channel_type"/>
<field name="private_key" password="1"/>
<field name="client_id"/>
<field name="client_secret" password="1"/>
</group>
</page>
</notebook>