diff --git a/modules/web_shop_shopify/CHANGELOG b/modules/web_shop_shopify/CHANGELOG index 9ae17ec544..81ec9430b3 100644 --- a/modules/web_shop_shopify/CHANGELOG +++ b/modules/web_shop_shopify/CHANGELOG @@ -1,3 +1,4 @@ +* Replace ShopifyAPI by shopifyapp * Add support for pick-up delivery method * Log the update of sales * Add support for gift card products diff --git a/modules/web_shop_shopify/__init__.py b/modules/web_shop_shopify/__init__.py index 51b4b761ce..27fb3cc58f 100644 --- a/modules/web_shop_shopify/__init__.py +++ b/modules/web_shop_shopify/__init__.py @@ -1,8 +1,8 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from . import routes, shopify_retry +from . import routes + +SHOPIFY_VERSION = '2024-10' __all__ = [routes] - -shopify_retry.patch() diff --git a/modules/web_shop_shopify/common.py b/modules/web_shop_shopify/common.py index c8d29e3de1..626da6f2af 100644 --- a/modules/web_shop_shopify/common.py +++ b/modules/web_shop_shopify/common.py @@ -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): diff --git a/modules/web_shop_shopify/exceptions.py b/modules/web_shop_shopify/exceptions.py index 1223225180..892ee6bac0 100644 --- a/modules/web_shop_shopify/exceptions.py +++ b/modules/web_shop_shopify/exceptions.py @@ -1,6 +1,12 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from trytond.exceptions import UserError, UserWarning +from trytond.exceptions import TrytonException, UserError, UserWarning + + +class GraphQLException(TrytonException): + def __init__(self, message): + super().__init__(message) + self.message = message class ShopifyError(UserError): diff --git a/modules/web_shop_shopify/graphql.py b/modules/web_shop_shopify/graphql.py index 3706fbe450..9dd7966875 100644 --- a/modules/web_shop_shopify/graphql.py +++ b/modules/web_shop_shopify/graphql.py @@ -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] diff --git a/modules/web_shop_shopify/product.py b/modules/web_shop_shopify/product.py index 044e899e90..6bcc4b9266 100644 --- a/modules/web_shop_shopify/product.py +++ b/modules/web_shop_shopify/product.py @@ -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 diff --git a/modules/web_shop_shopify/pyproject.toml b/modules/web_shop_shopify/pyproject.toml index d374ae25de..9e54677842 100644 --- a/modules/web_shop_shopify/pyproject.toml +++ b/modules/web_shop_shopify/pyproject.toml @@ -52,10 +52,9 @@ exclude = ['doc'] [tool.hatch.metadata.hooks.tryton] dependencies = [ - 'ShopifyAPI', - 'pyactiveresource', 'python-dateutil', 'python-sql', + 'shopifyapp', ] copyright = 'COPYRIGHT' readme = 'README.rst' diff --git a/modules/web_shop_shopify/routes.py b/modules/web_shop_shopify/routes.py index d93e48231f..b3417be01f 100644 --- a/modules/web_shop_shopify/routes.py +++ b/modules/web_shop_shopify/routes.py @@ -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() diff --git a/modules/web_shop_shopify/sale.py b/modules/web_shop_shopify/sale.py index a2b58c0fad..0b9d4503ad 100644 --- a/modules/web_shop_shopify/sale.py +++ b/modules/web_shop_shopify/sale.py @@ -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']: diff --git a/modules/web_shop_shopify/shopify_retry.py b/modules/web_shop_shopify/shopify_retry.py deleted file mode 100644 index a86404fe9b..0000000000 --- a/modules/web_shop_shopify/shopify_retry.py +++ /dev/null @@ -1,87 +0,0 @@ -# This file is part of Tryton. The COPYRIGHT file at the top level of -# this repository contains the full copyright notices and license terms. -import json -import logging -import time -import urllib - -from pyactiveresource.connection import ClientError -from shopify import Limits -from shopify.base import ShopifyConnection -from shopify.resources.graphql import GraphQL - -try: - from shopify.resources.graphql import GraphQLException -except ImportError: - class GraphQLException(Exception): - def __init__(self, response): - self._response = response - - @property - def errors(self): - return self._response['errors'] - -from trytond.protocols.wrappers import HTTPStatus -from trytond.tools.logging import format_args - -logger = logging.getLogger(__name__) - - -def patch(): - def _open(*args, **kwargs): - while True: - try: - return open_func(*args, **kwargs) - except ClientError as e: - if e.response.code == HTTPStatus.TOO_MANY_REQUESTS: - retry_after = float( - e.response.headers.get('Retry-After', 2)) - logger.debug( - "Shopify connection retry after %ss", retry_after) - time.sleep(retry_after) - else: - raise - else: - try: - if Limits.credit_maxed(): - logger.debug("Shopify connection credit maxed") - time.sleep(0.5) - except Exception: - pass - - if ShopifyConnection._open != _open: - open_func = ShopifyConnection._open - ShopifyConnection._open = _open - - def graphql_execute(self, *args, **kwargs): - log_message = "GraphQL execute %s" - log_args = ( - format_args(args, kwargs, logger.isEnabledFor(logging.DEBUG)),) - while True: - try: - result = graphql_execute_func(self, *args, **kwargs) - except urllib.error.HTTPError as e: - if e.code == HTTPStatus.TOO_MANY_REQUESTS: - retry_after = float(e.headers.get('Retry-After', 2)) - logger.debug("GraphQL retry after %ss", retry_after) - time.sleep(retry_after) - else: - logger.exception(log_message, *log_args) - raise GraphQLException(json.load(e.fp)) - if isinstance(result, str): - result = json.loads(result) - if result.get('errors'): - for error in result['errors']: - if error.get('extensions', {}).get('code') == 'THROTTLED': - logger.debug("GraphQL throttled") - time.sleep(0.5) - continue - logger.exception(log_message, *log_args) - raise GraphQLException(result) - logger.info(log_message, *log_args) - logger.debug("GraphQL Result: %r", result) - return result - - if GraphQL.execute != graphql_execute: - graphql_execute_func = GraphQL.execute - GraphQL.execute = graphql_execute diff --git a/modules/web_shop_shopify/stock.py b/modules/web_shop_shopify/stock.py index fafa1e7d6f..c892643603 100644 --- a/modules/web_shop_shopify/stock.py +++ b/modules/web_shop_shopify/stock.py @@ -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): diff --git a/modules/web_shop_shopify/tests/scenario_web_shop_shopify.rst b/modules/web_shop_shopify/tests/scenario_web_shop_shopify.rst index 2dcf67403b..442746f149 100644 --- a/modules/web_shop_shopify/tests/scenario_web_shop_shopify.rst +++ b/modules/web_shop_shopify/tests/scenario_web_shop_shopify.rst @@ -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() diff --git a/modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst b/modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst index 65f6f6a6f8..8a4e3803d5 100644 --- a/modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst +++ b/modules/web_shop_shopify/tests/scenario_web_shop_shopify_product_kit.rst @@ -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() diff --git a/modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst b/modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst index 0d53e1e295..99e94f048d 100644 --- a/modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst +++ b/modules/web_shop_shopify/tests/scenario_web_shop_shopify_secondary_unit.rst @@ -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() diff --git a/modules/web_shop_shopify/tests/test_scenario.py b/modules/web_shop_shopify/tests/test_scenario.py index a4d0566079..21b0865789 100644 --- a/modules/web_shop_shopify/tests/test_scenario.py +++ b/modules/web_shop_shopify/tests/test_scenario.py @@ -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', diff --git a/modules/web_shop_shopify/tests/tools.py b/modules/web_shop_shopify/tests/tools.py index 0f2348be29..14f3293c80 100644 --- a/modules/web_shop_shopify/tests/tools.py +++ b/modules/web_shop_shopify/tests/tools.py @@ -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') diff --git a/modules/web_shop_shopify/view/shop_form.xml b/modules/web_shop_shopify/view/shop_form.xml index 3a175cf306..afd7399b57 100644 --- a/modules/web_shop_shopify/view/shop_form.xml +++ b/modules/web_shop_shopify/view/shop_form.xml @@ -8,22 +8,21 @@ this repository contains the full copyright notices and license terms. --> - - diff --git a/modules/web_shop_shopify/web.py b/modules/web_shop_shopify/web.py index 52b7cf4982..d7674f85d8 100644 --- a/modules/web_shop_shopify/web.py +++ b/modules/web_shop_shopify/web.py @@ -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: