From f0447cb3d7bb6847697c284383d15f4497aa5c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 2 Apr 2026 17:25:24 +0200 Subject: [PATCH] Do not simply copy One2Many from product linked by template and product Closes #14739 --- modules/product/__init__.py | 6 ++- modules/product/product.py | 72 +++++++++++++++++++++++++++- modules/product_image/product.py | 10 +++- modules/sale_point/product.py | 11 +++-- modules/sale_project_task/product.py | 6 ++- 5 files changed, 95 insertions(+), 10 deletions(-) diff --git a/modules/product/__init__.py b/modules/product/__init__.py index e554d88192..dee1d80296 100644 --- a/modules/product/__init__.py +++ b/modules/product/__init__.py @@ -3,7 +3,8 @@ __all__ = [ 'price_digits', 'round_price', 'uom_conversion_digits', - 'ProductDeactivatableMixin', 'TemplateDeactivatableMixin'] + 'ProductDeactivatableMixin', 'TemplateDeactivatableMixin', + 'copy_template_filtered', 'copy_product_filtered'] def __getattr__(name): @@ -12,7 +13,8 @@ def __getattr__(name): return uom_conversion_digits elif name in { 'price_digits', 'round_price', - 'ProductDeactivatableMixin', 'TemplateDeactivatableMixin'}: + 'ProductDeactivatableMixin', 'TemplateDeactivatableMixin', + 'copy_template_filtered', 'copy_product_filtered'}: from . import product return getattr(product, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/modules/product/product.py b/modules/product/product.py index d7e5110b12..e1b46c02c1 100644 --- a/modules/product/product.py +++ b/modules/product/product.py @@ -33,7 +33,9 @@ except ImportError: from .exceptions import InvalidIdentifierCode from .ir import price_decimal -__all__ = ['price_digits', 'round_price', 'TemplateFunction'] +__all__ = [ + 'price_digits', 'round_price', 'TemplateFunction', + 'copy_template_filtered', 'copy_product_filtered'] logger = logging.getLogger(__name__) TYPES = [ @@ -57,7 +59,74 @@ def round_price(value, rounding=None): Decimal(1) / 10 ** price_digits[1], rounding=rounding) +def copy_template_filtered( + field_name, template_name='template', product_name='product'): + class TemplateCopyFiltered: + __slots__ = () + + @classmethod + def copy(cls, templates, default=None): + Target = getattr(cls, field_name).get_target() + assert (getattr(Target, template_name).model_name + == 'product.template') + assert (getattr(Target, product_name).model_name + == 'product.product') + default = default.copy() if default is not None else {} + copy_field = field_name not in default + default.setdefault(field_name) + new_templates = super().copy(templates, default=default) + if copy_field: + old2new, to_copy = {}, [] + for template, new_template in zip(templates, new_templates): + to_copy.extend( + l for l in getattr(template, field_name) + if not getattr(l, product_name)) + old2new[template.id] = new_template.id + if to_copy: + Target.copy(to_copy, { + template_name: lambda d: old2new[d[template_name]], + }) + return new_templates + return TemplateCopyFiltered + + +def copy_product_filtered( + field_name, template_name='template', product_name='product'): + class ProductCopyFiltered: + __slots__ = () + + @classmethod + def copy(cls, products, default=None): + Target = getattr(cls, field_name).get_target() + assert (getattr(Target, template_name).model_name + == 'product.template') + assert (getattr(Target, product_name).model_name + == 'product.product') + default = default.copy() if default is not None else {} + copy_field = field_name not in default + default.setdefault(field_name) + new_products = super().copy(products, default=default) + if copy_field: + template2new, product2new, to_copy = {}, {}, [] + for product, new_product in zip(products, new_products): + if targets := getattr(product, field_name): + to_copy.extend(targets) + template2new[product.template.id] = ( + new_product.template.id) + product2new[product.id] = new_product.id + if to_copy: + Target.copy(to_copy, { + product_name: lambda d: product2new[ + d[product_name]], + template_name: lambda d: template2new[ + d[template_name]], + }) + return new_products + return ProductCopyFiltered + + class Template( + copy_template_filtered('list_prices'), DeactivableMixin, ModelSQL, ModelView, CompanyMultiValueMixin): __name__ = "product.template" name = fields.Char( @@ -407,6 +476,7 @@ class _One2ManyWithInactive(fields.One2Many): class Product( + copy_product_filtered('list_prices'), TemplateDeactivatableMixin, tree('replaced_by'), ModelSQL, ModelView, CompanyMultiValueMixin): __name__ = "product.product" diff --git a/modules/product_image/product.py b/modules/product_image/product.py index 004bdba345..2819b7077a 100644 --- a/modules/product_image/product.py +++ b/modules/product_image/product.py @@ -12,6 +12,8 @@ import trytond.config as config from trytond.i18n import gettext from trytond.model import ( MatchMixin, ModelSQL, ModelView, Unique, fields, sequence_ordered) +from trytond.modules.product import ( + copy_product_filtered, copy_template_filtered) from trytond.pool import PoolMeta from trytond.pyson import Bool, Eval, If from trytond.tools import slugify @@ -96,13 +98,17 @@ class ImageURLMixin: yield image -class Template(ImageURLMixin, metaclass=PoolMeta): +class Template( + copy_template_filtered('images'), + ImageURLMixin, metaclass=PoolMeta): __name__ = 'product.template' __image_url__ = '/product/image' images = fields.One2Many('product.image', 'template', "Images") -class Product(ImageURLMixin, metaclass=PoolMeta): +class Product( + copy_product_filtered('images'), + ImageURLMixin, metaclass=PoolMeta): __name__ = 'product.product' __image_url__ = '/product/variant/image' images = fields.One2Many( diff --git a/modules/sale_point/product.py b/modules/sale_point/product.py index d3458d8be4..012761b7a7 100644 --- a/modules/sale_point/product.py +++ b/modules/sale_point/product.py @@ -2,7 +2,8 @@ # this repository contains the full copyright notices and license terms. from trytond.model import ModelSQL, fields from trytond.modules.company.model import CompanyValueMixin -from trytond.modules.product import price_digits, round_price +from trytond.modules.product import ( + copy_product_filtered, copy_template_filtered, price_digits, round_price) from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval @@ -26,7 +27,9 @@ class _GrossPriceMixin: today)) -class Template(_GrossPriceMixin, metaclass=PoolMeta): +class Template( + copy_template_filtered('gross_prices'), + _GrossPriceMixin, metaclass=PoolMeta): __name__ = 'product.template' gross_price = fields.MultiValue(fields.Numeric( @@ -46,7 +49,9 @@ class Template(_GrossPriceMixin, metaclass=PoolMeta): return super().multivalue_model(field) -class Product(_GrossPriceMixin, metaclass=PoolMeta): +class Product( + copy_product_filtered('gross_prices'), + _GrossPriceMixin, metaclass=PoolMeta): __name__ = 'product.product' gross_price = fields.MultiValue(fields.Numeric( diff --git a/modules/sale_project_task/product.py b/modules/sale_project_task/product.py index 5d84fbed1f..e133950313 100644 --- a/modules/sale_project_task/product.py +++ b/modules/sale_project_task/product.py @@ -2,11 +2,13 @@ # this repository contains the full copyright notices and license terms. from trytond.model import ModelSQL, ModelView, fields, sequence_ordered, tree +from trytond.modules.product import ( + copy_product_filtered, copy_template_filtered) from trytond.pool import Pool, PoolMeta from trytond.pyson import Bool, Eval, If -class Template(metaclass=PoolMeta): +class Template(copy_template_filtered('tasks'), metaclass=PoolMeta): __name__ = 'product.template' taskable = fields.Boolean( @@ -29,7 +31,7 @@ class Template(metaclass=PoolMeta): ]) -class Product(metaclass=PoolMeta): +class Product(copy_product_filtered('tasks'), metaclass=PoolMeta): __name__ = 'product.product' tasks = fields.One2Many(