Files
tryton/modules/web_shop_shopify/product.py
T

753 lines
25 KiB
Python
Raw Normal View History

2021-10-12 10:15:55 +02:00
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
2025-10-09 10:20:31 +02:00
import re
2021-11-20 00:14:56 +01:00
from decimal import Decimal
2025-10-09 10:20:31 +02:00
from urllib.parse import urljoin
2021-11-20 00:14:56 +01:00
2025-10-09 10:20:31 +02:00
from sql.conditionals import NullIf
from sql.operators import Equal
2021-10-12 10:15:55 +02:00
from trytond.i18n import gettext
2025-10-09 10:20:31 +02:00
from trytond.model import Exclude, ModelSQL, ModelView, fields
from trytond.modules.product.exceptions import TemplateValidationError
2021-12-11 17:58:45 +01:00
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, If
2026-03-22 21:05:26 +01:00
from trytond.tools import slugify
from trytond.transaction import Transaction
2021-10-12 10:15:55 +02:00
from . import graphql
from .common import IdentifiersMixin, IdentifiersUpdateMixin, id2gid
QUERY_COLLECTION = '''\
query GetCollection($id: ID!) {
collection(id: $id) %(fields)s
}'''
QUERY_PRODUCT = '''\
query GetProduct($id: ID!) {
product(id: $id) %(fields)s
}'''
QUERY_VARIANT = '''\
query GetProductVariant($id: ID!) {
productVariant(id: $id) %(fields)s
}'''
2021-10-12 10:15:55 +02:00
class Category(IdentifiersMixin, metaclass=PoolMeta):
__name__ = 'product.category'
@classmethod
def __setup__(cls):
super().__setup__()
cls._shopify_fields.add('name')
def get_shopify(self, shop):
shopify_id = self.get_shopify_identifier(shop)
if shopify_id:
shopify_id = id2gid('Collection', shopify_id)
2026-03-29 20:05:46 +02:00
collection = shop.shopify_request(
QUERY_COLLECTION % {
'fields': graphql.selection({
'id': None,
}),
2026-03-29 20:05:46 +02:00
}, {'id': shopify_id}).data['collection'] or {}
else:
collection = {}
collection['title'] = self.name[:255]
collection['metafields'] = metafields = []
managed_metafields = shop.managed_metafields()
for key, value in self.get_shopify_metafields(shop).items():
if key not in managed_metafields:
continue
namespace, key = key.split('.', 1)
metafields.append({
'namespace': namespace,
'key': key,
'value': value,
})
return collection
2021-10-12 10:15:55 +02:00
def get_shopify_metafields(self, shop):
return {}
class TemplateCategory(IdentifiersUpdateMixin, metaclass=PoolMeta):
__name__ = 'product.template-product.category'
@classmethod
def __setup__(cls):
super().__setup__()
cls._shopify_fields.update(['template', 'category'])
@classmethod
def get_shopify_identifier_to_update(cls, records):
return sum((list(r.template.shopify_identifiers) for r in records), [])
class Template(IdentifiersMixin, metaclass=PoolMeta):
__name__ = 'product.template'
shopify_uom = fields.Many2One(
2023-08-20 12:17:27 +02:00
'product.uom', "Shopify UoM",
2021-10-12 10:15:55 +02:00
states={
'readonly': Bool(Eval('shopify_identifiers', [-1])),
'invisible': ~Eval('salable', False),
},
2023-08-20 12:17:27 +02:00
help="The Unit of Measure of the product on Shopify.")
2025-10-09 10:20:31 +02:00
shopify_handle = fields.Char(
"Shopify Handle",
states={
'invisible': ~Eval('salable', False),
},
help="The string that's used to identify the product in URLs.\n"
"Leave empty to let Shopify generate one.")
2021-10-12 10:15:55 +02:00
@classmethod
def __setup__(cls):
super().__setup__()
2025-10-09 10:20:31 +02:00
t = cls.__table__()
cls._sql_constraints += [
('shopify_handle_unique',
Exclude(t,
(NullIf(t.shopify_handle, ''), Equal)),
'web_shop_shopify.msg_template_shopify_handle_unique'),
]
2021-10-12 10:15:55 +02:00
cls._shopify_fields.update([
'name', 'web_shop_description', 'attribute_set',
'customs_category', 'tariff_codes_category',
'country_of_origin', 'weight', 'weight_uom'])
2021-10-12 10:15:55 +02:00
categories = cls._shopify_uom_categories()
cls.shopify_uom.domain = [
('category', 'in', [Eval(c, -1) for c in categories]),
('digits', '=', 0),
]
@classmethod
def _shopify_uom_categories(cls):
return ['default_uom_category']
def get_shopify_uom(self):
return self.sale_uom
@classmethod
def get_shopify_identifier_to_update(cls, templates):
pool = Pool()
Product = pool.get('product.product')
products = [p for t in templates for p in t.products]
return (super().get_shopify_identifier_to_update(templates)
+ Product.get_shopify_identifier_to_update(products))
def get_shopify(self, shop, categories):
2021-10-12 10:15:55 +02:00
shopify_id = self.get_shopify_identifier(shop)
product = {}
2021-10-12 10:15:55 +02:00
if shopify_id:
shopify_id = id2gid('Product', shopify_id)
2026-03-29 20:05:46 +02:00
product = shop.shopify_request(
QUERY_PRODUCT % {
'fields': graphql.selection({
'id': None,
'status': None,
}),
2026-03-29 20:05:46 +02:00
}, {'id': shopify_id}).data['product'] or {}
if product.get('status') == 'ARCHIVED':
product['status'] = 'ACTIVE'
product['title'] = self.name
if self.web_shop_description:
product['descriptionHtml'] = self.web_shop_description
2025-10-09 10:20:31 +02:00
if self.shopify_handle:
product['handle'] = self.shopify_handle
product['productOptions'] = options = []
for i, attribute in enumerate(self.shopify_attributes, 1):
values = set()
for p in self.products:
if p.attributes and attribute.name in p.attributes:
values.add(p.attributes.get(attribute.name))
values = [
{'name': attribute.format(value)}
for value in sorted(values)]
options.append({
'name': attribute.string,
'position': i,
'values': values,
})
product['collections'] = collections = []
for category in categories:
if collection_id := category.get_shopify_identifier(shop):
collections.append(id2gid(
'Collection', collection_id))
product['metafields'] = metafields = []
managed_metafields = shop.managed_metafields()
for key, value in self.get_shopify_metafields(shop).items():
if key not in managed_metafields:
continue
namespace, key = key.split('.', 1)
metafields.append({
'namespace': namespace,
'key': key,
**value
})
2021-10-12 10:15:55 +02:00
return product
def get_shopify_metafields(self, shop):
return {}
@property
def shopify_attributes(self):
if not self.attribute_set:
return []
return filter(None, [
self.attribute_set.shopify_option1,
self.attribute_set.shopify_option2,
self.attribute_set.shopify_option3])
2025-10-09 10:20:31 +02:00
@classmethod
def validate_fields(cls, templates, field_names):
super().validate_fields(templates, field_names)
cls.check_shopify_handle(templates, field_names)
@classmethod
def check_shopify_handle(cls, templates, field_names):
if field_names and 'shopify_handle' not in field_names:
return
for template in templates:
if (template.shopify_handle
and not re.fullmatch(
r'[a-z0-9-]+', template.shopify_handle)):
raise TemplateValidationError(gettext(
'web_shop_shopify.msg_template_shopify_handle_invalid',
template=template.rec_name,
handle=template.shopify_handle,
))
2021-10-12 10:15:55 +02:00
class Template_SaleSecondaryUnit(metaclass=PoolMeta):
__name__ = 'product.template'
@classmethod
def _shopify_uom_categories(cls):
return super()._shopify_uom_categories() + [
'sale_secondary_uom_category']
def get_shopify_uom(self):
uom = super().get_shopify_uom()
if self.sale_secondary_uom and not self.sale_secondary_uom.digits:
uom = self.sale_secondary_uom
return uom
class Template_GiftCard(metaclass=PoolMeta):
__name__ = 'product.template'
@classmethod
def __setup__(cls):
super().__setup__()
readonly = cls.gift_card.states.get('readonly', False)
cls.gift_card.states['readonly'] = (
readonly | Eval('shopify_identifiers', []))
def get_shopify(self, shop, categories):
product = super().get_shopify(shop, categories)
if not product.get('id'):
# The product gift_card attribute cannot be changed after creation
product['giftCard'] = self.gift_card
return product
2021-10-12 10:15:55 +02:00
class Product(IdentifiersMixin, metaclass=PoolMeta):
__name__ = 'product.product'
shopify_sku = fields.Function(
fields.Char("SKU"), 'get_shopify_sku', searcher='search_shopify_sku')
@classmethod
def __setup__(cls):
super().__setup__()
2025-10-10 17:42:08 +02:00
cls._shopify_fields.update(['code', 'attributes', 'position'])
2021-10-12 10:15:55 +02:00
@classmethod
def get_shopify_identifier_to_update(cls, records):
pool = Pool()
InventoryItem = pool.get('product.shopify_inventory_item')
items = InventoryItem.browse(records)
return (super().get_shopify_identifier_to_update(records)
+ sum((list(i.shopify_identifiers) for i in items), []))
def set_shopify_identifier(self, web_shop, identifier=None):
pool = Pool()
InventoryItem = pool.get('product.shopify_inventory_item')
if not identifier:
inventory_item = InventoryItem(self.id)
inventory_item.set_shopify_identifier(web_shop)
return super().set_shopify_identifier(web_shop, identifier=identifier)
def get_shopify_sku(self, name):
return self.code
@classmethod
def search_shopify_sku(cls, name, clause):
return [('code',) + tuple(clause[1:])]
def get_shopify(
self, shop, sale_price, sale_tax, price, tax,
shop_taxes_included=True):
2021-10-12 10:15:55 +02:00
shopify_id = self.get_shopify_identifier(shop)
if shopify_id:
shopify_id = id2gid('ProductVariant', shopify_id)
2026-03-29 20:05:46 +02:00
variant = shop.shopify_request(
QUERY_VARIANT % {
'fields': graphql.selection({
'id': None,
}),
2026-03-29 20:05:46 +02:00
}, {'id': shopify_id}).data['productVariant'] or {}
else:
variant = {}
sale_price = self.shopify_price(
sale_price, sale_tax, taxes_included=shop_taxes_included)
if sale_price is not None:
variant['price'] = str(sale_price.quantize(Decimal('.00')))
else:
variant['price'] = None
2021-10-12 10:15:55 +02:00
price = self.shopify_price(
price, tax, taxes_included=shop_taxes_included)
2021-11-20 00:14:56 +01:00
if price is not None:
variant['compareAtPrice'] = str(
price.quantize(Decimal('.00')))
2021-11-20 00:14:56 +01:00
else:
variant['compareAtPrice'] = None
variant['taxable'] = bool(sale_tax)
2021-10-12 10:15:55 +02:00
for identifier in self.identifiers:
if identifier.type == 'ean':
variant['barcode'] = identifier.code
2021-10-12 10:15:55 +02:00
break
variant['optionValues'] = options = []
attributes = self.attributes or {}
for attribute in self.template.shopify_attributes:
value = attributes.get(attribute.name)
2021-10-12 10:15:55 +02:00
value = attribute.format(value)
options.append({
'optionName': attribute.string,
'name': value,
})
variant['metafields'] = metafields = []
managed_metafields = shop.managed_metafields()
for key, value in self.get_shopify_metafields(shop).items():
if key not in managed_metafields:
continue
namespace, key = key.split('.', 1)
metafields.append({
'namespace': namespace,
'key': key,
'value': value,
})
2021-10-12 10:15:55 +02:00
return variant
def get_shopify_metafields(self, shop):
return {}
def shopify_price(self, price, tax, taxes_included=True):
pool = Pool()
Uom = pool.get('product.uom')
if price is None or tax is None:
return None
if taxes_included:
price += tax
return Uom.compute_price(
self.sale_uom, price, self.shopify_uom,
factor=self.shopify_uom_factor, rate=self.shopify_uom_rate)
@property
def shopify_uom_factor(self):
return None
@property
def shopify_uom_rate(self):
return None
@property
def shopify_quantity(self):
pool = Pool()
Uom = pool.get('product.uom')
quantity = self.forecast_quantity
if quantity < 0:
quantity = 0
return Uom.compute_qty(
self.default_uom, quantity, self.shopify_uom, round=True,
factor=self.shopify_uom_factor, rate=self.shopify_uom_rate)
class Product_GiftCard(metaclass=PoolMeta):
__name__ = 'product.product'
def get_shopify(
self, shop, sale_price, sale_tax, price, tax,
shop_taxes_included=True):
variant = super().get_shopify(
shop, sale_price, sale_tax, price, tax,
shop_taxes_included=shop_taxes_included)
if self.gift_card:
# taxable is readonly for gift card on Shopify
variant.pop('taxable', None)
return variant
2025-10-09 10:20:31 +02:00
class ProductURL(metaclass=PoolMeta):
__name__ = 'product.web_shop_url'
def get_url(self, name):
url = super().get_url(name)
if (self.shop.type == 'shopify'
and (handle := self.product.template.shopify_handle)):
2026-03-29 20:05:46 +02:00
url = urljoin(self.shop.shopify_url, f'products/{handle}')
2025-10-09 10:20:31 +02:00
return url
2021-10-12 10:15:55 +02:00
class ShopifyInventoryItem(IdentifiersMixin, ModelSQL, ModelView):
__name__ = 'product.shopify_inventory_item'
product = fields.Function(
fields.Many2One('product.product', "Product"), 'get_product')
@classmethod
def table_query(cls):
return Pool().get('product.product').__table__()
def get_product(self, name):
return self.id
def get_shopify(self, shop, shop_weight_unit=None):
2021-10-12 10:15:55 +02:00
pool = Pool()
SaleLine = pool.get('sale.line')
ModelData = pool.get('ir.model.data')
Uom = pool.get('product.uom')
movable_types = SaleLine.movable_types()
2021-10-12 10:15:55 +02:00
inventory_item = {}
inventory_item['sku'] = self.product.shopify_sku
inventory_item['tracked'] = (
self.product.type in movable_types and not self.product.consumable)
inventory_item['requiresShipping'] = (
self.product.type in movable_types)
if getattr(self.product, 'weight', None) and shop_weight_unit:
units = {}
units['KILOGRAMS'] = ModelData.get_id('product', 'uom_kilogram')
units['GRAMS'] = ModelData.get_id('product', 'uom_gram')
units['POUNDS'] = ModelData.get_id('product', 'uom_pound')
units['OUNCES'] = ModelData.get_id('product', 'uom_ounce')
weight = self.product.weight
weight_unit = self.product.weight_uom
if self.product.weight_uom.id not in units.values():
weight_unit = Uom(units[shop_weight_unit])
weight = Uom.compute_qty(
self.product.weight_uom, weight, weight_unit)
weight_unit = {
v: k for k, v in units.items()}[weight_unit.id]
inventory_item['measurement'] = {
'weight': {
'unit': weight_unit,
'value': weight,
},
}
2021-10-12 10:15:55 +02:00
return inventory_item
class ShopifyInventoryItem_Customs(metaclass=PoolMeta):
__name__ = 'product.shopify_inventory_item'
def get_shopify(self, shop, shop_weight_unit=None):
2021-10-12 10:15:55 +02:00
pool = Pool()
Date = pool.get('ir.date')
inventory_item = super().get_shopify(
shop, shop_weight_unit=shop_weight_unit)
with Transaction().set_context(company=shop.company.id):
today = Date.today()
inventory_item['countryCodeOfOrigin'] = (
self.product.country_of_origin.code
if self.product.country_of_origin else None)
tariff_code = self.product.get_tariff_code(
{'date': today, 'country': None})
inventory_item['harmonizedSystemCode'] = (
tariff_code.code if tariff_code else None)
country_harmonized_system_codes = []
countries = set()
for tariff_code in self.product.get_tariff_codes({'date': today}):
if (tariff_code.country
and tariff_code.country not in countries):
country_harmonized_system_codes.append({
'harmonizedSystemCode': tariff_code.code,
'countryCode': tariff_code.country.code,
})
countries.add(tariff_code.country)
inventory_item['countryHarmonizedSystemCodes'] = (
country_harmonized_system_codes)
2021-10-12 10:15:55 +02:00
return inventory_item
class Product_TariffCode(IdentifiersUpdateMixin, metaclass=PoolMeta):
__name__ = 'product-customs.tariff.code'
@classmethod
def __setup__(cls):
super().__setup__()
cls._shopify_fields.update(['product', 'tariff_code'])
@classmethod
def get_shopify_identifier_to_update(cls, records):
pool = Pool()
Template = pool.get('product.template')
Category = pool.get('product.category')
templates = set()
categories = set()
for record in records:
if isinstance(record.product, Template):
templates.add(record.product)
elif isinstance(record.product, Category):
categories.add(record.product)
if categories:
2026-03-22 21:05:26 +01:00
templates.update(Template.search([
('customs_category', 'in', map(int, categories)),
]))
2021-10-12 10:15:55 +02:00
templates = Template.browse(list(templates))
return Template.get_shopify_identifier_to_update(templates)
class ProductIdentifier(IdentifiersUpdateMixin, metaclass=PoolMeta):
__name__ = 'product.identifier'
@classmethod
def __setup__(cls):
super().__setup__()
cls._shopify_fields.update(['product', 'code'])
@classmethod
def get_shopify_identifier_to_update(cls, identifiers):
return sum((
list(i.product.shopify_identifiers) for i in identifiers), [])
class Product_SaleSecondaryUnit(metaclass=PoolMeta):
__name__ = 'product.product'
@property
def shopify_uom_factor(self):
factor = super().shopify_uom_factor
if (self.sale_secondary_uom
and self.shopify_uom.category
== self.sale_secondary_uom.category):
factor = self.sale_secondary_uom_normal_factor
return factor
@property
def shopify_uom_rate(self):
rate = super().shopify_uom_rate
if (self.sale_secondary_uom
and self.shopify_uom.category
== self.sale_secondary_uom.category):
rate = self.sale_secondary_uom_normal_rate
return rate
class AttributeSet(IdentifiersUpdateMixin, metaclass=PoolMeta):
__name__ = 'product.attribute.set'
shopify_option1 = fields.Many2One(
'product.attribute', "Option 1",
domain=[
('id', 'in', Eval('attributes', [])),
If(Eval('shopify_option2'),
('id', '!=', Eval('shopify_option2')),
()),
If(Eval('shopify_option3'),
('id', '!=', Eval('shopify_option3')),
()),
])
2021-10-12 10:15:55 +02:00
shopify_option2 = fields.Many2One(
'product.attribute', "Option 2",
domain=[
('id', 'in', Eval('attributes', [])),
If(Eval('shopify_option1'),
('id', '!=', Eval('shopify_option1')),
('id', '=', None)),
If(Eval('shopify_option3'),
('id', '!=', Eval('shopify_option3')),
()),
],
states={
'invisible': ~Eval('shopify_option1'),
})
2021-10-12 10:15:55 +02:00
shopify_option3 = fields.Many2One(
'product.attribute', "Option 3",
domain=[
('id', 'in', Eval('attributes', [])),
If(Eval('shopify_option1'),
('id', '!=', Eval('shopify_option1')),
()),
If(Eval('shopify_option2'),
('id', '!=', Eval('shopify_option2')),
('id', '=', None)),
],
states={
'invisible': ~Eval('shopify_option2'),
})
2021-10-12 10:15:55 +02:00
@classmethod
def __setup__(cls):
super().__setup__()
cls._shopify_fields.update(
['shopify_option1', 'shopify_option2', 'shopify_option3'])
@classmethod
def get_shopify_identifier_to_update(cls, sets):
pool = Pool()
Template = pool.get('product.template')
2026-03-22 21:05:26 +01:00
templates = Template.search([
('attribute_set', 'in', sets),
])
2021-10-12 10:15:55 +02:00
return Template.get_shopify_identifier_to_update(templates)
class Attribute(IdentifiersUpdateMixin, metaclass=PoolMeta):
__name__ = 'product.attribute'
@classmethod
def __setup__(cls):
super().__setup__()
cls._shopify_fields.add('selection')
domain = [
('type', '!=', 'shopify'),
]
if cls.web_shops.domain:
cls.web_shops.domain = [cls.web_shops.domain, domain]
else:
cls.web_shops.domain = domain
@classmethod
def get_shopify_identifier_to_update(cls, attributes):
pool = Pool()
Set = pool.get('product.attribute.set')
sets = Set.browse(sum((a.sets for a in attributes), ()))
return Set.get_shopify_identifier_to_update(sets)
class Template_Image(metaclass=PoolMeta):
__name__ = 'product.template'
@property
def shopify_images(self):
for image in self.images_used:
if image.web_shop:
yield image
def get_shopify(self, shop, categories):
product = super().get_shopify(shop, categories)
product['files'] = files = []
for image in self.shopify_images:
file = {
'alt': image.description,
'contentType': 'IMAGE',
'filename': image.shopify_name,
}
if image_id := image.get_shopify_identifier(shop):
file['id'] = id2gid('MediaImage', image_id)
else:
file['originalSource'] = self.get_image_url(
_external=True, id=image.id)
files.append(file)
return product
class Product_Image(metaclass=PoolMeta):
__name__ = 'product.product'
@property
def shopify_images(self):
for image in self.images_used:
if image.web_shop:
yield image
def get_shopify(
self, shop, sale_price, sale_tax, price, tax,
shop_taxes_included=True):
variant = super().get_shopify(
shop, sale_price, sale_tax, price, tax,
shop_taxes_included=shop_taxes_included)
for image in self.shopify_images:
file = {
'alt': image.description,
'contentType': 'IMAGE',
'filename': image.shopify_name,
}
if image_id := image.get_shopify_identifier(shop):
file['id'] = id2gid('MediaImage', image_id)
else:
file['originalSource'] = self.get_image_url(
_external=True, id=image.id)
variant['file'] = file
break
else:
variant['file'] = None
return variant
2021-10-12 10:15:55 +02:00
class Image(IdentifiersMixin, metaclass=PoolMeta):
__name__ = 'product.image'
@classmethod
def __setup__(cls):
super().__setup__()
cls._shopify_fields.update(['template', 'product', 'attributes'])
@classmethod
def on_write(cls, images, values):
2021-10-12 10:15:55 +02:00
pool = Pool()
Identifier = pool.get('web.shop.shopify_identifier')
callback = super().on_write(images, values)
if values.keys() & {'image', 'template', 'web_shop'}:
to_delete = []
for image in images:
to_delete.extend(image.shopify_identifiers)
if to_delete:
callback.append(lambda: Identifier.delete(to_delete))
return callback
2021-10-12 10:15:55 +02:00
@classmethod
def get_shopify_identifier_to_update(cls, images):
return (
sum((list(i.template.shopify_identifiers) for i in images), [])
+ sum(
(list(p.shopify_identifiers)
for i in images for p in i.template.products), []))
2021-10-12 10:15:55 +02:00
@property
2021-10-13 09:18:11 +02:00
def shopify_name(self):
2021-10-12 10:15:55 +02:00
if self.product:
name = self.product.name
2021-10-12 10:15:55 +02:00
else:
name = self.template.name
name = slugify(name)
return f'{name}.jpg'
2021-10-12 10:15:55 +02:00
class Image_Attribute(metaclass=PoolMeta):
__name__ = 'product.image'
@property
2021-10-13 09:18:11 +02:00
def shopify_name(self):
name = super().shopify_name
2021-10-12 10:15:55 +02:00
if self.product:
attributes_name = self.product.attributes_name
else:
attributes_name = self.attributes_name
if attributes_name:
2021-10-13 09:18:11 +02:00
name += ' ' + attributes_name
2021-10-12 10:15:55 +02:00
return name