# 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 urllib.parse from decimal import Decimal import pyactiveresource import shopify from shopify.api_version import ApiVersion from trytond.cache import Cache from trytond.config import config from trytond.i18n import gettext from trytond.model import ( MatchMixin, ModelSQL, ModelView, Unique, fields, sequence_ordered) from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval from trytond.tools import grouped_slice from trytond.transaction import Transaction from trytond.url import http_host from .common import IdentifierMixin, IdentifiersMixin from .exceptions import ShopifyError EDIT_ORDER_DELAY = dt.timedelta(days=60 + 1) class Shop(metaclass=PoolMeta): __name__ = 'web.shop' _states = { 'required': Eval('type') == 'shopify', '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_webhook_shared_secret = fields.Char( "Webhook Shared Secret", strip=False, states={ 'invisible': _states['invisible'], }) shopify_webhook_endpoint_order = fields.Function( fields.Char( "Webhook Order Endpoint", help="The URL to be called by Shopify for Order events."), 'on_change_with_shopify_webhook_endpoint_order') shopify_warehouses = fields.One2Many( 'web.shop-stock.location', 'shop', "Warehouses", states=_states) shopify_payment_journals = fields.One2Many( 'web.shop.shopify_payment_journal', 'shop', "Payment Journals", states=_states) del _states @classmethod def __setup__(cls): super().__setup__() cls.type.selection.append(('shopify', "Shopify")) invisible = Eval('type') == 'shopify' for field in [cls.attributes, cls.attributes_removed, cls.warehouses]: if field.states.get('invisible'): field.states['invisible'] |= invisible else: field.states['invisible'] = invisible @classmethod def get_shopify_versions(cls): return [(None, "")] + sorted( ((v, v) for v in ApiVersion.versions), reverse=True) @fields.depends('name') def on_change_with_shopify_webhook_endpoint_order(self, name=None): if not self.name: return url_part = { 'database_name': Transaction().database.name, 'shop': self.name, } return http_host() + ( urllib.parse.quote( '/%(database_name)s/web_shop_shopify/webhook/%(shop)s/order' % url_part)) @classmethod def view_attributes(cls): return super().view_attributes() + [ ('//page[@id="shopify"]', 'states', { 'invisible': Eval('type') != 'shopify', }), ] @classmethod def validate_fields(cls, shops, field_names): super().validate_fields(shops, field_names) if field_names & {'type', 'products'}: for shop in shops: if shop.type == 'shopify': for product in shop.products: shop._shopify_check_product(product) def _shopify_check_product(self, product): if not product.template.shopify_uom: shopify_uom = product.template.get_shopify_uom() if shopify_uom.digits: raise ShopifyError(gettext( 'web_shop_shopify.' 'msg_product_shopify_uom_digits', product=product.rec_name)) @property def to_sync(self): result = super().to_sync if self.type == 'shopify': result = True return result def get_sale(self, party=None): sale = super().get_sale(party=party) if self.type == 'shopify': sale.invoice_method = 'shipment' return sale def shopify_session(self): return shopify.Session.temp( self.shopify_url, self.shopify_version, self.shopify_password) def get_payment_journal(self, currency_code, pattern): for payment_journal in self.shopify_payment_journals: if (payment_journal.journal.currency.code == currency_code and payment_journal.match(pattern)): return payment_journal.journal def managed_metafields(self): return set() def __sync_metafields(self, resource, metafields): metafields = metafields.copy() managed_metafields = self.managed_metafields() assert metafields.keys() <= managed_metafields for metafield in resource.metafields(): key = '.'.join([metafield.namespace, metafield.key]) value = metafield.to_dict() if key not in metafields: if key in managed_metafields: metafield.destroy() elif metafields[key] != value: for k, v in metafields.pop(key).items(): setattr(metafield, k, v) metafield.save() for key, value in metafields.items(): namespace, key = key.split('.', 1) value['namespace'] = namespace value['key'] = key resource.add_metafield(shopify.Metafield(value)) @classmethod def shopify_update_product(cls, shops=None): """Update Shopify Products The transaction is committed after the creation of each new resource. """ pool = Pool() InventoryItem = pool.get('product.shopify_inventory_item') transaction = Transaction() if shops is None: shops = cls.search([ ('type', '=', 'shopify'), ]) for shop in shops: with shop.shopify_session(): shopify_shop = shopify.Shop.current() shop_language = ( shop.language.code if shop.language else transaction.language) categories = shop.get_categories() products, prices, taxes = shop.get_products() if shopify_shop.currency.lower() != shop.currency.code.lower(): raise ShopifyError(gettext( 'web_shop_shopify.msg_shop_currency_different', shop=shop.rec_name, shop_currency=shop.currency.code, shopify_currency=shopify_shop.currency)) if (shopify_shop.primary_locale.lower() != shop_language.lower()): raise ShopifyError(gettext( 'web_shop_shopify.msg_shop_locale_different', shop=shop.rec_name, shop_language=shop_language, shopify_primary_locale=shopify_shop.primary_locale )) for category in categories: shop.__shopify_update_category(category) categories = set(categories) inventory_items = InventoryItem.browse(products) for product, inventory_item in zip(products, inventory_items): price = prices[product.id] tax = taxes[product.id] template = product.template if not template.shopify_uom: shop._shopify_check_product(product) template.shopify_uom = template.get_shopify_uom() template.save() shop.__shopify_update_template( shopify_shop, categories, template, product, price, tax) shop.__shopify_update_product( shopify_shop, product, price, tax) shop.__shopify_update_inventory_item(inventory_item) for category in shop.categories_removed: shop.__shopify_remove_category(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_template(template) else: shop.__shopify_remove_product(product) shop.products_removed = [] cls.save(shops) def __shopify_update_category(self, category): if not category.is_shopify_to_update(self): return custom_collection = category.get_shopify(self) if not custom_collection.save(): raise ShopifyError(gettext( 'web_shop_shopify.msg_custom_collection_fail', category=category.rec_name, error="\n".join( custom_collection.errors.full_messages()))) identifier = category.set_shopify_identifier( self, custom_collection.id) if identifier.to_update: identifier.to_update = False identifier.save() Transaction().commit() self.__sync_metafields( custom_collection, category.get_shopify_metafields(self)) def __shopify_remove_category(self, category): shopify_id = category.get_shopify_identifier(self) if shopify_id: if shopify.CustomCollection.exists(shopify_id): shopify.CustomCollection.find(shopify_id).destroy() category.set_shopify_identifier(self) def __shopify_update_template( self, shopify_shop, categories, template, product, price, tax): if not template.is_shopify_to_update(self): return shopify_product = template.get_shopify(self) new = shopify_product.is_new() if new: shopify_product.variants = [ product.get_shopify( self, price, tax, shop_taxes_included=shopify_shop.taxes_included, shop_weight_unit=shopify_shop.weight_unit)] else: # Set fake value for missing new options for j, variant in enumerate(shopify_product.variants): for i, _ in range(len(shopify_product.options), 1): name = 'option%i' % i if not getattr(variant, name, None): setattr(variant, name, '_option%i-%i' % (i, j)) if not shopify_product.save(): raise ShopifyError(gettext( 'web_shop_shopify.msg_product_fail', template=template.rec_name, error="\n".join(shopify_product.errors.full_messages()))) identifier = template.set_shopify_identifier( self, shopify_product.id) if identifier.to_update: identifier.to_update = False identifier.save() if new: variant, = shopify_product.variants product.set_shopify_identifier(self, variant.id) Transaction().commit() self.__sync_metafields( shopify_product, template.get_shopify_metafields(self)) collection_ids = { c.id for c in shopify_product.collections()} for category in template.categories_all: while category: if category in categories: custom_collection = ( shopify.CustomCollection.find( category.get_shopify_identifier( self))) if custom_collection.id in collection_ids: collection_ids.remove( custom_collection.id) else: shopify_product.add_to_collection( custom_collection) category = category.parent for collection_id in collection_ids: collection = shopify.CustomCollection.find( collection_id) shopify_product.remove_from_collection(collection) self.__shopify_update_images(template, shopify_product) def __shopify_remove_template(self, template): shopify_id = template.get_shopify_identifier(self) if not shopify_id: return if shopify.Product.exists(shopify_id): shopify.Product.find(shopify_id).destroy() template.set_shopify_identifier(self) for product in template.products: product.set_shopify_identifier(self) if getattr(template, 'images', None): for image in template.images: image.set_shopify_identifier(self) def __shopify_update_images(self, template, shopify_product): if not getattr(template, 'images', None): return transaction = Transaction() image_ids = set() for i, image in enumerate(filter( lambda i: i.web_shop, template.images_used), 1): product_image = image.get_shopify(self) new_image = not product_image.id product_image.position = i if not product_image.save(): raise ShopifyError(gettext( 'web_shop_shopify' '.msg_product_image_fail', image=image.rec_name, template=template.rec_name, error="\n".join( product_image.errors .full_messages()))) image_ids.add(product_image.id) if new_image: image.set_shopify_identifier( self, product_image.id) transaction.commit() for image in shopify_product.images: if image.id not in image_ids: image.destroy() def __shopify_update_product(self, shopify_shop, product, price, tax): update_extra = {'price': str(price), 'tax': str(tax)} if not product.is_shopify_to_update(self, **update_extra): return variant = product.get_shopify( self, price, tax, shop_taxes_included=shopify_shop.taxes_included, shop_weight_unit=shopify_shop.weight_unit) if not variant.save(): raise ShopifyError(gettext( 'web_shop_shopify.msg_variant_fail', product=product.rec_name, error="\n".join(variant.errors.full_messages()) )) identifier = product.set_shopify_identifier(self, variant.id) if identifier.to_update or identifier.to_update_extra != update_extra: identifier.to_update = False identifier.to_update_extra = update_extra identifier.save() Transaction().commit() self.__sync_metafields(variant, product.get_shopify_metafields(self)) def __shopify_update_inventory_item(self, inventory_item): if not inventory_item.is_shopify_to_update(self): return shopify_inventory_item = inventory_item.get_shopify(self) if shopify_inventory_item: if not shopify_inventory_item.save(): raise ShopifyError(gettext( 'web_shop_shopify.msg_inventory_item_fail', product=inventory_item.product.rec_name, error="\n".join( inventory_item.errors.full_messages()))) identifier = inventory_item.set_shopify_identifier( self, shopify_inventory_item.id if shopify_inventory_item.tracked else None) if identifier and identifier.to_update: identifier.to_update = False identifier.save() Transaction().commit() def __shopify_remove_product(self, product): shopify_id = product.get_shopify_identifier(self) if shopify_id: if shopify.Variant.exists(shopify_id): shopify.Variant.find(shopify_id).destroy() product.set_shopify_identifier(self) @classmethod def shopify_update_inventory(cls, shops=None): """Update Shopify Inventory""" pool = Pool() Product = pool.get('product.product') if shops is None: shops = cls.search([ ('type', '=', 'shopify'), ]) for shop in shops: for shop_warehouse in shop.shopify_warehouses: location_id = shop_warehouse.shopify_id if not location_id: continue location_id = int(location_id) with Transaction().set_context( shop.get_context(), **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) def __shopify_update_inventory(self, products, location_id): pool = Pool() InventoryItem = pool.get('product.shopify_inventory_item') inventory_items = InventoryItem.browse(products) product2quantity = {p.id: int(p.shopify_quantity) for p in products} shopify2product = { i.get_shopify_identifier(self): i.id for i in inventory_items} shopify2product.pop(None, None) product2shopify = {v: k for k, v in shopify2product.items()} location = shopify.Location.find(location_id) for i, inventory_level in enumerate( location.inventory_levels(limit=250, no_iter_next=False)): inventory_item_id = inventory_level.inventory_item_id product_id = shopify2product.get(inventory_item_id) if product_id is None: continue quantity = product2quantity.pop(product_id) if inventory_level.available != quantity: try: shopify.InventoryLevel.set( location_id, inventory_item_id, quantity) except pyactiveresource.connection.ResourceNotFound: pass for product_id, quantity in product2quantity.items(): inventory_item_id = product2shopify.get(product_id) if inventory_item_id is None: continue try: shopify.InventoryLevel.set( location_id, inventory_item_id, quantity) except pyactiveresource.connection.ResourceNotFound: pass @classmethod def shopify_fetch_order(cls, shops=None): """Fetch new Shopify Order""" pool = Pool() Sale = pool.get('sale.sale') Payment = pool.get('account.payment') context = Transaction().context if shops is None: shops = cls.search([ ('type', '=', 'shopify'), ]) cls.lock(shops) for shop in shops: last_sales = Sale.search([ ('web_shop', '=', shop.id), ], order=[('shopify_identifier_signed', 'DESC')], limit=1) if last_sales: last_sale, = last_sales last_order_id = last_sale.shopify_identifier else: last_order_id = '' with shop.shopify_session(): if 'shopify_orders' in context: orders = shopify.Order.find( ids=context['shopify_orders'], limit=250, no_iter_next=False) else: orders = shopify.Order.find( status='open', since_id=last_order_id, limit=250, no_iter_next=False) sales = [] for i, order in enumerate(orders): sales.append(Sale.get_from_shopify(shop, order)) Sale.save(sales) for sale, order in zip(sales, orders): sale.shopify_tax_adjustment = ( Decimal(order.total_price) - sale.total_amount) Sale.save(sales) Sale.quote(sales) for sale, order in zip(sales, orders): Payment.get_from_shopify(sale, order) @classmethod def shopify_update_order(cls, shops=None): """Update existing sale from Shopify""" pool = Pool() Sale = pool.get('sale.sale') if shops is None: shops = cls.search([ ('type', '=', 'shopify'), ]) cls.lock(shops) now = dt.datetime.now() for shop in shops: sales = Sale.search([ ('web_shop', '=', shop.id), ('shopify_identifier', '!=', None), ['OR', ('state', 'in', ['quotation', 'confirmed', 'processing']), ('create_date', '>=', now - EDIT_ORDER_DELAY), ], ]) for sub_sales in grouped_slice(sales, count=250): cls._shopify_update_order(shop, list(sub_sales)) @classmethod def _shopify_update_order(cls, shop, sales): assert shop.type == 'shopify' assert all(s.web_shop == shop for s in sales) with shop.shopify_session(): orders = shopify.Order.find( ids=','.join(str(s.shopify_identifier) for s in sales), status='any') id2order = {o.id: o for o in orders} to_update = [] orders = [] for sale in sales: try: order = id2order[sale.shopify_identifier] except KeyError: continue to_update.append(sale) orders.append(order) cls.shopify_update_sale(to_update, orders) @classmethod def shopify_update_sale(cls, sales, orders): """Update sales based on Shopify orders""" pool = Pool() Amendment = pool.get('sale.amendment') Payment = pool.get('account.payment') Sale = pool.get('sale.sale') assert len(sales) == len(orders) to_update = {} for sale, order in zip(sales, orders): assert sale.shopify_identifier == 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 to_update[sale] = order Payment.get_from_shopify(sale, order) Sale.save(to_update.keys()) for sale, order in to_update.items(): sale.shopify_tax_adjustment = ( Decimal(order.current_total_price) - sale.total_amount) Sale.store_cache(to_update.keys()) Amendment._clear_sale(to_update.keys()) Sale.__queue__.process(to_update.keys()) class ShopShopifyIdentifier(IdentifierMixin, ModelSQL, ModelView): "Shopify Identifier" __name__ = 'web.shop.shopify_identifier' record = fields.Reference("Record", 'get_records', required=True) web_shop = fields.Many2One( 'web.shop', "Web Shop", required=True, ondelete='CASCADE') to_update = fields.Boolean("To Update") to_update_extra = fields.Dict(None, "To Update Extra") @classmethod def __setup__(cls): super().__setup__() cls.shopify_identifier_signed.states = { 'required': True, } t = cls.__table__() cls._sql_constraints += [ ('record_web_shop_unique', Unique(t, t.record, t.shopify_identifier_signed), 'web_shop_shopify.msg_identifier_record_web_shop_unique'), ] cls._buttons.update({ 'set_to_update': {}, }) @classmethod def get_records(cls): pool = Pool() Model = pool.get('ir.model') get_name = Model.get_name models = (klass.__name__ for _, klass in pool.iterobject() if issubclass(klass, IdentifiersMixin)) return [(m, get_name(m)) for m in models] @classmethod def set_to_update(cls, identifiers): cls.write(identifiers, {'to_update': True}) class Shop_Warehouse(ModelView, metaclass=PoolMeta): __name__ = 'web.shop-stock.location' shopify_stock_skip_warehouse = fields.Boolean( "Only storage zone", help="Check to use only the quantity of the storage zone.") shopify_id = fields.Selection( 'get_shopify_locations', "Shopify ID") _shopify_locations_cache = Cache( __name__ + '.get_shopify_locations', duration=config.getint( 'web_shop_shopify', 'locations_cache', default=15 * 60), context=False) @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('shop') t = cls.__table__() cls._sql_constraints += [ ('shopify_id_unique', Unique(t, t.shopify_id), 'web_shop_shopify.msg_location_id_unique'), ] @fields.depends( 'shop', '_parent_shop.shopify_url', '_parent_shop.shopify_version', '_parent_shop.shopify_password') def get_shopify_locations(self): locations = [(None, "")] if 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(): locations += [ (str(l.id), l.name) for l in shopify.Location.find(no_iter_next=False)] self._shopify_locations_cache.set(self.shop.id, locations) except (AttributeError, shopify.VersionNotFoundError, pyactiveresource.connection.Error): pass return locations def get_shopify_inventory_context(self): return { 'locations': [self.warehouse.id], 'stock_skip_warehouse': self.shopify_stock_skip_warehouse, 'with_childs': True, } class Shop_Attribute(metaclass=PoolMeta): __name__ = 'web.shop-product.attribute' @classmethod def __setup__(cls): super().__setup__() domain = [ ('type', '!=', 'shopify'), ] if cls.shop.domain: cls.shop.domain = [cls.shop.domain, domain] else: cls.shop.domain = domain class ShopShopifyPaymentJournal( sequence_ordered(), MatchMixin, ModelSQL, ModelView): "Shopify Payment Journal" __name__ = 'web.shop.shopify_payment_journal' shop = fields.Many2One( 'web.shop', "Shop", required=True, ondelete='CASCADE', domain=[ ('type', '=', 'shopify'), ]) gateway = fields.Char( "Gateway", help="The payment gateway name for which the journal must be used.") journal = fields.Many2One( 'account.payment.journal', "Journal", required=True, domain=[ ('process_method', '=', 'shopify'), ]) @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('shop') # TODO: add wizard to export translations