@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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})
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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']:
|
||||||
|
|||||||
@@ -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
|
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',
|
||||||
|
|||||||
@@ -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})
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user