Files
2025-12-26 13:11:43 +00:00

718 lines
28 KiB
Python
Executable File

# 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