details: https://code.tryton.org/tryton/commit/3e58474b6aed
branch: default
user: Cédric Krier <[email protected]>
date: Sun Mar 29 20:05:46 2026 +0200
description:
Replace ShopifyAPI by shopifyapp
Closes #14243
diffstat:
modules/web_shop_shopify/CHANGELOG |
1 +
modules/web_shop_shopify/__init__.py |
6 +-
modules/web_shop_shopify/common.py |
5 +-
modules/web_shop_shopify/exceptions.py |
8 +-
modules/web_shop_shopify/graphql.py |
18 +-
modules/web_shop_shopify/product.py |
15 +-
modules/web_shop_shopify/pyproject.toml |
3 +-
modules/web_shop_shopify/routes.py |
27 +-
modules/web_shop_shopify/sale.py |
171 +--
modules/web_shop_shopify/shopify_retry.py |
87 --
modules/web_shop_shopify/stock.py |
21 +-
modules/web_shop_shopify/tests/scenario_web_shop_shopify.rst |
49 +-
modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst |
39 +-
modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst |
31 +-
modules/web_shop_shopify/tests/test_scenario.py |
4 +-
modules/web_shop_shopify/tests/tools.py |
126 +-
modules/web_shop_shopify/view/shop_form.xml |
15 +-
modules/web_shop_shopify/web.py |
420 +++++----
18 files changed, 490 insertions(+), 556 deletions(-)
diffs (1932 lines):
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/CHANGELOG
--- a/modules/web_shop_shopify/CHANGELOG Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/CHANGELOG Sun Mar 29 20:05:46 2026 +0200
@@ -1,3 +1,4 @@
+* Replace ShopifyAPI by shopifyapp
* Add support for pick-up delivery method
* Log the update of sales
* Add support for gift card products
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/__init__.py
--- a/modules/web_shop_shopify/__init__.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/__init__.py Sun Mar 29 20:05:46 2026 +0200
@@ -1,8 +1,8 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
-from . import routes, shopify_retry
+from . import routes
+
+SHOPIFY_VERSION = '2024-10'
__all__ = [routes]
-
-shopify_retry.patch()
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/common.py
--- a/modules/web_shop_shopify/common.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/common.py Sun Mar 29 20:05:46 2026 +0200
@@ -68,8 +68,9 @@
and getattr(self, 'web_shop', None)
and self.shopify_identifier_char):
return urljoin(
- self.web_shop.shopify_url + '/admin/',
- f'{self.shopify_resource}/{self.shopify_identifier_char}')
+ self.web_shop.shopify_url,
+ f'admin/{self.shopify_resource}/{self.shopify_identifier_char}'
+ )
@classmethod
def copy(cls, records, default=None):
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/exceptions.py
--- a/modules/web_shop_shopify/exceptions.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/exceptions.py Sun Mar 29 20:05:46 2026 +0200
@@ -1,6 +1,12 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
-from trytond.exceptions import UserError, UserWarning
+from trytond.exceptions import TrytonException, UserError, UserWarning
+
+
+class GraphQLException(TrytonException):
+ def __init__(self, message):
+ super().__init__(message)
+ self.message = message
class ShopifyError(UserError):
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/graphql.py
--- a/modules/web_shop_shopify/graphql.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/graphql.py Sun Mar 29 20:05:46 2026 +0200
@@ -1,10 +1,6 @@
# 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 shopify
-
-from .shopify_retry import GraphQLException
-
def deep_merge(d1, d2):
"Merge 2 fields dictionary"
@@ -27,7 +23,7 @@
return _format('', fields).strip()
-def iterate(query, params, query_name, path=None, data=None):
+def iterate(shop, query, params, query_name, path=None, data=None):
def getter(data):
if path:
for name in path.split('.'):
@@ -35,13 +31,11 @@
return data
if data is None:
- data = shopify.GraphQL().execute(
+ data = shop.shopify_request(
query, {
**params,
'cursor': None,
- })['data'][query_name]
- if errors := data.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ }).data[query_name]
while True:
lst = getter(data)
@@ -49,10 +43,8 @@
yield item
if not lst['pageInfo']['hasNextPage']:
break
- data = shopify.GraphQL().execute(
+ data = shop.shopify_request(
query, {
**params,
'cursor': lst['pageInfo']['endCursor'],
- })['data'][query_name]
- if errors := data.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ }).data[query_name]
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/product.py
--- a/modules/web_shop_shopify/product.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/product.py Sun Mar 29 20:05:46 2026 +0200
@@ -4,7 +4,6 @@
from decimal import Decimal
from urllib.parse import urljoin
-import shopify
from sql.conditionals import NullIf
from sql.operators import Equal
@@ -47,12 +46,12 @@
shopify_id = self.get_shopify_identifier(shop)
if shopify_id:
shopify_id = id2gid('Collection', shopify_id)
- collection = shopify.GraphQL().execute(
+ collection = shop.shopify_request(
QUERY_COLLECTION % {
'fields': graphql.selection({
'id': None,
}),
- }, {'id': shopify_id})['data']['collection'] or {}
+ }, {'id': shopify_id}).data['collection'] or {}
else:
collection = {}
collection['title'] = self.name[:255]
@@ -144,13 +143,13 @@
product = {}
if shopify_id:
shopify_id = id2gid('Product', shopify_id)
- product = shopify.GraphQL().execute(
+ product = shop.shopify_request(
QUERY_PRODUCT % {
'fields': graphql.selection({
'id': None,
'status': None,
}),
- }, {'id': shopify_id})['data']['product'] or {}
+ }, {'id': shopify_id}).data['product'] or {}
if product.get('status') == 'ARCHIVED':
product['status'] = 'ACTIVE'
product['title'] = self.name
@@ -298,12 +297,12 @@
shopify_id = self.get_shopify_identifier(shop)
if shopify_id:
shopify_id = id2gid('ProductVariant', shopify_id)
- variant = shopify.GraphQL().execute(
+ variant = shop.shopify_request(
QUERY_VARIANT % {
'fields': graphql.selection({
'id': None,
}),
- }, {'id': shopify_id})['data']['productVariant'] or {}
+ }, {'id': shopify_id}).data['productVariant'] or {}
else:
variant = {}
sale_price = self.shopify_price(
@@ -405,7 +404,7 @@
url = super().get_url(name)
if (self.shop.type == 'shopify'
and (handle := self.product.template.shopify_handle)):
- url = urljoin(self.shop.shopify_url + '/', f'products/{handle}')
+ url = urljoin(self.shop.shopify_url, f'products/{handle}')
return url
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/pyproject.toml
--- a/modules/web_shop_shopify/pyproject.toml Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/pyproject.toml Sun Mar 29 20:05:46 2026 +0200
@@ -52,10 +52,9 @@
[tool.hatch.metadata.hooks.tryton]
dependencies = [
- 'ShopifyAPI',
- 'pyactiveresource',
'python-dateutil',
'python-sql',
+ 'shopifyapp',
]
copyright = 'COPYRIGHT'
readme = 'README.rst'
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/routes.py
--- a/modules/web_shop_shopify/routes.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/routes.py Sun Mar 29 20:05:46 2026 +0200
@@ -1,10 +1,10 @@
# 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
+from shopify_app import RequestInput, verify_webhook_req
+
from trytond.protocols.wrappers import (
HTTPStatus, Response, abort, redirect, with_pool, with_transaction)
from trytond.wsgi import app
@@ -12,10 +12,12 @@
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'))
+def request_to_shopify_req(request):
+ return RequestInput(
+ method=request.method,
+ headers=request.headers,
+ url=request.url,
+ body=request.get_data())
@app.route(
@@ -26,12 +28,11 @@
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)
+
+ result = verify_webhook_req(request_to_shopify_req(request))
+ if not result.ok:
+ logger.debug("unauthorized %s", result)
+ abort(result.response.status, result.response.body)
topic = request.headers.get('X-Shopify-Topic')
order = request.get_json()
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/sale.py
--- a/modules/web_shop_shopify/sale.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/sale.py Sun Mar 29 20:05:46 2026 +0200
@@ -5,7 +5,6 @@
from itertools import zip_longest
import dateutil
-import shopify
from trytond.i18n import gettext
from trytond.model import ModelView, Unique, fields
@@ -18,8 +17,7 @@
from . import graphql
from .common import IdentifierMixin, gid2id, id2gid, setattr_changed
-from .exceptions import ShopifyError
-from .shopify_retry import GraphQLException
+from .exceptions import GraphQLException, ShopifyError
QUERY_ORDER = '''\
query GetOrder($id: ID!) {
@@ -315,7 +313,7 @@
sale.shopify_identifier = gid2id(order['id'])
shopify_fulfillments = graphql.iterate(
- QUERY_ORDER_CURSOR % {
+ shop, QUERY_ORDER_CURSOR % {
'fields': graphql.selection({
'fulfillmentOrders(first: 10, after: $cursor)': (
shopify_fields[
@@ -376,7 +374,7 @@
refund_line_items = defaultdict(list)
for refund in order['refunds']:
shopify_refund_line_items = graphql.iterate(
- QUERY_REFUND_CURSOR % {
+ shop, QUERY_REFUND_CURSOR % {
'fields': graphql.selection({
'refundLineItems(first: 10, after: $cursor)': (
shopify_fields['refunds'][
@@ -391,7 +389,7 @@
line2warehouses = defaultdict(set)
shopify_fulfillment_orders = graphql.iterate(
- QUERY_ORDER_CURSOR % {
+ shop, QUERY_ORDER_CURSOR % {
'fields': graphql.selection({
'fulfillmentOrders(first: 10, after: $cursor)': (
shopify_fields[
@@ -414,7 +412,7 @@
if method_type == 'PICK_UP':
shipment_address = sale.warehouse.address
shopify_line_items = graphql.iterate(
- QUERY_FULFILLMENT_ORDER % {
+ shop, QUERY_FULFILLMENT_ORDER % {
'fields': graphql.selection({
'lineItems(first: 100, after: $cursor)': (
shopify_fields[
@@ -451,7 +449,7 @@
l.shopify_identifier]
lines = []
shopify_line_items = graphql.iterate(
- QUERY_ORDER_CURSOR % {
+ shop, QUERY_ORDER_CURSOR % {
'fields': graphql.selection({
'lineItems(first: 100, after: $cursor)': (
shopify_fields['lineItems(first: 100)']),
@@ -481,7 +479,7 @@
lines.append(Line.get_from_shopify(
sale, line_item, quantity, warehouse=warehouse, line=line))
shopify_shipping_lines = graphql.iterate(
- QUERY_ORDER_CURSOR % {
+ shop, QUERY_ORDER_CURSOR % {
'fields': graphql.selection({
'shippingLines(first: 10, after: $cursor)': (
shopify_fields['shippingLines(first: 10)']),
@@ -594,96 +592,85 @@
pool = Pool()
Payment = pool.get('account.payment')
self.lock()
- with self.web_shop.shopify_session():
- for shipment in self.shipments:
- fulfillment = shipment.get_shopify(self)
- if fulfillment:
- try:
- result = shopify.GraphQL().execute(
- MUTATION_FULFILLMENT_CREATE,
- {'fulfillment': fulfillment}
- )['data']['fulfillmentCreate']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
- fulfillment = result['fulfillment']
- except GraphQLException as e:
- raise ShopifyError(gettext(
- 'web_shop_shopify.msg_fulfillment_fail',
- sale=self.rec_name,
- error="\n".join(
- err['message'] for err in e.errors))
- ) from e
- shipment.set_shopify_identifier(
- self, gid2id(fulfillment['id']))
- Transaction().commit()
- # Start a new transaction as commit release the lock
- self.__class__.__queue__._process_shopify(self)
- return
- elif shipment.state == 'cancelled':
- fulfillment_id = shipment.get_shopify_identifier(self)
- if fulfillment_id:
- fulfillment_id = id2gid('Fulfillment', fulfillment_id)
- result = shopify.GraphQL().execute(
- MUTATION_FULFILLMENT_CANCEL,
- {'id': fulfillment_id}
- )['data']['fulfillmentCancel']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
-
- # TODO: manage drop shipment
+ shopify_request = self.web_shop.shopify_request
+ for shipment in self.shipments:
+ fulfillment = shipment.get_shopify(self)
+ if fulfillment:
+ try:
+ fulfillment = shopify_request(
+ MUTATION_FULFILLMENT_CREATE,
+ {'fulfillment': fulfillment},
+ user_errors='fulfillmentCreate.userErrors',
+ ).data['fulfillmentCreate']['fulfillment']
+ except GraphQLException as e:
+ raise ShopifyError(gettext(
+ 'web_shop_shopify.msg_fulfillment_fail',
+ sale=self.rec_name,
+ error=e.message)) from e
+ shipment.set_shopify_identifier(
+ self, gid2id(fulfillment['id']))
+ Transaction().commit()
+ # Start a new transaction as commit release the lock
+ self.__class__.__queue__._process_shopify(self)
+ return
+ elif shipment.state == 'cancelled':
+ fulfillment_id = shipment.get_shopify_identifier(self)
+ if fulfillment_id:
+ fulfillment_id = id2gid('Fulfillment', fulfillment_id)
+ self.web_shop.shopify_request(
+ MUTATION_FULFILLMENT_CANCEL,
+ {'id': fulfillment_id},
+ user_errors='fulfillmentCancel.userErrors')
- shopify_id = id2gid('Order', self.shopify_identifier)
- if self.shipment_state == 'sent' or self.state == 'done':
- # TODO: manage shopping refund
- refund = self.get_shopify_refund(
- shipping=self.shipment_state == 'none')
- if refund:
- try:
- result = shopify.GraphQL().execute(
- MUTATION_REFUND_CREATE,
- {'input': refund})['data']['refundCreate']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
- except GraphQLException as e:
- raise ShopifyError(gettext(
- 'web_shop_shopify.msg_refund_fail',
- sale=self.rec_name,
- error="\n".join(
- err['message'] for err in e.errors))
- ) from e
- order = shopify.GraphQL().execute(
- QUERY_ORDER % {
- 'fields': graphql.selection(self.shopify_fields()),
- }, {'id': shopify_id})['data']['order']
- Payment.get_from_shopify(self, order)
+ # TODO: manage drop shipment
- shopify_id = id2gid('Order', self.shopify_identifier)
- order = shopify.GraphQL().execute(
- QUERY_ORDER_CLOSED, {'id': shopify_id})['data']['order']
- if self.state == 'done':
- if not order['closed']:
- result = shopify.GraphQL().execute(
- MUTATION_ORDER_CLOSE, {
- 'input': {
- 'id': shopify_id,
- },
- })['data']['orderClose']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
- elif order['closed']:
- result = shopify.GraphQL().execute(
- MUTATION_ORDER_OPEN, {
+ shopify_id = id2gid('Order', self.shopify_identifier)
+ if self.shipment_state == 'sent' or self.state == 'done':
+ # TODO: manage shopping refund
+ refund = self.get_shopify_refund(
+ shipping=self.shipment_state == 'none')
+ if refund:
+ try:
+ shopify_request(
+ MUTATION_REFUND_CREATE,
+ {'input': refund},
+ user_errors='refundCreate.userErrors')
+ except GraphQLException as e:
+ raise ShopifyError(gettext(
+ 'web_shop_shopify.msg_refund_fail',
+ sale=self.rec_name,
+ error=e.message)) from e
+ order = shopify_request(
+ QUERY_ORDER % {
+ 'fields': graphql.selection(self.shopify_fields()),
+ }, {'id': shopify_id}).data['order']
+ Payment.get_from_shopify(self, order)
+
+ shopify_id = id2gid('Order', self.shopify_identifier)
+ order = shopify_request(
+ QUERY_ORDER_CLOSED, {'id': shopify_id}).data['order']
+ if self.state == 'done':
+ if not order['closed']:
+ shopify_request(
+ MUTATION_ORDER_CLOSE, {
'input': {
'id': shopify_id,
},
- })['data']['orderOpen']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ },
+ user_errors='orderClose.userErrors')
+ elif order['closed']:
+ shopify_request(
+ MUTATION_ORDER_OPEN, {
+ 'input': {
+ 'id': shopify_id,
+ },
+ },
+ user_errors='orderOpen.userErrors')
def get_shopify_refund(self, shipping=False):
order_id = id2gid('Order', self.shopify_identifier)
shopify_line_items = graphql.iterate(
- QUERY_ORDER_FULFILLABLE_QUANTITIES,
+ self.web_shop, QUERY_ORDER_FULFILLABLE_QUANTITIES,
{'id': order_id}, 'order', 'lineItems')
fulfillable_quantities = {
gid2id(l['id']): l['fulfillableQuantity']
@@ -693,11 +680,11 @@
if not refund_line_items:
return
- order = shopify.GraphQL().execute(
+ order = self.web_shop.shopify_request(
QUERY_ORDER_SUGGESTED_REFUND, {
'id': order_id,
'refundShipping': shipping,
- 'refundLineItems': refund_line_items})['data']['order']
+ 'refundLineItems': refund_line_items}).data['order']
currencies = set()
transactions = []
for transaction in order['suggestedRefund']['suggestedTransactions']:
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/shopify_retry.py
--- a/modules/web_shop_shopify/shopify_retry.py Wed Mar 25 22:11:59 2026 +0100
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,87 +0,0 @@
-# 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 json
-import logging
-import time
-import urllib
-
-from pyactiveresource.connection import ClientError
-from shopify import Limits
-from shopify.base import ShopifyConnection
-from shopify.resources.graphql import GraphQL
-
-try:
- from shopify.resources.graphql import GraphQLException
-except ImportError:
- class GraphQLException(Exception):
- def __init__(self, response):
- self._response = response
-
- @property
- def errors(self):
- return self._response['errors']
-
-from trytond.protocols.wrappers import HTTPStatus
-from trytond.tools.logging import format_args
-
-logger = logging.getLogger(__name__)
-
-
-def patch():
- def _open(*args, **kwargs):
- while True:
- try:
- return open_func(*args, **kwargs)
- except ClientError as e:
- if e.response.code == HTTPStatus.TOO_MANY_REQUESTS:
- retry_after = float(
- e.response.headers.get('Retry-After', 2))
- logger.debug(
- "Shopify connection retry after %ss", retry_after)
- time.sleep(retry_after)
- else:
- raise
- else:
- try:
- if Limits.credit_maxed():
- logger.debug("Shopify connection credit maxed")
- time.sleep(0.5)
- except Exception:
- pass
-
- if ShopifyConnection._open != _open:
- open_func = ShopifyConnection._open
- ShopifyConnection._open = _open
-
- def graphql_execute(self, *args, **kwargs):
- log_message = "GraphQL execute %s"
- log_args = (
- format_args(args, kwargs, logger.isEnabledFor(logging.DEBUG)),)
- while True:
- try:
- result = graphql_execute_func(self, *args, **kwargs)
- except urllib.error.HTTPError as e:
- if e.code == HTTPStatus.TOO_MANY_REQUESTS:
- retry_after = float(e.headers.get('Retry-After', 2))
- logger.debug("GraphQL retry after %ss", retry_after)
- time.sleep(retry_after)
- else:
- logger.exception(log_message, *log_args)
- raise GraphQLException(json.load(e.fp))
- if isinstance(result, str):
- result = json.loads(result)
- if result.get('errors'):
- for error in result['errors']:
- if error.get('extensions', {}).get('code') == 'THROTTLED':
- logger.debug("GraphQL throttled")
- time.sleep(0.5)
- continue
- logger.exception(log_message, *log_args)
- raise GraphQLException(result)
- logger.info(log_message, *log_args)
- logger.debug("GraphQL Result: %r", result)
- return result
-
- if GraphQL.execute != graphql_execute:
- graphql_execute_func = GraphQL.execute
- GraphQL.execute = graphql_execute
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/stock.py
--- a/modules/web_shop_shopify/stock.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/stock.py Sun Mar 29 20:05:46 2026 +0200
@@ -3,8 +3,6 @@
from collections import defaultdict
-import shopify
-
from trytond.i18n import gettext, lazy_gettext
from trytond.model import ModelSQL, ModelView, Unique, Workflow, fields
from trytond.model.exceptions import AccessError
@@ -12,8 +10,7 @@
from . import graphql
from .common import IdentifierMixin, id2gid
-from .exceptions import ShopifyError
-from .shopify_retry import GraphQLException
+from .exceptions import GraphQLException, ShopifyError
QUERY_FULFILLMENT_ORDERS = '''\
query FulfillmentOrders($orderId: ID!) {
@@ -78,10 +75,10 @@
},
})
order_id = id2gid('Order', sale.shopify_identifier)
- fulfillment_orders = shopify.GraphQL().execute(
+ fulfillment_orders = sale.web_shop.shopify_request(
QUERY_FULFILLMENT_ORDERS % {
'fields': graphql.selection(fulfillment_order_fields),
- }, {'orderId': order_id})['data']['order']['fulfillmentOrders']
+ }, {'orderId': order_id}).data['order']['fulfillmentOrders']
line_items = defaultdict(list)
for move in self.outgoing_moves:
if move.sale == sale:
@@ -170,6 +167,7 @@
@classmethod
def _shopify_prepared_for_pickup(cls, shipments):
mutation = MUTATION_FULFILLMENT_ORDER_LINE_ITEMS_PREPARED_FOR_PICKUP
+ output = 'fulfillmentOrderLineItemsPreparedForPickup'
fullfilments = defaultdict(set)
for shipment in shipments:
if shipment.state == 'packed':
@@ -181,13 +179,11 @@
id2gid('FulfillmentOrder', r.shopify_identifier)
for r in records})
try:
- result = shopify.GraphQL().execute(
+ shop.shopify_request(
mutation, {
'input': list(fullfilment_ids),
- }
- )['data']['fulfillmentOrderLineItemsPreparedForPickup']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ },
+ user_errors=f'{output}.userErrors')
except GraphQLException as e:
shipments = ", ".join(
r.shipment.rec_name for r in records[:5])
@@ -200,8 +196,7 @@
'.msg_fulfillment_prepared_for_pickup_fail',
shipments=shipments,
sales=sales,
- error="\n".join(
- err['message'] for err in e.errors))) from e
+ error=e.message)) from e
class ShipmentShopifyIdentifier(IdentifierMixin, ModelSQL, ModelView):
diff -r f859e3843468 -r 3e58474b6aed
modules/web_shop_shopify/tests/scenario_web_shop_shopify.rst
--- a/modules/web_shop_shopify/tests/scenario_web_shop_shopify.rst Wed Mar
25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/tests/scenario_web_shop_shopify.rst Sun Mar
29 20:05:46 2026 +0200
@@ -13,9 +13,6 @@
>>> from itertools import cycle
>>> from unittest.mock import patch
- >>> import shopify
- >>> from shopify.api_version import ApiVersion
-
>>> from proteus import Model
>>> from trytond.modules.account.tests.tools import (
... create_chart, create_fiscalyear, create_tax, get_accounts)
@@ -137,21 +134,15 @@
>>> web_shop = WebShop(name="Web Shop")
>>> web_shop.type = 'shopify'
- >>> web_shop.shopify_url = os.getenv('SHOPIFY_URL')
- >>> web_shop.shopify_password = os.getenv('SHOPIFY_PASSWORD')
- >>> web_shop.shopify_version = sorted(ApiVersion.versions, reverse=True)[1]
+ >>> web_shop.shopify_shop_name = os.getenv('SHOPIFY_SHOP')
+ >>> web_shop.shopify_access_token = os.getenv('SHOPIFY_ACCESS_TOKEN')
>>> shop_warehouse = web_shop.shopify_warehouses.new()
>>> shop_warehouse.warehouse, = Location.find([('type', '=', 'warehouse')])
>>> shopify_payment_journal = web_shop.shopify_payment_journals.new()
>>> shopify_payment_journal.journal = payment_journal
>>> web_shop.save()
- >>> shopify.ShopifyResource.activate_session(shopify.Session(
- ... web_shop.shopify_url,
- ... web_shop.shopify_version,
- ... web_shop.shopify_password))
-
- >>> location = tools.get_location()
+ >>> location = tools.get_location(web_shop)
>>> shop_warehouse, = web_shop.shopify_warehouses
>>> shop_warehouse.shopify_id = str(gid2id(location['id']))
@@ -389,7 +380,7 @@
>>> inventory_item_ids = [i.shopify_identifier
... for inv in inventory_items for i in inv.shopify_identifiers]
>>> for _ in range(MAX_SLEEP):
- ... inventory_levels = tools.get_inventory_levels(location)
+ ... inventory_levels = tools.get_inventory_levels(web_shop, location)
... if inventory_levels and len(inventory_levels) == 2:
... break
... time.sleep(FETCH_SLEEP)
@@ -448,7 +439,7 @@
>>> len(product2.shopify_identifiers)
0
>>> identifier, = product2.template.shopify_identifiers
- >>> tools.get_product(identifier.shopify_identifier)['status']
+ >>> tools.get_product(web_shop, identifier.shopify_identifier)['status']
'ARCHIVED'
>>> variant1.reload()
>>> len(variant1.shopify_identifiers)
@@ -470,7 +461,7 @@
... ''.join(random.choice(string.digits) for _ in range(3)))
>>> customer_address_phone = '+32-495-555-' + (
... ''.join(random.choice(string.digits) for _ in range(3)))
- >>> customer = tools.create_customer({
+ >>> customer = tools.create_customer(web_shop, {
... 'lastName': "Customer",
... 'email': (''.join(
... random.choice(string.ascii_letters) for _ in range(10))
@@ -479,7 +470,7 @@
... 'locale': 'en-CA',
... })
- >>> order = tools.create_order({
+ >>> order = tools.create_order(web_shop, {
... 'customer': {
... 'toAssociate': {
... 'id': customer['id'],
@@ -596,7 +587,7 @@
Capture full amount::
>>> transaction = tools.capture_order(
- ... order['id'], 258.98, order['transactions'][0]['id'])
+ ... web_shop, order['id'], 258.98, order['transactions'][0]['id'])
>>> with config.set_context(shopify_orders=[gid2id(order['id'])]):
... cron_update_order, = Cron.find([
@@ -633,7 +624,7 @@
>>> len(sale.invoices)
0
- >>> order = tools.get_order(order['id'])
+ >>> order = tools.get_order(web_shop, order['id'])
>>> order['displayFulfillmentStatus']
'PARTIALLY_FULFILLED'
>>> len(order['fulfillments'])
@@ -651,7 +642,7 @@
>>> shipment.state
'shipped'
- >>> order = tools.get_order(order['id'])
+ >>> order = tools.get_order(web_shop, order['id'])
>>> order['displayFulfillmentStatus']
'FULFILLED'
>>> len(order['fulfillments'])
@@ -661,7 +652,7 @@
>>> shipment.state
'cancelled'
- >>> order = tools.get_order(order['id'])
+ >>> order = tools.get_order(web_shop, order['id'])
>>> order['displayFulfillmentStatus']
'PARTIALLY_FULFILLED'
>>> len(order['fulfillments'])
@@ -685,7 +676,7 @@
>>> len(sale.invoices)
0
- >>> order = tools.get_order(order['id'])
+ >>> order = tools.get_order(web_shop, order['id'])
>>> order['displayFulfillmentStatus']
'PARTIALLY_FULFILLED'
>>> len(order['fulfillments'])
@@ -700,7 +691,7 @@
... shipment_exception.form.ignore_moves.find())
>>> shipment_exception.execute('handle')
- >>> order = tools.get_order(order['id'])
+ >>> order = tools.get_order(web_shop, order['id'])
>>> order['displayFulfillmentStatus']
'FULFILLED'
>>> len(order['fulfillments'])
@@ -731,25 +722,25 @@
>>> sale.reload()
>>> sale.state
'done'
- >>> order = tools.get_order(order['id'])
+ >>> order = tools.get_order(web_shop, order['id'])
>>> bool(order['closed'])
True
Clean up::
- >>> tools.delete_order(order['id'])
+ >>> tools.delete_order(web_shop, order['id'])
>>> for product in ShopifyIdentifier.find(
... [('record', 'like', 'product.template,%')]):
- ... tools.delete_product(id2gid('Product', product.shopify_identifier))
+ ... tools.delete_product(
+ ... web_shop, id2gid('Product', product.shopify_identifier))
>>> for category in ShopifyIdentifier.find(
... [('record', 'like', 'product.category,%')]):
- ... tools.delete_collection(id2gid('Collection',
category.shopify_identifier))
+ ... tools.delete_collection(
+ ... web_shop, id2gid('Collection', category.shopify_identifier))
>>> for _ in range(MAX_SLEEP):
... try:
- ... tools.delete_customer(customer['id'])
+ ... tools.delete_customer(web_shop, customer['id'])
... except Exception:
... time.sleep(FETCH_SLEEP)
... else:
... break
-
- >>> shopify.ShopifyResource.clear_session()
diff -r f859e3843468 -r 3e58474b6aed
modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst
--- a/modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst
Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst
Sun Mar 29 20:05:46 2026 +0200
@@ -10,9 +10,6 @@
>>> import time
>>> from decimal import Decimal
- >>> import shopify
- >>> from shopify.api_version import ApiVersion
-
>>> from proteus import Model
>>> from trytond.modules.account.tests.tools import (
... create_chart, create_fiscalyear, get_accounts)
@@ -81,21 +78,15 @@
>>> web_shop = WebShop(name="Web Shop")
>>> web_shop.type = 'shopify'
- >>> web_shop.shopify_url = os.getenv('SHOPIFY_URL')
- >>> web_shop.shopify_password = os.getenv('SHOPIFY_PASSWORD')
- >>> web_shop.shopify_version = sorted(ApiVersion.versions, reverse=True)[1]
+ >>> web_shop.shopify_shop_name = os.getenv('SHOPIFY_SHOP')
+ >>> web_shop.shopify_access_token = os.getenv('SHOPIFY_ACCESS_TOKEN')
>>> shop_warehouse = web_shop.shopify_warehouses.new()
>>> shop_warehouse.warehouse, = Location.find([('type', '=', 'warehouse')])
>>> shopify_payment_journal = web_shop.shopify_payment_journals.new()
>>> shopify_payment_journal.journal = payment_journal
>>> web_shop.save()
- >>> shopify.ShopifyResource.activate_session(shopify.Session(
- ... web_shop.shopify_url,
- ... web_shop.shopify_version,
- ... web_shop.shopify_password))
-
- >>> location = tools.get_location()
+ >>> location = tools.get_location(web_shop)
>>> shop_warehouse, = web_shop.shopify_warehouses
>>> shop_warehouse.shopify_id = str(gid2id(location['id']))
@@ -163,14 +154,14 @@
Create an order on Shopify::
- >>> customer = tools.create_customer({
+ >>> customer = tools.create_customer(web_shop, {
... 'lastName': "Customer",
... 'email': (''.join(
... random.choice(string.ascii_letters) for _ in range(10))
... + '@example.com')
... })
- >>> order = tools.create_order({
+ >>> order = tools.create_order(web_shop, {
... 'customerId': customer['id'],
... 'shippingAddress': {
... 'lastName': "Customer",
@@ -203,7 +194,7 @@
'AUTHORIZED'
>>> transaction = tools.capture_order(
- ... order['id'], 300, order['transactions'][0]['id'])
+ ... web_shop, order['id'], 300, order['transactions'][0]['id'])
Run fetch order::
@@ -239,7 +230,7 @@
>>> shipment.state
'done'
- >>> order = tools.get_order(order['id'])
+ >>> order = tools.get_order(web_shop, order['id'])
>>> order['displayFulfillmentStatus']
'PARTIALLY_FULFILLED'
>>> fulfillment, = order['fulfillments']
@@ -263,7 +254,7 @@
>>> shipment.state
'done'
- >>> order = tools.get_order(order['id'])
+ >>> order = tools.get_order(web_shop, order['id'])
>>> order['displayFulfillmentStatus']
'PARTIALLY_FULFILLED'
>>> fulfillment, = order['fulfillments']
@@ -282,25 +273,25 @@
>>> shipment.state
'done'
- >>> order = tools.get_order(order['id'])
+ >>> order = tools.get_order(web_shop, order['id'])
>>> order['displayFulfillmentStatus']
'FULFILLED'
Clean up::
- >>> tools.delete_order(order['id'])
+ >>> tools.delete_order(web_shop, order['id'])
>>> for product in ShopifyIdentifier.find(
... [('record', 'like', 'product.template,%')]):
- ... tools.delete_product(id2gid('Product', product.shopify_identifier))
+ ... tools.delete_product(
+ ... web_shop, id2gid('Product', product.shopify_identifier))
>>> for category in ShopifyIdentifier.find(
... [('record', 'like', 'product.category,%')]):
- ... tools.delete_collection(id2gid('Collection',
category.shopify_identifier))
+ ... tools.delete_collection(
+ ... web_shop, id2gid('Collection', category.shopify_identifier))
>>> for _ in range(MAX_SLEEP):
... try:
- ... tools.delete_customer(customer['id'])
+ ... tools.delete_customer(web_shop, customer['id'])
... except Exception:
... time.sleep(FETCH_SLEEP)
... else:
... break
-
- >>> shopify.ShopifyResource.clear_session()
diff -r f859e3843468 -r 3e58474b6aed
modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst
---
a/modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst
Wed Mar 25 22:11:59 2026 +0100
+++
b/modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst
Sun Mar 29 20:05:46 2026 +0200
@@ -10,9 +10,6 @@
>>> import time
>>> from decimal import Decimal
- >>> import shopify
- >>> from shopify.api_version import ApiVersion
-
>>> from proteus import Model
>>> from trytond.modules.account.tests.tools import (
... create_chart, create_fiscalyear, get_accounts)
@@ -81,21 +78,15 @@
>>> web_shop = WebShop(name="Web Shop")
>>> web_shop.type = 'shopify'
- >>> web_shop.shopify_url = os.getenv('SHOPIFY_URL')
- >>> web_shop.shopify_password = os.getenv('SHOPIFY_PASSWORD')
- >>> web_shop.shopify_version = sorted(ApiVersion.versions, reverse=True)[1]
+ >>> web_shop.shopify_shop_name = os.getenv('SHOPIFY_SHOP')
+ >>> web_shop.shopify_access_token = os.getenv('SHOPIFY_ACCESS_TOKEN')
>>> shop_warehouse = web_shop.shopify_warehouses.new()
>>> shop_warehouse.warehouse, = Location.find([('type', '=', 'warehouse')])
>>> shopify_payment_journal = web_shop.shopify_payment_journals.new()
>>> shopify_payment_journal.journal = payment_journal
>>> web_shop.save()
- >>> shopify.ShopifyResource.activate_session(shopify.Session(
- ... web_shop.shopify_url,
- ... web_shop.shopify_version,
- ... web_shop.shopify_password))
-
- >>> location = tools.get_location()
+ >>> location = tools.get_location(web_shop)
>>> shop_warehouse, = web_shop.shopify_warehouses
>>> shop_warehouse.shopify_id = str(gid2id(location['id']))
@@ -151,14 +142,14 @@
Create an order on Shopify::
- >>> customer = tools.create_customer({
+ >>> customer = tools.create_customer(web_shop, {
... 'lastName': "Customer",
... 'email': (''.join(
... random.choice(string.ascii_letters) for _ in range(10))
... + '@example.com')
... })
- >>> order = tools.create_order({
+ >>> order = tools.create_order(web_shop, {
... 'customerId': customer['id'],
... 'shippingAddress': {
... 'lastName': "Customer",
@@ -231,19 +222,19 @@
Clean up::
- >>> tools.delete_order(order['id'])
+ >>> tools.delete_order(web_shop, order['id'])
>>> for product in ShopifyIdentifier.find(
... [('record', 'like', 'product.template,%')]):
- ... tools.delete_product(id2gid('Product', product.shopify_identifier))
+ ... tools.delete_product(
+ ... web_shop, id2gid('Product', product.shopify_identifier))
>>> for category in ShopifyIdentifier.find(
... [('record', 'like', 'product.category,%')]):
- ... tools.delete_collection(id2gid('Collection',
category.shopify_identifier))
+ ... tools.delete_collection(
+ ... web_shop, id2gid('Collection', category.shopify_identifier))
>>> for _ in range(MAX_SLEEP):
... try:
- ... tools.delete_customer(customer['id'])
+ ... tools.delete_customer(web_shop, customer['id'])
... except Exception:
... time.sleep(FETCH_SLEEP)
... else:
... break
-
- >>> shopify.ShopifyResource.clear_session()
diff -r f859e3843468 -r 3e58474b6aed
modules/web_shop_shopify/tests/test_scenario.py
--- a/modules/web_shop_shopify/tests/test_scenario.py Wed Mar 25 22:11:59
2026 +0100
+++ b/modules/web_shop_shopify/tests/test_scenario.py Sun Mar 29 20:05:46
2026 +0200
@@ -8,8 +8,8 @@
def load_tests(*args, **kwargs):
if (not TEST_NETWORK
- or not (os.getenv('SHOPIFY_PASSWORD')
- and os.getenv('SHOPIFY_URL'))):
+ or not (os.getenv('SHOPIFY_ACCESS_TOKEN')
+ and os.getenv('SHOPIFY_SHOP'))):
kwargs.setdefault('skips', set()).update({
'scenario_web_shop_shopify.rst',
'scenario_web_shop_shopify_secondary_unit.rst',
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/tests/tools.py
--- a/modules/web_shop_shopify/tests/tools.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/tests/tools.py Sun Mar 29 20:05:46 2026 +0200
@@ -1,24 +1,53 @@
# 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 shopify
+from shopify_app import admin_graphql_request
+from trytond.modules.web_shop_shopify import SHOPIFY_VERSION
from trytond.modules.web_shop_shopify.common import id2gid
-from trytond.modules.web_shop_shopify.shopify_retry import GraphQLException
+from trytond.modules.web_shop_shopify.exceptions import GraphQLException
-def get_location():
- return shopify.GraphQL().execute('''{
+def shopify_request(shop, query, variables=None, user_errors=None):
+ result = admin_graphql_request(
+ query=query,
+ variables=variables,
+ shop=shop.shopify_shop_name,
+ access_token=shop.shopify_access_token,
+ api_version=SHOPIFY_VERSION)
+ if not result.ok:
+ msg = []
+ if result.log and result.log.detail:
+ msg.append(result.log.detail)
+ for log in result.http_logs:
+ if log.detail:
+ msg.append(log.detail)
+ raise GraphQLException("\n".join(msg))
+ if user_errors:
+ names = user_errors.split('.')
+ errors = result.data
+ for name in names:
+ errors = getattr(errors, name, None)
+ if errors is None:
+ break
+ if errors:
+ raise GraphQLException("\n".join(
+ f"{e['field']}: {e['message']}" for e in errors))
+ return result
+
+
+def get_location(shop):
+ return shopify_request(shop, '''{
locations(first:1) {
nodes {
id
}
}
- }''')['data']['locations']['nodes'][0]
+ }''').data['locations']['nodes'][0]
-def get_inventory_levels(location):
- return shopify.GraphQL().execute('''query InventoryLevels($id: ID!) {
+def get_inventory_levels(shop, location):
+ return shopify_request(shop, '''query InventoryLevels($id: ID!) {
location(id: $id) {
inventoryLevels(first: 250) {
nodes {
@@ -34,21 +63,21 @@
}
}''', {
'id': location['id'],
- })['data']['location']['inventoryLevels']['nodes']
+ }).data['location']['inventoryLevels']['nodes']
-def get_product(id):
- return shopify.GraphQL().execute('''query Product($id: ID!) {
+def get_product(shop, id):
+ return shopify_request(shop, '''query Product($id: ID!) {
product(id: $id) {
status
}
}''', {
'id': id2gid('Product', id),
- })['data']['product']
+ }).data['product']
-def delete_product(id):
- result = shopify.GraphQL().execute('''mutation productDelete($id: ID!) {
+def delete_product(shop, id):
+ shopify_request(shop, '''mutation productDelete($id: ID!) {
productDelete(input: {id: $id}) {
userErrors {
field
@@ -57,13 +86,12 @@
}
}''', {
'id': id,
- })['data']['productDelete']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ },
+ user_errors='productDelete.userErrors')
-def delete_collection(id):
- result = shopify.GraphQL().execute('''mutation collectionDelete($id: ID!) {
+def delete_collection(shop, id):
+ shopify_request(shop, '''mutation collectionDelete($id: ID!) {
collectionDelete(input: {id: $id}) {
userErrors {
field
@@ -72,13 +100,12 @@
}
}''', {
'id': id,
- })['data']['collectionDelete']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ },
+ user_errors='collectionDelete.userErrors')
-def create_customer(customer):
- result = shopify.GraphQL().execute('''mutation customerCreate(
+def create_customer(shop, customer):
+ return shopify_request(shop, '''mutation customerCreate(
$input: CustomerInput!) {
customerCreate(input: $input) {
customer {
@@ -91,14 +118,13 @@
}
}''', {
'input': customer,
- })['data']['customerCreate']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
- return result['customer']
+ },
+ user_errors='customerCreate.userErrors',
+ ).data['customerCreate']['customer']
-def delete_customer(id):
- result = shopify.GraphQL().execute('''mutation customerDelete($id: ID!) {
+def delete_customer(shop, id):
+ shopify_request(shop, '''mutation customerDelete($id: ID!) {
customerDelete(input: {id: $id}) {
userErrors {
field
@@ -107,13 +133,12 @@
}
}''', {
'id': id,
- })['data']['customerDelete']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ },
+ user_errors='customerDelete.userErrors')
-def create_order(order):
- result = shopify.GraphQL().execute('''mutation orderCreate(
+def create_order(shop, order):
+ return shopify_request(shop, '''mutation orderCreate(
$order: OrderCreateOrderInput!) {
orderCreate(order: $order) {
order {
@@ -141,14 +166,13 @@
}
}''', {
'order': order,
- })['data']['orderCreate']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
- return result['order']
+ },
+ user_errors='orderCreate.userErrors',
+ ).data['orderCreate']['order']
-def get_order(id):
- return shopify.GraphQL().execute('''query Order($id: ID!) {
+def get_order(shop, id):
+ return shopify_request(shop, '''query Order($id: ID!) {
order(id: $id) {
id
totalPriceSet {
@@ -174,11 +198,11 @@
}
}''', {
'id': id,
- })['data']['order']
+ }).data['order']
-def capture_order(id, amount, parent_transaction_id):
- result = shopify.GraphQL().execute('''mutation orderCapture(
+def capture_order(shop, id, amount, parent_transaction_id):
+ return shopify_request(shop, '''mutation orderCapture(
$input: OrderCaptureInput!) {
orderCapture(input: $input) {
transaction {
@@ -195,14 +219,13 @@
'id': id,
'parentTransactionId': parent_transaction_id,
},
- })['data']['orderCapture']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
- return result['transaction']
+ },
+ user_errors='orderCapture.userErrors',
+ ).data['orderCapture']['transaction']
-def delete_order(id):
- result = shopify.GraphQL().execute('''mutation orderDelete($orderId: ID!) {
+def delete_order(shop, id):
+ shopify_request(shop, '''mutation orderDelete($orderId: ID!) {
orderDelete(orderId: $orderId) {
userErrors {
field
@@ -211,6 +234,5 @@
}
}''', {
'orderId': id,
- })['data']['orderDelete']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ },
+ user_errors='orderDelete.userErrors')
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/view/shop_form.xml
--- a/modules/web_shop_shopify/view/shop_form.xml Wed Mar 25 22:11:59
2026 +0100
+++ b/modules/web_shop_shopify/view/shop_form.xml Sun Mar 29 20:05:46
2026 +0200
@@ -8,22 +8,21 @@
</page>
</xpath>
<xpath expr="//page[@id='products']" position="after">
- <page string="Shopify" id="shopify" col="6">
- <label name="shopify_url"/>
- <field name="shopify_url"/>
- <label name="shopify_password"/>
- <field name="shopify_password" widget="password"/>
- <label name="shopify_version"/>
- <field name="shopify_version"/>
+ <page string="Shopify" id="shopify">
+ <label name="shopify_shop_name"/>
+ <field name="shopify_shop_name"/>
+ <label name="shopify_access_token"/>
+ <field name="shopify_access_token" widget="password"/>
<label name="shopify_webhook_shared_secret"/>
<field name="shopify_webhook_shared_secret"/>
<label name="shopify_webhook_endpoint_order"/>
<field name="shopify_webhook_endpoint_order"/>
+
<label name="shopify_fulfillment_notify_customer"/>
<field name="shopify_fulfillment_notify_customer"/>
- <field name="shopify_payment_journals" colspan="6"/>
+ <field name="shopify_payment_journals" colspan="4"/>
</page>
</xpath>
</data>
diff -r f859e3843468 -r 3e58474b6aed modules/web_shop_shopify/web.py
--- a/modules/web_shop_shopify/web.py Wed Mar 25 22:11:59 2026 +0100
+++ b/modules/web_shop_shopify/web.py Sun Mar 29 20:05:46 2026 +0200
@@ -1,14 +1,15 @@
# 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 logging
import urllib.parse
from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from operator import attrgetter
-import shopify
-from shopify.api_version import ApiVersion
+from shopify_app import admin_graphql_request
+from sql.functions import Position, Substring
import trytond.config as config
from trytond.cache import Cache
@@ -18,14 +19,17 @@
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval
from trytond.tools import grouped_slice
+from trytond.tools.logging import format_args
from trytond.transaction import Transaction
from trytond.url import http_host
-from . import graphql
+from . import SHOPIFY_VERSION, graphql
from .common import IdentifierMixin, IdentifiersMixin, gid2id, id2gid
-from .exceptions import ShopifyCredentialWarning, ShopifyError
+from .exceptions import (
+ GraphQLException, ShopifyCredentialWarning, ShopifyError)
from .product import QUERY_PRODUCT
-from .shopify_retry import GraphQLException
+
+logger = logging.getLogger(__name__)
EDIT_ORDER_DELAY = dt.timedelta(days=60 + 1)
@@ -166,10 +170,12 @@
'invisible': Eval('type') != 'shopify',
}
- shopify_url = fields.Char("Shop URL", states=_states)
- shopify_version = fields.Selection(
- 'get_shopify_versions', "Version", states=_states)
- shopify_password = fields.Char("Access Token", states=_states, strip=False)
+ shopify_shop_name = fields.Char("Shop Name", states=_states)
+ shopify_url = fields.Function(
+ fields.Char("Shop URL"),
+ 'on_change_with_shopify_url')
+ shopify_access_token = fields.Char(
+ "Access Token", states=_states, strip=False)
shopify_webhook_shared_secret = fields.Char(
"Webhook Shared Secret", strip=False,
states={
@@ -205,9 +211,31 @@
field.states['invisible'] = invisible
@classmethod
- def get_shopify_versions(cls):
- return [(None, "")] + sorted(
- ((v, v) for v in ApiVersion.versions), reverse=True)
+ def __register__(cls, module):
+ table_h = cls.__table_handler__(module)
+ table = cls.__table__()
+ cursor = Transaction().connection.cursor()
+
+ super().__register__(module)
+
+ # Migration from 7.8: replace url by shop_name
+ if table_h.column_exist('shopify_url'):
+ cursor.execute(*table.update(
+ [table.shopify_shop_name],
+ [Substring(
+ table.shopify_url,
+ Position('//', table.shopify_url) + 2,
+ Position('.', table.shopify_url)
+ - Position('//', table.shopify_url) - 2)]))
+ table_h.drop_column('shopify_url')
+
+ # Migration from 7.8: rename password into access_token
+ table_h.column_rename('shopify_password', 'shopify_access_token')
+
+ @fields.depends('shopify_shop_name')
+ def on_change_with_shopify_url(self, name=None):
+ if self.shopify_shop_name:
+ return f"https://{self.shopify_shop_name}.myshopify.com/"
@fields.depends('name')
def on_change_with_shopify_webhook_endpoint_order(self, name=None):
@@ -273,20 +301,56 @@
if self.type == 'shopify':
self._shopify_update_order(self, sales)
- def shopify_session(self):
- return shopify.Session.temp(
- self.shopify_url, self.shopify_version, self.shopify_password)
+ def shopify_request(
+ self, query, variables=None, headers=None, max_retries=2,
+ user_errors=None):
+ result = admin_graphql_request(
+ query=query,
+ shop=self.shopify_shop_name,
+ access_token=self.shopify_access_token,
+ api_version=SHOPIFY_VERSION,
+ variables=variables,
+ headers=headers,
+ max_retries=max_retries)
+ if not result.ok:
+ logger.exception(
+ "shopify request %s", format_args(
+ query, {
+ 'variables': variables,
+ 'headers': headers,
+ 'max_retries': max_retries,
+ }, logger.isEnabledFor(logging.DEBUG)))
+ msg = []
+ if result.log and result.log.detail:
+ msg.append(result.log.detail)
+ for log in result.http_logs:
+ if log.detail:
+ msg.append(log.detail)
+ raise GraphQLException("\n".join(msg))
+ if user_errors:
+ names = user_errors.split('.')
+ errors = result.data
+ for name in names:
+ errors = getattr(errors, name, None)
+ if errors is None:
+ break
+ if errors:
+ raise GraphQLException("\n".join(
+ f"{e['field']}: {e['message']}" for e in errors))
+ return result
def shopify_shop(self, fields):
- return shopify.GraphQL().execute(QUERY_SHOP % {
+ result = self.shopify_request(QUERY_SHOP % {
'fields': graphql.selection(fields),
- })['data']['shop']
+ })
+ return result.data['shop']
def shopify_shop_locales(self, fields):
- return shopify.GraphQL().execute(
+ result = self.shopify_request(
QUERY_SHOP_LOCALES % {
'fields': graphql.selection(fields),
- })['data']['shopLocales']
+ })
+ return result.data['shopLocales']
def get_payment_journal(self, currency_code, pattern):
for payment_journal in self.shopify_payment_journals:
@@ -321,75 +385,74 @@
'primary': None,
})
for shop in shops:
- with shop.shopify_session():
- shopify_shop = shop.shopify_shop(shop_fields)
- shop_language = (
- shop.language.code if shop.language
- else transaction.language)
- categories = shop.get_categories()
- products, prices, taxes = shop.get_products(
- key=lambda p: p.template.id)
- sale_prices, sale_taxes = prices, taxes
+ shopify_shop = shop.shopify_shop(shop_fields)
+ shop_language = (
+ shop.language.code if shop.language
+ else transaction.language)
+ categories = shop.get_categories()
+ products, prices, taxes = shop.get_products(
+ key=lambda p: p.template.id)
+ sale_prices, sale_taxes = prices, taxes
- context = shop.get_context()
- with Transaction().set_context(_non_sale_price=True):
- sale_context = shop.get_context()
- if context != sale_context:
- _, prices, taxes = shop.get_products()
+ context = shop.get_context()
+ with Transaction().set_context(_non_sale_price=True):
+ sale_context = shop.get_context()
+ if context != sale_context:
+ _, prices, taxes = shop.get_products()
- if shopify_shop['currencyCode'] != shop.currency.code:
- raise ShopifyError(gettext(
- 'web_shop_shopify.msg_shop_currency_different',
- shop=shop.rec_name,
- shop_currency=shop.currency.code,
- shopify_currency=shopify_shop['currencyCode']))
- shop_locales = shop.shopify_shop_locales(shop_locales_fields)
- primary_locale = next(
- filter(lambda l: l['primary'], shop_locales))
- if primary_locale['locale'] != shop_language:
- raise ShopifyError(gettext(
- 'web_shop_shopify.msg_shop_locale_different',
- shop=shop.rec_name,
- shop_language=shop_language,
- shopify_primary_locale=primary_locale['locale'],
- ))
+ if shopify_shop['currencyCode'] != shop.currency.code:
+ raise ShopifyError(gettext(
+ 'web_shop_shopify.msg_shop_currency_different',
+ shop=shop.rec_name,
+ shop_currency=shop.currency.code,
+ shopify_currency=shopify_shop['currencyCode']))
+ shop_locales = shop.shopify_shop_locales(shop_locales_fields)
+ primary_locale = next(
+ filter(lambda l: l['primary'], shop_locales))
+ if primary_locale['locale'] != shop_language:
+ raise ShopifyError(gettext(
+ 'web_shop_shopify.msg_shop_locale_different',
+ shop=shop.rec_name,
+ shop_language=shop_language,
+ shopify_primary_locale=primary_locale['locale'],
+ ))
- for category in categories:
- shop._shopify_update_collection(category)
+ for category in categories:
+ shop._shopify_update_collection(category)
- categories = set(categories)
- inventory_items = dict(
- zip(products, InventoryItem.browse(products)))
- for template, t_products in groupby(
- products, key=lambda p: p.template):
- t_products = sorted(
- t_products, key=lambda p: p.position or 0)
- p_inventory_items = [
- inventory_items[p] for p in t_products]
- p_sale_prices = [sale_prices[p.id] for p in t_products]
- p_sale_taxes = [sale_taxes[p.id] for p in t_products]
- p_prices = [prices[p.id] for p in t_products]
- p_taxes = [taxes[p.id] for p in t_products]
- if shop._shopify_product_is_to_update(
- template, t_products, p_sale_prices, p_sale_taxes,
- p_prices, p_taxes):
- shop._shopify_update_product(
- shopify_shop, categories, template, t_products,
- p_inventory_items, p_sale_prices, p_sale_taxes,
- p_prices, p_taxes)
- Transaction().commit()
+ categories = set(categories)
+ inventory_items = dict(
+ zip(products, InventoryItem.browse(products)))
+ for template, t_products in groupby(
+ products, key=lambda p: p.template):
+ t_products = sorted(
+ t_products, key=lambda p: p.position or 0)
+ p_inventory_items = [
+ inventory_items[p] for p in t_products]
+ p_sale_prices = [sale_prices[p.id] for p in t_products]
+ p_sale_taxes = [sale_taxes[p.id] for p in t_products]
+ p_prices = [prices[p.id] for p in t_products]
+ p_taxes = [taxes[p.id] for p in t_products]
+ if shop._shopify_product_is_to_update(
+ template, t_products, p_sale_prices, p_sale_taxes,
+ p_prices, p_taxes):
+ shop._shopify_update_product(
+ shopify_shop, categories, template, t_products,
+ p_inventory_items, p_sale_prices, p_sale_taxes,
+ p_prices, p_taxes)
+ Transaction().commit()
- for category in shop.categories_removed:
- shop._shopify_remove_collection(category)
- shop.categories_removed = []
+ for category in shop.categories_removed:
+ shop._shopify_remove_collection(category)
+ shop.categories_removed = []
- products = set(products)
- for product in shop.products_removed:
- template = product.template
- if set(template.products).isdisjoint(products):
- shop._shopify_remove_product(template)
- product.set_shopify_identifier(shop)
- shop.products_removed = []
+ products = set(products)
+ for product in shop.products_removed:
+ template = product.template
+ if set(template.products).isdisjoint(products):
+ shop._shopify_remove_product(template)
+ product.set_shopify_identifier(shop)
+ shop.products_removed = []
cls.save(shops)
def _shopify_update_collection(self, category, collection_fields=None):
@@ -406,21 +469,19 @@
MUTATION = MUTATION_COLLECTION_CREATE
output = 'collectionCreate'
try:
- result = shopify.GraphQL().execute(
+ result = self.shopify_request(
MUTATION % {
'fields': graphql.selection(collection_fields),
}, {
'input': collection,
- })['data'][output]
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
- collection = result['collection']
+ },
+ user_errors=f'{output}.userErrors')
+ collection = result.data[output]['collection']
except GraphQLException as e:
raise ShopifyError(gettext(
'web_shop_shopify.msg_custom_collection_fail',
category=category.rec_name,
- error="\n".join(
- err['message'] for err in e.errors))) from e
+ error=e.message)) from e
identifier = category.set_shopify_identifier(
self, gid2id(collection['id']))
if identifier.to_update:
@@ -434,14 +495,13 @@
if shopify_id:
shopify_id = id2gid('Collection', shopify_id)
try:
- result = shopify.GraphQL().execute(
+ self.shopify_request(
MUTATION_COLLECTION_DELETE, {
'input': {
'id': shopify_id,
}
- })['data']['collectionDelete']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ },
+ user_errors='collectionDelete.userErrors')
except GraphQLException:
pass
category.set_shopify_identifier(self)
@@ -522,13 +582,12 @@
'input': shopify_product,
}
try:
- result = shopify.GraphQL().execute(
+ result = self.shopify_request(
MUTATION_PRODUCT_SET % {
'fields': graphql.selection(product_fields),
- }, data)['data']['productSet']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
- shopify_product = result['product']
+ }, data,
+ user_errors='productSet.userErrors')
+ shopify_product = result.data['productSet']['product']
identifiers = []
identifier = template.set_shopify_identifier(
@@ -538,7 +597,7 @@
identifiers.append(identifier)
shopify_variants = graphql.iterate(
- QUERY_PRODUCT_CURSOR % {
+ self, QUERY_PRODUCT_CURSOR % {
'fields': graphql.selection({
'variants(first: 250, after: $cursor)': (
product_fields['variants(first: 250)']),
@@ -579,34 +638,31 @@
raise ShopifyError(gettext(
'web_shop_shopify.msg_product_fail',
template=template.rec_name,
- error="\n".join(
- err['message'] for err in e.errors))) from e
+ error=e.message)) from e
def _shopify_remove_product(self, template):
shopify_id = template.get_shopify_identifier(self)
if shopify_id:
shopify_id = id2gid('Product', shopify_id)
- product = shopify.GraphQL().execute(
+ product = self.shopify_request(
QUERY_PRODUCT % {
'fields': graphql.selection({
'id': None,
}),
- }, {'id': shopify_id})['data']['product']
+ }, {'id': shopify_id}).data['product']
if product:
try:
- result = shopify.GraphQL().execute(
+ self.shopify_request(
MUTATION_PRODUCT_CHANGE_STATUS, {
'productId': shopify_id,
'status': 'ARCHIVED',
- })['data']['productChangeStatus']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ },
+ user_errors='productChangeStatus.userErrors')
except GraphQLException as e:
raise ShopifyError(gettext(
'web_shop_shopify.msg_product_fail',
template=template.rec_name,
- error="\n".join(
- err['message'] for err in e.errors))) from e
+ error=e.message)) from e
@classmethod
def shopify_update_inventory(cls, shops=None):
@@ -627,8 +683,7 @@
**shop_warehouse.get_shopify_inventory_context()):
products = Product.browse([
p for p in shop.products if p.shopify_uom])
- with shop.shopify_session():
- shop._shopify_update_inventory(products, location_id)
+ shop._shopify_update_inventory(products, location_id)
def _shopify_update_inventory(self, products, location_id):
pool = Pool()
@@ -645,24 +700,21 @@
def set_quantities():
try:
for quantity in quantities:
- result = shopify.GraphQL().execute(
+ self.shopify_request(
MUTATION_INVENTORY_ACTIVATE, {
'inventoryItemId': quantity['inventoryItemId'],
'locationId': quantity['locationId'],
- })['data']['inventoryActivate']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
- result = shopify.GraphQL().execute(
+ },
+ user_errors='inventoryActivate.userErrors')
+ self.shopify_request(
MUTATION_INVENTORY_SET_QUANTITIES, {
'input': input,
- })['data']['inventorySetQuantities']
- if errors := result.get('userErrors'):
- raise GraphQLException({'errors': errors})
+ },
+ user_errors='inventorySetQuantities.userErrors')
except GraphQLException as e:
raise ShopifyError(gettext(
'web_shop_shopify.msg_inventory_set_fail',
- error="\n".join(
- err['message'] for err in e.errors))) from e
+ error=e.message)) from e
quantities.clear()
for product, inventory_item in zip(products, inventory_items):
@@ -702,38 +754,37 @@
last_order_id = last_sale.shopify_identifier
else:
last_order_id = ''
- with shop.shopify_session():
- if pool.test and 'shopify_orders' in context:
- query = ' OR '.join(
- f'id:{id}' for id in context['shopify_orders'])
- elif last_order_id:
- query = f'-status:cancelled AND id:>{last_order_id}'
- else:
- query = '-status:cancelled'
- orders = shopify.GraphQL().execute(
- QUERY_ORDERS % {
- 'query': query,
- 'fields': graphql.selection(fields),
- })['data']['orders']
- sales = []
- for order in orders['nodes']:
- sales.append(Sale.get_from_shopify(shop, order))
- Sale.save(sales)
- for sale, order in zip(sales, orders['nodes']):
- total_price = Decimal(
- order['currentTotalPriceSet']['presentmentMoney'][
- 'amount'])
- sale.shopify_tax_adjustment = (
- total_price - sale.total_amount)
- Sale.save(sales)
- to_quote = [
- s for s in sales if s.party != s.web_shop.guest_party]
- if to_quote:
- Sale.quote(to_quote)
- for sale, order in zip(sales, orders['nodes']):
- if sale.state != 'draft':
- Payment.get_from_shopify(sale, order)
- Sale.payment_confirm(sales)
+ if pool.test and 'shopify_orders' in context:
+ query = ' OR '.join(
+ f'id:{id}' for id in context['shopify_orders'])
+ elif last_order_id:
+ query = f'-status:cancelled AND id:>{last_order_id}'
+ else:
+ query = '-status:cancelled'
+ orders = shop.shopify_request(
+ QUERY_ORDERS % {
+ 'query': query,
+ 'fields': graphql.selection(fields),
+ }).data['orders']
+ sales = []
+ for order in orders['nodes']:
+ sales.append(Sale.get_from_shopify(shop, order))
+ Sale.save(sales)
+ for sale, order in zip(sales, orders['nodes']):
+ total_price = Decimal(
+ order['currentTotalPriceSet']['presentmentMoney'][
+ 'amount'])
+ sale.shopify_tax_adjustment = (
+ total_price - sale.total_amount)
+ Sale.save(sales)
+ to_quote = [
+ s for s in sales if s.party != s.web_shop.guest_party]
+ if to_quote:
+ Sale.quote(to_quote)
+ for sale, order in zip(sales, orders['nodes']):
+ if sale.state != 'draft':
+ Payment.get_from_shopify(sale, order)
+ Sale.payment_confirm(sales)
@classmethod
def shopify_update_order(cls, shops=None):
@@ -768,15 +819,14 @@
assert shop.type == 'shopify'
assert all(s.web_shop == shop for s in sales)
fields = {'nodes': Sale.shopify_fields()}
- with shop.shopify_session():
- query = ' OR '.join(
- f'id:{s.shopify_identifier}' for s in sales)
- orders = shopify.GraphQL().execute(
- QUERY_ORDERS % {
- 'query': query,
- 'fields': graphql.selection(fields),
- })['data']['orders']
- id2order = {gid2id(o['id']): o for o in orders['nodes']}
+ query = ' OR '.join(
+ f'id:{s.shopify_identifier}' for s in sales)
+ orders = shop.shopify_request(
+ QUERY_ORDERS % {
+ 'query': query,
+ 'fields': graphql.selection(fields),
+ }).data['orders']
+ id2order = {gid2id(o['id']): o for o in orders['nodes']}
to_update = []
orders = []
@@ -802,15 +852,14 @@
for sale, order in zip(sales, orders):
assert sale.shopify_identifier == gid2id(order['id'])
shop = sale.web_shop
- with shop.shopify_session():
- sale = Sale.get_from_shopify(shop, order, sale=sale)
- if sale._changed_values():
- sale.untaxed_amount_cache = None
- sale.tax_amount_cache = None
- sale.total_amount_cache = None
- sale.shopify_tax_adjustment = None
- to_update[sale] = order
- states_to_restore[sale.state].append(sale)
+ sale = Sale.get_from_shopify(shop, order, sale=sale)
+ if sale._changed_values():
+ sale.untaxed_amount_cache = None
+ sale.tax_amount_cache = None
+ sale.total_amount_cache = None
+ sale.shopify_tax_adjustment = None
+ to_update[sale] = order
+ states_to_restore[sale.state].append(sale)
Sale.write(list(to_update.keys()), {'state': 'draft'})
with Transaction().set_context(_log=True):
Sale.save(to_update.keys())
@@ -841,9 +890,7 @@
for sale, order in zip(sales, orders):
if sale.state != 'draft':
- shop = sale.web_shop
- with shop.shopify_session():
- Payment.get_from_shopify(sale, order)
+ Payment.get_from_shopify(sale, order)
Sale.payment_confirm(sales)
@classmethod
@@ -855,7 +902,8 @@
if (mode == 'write'
and external
and values.keys() & {
- 'shopify_url', 'shopify_password',
+ 'shopify_shop_name',
+ 'shopify_access_token',
'shopify_webhook_shared_secret'}):
warning_name = Warning.format('shopify_credential', shops)
if Warning.check(warning_name):
@@ -904,7 +952,7 @@
try:
shopify_media = graphql.iterate(
- QUERY_PRODUCT_CURSOR % {
+ self, QUERY_PRODUCT_CURSOR % {
'fields': graphql.selection({
'media(first: 250, after: $cursor)': (
product_fields['media(first: 250)']),
@@ -929,8 +977,7 @@
raise ShopifyError(gettext(
'web_shop_shopify.msg_product_fail',
template=template.rec_name,
- error="\n".join(
- err['message'] for err in e.errors))) from e
+ error=e.message)) from e
class ShopShopifyIdentifier(IdentifierMixin, ModelSQL, ModelView):
@@ -1019,22 +1066,21 @@
]
@fields.depends(
- 'shop', '_parent_shop.shopify_url', '_parent_shop.shopify_version',
- '_parent_shop.shopify_password')
+ 'shop',
+ '_parent_shop.shopify_shop_name',
+ '_parent_shop.shopify_access_token')
def get_shopify_locations(self):
locations = [(None, "")]
- session = attrgetter(
- 'shopify_url', 'shopify_version', 'shopify_password')
+ session = attrgetter('shopify_shop_name', 'shopify_access_token')
if self.shop and all(session(self.shop)):
locations_cache = self._shopify_locations_cache.get(self.shop.id)
if locations_cache is not None:
return locations_cache
try:
- with self.shop.shopify_session():
- for location in graphql.iterate(
- QUERY_LOCATIONS, {}, 'locations'):
- locations.append(
- (str(gid2id(location['id'])), location['name']))
+ for location in graphql.iterate(
+ self.shop, QUERY_LOCATIONS, {}, 'locations'):
+ locations.append(
+ (str(gid2id(location['id'])), location['name']))
locations = self._shopify_locations_cache.set(
self.shop.id, locations)
except GraphQLException: