Files

422 lines
18 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from contextlib import contextmanager
from datetime import datetime, timedelta
import gzip
import json
import logging
from pprint import pformat
import requests
from werkzeug.urls import url_parse
from xml.etree import ElementTree
from odoo.exceptions import UserError, ValidationError
from odoo.tools.urls import urljoin as url_join
from odoo.addons.sale_amazon import const
_logger = logging.getLogger(__name__)
#=== CUSTOM EXCEPTION CLASSES ===#
class AmazonRateLimitError(Exception):
""" When the API rate limit of Amazon is reached. """
def __init__(self, operation):
self.operation = operation
super().__init__()
#=== ONBOARDING ===#
def exchange_authorization_code(authorization_code, account):
""" Exchange the LWA authorization code for the LWA refresh token and save it on the account.
:param str authorization_code: The authorization code to exchange with the LWA refresh token.
:param recordset account: The account for which a refresh token must be exchanged, as an
`amazon.account` record.
:return: None
"""
data = {
'grant_type': 'authorization_code',
'code': authorization_code,
}
endpoint = const.PROXY_ENDPOINTS['authorization']
response_content = make_proxy_request(endpoint, account.env, payload=data)
account.refresh_token = response_content['refresh_token']
def ensure_account_is_set_up(account, require_marketplaces=True):
""" Ensure that the fields required for SP-API calls are filled.
This method must be called at the start of every flow that require making API requests. If a
flow makes several API requests in a row, it is enough to only call this method before the first
request is made.
:param recordset account: The account for which the fields must be checked, as an
`amazon.account` record.
:param bool require_marketplaces: Whether the field `active_marketplace_ids` is considered
required or not.
:return: None
:raise UserError: If one of the required fields is not filled.
"""
# Check that the API keys and tokens are set.
if not account.refresh_token or not account.seller_key:
raise UserError(account.env._("You first need to authorize the Amazon account %s.", account.name))
# Check that the marketplaces are set when required.
if require_marketplaces and not account.active_marketplace_ids:
raise UserError(account.env._(
"You first need to set the marketplaces to synchronize for the Amazon account %s.",
account.name))
#=== PROXY COMMUNICATIONS ===#
def make_proxy_request(endpoint, env, payload=None):
""" Make a request to the Amazon proxy at the specified endpoint.
:param str endpoint: The proxy endpoint to be reached by the request.
:param Environment env: An `odoo.api.Environment`.
:param dict payload: The Amazon-specific payload of the request.
:return: The JSON-formatted content of the response.
:rtype: dict
:raise ValidationError: If a `RequestException` occurs.
"""
url = url_join(const.PROXY_URL, endpoint)
ICP = env['ir.config_parameter']
data = {
'db_uuid': ICP.sudo().get_param('database.uuid'),
'db_enterprise_code': ICP.sudo().get_param('database.enterprise_code'),
'amazon_data': json.dumps(payload or {}),
}
try:
response = requests.post(url, data=data, timeout=60)
try:
response.raise_for_status()
except requests.exceptions.HTTPError:
response_content = response.json()
error_code = response_content.get('error')
error_description = response_content.get('error_description')
_logger.exception(
"Invalid API request (error code: %s, description: %s) with data:\n%s",
error_code, error_description, pformat(data)
)
raise ValidationError(
env._(
"Error code: %(error_code)s; description: %(error_message)s",
error_code=error_code, error_message=error_description
)
)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
_logger.warning("Could not establish the connection to the proxy.", exc_info=True)
raise ValidationError(env._("Could not establish the connection to the proxy."))
return response.json()
#=== API COMMUNICATIONS ===#
def make_sp_api_request(account, operation, path_parameter='', payload=None, method='GET'):
""" Make a request to the SP-API for the specified operation.
See https://developer-docs.amazon.com/sp-api/docs/connecting-to-the-selling-partner-api.
Note: account.ensure_one()
:param recordset account: The Amazon account on behalf of which the request is made.
:param str operation: The SP-API operation to be called by the request.
:param str path_parameter: The variable that SP-API paths are interpolated with.
:param dict payload: The payload of the request.
:param string method: The HTTP method of the request ('GET' or 'POST').
:return: The JSON-formatted content of the response.
:rtype: dict
:raise ValidationError: If an HTTP error occurs.
:raise AmazonRateLimitError: If the rate limit was reached.
"""
account.ensure_one()
# Build the request URL based on the API path and domain.
path = const.API_OPERATIONS_MAPPING[operation]['url_path'].format(param=path_parameter)
domain = const.API_DOMAINS_MAPPING[account.base_marketplace_id.region]
url = url_join(domain, path)
payload = payload or {}
# Refresh the credentials used to sign the request.
refresh_access_token(account)
# Build the request headers
host = url_parse(domain).netloc
now = datetime.utcnow()
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'host': host,
'x-amz-access-token': account.access_token,
'x-amz-date': now.strftime('%Y%m%dT%H%M%SZ'),
}
try:
if method == 'GET':
response = requests.get(url, params=payload, headers=headers, timeout=60)
else: # 'POST'
response = requests.post(url, json=payload, headers=headers, timeout=60)
try:
response.raise_for_status()
except requests.exceptions.HTTPError:
if response.status_code == 429:
raise AmazonRateLimitError(operation)
else:
errors = response.json().get('errors')
error_code = errors and errors[0].get('code')
error_message = errors and errors[0].get('message')
_logger.exception(
"Invalid API request (error code: %s, description: %s) with data:\n%s",
error_code, error_message, pformat(payload)
)
raise ValidationError(account.env._(
"The communication with the API failed.\nError code: %(error_code)s; description: %(error_message)s",
error_code=error_code, error_message=error_message))
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
_logger.exception("Unable to reach endpoint at %s", url)
raise ValidationError(account.env._("Could not establish the connection to the API."))
json_response = response.json()
if const.API_OPERATIONS_MAPPING[operation].get('log_response', True):
request_id = response.headers.get("x-amzn-RequestId") or "NA"
_logger.info(
"SPAPI response for operation %(operation)s (%(request_id)s):\n%(response)s",
{"operation": operation, "request_id": request_id, "response": pformat(json_response)},
)
return json_response
def refresh_access_token(account):
""" Request a new LWA access token if it is expired and save it on the account.
:param recordset account: The account for which an access token must be requested, as an
`amazon.account` record.
:return: None
"""
if datetime.utcnow() > account.access_token_expiry - timedelta(minutes=5):
payload = {
'grant_type': 'refresh_token',
'refresh_token': account.refresh_token,
}
endpoint = const.PROXY_ENDPOINTS['authorization']
response_content = make_proxy_request(endpoint, account.env, payload=payload)
account.write({
'access_token': response_content['access_token'],
'access_token_expiry': datetime.utcnow() + timedelta(
seconds=response_content['expires_in']
),
})
#=== FEEDS MANAGEMENT ===#
def build_json_feed(account, messages):
""" Build JSON feed data to be sent to the SP-API.
https://github.com/amzn/selling-partner-api-models/blob/main/schemas/feeds/listings-feed-schema-v2.json
:param recordset account: The Amazon account on behalf of which the feed should be built, as an
`amazon.account` record.
:param list[dict] messages: The list of messages to include in the feed. See the reference for
message format.
:return: The JSON encoded feed.
:rtype: str
"""
return json.dumps(
{
'header': {
'sellerId': account.seller_key,
'version': '2.0',
'issueLocale': account.env.lang,
},
'messages': [
{
'messageId': int(datetime.utcnow().timestamp() + i) % 2147483647 + 1,
**message # Allow message to override `messageId`
}
for i, message in enumerate(messages)
],
},
separators=(',', ':'),
)
def build_feed(account, message_type, messages_builder, *args, **kwargs):
""" Build XML feed data to be sent to the SP-API.
:param recordset account: The Amazon account on behalf of which the feed should be built, as an
`amazon.account` record.
:param str message_type: The category of the feed to be built.
:param function messages_builder: The function to build the 'Message' elements.
:param list args: The arguments to pass to the `messages_builder` function.
:param dict kwargs: The keyword arguments to pass to the `messages_builder` function.
:return: The XML feed.
:rtype: str
"""
XSI = 'http://www.w3.org/2001/XMLSchema-instance'
root = ElementTree.Element(
'AmazonEnvelope', {f'{"{" + XSI + "}"}noNamespaceSchemaLocation': 'amzn-envelope.xsd'}
)
header = ElementTree.SubElement(root, 'Header')
ElementTree.SubElement(header, 'DocumentVersion').text = '1.01'
ElementTree.SubElement(header, 'MerchantIdentifier').text = account.seller_key
ElementTree.SubElement(root, 'MessageType').text = message_type
messages_builder(root, *args, **kwargs)
for i, message in enumerate(root.findall('Message')):
message_id = ElementTree.Element('MessageID')
message_id.text = f'{int(datetime.utcnow().timestamp())}{i}'
message.insert(0, message_id) # Insert the message ID before the other elements.
return ElementTree.tostring(root, encoding='UTF-8', method='xml')
def submit_feed(account, feed, feed_type, feed_content_type=None, marketplace_api_refs=None):
""" Submit the provided feed to the SP-API.
:param recordset account: The Amazon account on behalf of which the feed should be submitted, as
an `amazon.account` record.
:param str feed: The feed to submit.
:param str feed_type: The type of the feed to submit. E.g., 'POST_ORDER_ACKNOWLEDGEMENT_DATA'.
:param str feed_content_type: The mimetype of the content. E.g., 'application/json'.
:param list[str] marketplace_api_refs: The marketplace to which the feed must apply.
Defaults to the account active marketplaces.
:return: The feed id returned by the SP-API.
:rtype: str
"""
feed_content_type = feed_content_type or 'text/xml; charset=UTF-8'
marketplace_api_refs = marketplace_api_refs or account.active_marketplace_ids.mapped('api_ref')
def _create_feed_document():
""" Create a feed document.
:return: The feed document id and the pre-signed URL to upload the feed to.
:rtype: tuple[str, str]
"""
_payload = {'contentType': feed_content_type}
_response_content = make_sp_api_request(
account, 'createFeedDocument', payload=_payload, method='POST'
)
return _response_content['feedDocumentId'], _response_content['url']
def _upload_feed_data():
""" Upload the feed to the URL returned by Amazon.
:return: None
"""
headers = {'content-Type': feed_content_type}
try:
response = requests.put(upload_url, data=feed, headers=headers, timeout=60)
try:
response.raise_for_status()
except requests.exceptions.HTTPError:
_logger.exception("Invalid API request with data:\n%s", feed)
raise ValidationError(account.env._("The communication with the API failed."))
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
_logger.exception("Could not establish the connection to the feed URL.")
raise ValidationError(account.env._("Could not establish the connection to the feed URL."))
def _create_feed():
""" Create the feed and return its id.
:return: The feed id.
:rtype: str
"""
_payload = {
'feedType': feed_type,
'marketplaceIds': marketplace_api_refs,
'inputFeedDocumentId': feed_document_id,
}
_response_content = make_sp_api_request(
account, 'createFeed', payload=_payload, method='POST'
)
return _response_content['feedId']
feed_document_id, upload_url = _create_feed_document()
_upload_feed_data()
feed_id = _create_feed()
return feed_id
def get_feed_document(account, document_ref):
""" Return the document corresponding to the provided document reference.
The document reference is first used to fetch the URL of the document; the document is then read
directly from that URL.
:param amazon.account account: The Amazon account on behalf of which the document is fetched.
:param str document_ref: The reference of the document.
:return: The document content as a `dict` or `ElementTree.Element` depending on the feed type
this document originates from.
:raise ValidationError: If an HTTP error occurs.
"""
document_infos = make_sp_api_request(account, 'getFeedDocument', path_parameter=document_ref)
document_url = document_infos['url']
try:
response = requests.get(document_url, timeout=60)
response.raise_for_status()
document = response.content
if document_infos.get('compressionAlgorithm') == 'GZIP':
document = gzip.decompress(document)
if 'application/json' in response.headers.get('Content-Type', ''):
document = json.loads(document)
else:
document = ElementTree.fromstring(document)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
_logger.exception(
"Could not establish the connection to download the feed document at %s", document_url
)
raise ValidationError(account.env._("Could not establish the connection to the API."))
except requests.exceptions.HTTPError:
_logger.exception(
"Invalid API request while downloading the feed document at %s", document_url
)
raise ValidationError(account.env._("The communication with the API failed."))
except (ElementTree.ParseError, json.JSONDecodeError):
_logger.exception("Could not parse the feed document at %s", document_url)
raise ValidationError(account.env._("Could not process the feed document send by Amazon."))
return document
#=== HELPERS ====#
def pull_batch_data(account, operation, payload, path_parameter='', method='GET'):
""" Pull a batch of data from the SP-API.
If request results are paginated, the 'paginationToken' returned with the response is added to
the payload to pull the next page's batch with the following call.
:param recordset account: The Amazon account on behalf of which the data must be pulled.
:param str operation: The SP-API operation to be called by the request.
:param dict payload: The payload of the request.
:param str path_parameter: The variable that SP-API paths are interpolated with.
:param string method: The HTTP method of the request ('GET' or 'POST').
:return: The batch data and whether a next page exists.
:rtype: tuple[dict, bool]
"""
response_content = make_sp_api_request(
account, operation, path_parameter=path_parameter, payload=payload, method=method
)
pagination_token = response_content.get('paginationToken')
has_next_page = bool(pagination_token)
payload['paginationToken'] = pagination_token
return response_content, has_next_page
@contextmanager
def preserve_credentials(account):
""" Context manager to load credentials from the account and save them again when exiting.
Use this in situations where the cache is invalidated and you need to re-use the credentials.
:param recordset account: The Amazon account whose credentials must be preserved, as a
`amazon.account` record.
:return: None
"""
fields_to_preserve = [
'access_token',
'access_token_expiry',
]
credentials = {field: account[field] for field in fields_to_preserve} # Load credentials.
yield # Execute the code in the context.
account.write(credentials) # Restore credentials.