diff --git a/connector_walmart/__manifest__.py b/connector_walmart/__manifest__.py index aea4c191..ba499745 100644 --- a/connector_walmart/__manifest__.py +++ b/connector_walmart/__manifest__.py @@ -3,7 +3,7 @@ { 'name': 'Walmart Connector', - 'version': '11.0.1.1.0', + 'version': '11.0.1.2.0', 'category': 'Connector', 'depends': [ 'account', diff --git a/connector_walmart/components/api/__init__.py b/connector_walmart/components/api/__init__.py index ff014f05..39a0d13c 100644 --- a/connector_walmart/components/api/__init__.py +++ b/connector_walmart/components/api/__init__.py @@ -1 +1,7 @@ -from . import walmart +"""python-walmart - Walmart Marketplace API""" + +__version__ = '0.0.6' +__author__ = 'Fulfil.IO Inc. ' +__all__ = [] + +from .walmart import Walmart # noqa diff --git a/connector_walmart/components/api/exceptions.py b/connector_walmart/components/api/exceptions.py new file mode 100644 index 00000000..2c42717e --- /dev/null +++ b/connector_walmart/components/api/exceptions.py @@ -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 diff --git a/connector_walmart/components/api/walmart.py b/connector_walmart/components/api/walmart.py index dfcf41d8..d20bdf3f 100644 --- a/connector_walmart/components/api/walmart.py +++ b/connector_walmart/components/api/walmart.py @@ -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) \ No newline at end of file + +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, + }, + ) diff --git a/connector_walmart/models/sale_order/common.py b/connector_walmart/models/sale_order/common.py index b173ad04..6574e233 100644 --- a/connector_walmart/models/sale_order/common.py +++ b/connector_walmart/models/sale_order/common.py @@ -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.') diff --git a/connector_walmart/models/stock_picking/common.py b/connector_walmart/models/stock_picking/common.py index d1b96b4d..459c6e78 100644 --- a/connector_walmart/models/stock_picking/common.py +++ b/connector_walmart/models/stock_picking/common.py @@ -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))) diff --git a/connector_walmart/models/stock_picking/exporter.py b/connector_walmart/models/stock_picking/exporter.py index 7027333d..bd84dcfd 100644 --- a/connector_walmart/models/stock_picking/exporter.py +++ b/connector_walmart/models/stock_picking/exporter.py @@ -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 diff --git a/connector_walmart/models/walmart_backend/common.py b/connector_walmart/models/walmart_backend/common.py index 673857f1..93936a36 100644 --- a/connector_walmart/models/walmart_backend/common.py +++ b/connector_walmart/models/walmart_backend/common.py @@ -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() diff --git a/connector_walmart/views/walmart_backend_views.xml b/connector_walmart/views/walmart_backend_views.xml index 89e74030..f2d0bbb0 100644 --- a/connector_walmart/views/walmart_backend_views.xml +++ b/connector_walmart/views/walmart_backend_views.xml @@ -17,9 +17,8 @@ - - - + +