Replace ShopifyAPI by shopifyapp

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