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', 'name': 'Walmart Connector',
'version': '11.0.1.1.0', 'version': '11.0.1.2.0',
'category': 'Connector', 'category': 'Connector',
'depends': [ 'depends': [
'account', '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 # OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
# OF THE POSSIBILITY OF SUCH DAMAGE. # OF THE POSSIBILITY OF SUCH DAMAGE.
# © 2017 Hibou Corp. - Extended and converted to v3/JSON
import requests import requests
import base64 import uuid
import time import csv
from uuid import uuid4 import io
# from lxml import etree import zipfile
# from lxml.builder import E, ElementMaker
from json import dumps, loads
from Crypto.Hash import SHA256 from datetime import datetime
from Crypto.PublicKey import RSA from requests.auth import HTTPBasicAuth
from Crypto.Signature import PKCS1_v1_5 from lxml import etree
from lxml.builder import E, ElementMaker
try: from .exceptions import WalmartAuthenticationError
from urllib.parse import urlencode
except ImportError:
from urllib import urlencode 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): class Walmart(object):
def __init__(self, consumer_id, channel_type, private_key): def __init__(self, client_id, client_secret):
self.base_url = 'https://marketplace.walmartapis.com/v3/%s' """To get client_id and client_secret for your Walmart Marketplace
self.consumer_id = consumer_id visit: https://developer.walmart.com/#/generateKey
self.channel_type = channel_type """
self.private_key = private_key self.client_id = client_id
self.session = requests.Session() self.client_secret = client_secret
self.session.headers['Accept'] = 'application/json' self.token = None
self.session.headers['WM_SVC.NAME'] = 'Walmart Marketplace' self.token_expires_in = None
self.session.headers['WM_CONSUMER.ID'] = self.consumer_id self.base_url = "https://marketplace.walmartapis.com/v3"
self.session.headers['WM_CONSUMER.CHANNEL.TYPE'] = self.channel_type
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 @property
def items(self): def items(self):
@@ -80,42 +102,70 @@ class Walmart(object):
def orders(self): def orders(self):
return Orders(connection=self) return Orders(connection=self)
def get_sign(self, url, method, timestamp): @property
return self.sign_data( def report(self):
'\n'.join([self.consumer_id, url, method, timestamp]) + '\n' return Report(connection=self)
)
def sign_data(self, data): @property
rsakey = RSA.importKey(base64.b64decode(self.private_key)) def feed(self):
signer = PKCS1_v1_5.new(rsakey) return Feed(connection=self)
digest = SHA256.new()
digest.update(data.encode('utf-8'))
sign = signer.sign(digest)
return base64.b64encode(sign)
def get_headers(self, url, method): def send_request(
timestamp = str(int(round(time.time() * 1000))) 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 = { headers = {
'WM_SEC.AUTH_SIGNATURE': self.get_sign(url, method, timestamp), "WM_QOS.CORRELATION_ID": uuid.uuid4().hex,
'WM_SEC.TIMESTAMP': timestamp,
'WM_QOS.CORRELATION_ID': str(uuid4()),
} }
if method in ('POST', ): if request_headers:
headers['Content-Type'] = 'application/json' headers.update(request_headers)
return headers
def send_request(self, method, url, params=None, body=None): response = None
encoded_url = url if method == "GET":
if params: response = self.session.get(url, params=params, headers=headers)
encoded_url += '?%s' % urlencode(params) elif method == "PUT":
headers = self.get_headers(encoded_url, method) 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': if response is not None:
return loads(self.session.get(url, params=params, headers=headers).text) try:
elif method == 'PUT': response.raise_for_status()
return loads(self.session.put(url, params=params, headers=headers).text) except requests.exceptions.HTTPError:
elif method == 'POST': if response.status_code == 401:
return loads(self.session.post(url, data=body, headers=headers).text) 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): class Resource(object):
@@ -128,24 +178,21 @@ class Resource(object):
@property @property
def url(self): def url(self):
return self.connection.base_url % self.path return "{}/{}".format(self.connection.base_url, self.path)
def all(self, **kwargs): def all(self, **kwargs):
return self.connection.send_request( return self.connection.send_request(
method='GET', url=self.url, params=kwargs) method="GET", url=self.url, params=kwargs
)
def get(self, id): def get(self, id):
url = self.url + '/%s' % id url = "{}/{}".format(self.url, id)
return self.connection.send_request(method='GET', url=url) return self.connection.send_request(method="GET", url=url)
def update(self, **kwargs): def update(self, **kwargs):
return self.connection.send_request( return self.connection.send_request(
method='PUT', url=self.url, params=kwargs) 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))
class Items(Resource): class Items(Resource):
@@ -155,6 +202,14 @@ class Items(Resource):
path = 'items' 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): class Inventory(Resource):
""" """
@@ -164,6 +219,74 @@ class Inventory(Resource):
path = 'inventory' path = 'inventory'
feedType = '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): def get_payload(self, items):
return etree.tostring( return etree.tostring(
E.InventoryFeed( E.InventoryFeed(
@@ -191,55 +314,53 @@ class Prices(Resource):
feedType = 'price' feedType = 'price'
def get_payload(self, items): def get_payload(self, items):
# root = ElementMaker( root = ElementMaker(
# nsmap={'gmp': 'http://walmart.com/'} nsmap={'gmp': 'http://walmart.com/'}
# ) )
# return etree.tostring( return etree.tostring(
# root.PriceFeed( root.PriceFeed(
# E.PriceHeader(E('version', '1.5')), E.PriceHeader(E('version', '1.5')),
# *[E.Price( *[E.Price(
# E( E(
# 'itemIdentifier', 'itemIdentifier',
# E('sku', item['sku']) E('sku', item['sku'])
# ), ),
# E( E(
# 'pricingList', 'pricingList',
# E( E(
# 'pricing', 'pricing',
# E( E(
# 'currentPrice', 'currentPrice',
# E( E(
# 'value', 'value',
# **{ **{
# 'currency': item['currenctCurrency'], 'currency': item['currenctCurrency'],
# 'amount': item['currenctPrice'] 'amount': item['currenctPrice']
# } }
# ) )
# ), ),
# E('currentPriceType', item['priceType']), E('currentPriceType', item['priceType']),
# E( E(
# 'comparisonPrice', 'comparisonPrice',
# E( E(
# 'value', 'value',
# **{ **{
# 'currency': item['comparisonCurrency'], 'currency': item['comparisonCurrency'],
# 'amount': item['comparisonPrice'] 'amount': item['comparisonPrice']
# } }
# ) )
# ), ),
# E( E(
# 'priceDisplayCode', 'priceDisplayCode',
# **{ **{
# 'submapType': item['displayCode'] 'submapType': item['displayCode']
# } }
# ), ),
# ) )
# ) )
# ) for item in items] ) for item in items]
# ), xml_declaration=True, encoding='utf-8' ), xml_declaration=True, encoding='utf-8'
# ) )
payload = {}
return
class Orders(Resource): class Orders(Resource):
@@ -250,158 +371,165 @@ class Orders(Resource):
path = 'orders' path = 'orders'
def all(self, **kwargs): def all(self, **kwargs):
next_cursor = kwargs.pop('nextCursor', '') try:
return self.connection.send_request( return super(Orders, self).all(**kwargs)
method='GET', url=self.url + next_cursor, params=kwargs) except requests.exceptions.HTTPError as exc:
if exc.response.status_code == 404:
def released(self, **kwargs): # If no orders are there on walmart matching the query
next_cursor = kwargs.pop('nextCursor', '') # filters, it throws 404. In this case return an empty
url = self.url + '/released' # list to make the API consistent
return self.connection.send_request( return {
method='GET', url=url + next_cursor, params=kwargs) "list": {
"elements": {
"order": [],
}
}
}
raise
def acknowledge(self, id): def acknowledge(self, id):
url = self.url + '/%s/acknowledge' % 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): def cancel(self, id, lines):
url = self.url + '/%s/cancel' % id url = self.url + '/%s/cancel' % id
return self.connection.send_request( return self.send_request(
method='POST', url=url, body=self.get_cancel_payload(lines)) method='POST', url=url, data=self.get_cancel_payload(lines))
def get_cancel_payload(self, lines): def get_cancel_payload(self, lines):
""" element = ElementMaker(
{ namespace='http://walmart.com/mp/orders',
"orderCancellation": { nsmap={
"orderLines": { 'ns2': 'http://walmart.com/mp/orders',
"orderLine": [ 'ns3': 'http://walmart.com/'
{
"lineNumber": "1",
"orderLineStatuses": {
"orderLineStatus": [
{
"status": "Cancelled",
"cancellationReason": "CUSTOMER_REQUESTED_SELLER_TO_CANCEL",
"statusQuantity": {
"unitOfMeasurement": "EA",
"amount": "1"
}
}
]
}
}
]
} }
} )
} return etree.tostring(
:param lines: element(
:return: string 'orderCancellation',
""" element(
payload = { 'orderLines',
'orderCancellation': { *[element(
'orderLines': [{ 'orderLine',
'lineNumber': line['number'], element('lineNumber', line),
'orderLineStatuses': { element(
'orderLineStatus': [ 'orderLineStatuses',
{ element(
'status': 'Cancelled', 'orderLineStatus',
'cancellationReason': 'CUSTOMER_REQUESTED_SELLER_TO_CANCEL', element('status', 'Cancelled'),
'statusQuantity': { element(
'unitOfMeasurement': 'EA', 'cancellationReason', 'CANCEL_BY_SELLER'),
'amount': line['amount'], element(
} 'statusQuantity',
} element('unitOfMeasurement', 'EACH'),
] element('amount', '1')
} )
} for line in lines] )
} )
} ) for line in lines]
return dumps(payload) )
), xml_declaration=True, encoding='utf-8'
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)
) )
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) ] :param order_id: Purchase order ID of an order
:return: :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",
}]
""" """
""" url = self.url + "/{}/shipping".format(order_id)
{
"orderShipment": { order_lines = []
"orderLines": { for line in lines:
"orderLine": [ ship_time = line.get("ship_time", "")
{ if ship_time:
"lineNumber": "1", ship_time = epoch_milliseconds(ship_time)
"orderLineStatuses": { order_lines.append({
"orderLineStatus": [ "lineNumber": line["line_number"],
{ "orderLineStatuses": {
"orderLineStatus": [{
"status": "Shipped", "status": "Shipped",
"statusQuantity": { "statusQuantity": {
"unitOfMeasurement": "EA", "unitOfMeasurement": line.get("uom", "EACH"),
"amount": "1" "amount": str(int(line["quantity"])),
}, },
"trackingInfo": { "trackingInfo": {
"shipDateTime": 1488480443000, "shipDateTime": ship_time,
"carrierName": { "carrierName": {
"otherCarrier": null, "otherCarrier": line.get("other_carrier"),
"carrier": "UPS" "carrier": line["carrier"],
}, },
"methodCode": "Express", "methodCode": line.get("carrier_service", ""),
"trackingNumber": "12345", "trackingNumber": line["tracking_number"],
"trackingURL": "www.fedex.com" "trackingURL": line.get("tracking_url", "")
} }
} }],
]
}
} }
] })
}
}
}
:param lines:
:return:
"""
payload = { body = {
"orderShipment": { "orderShipment": {
"orderLines": { "orderLines": {
"orderLine": [ "orderLine": order_lines,
{
"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]
} }
} }
} }
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 """ Returns the order after ack
:rtype: dict :rtype: dict
""" """
_logger.warn('BEFORE ACK ' + str(id)) _logger.info('BEFORE ACK ' + str(id))
api_instance = self.api_instance api_instance = self.api_instance
record = api_instance.orders.acknowledge(id) record = api_instance.orders.acknowledge(id)
_logger.warn('AFTER ACK RECORD: ' + str(record)) _logger.info('AFTER ACK RECORD: ' + str(record))
if 'order' in record: if 'order' in record:
return record['order'] return record['order']
raise RetryableJobError('Acknowledge Order "' + str(id) + '" did not return an order response.') 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): def create(self, id, lines):
api_instance = self.api_instance api_instance = self.api_instance
_logger.warn('BEFORE SHIPPING %s list: %s' % (str(id), str(lines))) _logger.info('BEFORE SHIPPING %s list: %s' % (str(id), str(lines)))
record = api_instance.orders.ship(id, lines) record = api_instance.orders.create_shipment(id, lines)
_logger.warn('AFTER SHIPPING RECORD: ' + str(record)) _logger.info('AFTER SHIPPING RECORD: ' + str(record))
if 'order' in record: if 'order' in record:
return record['order'] return record['order']
raise RetryableJobError('Shipping Order %s did not return an order response. (lines: %s)' % (str(id), str(lines))) 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. Normalizes picking line data into the format to export to Walmart.
:param binding: walmart.stock.picking :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 ship_date = binding.date_done
# in ms # 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 = [] lines = []
for line in binding.move_lines: for line in binding.move_lines:
sale_line = line.sale_line_id sale_line = line.sale_line_id
@@ -43,19 +43,17 @@ class WalmartPickingExporter(Component):
continue continue
number = walmart_sale_line.walmart_number 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 carrier = binding.carrier_id.walmart_carrier_code
methodCode = binding.walmart_order_id.shipping_method_code carrier_service = binding.walmart_order_id.shipping_method_code
trackingNumber = binding.carrier_tracking_ref tracking_number = binding.carrier_tracking_ref
trackingUrl = None
lines.append(dict( lines.append(dict(
shipDateTime=ship_date_time, line_number=number,
number=number, quantity=amount,
amount=amount, ship_time=ship_date_time,
carrier=carrier, carrier=carrier,
methodCode=methodCode, carrier_service=carrier_service,
trackingNumber=trackingNumber, tracking_number=tracking_number,
trackingUrl=trackingUrl,
)) ))
return lines return lines

View File

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

View File

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