Replace ShopifyAPI by shopifyapp

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