details:   https://code.tryton.org/tryton/commit/a15addd781e0
branch:    default
user:      Cédric Krier <[email protected]>
date:      Thu Apr 02 17:25:24 2026 +0200
description:
        Do not simply copy One2Many from product linked by template and product

        Closes #14739
diffstat:

 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(-)

diffs (214 lines):

diff -r d4c4995588f3 -r a15addd781e0 modules/product/__init__.py
--- a/modules/product/__init__.py       Thu Apr 02 15:33:36 2026 +0200
+++ b/modules/product/__init__.py       Thu Apr 02 17:25:24 2026 +0200
@@ -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 @@
         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 -r d4c4995588f3 -r a15addd781e0 modules/product/product.py
--- a/modules/product/product.py        Thu Apr 02 15:33:36 2026 +0200
+++ b/modules/product/product.py        Thu Apr 02 17:25:24 2026 +0200
@@ -33,7 +33,9 @@
 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 @@
         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 Product(
+        copy_product_filtered('list_prices'),
         TemplateDeactivatableMixin, tree('replaced_by'), ModelSQL, ModelView,
         CompanyMultiValueMixin):
     __name__ = "product.product"
diff -r d4c4995588f3 -r a15addd781e0 modules/product_image/product.py
--- a/modules/product_image/product.py  Thu Apr 02 15:33:36 2026 +0200
+++ b/modules/product_image/product.py  Thu Apr 02 17:25:24 2026 +0200
@@ -12,6 +12,8 @@
 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 @@
                 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 -r d4c4995588f3 -r a15addd781e0 modules/sale_point/product.py
--- a/modules/sale_point/product.py     Thu Apr 02 15:33:36 2026 +0200
+++ b/modules/sale_point/product.py     Thu Apr 02 17:25:24 2026 +0200
@@ -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 @@
                 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 @@
         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 -r d4c4995588f3 -r a15addd781e0 modules/sale_project_task/product.py
--- a/modules/sale_project_task/product.py      Thu Apr 02 15:33:36 2026 +0200
+++ b/modules/sale_project_task/product.py      Thu Apr 02 17:25:24 2026 +0200
@@ -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 Product(metaclass=PoolMeta):
+class Product(copy_product_filtered('tasks'), metaclass=PoolMeta):
     __name__ = 'product.product'
 
     tasks = fields.One2Many(

Reply via email to