# 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 io import math from urllib.parse import quote, urlencode, urljoin import PIL.Image from trytond.config import config from trytond.model import ( MatchMixin, ModelSQL, ModelView, Unique, fields, sequence_ordered) from trytond.pool import PoolMeta from trytond.pyson import Bool, Eval, If from trytond.tools import slugify from trytond.transaction import Transaction from trytond.url import http_host from trytond.wsgi import Base64Converter if config.getboolean('product', 'image_filestore', default=False): file_id = 'image_id' store_prefix = config.get('product', 'image_prefix', default=None) else: file_id = None store_prefix = None SIZE_MAX = config.getint('product', 'image_size_max', default=2048) URL_BASE = config.get('product', 'image_base', default='') URL_EXTERNAL_BASE = config.get('product', 'image_base', default=http_host()) class ImageURLMixin: __slots__ = () __image_url__ = None images = None image_url = fields.Function(fields.Char("Image URL"), '_get_image_url') def _get_image_url(self, name): return self.get_image_url(s=64) def _image_url(self, base, **args): if self.code and list(self.images_used): url = urljoin( base, quote('%(prefix)s/%(code)s/%(database)s/%(name)s' % { 'prefix': self.__image_url__, 'database': Base64Converter(None).to_url( Transaction().database.name), 'code': quote(self.code, ''), 'name': slugify(self.name), })) if args: size = args.pop('s', None) width = args.pop('w', None) height = args.pop('h', None) index = args.pop('i', None) args = {k: int(bool(v)) for k, v in args.items()} if size: args['s'] = size if width: args['w'] = width if height: args['h'] = height if index is not None: args['i'] = index url += '?' + urlencode(args) return url def get_image_url(self, _external=False, **args): return self._image_url( URL_EXTERNAL_BASE if _external else URL_BASE, **args) @property def images_used(self): yield from self.images def get_images(self, pattern): Image = self.__class__.images.get_target() pattern = pattern.copy() for key in set(pattern.keys()) - Image.allowed_match_keys(): del pattern[key] pattern = {k: bool(int(v)) for k, v in pattern.items()} for image in self.images_used: if image.match(pattern, match_none=True): yield image class Template(ImageURLMixin, metaclass=PoolMeta): __name__ = 'product.template' __image_url__ = '/product/image' images = fields.One2Many('product.image', 'template', "Images") class Product(ImageURLMixin, metaclass=PoolMeta): __name__ = 'product.product' __image_url__ = '/product/variant/image' images = fields.One2Many( 'product.image', 'product', "Images", domain=[ ('template', '=', Eval('template', -1)), ]) @property def images_used(self): yield from super().images_used for image in self.template.images_used: if not image.product: yield image class _ImageMixin: __slots__ = () image = fields.Binary( "Image", file_id=file_id, store_prefix=store_prefix, required=True) image_id = fields.Char("Image ID", readonly=True) class ImageMixin(_ImageMixin): __slots__ = () cache = None @classmethod def allowed_match_keys(cls): return set() @classmethod def create(cls, vlist): vlist = [v.copy() for v in vlist] for values in vlist: if values.get('image'): values['image'] = cls.convert(values['image']) return super().create(vlist) @classmethod def write(cls, *args): actions = iter(args) args = [] for images, values in zip(actions, actions): if values.get('image'): values = values.copy() values['image'] = cls.convert(values['image']) args.append(images) args.append(values) super().write(*args) cls.clear_cache(sum(args[0:None:2], [])) @classmethod def _round_size(cls, size): return min(( 2 ** math.ceil(math.log2(size)), 10 * math.ceil(size / 10) if size <= 100 else 50 * math.ceil(size / 50))) def get(self, size=400): if isinstance(size, int): size = (size, size) size = tuple(map(self._round_size, size)) if not all(0 < s <= SIZE_MAX for s in size): raise ValueError(f"Invalid size {size}") for cache in self.cache: if (cache.width, cache.height) == size: return cache.image with Transaction().new_transaction(): cache = self._store_cache(size, self._resize(size)) # Save cache only if record is already committed if self.__class__.search([('id', '=', self.id)]): cache.save() return cache.image @classmethod def convert(cls, image, **_params): data = io.BytesIO() img = PIL.Image.open(io.BytesIO(image)) img.thumbnail((SIZE_MAX, SIZE_MAX)) if img.mode in {'RGBA', 'P'}: img.convert('RGBA') background = PIL.Image.new('RGBA', img.size, (255, 255, 255)) background.alpha_composite(img) img = background.convert('RGB') img.save(data, format='jpeg', optimize=True, **_params) return data.getvalue() def _resize(self, size=64, **_params): data = io.BytesIO() img = PIL.Image.open(io.BytesIO(self.image)) if isinstance(size, int): size = (size, size) img.thumbnail(size) img.save(data, format='jpeg', optimize=True, **_params) return data.getvalue() def _store_cache(self, size, image): Cache = self.__class__.cache.get_target() if isinstance(size, int): width = height = size else: width, height = size return Cache( image=image, width=width, height=height) @classmethod def clear_cache(cls, images): Cache = cls.cache.get_target() caches = [c for i in images for c in i.cache] Cache.delete(caches) class Image(ImageMixin, sequence_ordered(), ModelSQL, ModelView, MatchMixin): "Product Image" __name__ = 'product.image' template = fields.Many2One( 'product.template', "Product", required=True, ondelete='CASCADE', domain=[ If(Bool(Eval('product')), ('products', '=', Eval('product')), ()), ]) product = fields.Many2One( 'product.product', "Variant", domain=[ If(Bool(Eval('template')), ('template', '=', Eval('template')), ()), ]) cache = fields.One2Many( 'product.image.cache', 'product_image', "Cache", readonly=True) @classmethod def __setup__(cls): super().__setup__() cls.__access__.update(['product', 'template']) def _store_cache(self, size, image): cache = super()._store_cache(size, image) cache.product_image = self return cache class ImageCacheMixin(_ImageMixin): __slots__ = () width = fields.Integer( "Width", required=True, domain=[ ('width', '>', 0), ('width', '<=', SIZE_MAX), ]) height = fields.Integer( "Height", required=True, domain=[ ('height', '>', 0), ('height', '<=', SIZE_MAX), ]) class ImageCache(ImageCacheMixin, ModelSQL): "Product Image Cache" __name__ = 'product.image.cache' product_image = fields.Many2One( 'product.image', "Product Image", required=True, ondelete='CASCADE') @classmethod def __setup__(cls): super().__setup__() t = cls.__table__() cls._sql_constraints += [ ('dimension_unique', Unique(t, t.product_image, t.width, t.height), 'product_image.msg_image_cache_size_unique'), ] @classmethod def __register__(cls, module): cursor = Transaction().connection.cursor() table = cls.__table__() table_h = cls.__table_handler__(module) super().__register__(module) # Migration from 7.0: split size into width and height table_h.drop_constraint('size_unique') if table_h.column_exist('size'): cursor.execute(*table.update( [table.width, table.height], [table.size, table.size])) table_h.drop_column('size') class Category(ImageURLMixin, metaclass=PoolMeta): __name__ = 'product.category' __image_url__ = '/product-category/image' images = fields.One2Many('product.category.image', 'category', "Images") class CategoryImage( ImageMixin, sequence_ordered(), ModelSQL, ModelView, MatchMixin): "Category Image" __name__ = 'product.category.image' category = fields.Many2One( 'product.category', "Category", required=True, ondelete='CASCADE') cache = fields.One2Many( 'product.category.image.cache', 'category_image', "Cache", readonly=True) @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('category') def _store_cache(self, size, image): cache = super()._store_cache(size, image) cache.category_image = self return cache class CategoryImageCache(ImageCacheMixin, ModelSQL): "Category Image Cache" __name__ = 'product.category.image.cache' category_image = fields.Many2One( 'product.category.image', "Category Image", required=True, ondelete='CASCADE') @classmethod def __setup__(cls): super().__setup__() t = cls.__table__() cls._sql_constraints += [ ('dimension_unique', Unique(t, t.category_image, t.width, t.height), 'product_image.msg_image_cache_size_unique'), ]