@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -68,8 +68,9 @@ class IdentifierMixin:
|
||||
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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 @@ def selection(fields):
|
||||
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 @@ def iterate(query, params, query_name, path=None, data=None):
|
||||
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 @@ def iterate(query, params, query_name, path=None, data=None):
|
||||
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]
|
||||
|
||||
@@ -4,7 +4,6 @@ import re
|
||||
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 @@ class Category(IdentifiersMixin, metaclass=PoolMeta):
|
||||
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 @@ class Template(IdentifiersMixin, metaclass=PoolMeta):
|
||||
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 @@ class Product(IdentifiersMixin, metaclass=PoolMeta):
|
||||
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 @@ class ProductURL(metaclass=PoolMeta):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -52,10 +52,9 @@ exclude = ['doc']
|
||||
|
||||
[tool.hatch.metadata.hooks.tryton]
|
||||
dependencies = [
|
||||
'ShopifyAPI',
|
||||
'pyactiveresource',
|
||||
'python-dateutil',
|
||||
'python-sql',
|
||||
'shopifyapp',
|
||||
]
|
||||
copyright = 'COPYRIGHT'
|
||||
readme = 'README.rst'
|
||||
|
||||
@@ -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 @@ from trytond.wsgi import app
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def verify_webhook(data, hmac_header, secret):
|
||||
digest = hmac.new(secret, data, hashlib.sha256).digest()
|
||||
computed_hmac = base64.b64encode(digest)
|
||||
return hmac.compare_digest(computed_hmac, hmac_header.encode('utf-8'))
|
||||
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 @@ def order(request, pool, shop):
|
||||
Sale = pool.get('sale.sale')
|
||||
Shop = pool.get('web.shop')
|
||||
shop = Shop.get(shop)
|
||||
data = request.get_data()
|
||||
verified = verify_webhook(
|
||||
data, request.headers.get('X-Shopify-Hmac-SHA256'),
|
||||
shop.shopify_webhook_shared_secret.encode('utf-8'))
|
||||
if not verified:
|
||||
abort(HTTPStatus.UNAUTHORIZED)
|
||||
|
||||
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()
|
||||
|
||||
@@ -5,7 +5,6 @@ from decimal import Decimal
|
||||
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 trytond.transaction import Transaction
|
||||
|
||||
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 @@ class Sale(IdentifierMixin, metaclass=PoolMeta):
|
||||
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 @@ class Sale(IdentifierMixin, metaclass=PoolMeta):
|
||||
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 @@ class Sale(IdentifierMixin, metaclass=PoolMeta):
|
||||
|
||||
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 @@ class Sale(IdentifierMixin, metaclass=PoolMeta):
|
||||
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 @@ class Sale(IdentifierMixin, metaclass=PoolMeta):
|
||||
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 @@ class Sale(IdentifierMixin, metaclass=PoolMeta):
|
||||
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 @@ class Sale(IdentifierMixin, metaclass=PoolMeta):
|
||||
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})
|
||||
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')
|
||||
|
||||
# TODO: manage drop shipment
|
||||
# TODO: manage drop shipment
|
||||
|
||||
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)
|
||||
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.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)
|
||||
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 @@ class Sale(IdentifierMixin, metaclass=PoolMeta):
|
||||
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']:
|
||||
|
||||
@@ -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
|
||||
@@ -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 trytond.pool import Pool, PoolMeta
|
||||
|
||||
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 @@ class ShipmentOut(metaclass=PoolMeta):
|
||||
},
|
||||
})
|
||||
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 @@ class ShipmentOut(metaclass=PoolMeta):
|
||||
@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 @@ class ShipmentOut(metaclass=PoolMeta):
|
||||
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 @@ class ShipmentOut(metaclass=PoolMeta):
|
||||
'.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):
|
||||
|
||||
@@ -13,9 +13,6 @@ Imports::
|
||||
>>> 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 @@ Define a web shop::
|
||||
|
||||
>>> 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 @@ Check inventory item::
|
||||
>>> 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 @@ Run update product::
|
||||
>>> 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 @@ Create an order on Shopify::
|
||||
... ''.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 @@ Create an order on Shopify::
|
||||
... 'locale': 'en-CA',
|
||||
... })
|
||||
|
||||
>>> order = tools.create_order({
|
||||
>>> order = tools.create_order(web_shop, {
|
||||
... 'customer': {
|
||||
... 'toAssociate': {
|
||||
... 'id': customer['id'],
|
||||
@@ -596,7 +587,7 @@ Run fetch order::
|
||||
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 @@ Make a partial shipment::
|
||||
>>> 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 @@ Ship and cancel remaining shipment::
|
||||
>>> 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 @@ Ship and cancel remaining shipment::
|
||||
>>> 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 @@ Cancel remaining shipment::
|
||||
>>> 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 @@ Ignore shipment exception::
|
||||
... 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 @@ Post invoice::
|
||||
>>> 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()
|
||||
|
||||
@@ -10,9 +10,6 @@ Imports::
|
||||
>>> 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 @@ Define a web shop::
|
||||
|
||||
>>> 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 @@ Run update product::
|
||||
|
||||
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 @@ Create an order on Shopify::
|
||||
'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 @@ Make a partial shipment of components::
|
||||
>>> 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 @@ Make a partial shipment for a single component::
|
||||
>>> 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 @@ Ship remaining::
|
||||
>>> 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()
|
||||
|
||||
@@ -10,9 +10,6 @@ Imports::
|
||||
>>> 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 @@ Define a web shop::
|
||||
|
||||
>>> 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 @@ Run update product::
|
||||
|
||||
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 @@ Run fetch order::
|
||||
|
||||
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()
|
||||
|
||||
@@ -8,8 +8,8 @@ from trytond.tests.test_tryton import TEST_NETWORK, load_doc_tests
|
||||
|
||||
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',
|
||||
|
||||
@@ -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 @@ def get_inventory_levels(location):
|
||||
}
|
||||
}''', {
|
||||
'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 @@ def delete_product(id):
|
||||
}
|
||||
}''', {
|
||||
'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 @@ def delete_collection(id):
|
||||
}
|
||||
}''', {
|
||||
'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 @@ def create_customer(customer):
|
||||
}
|
||||
}''', {
|
||||
'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 @@ def delete_customer(id):
|
||||
}
|
||||
}''', {
|
||||
'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 @@ def create_order(order):
|
||||
}
|
||||
}''', {
|
||||
'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 @@ def get_order(id):
|
||||
}
|
||||
}''', {
|
||||
'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 @@ def capture_order(id, amount, parent_transaction_id):
|
||||
'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 @@ def delete_order(id):
|
||||
}
|
||||
}''', {
|
||||
'orderId': id,
|
||||
})['data']['orderDelete']
|
||||
if errors := result.get('userErrors'):
|
||||
raise GraphQLException({'errors': errors})
|
||||
},
|
||||
user_errors='orderDelete.userErrors')
|
||||
|
||||
@@ -8,22 +8,21 @@ this repository contains the full copyright notices and license terms. -->
|
||||
</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>
|
||||
|
||||
+233
-187
@@ -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.model import (
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
'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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
'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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
'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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
**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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
|
||||
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 @@ class Shop(metaclass=PoolMeta):
|
||||
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 @@ class Shop_Image(metaclass=PoolMeta):
|
||||
|
||||
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 @@ class Shop_Image(metaclass=PoolMeta):
|
||||
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 @@ class Shop_Warehouse(ModelView, metaclass=PoolMeta):
|
||||
]
|
||||
|
||||
@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:
|
||||
|
||||
Reference in New Issue
Block a user