Support webhook for orders

issue11086
review377741007
This commit is contained in:
Cédric Krier committed 2022-02-03 22:45:21 +01:00
1 parent 6a50df5764
commit 4594c27fda
7 files changed
+105 -6

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
* Support webhook for orders
* Remove API backoff time
* Support edit orders from Shopify
* Define the metafields managed by Tryton
+3 -2
View File
@@ -3,9 +3,10 @@
from trytond.pool import Pool
from . import account, ir, party, product, sale, shopify_retry, stock, web
from . import (
account, ir, party, product, routes, sale, shopify_retry, stock, web)
__all__ = ['register']
__all__ = ['register', 'routes']
shopify_retry.patch()
+3
View File
@@ -34,3 +34,6 @@ the "Password" with the copied one.
Different scheduled tasks are responsible for uploading products and
inventories and fetching and updating orders as `Sales <sale:model-sale.sale>`.
You can also register a `webhook <https://shopify.dev/apps/webhooks>`_ for each
order event to get updates when they happen.
+4 -4
View File
@@ -17,14 +17,14 @@ this repository contains the full copyright notices and license terms. -->
<record model="ir.cron" id="cron_fetch_order">
<field name="method">web.shop|shopify_fetch_order</field>
<field name="interval_number" eval="15"/>
<field name="interval_type">minutes</field>
<field name="interval_number" eval="1"/>
<field name="interval_type">days</field>
</record>
<record model="ir.cron" id="cron_update_order">
<field name="method">web.shop|shopify_update_order</field>
<field name="interval_number" eval="30"/>
<field name="interval_type">minutes</field>
<field name="interval_number" eval="1"/>
<field name="interval_type">days</field>
</record>
</data>
</tryton>
+63
View File
@@ -0,0 +1,63 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import base64
import hashlib
import hmac
import logging
try:
from http import HTTPStatus
except ImportError:
from http import client as HTTPStatus
from werkzeug.exceptions import abort
from werkzeug.wrappers import Response
from trytond.protocols.wrappers import with_pool, with_transaction
from trytond.wsgi import app
logger = logging.getLogger(__name__)
def verify_webhook(data, hmac_header, secret):
digest = hmac.new(secret, data, hashlib.sha256).digest()
computed_hmac = base64.b64encode(digest)
return hmac.compare_digest(computed_hmac, hmac_header.encode('utf-8'))
@app.route(
'/<database_name>/web_shop_shopify/webhook/<shop>/order', methods={'POST'})
@with_pool
@with_transaction(context={'_skip_warnings': True})
def order(request, pool, shop):
Sale = pool.get('sale.sale')
Shop = pool.get('web.shop')
shop = Shop.get(shop)
data = request.get_data()
verified = verify_webhook(
data, request.headers.get('X-Shopify-Hmac-SHA256'),
shop.shopify_webhook_shared_secret.encode('utf-8'))
if not verified:
abort(HTTPStatus.UNAUTHORIZED)
topic = request.headers.get('X-Shopify-Topic')
order = request.get_json()
if topic == 'orders/create':
if not Sale.search([
('web_shop', '=', shop.id),
('shopify_identifier', '=', order['id']),
], order=[], limit=1):
Shop.__queue__.shopify_fetch_order([shop])
elif topic in {'orders/updated', 'orders/payment'}:
sales = Sale.search([
('web_shop', '=', shop.id),
('shopify_identifier', '=', order['id']),
], order=[], limit=1)
if not sales:
Shop.__queue__.shopify_fetch_order([shop])
else:
sale, = sales
Shop.__queue__.shopify_update_sale([sale], [order])
else:
logger.info("Unsupported topic '%s'", topic)
return Response(status=HTTPStatus.NO_CONTENT)
+5
View File
@@ -16,6 +16,11 @@ this repository contains the full copyright notices and license terms. -->
<label name="shopify_version"/>
<field name="shopify_version"/>
<label name="shopify_webhook_shared_secret"/>
<field name="shopify_webhook_shared_secret"/>
<label name="shopify_webhook_endpoint_order"/>
<field name="shopify_webhook_endpoint_order"/>
<field name="shopify_payment_journals" colspan="6"/>
</page>
</xpath>
+26
View File
@@ -1,6 +1,7 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime as dt
import urllib.parse
from decimal import Decimal
import pyactiveresource
@@ -16,6 +17,7 @@ from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval
from trytond.tools import grouped_slice
from trytond.transaction import Transaction
from trytond.url import http_host
from .common import IdentifierMixin, IdentifiersMixin
from .exceptions import ShopifyError
@@ -37,6 +39,17 @@ class Shop(metaclass=PoolMeta):
'get_shopify_versions', "Version", states=_states, depends=_depends)
shopify_password = fields.Char(
"Password", states=_states, depends=_depends)
shopify_webhook_shared_secret = fields.Char(
"Webhook Shared Secret",
states={
'invisible': _states['invisible'],
},
depends=_depends)
shopify_webhook_endpoint_order = fields.Function(
fields.Char(
"Webhook Order Endpoint",
help="The URL to be called by Shopify for Order events."),
'on_change_with_shopify_webhook_endpoint_order')
shopify_warehouses = fields.One2Many(
'web.shop-stock.location', 'shop', "Warehouses",
states=_states, depends=_depends)
@@ -63,6 +76,19 @@ class Shop(metaclass=PoolMeta):
return [(None, "")] + sorted(
((v, v) for v in ApiVersion.versions), reverse=True)
@fields.depends('name')
def on_change_with_shopify_webhook_endpoint_order(self, name=None):
if not self.name:
return
url_part = {
'database_name': Transaction().database.name,
'shop': self.name,
}
return http_host() + (
urllib.parse.quote(
'/%(database_name)s/web_shop_shopify/webhook/%(shop)s/order' %
url_part))
@classmethod
def view_attributes(cls):
return super().view_attributes() + [