diff --git a/l10n_eu_product_adr/14.0.1.1.0/pre-migration.py b/l10n_eu_product_adr/14.0.1.1.0/pre-migration.py new file mode 100644 index 000000000..775f513e8 --- /dev/null +++ b/l10n_eu_product_adr/14.0.1.1.0/pre-migration.py @@ -0,0 +1,28 @@ +# Copyright 2021 Opener B.V. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +try: + from openupgradelib import openupgrade +except ImportError: + openupgrade = None + + +def migrate(cr, version): + """Move adr data from product_template to product_product""" + if ( + not openupgrade + or not openupgrade.column_exists(cr, "product_template", "adr_goods_id") + or openupgrade.column_exists(cr, "product_product", "adr_goods_id") + ): + return + cr.execute( + """ + ALTER TABLE product_product + ADD COLUMN adr_goods_id INTEGER, + ADD COLUMN is_dangerous BOOLEAN; + UPDATE product_product pp + SET adr_goods_id = pt.adr_goods_id, + is_dangerous = pt.is_dangerous + FROM product_template pt + WHERE pt.id = pp.product_tmpl_id + """ + ) diff --git a/l10n_eu_product_adr/__manifest__.py b/l10n_eu_product_adr/__manifest__.py index 20eb6c119..806d0e072 100644 --- a/l10n_eu_product_adr/__manifest__.py +++ b/l10n_eu_product_adr/__manifest__.py @@ -4,7 +4,7 @@ { "name": "ADR Dangerous Goods", "summary": "Allows to set appropriate danger class and components", - "version": "14.0.1.0.0", + "version": "14.0.1.1.0", "category": "Inventory/Delivery", "website": "https://github.com/OCA/community-data-files", "author": "Opener B.V., Camptocamp, Odoo Community Association (OCA)", @@ -24,7 +24,9 @@ "views/adr_label_views.xml", "views/adr_packing_instruction_views.xml", "views/menu.xml", + # NB. product template views need to come before product product views "views/product_template_views.xml", + "views/product_product_views.xml", "views/stock_picking_views.xml", "security/ir.model.access.csv", ], diff --git a/l10n_eu_product_adr/migrations/14.0.1.1.0/pre-migration.py b/l10n_eu_product_adr/migrations/14.0.1.1.0/pre-migration.py new file mode 100644 index 000000000..775f513e8 --- /dev/null +++ b/l10n_eu_product_adr/migrations/14.0.1.1.0/pre-migration.py @@ -0,0 +1,28 @@ +# Copyright 2021 Opener B.V. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +try: + from openupgradelib import openupgrade +except ImportError: + openupgrade = None + + +def migrate(cr, version): + """Move adr data from product_template to product_product""" + if ( + not openupgrade + or not openupgrade.column_exists(cr, "product_template", "adr_goods_id") + or openupgrade.column_exists(cr, "product_product", "adr_goods_id") + ): + return + cr.execute( + """ + ALTER TABLE product_product + ADD COLUMN adr_goods_id INTEGER, + ADD COLUMN is_dangerous BOOLEAN; + UPDATE product_product pp + SET adr_goods_id = pt.adr_goods_id, + is_dangerous = pt.is_dangerous + FROM product_template pt + WHERE pt.id = pp.product_tmpl_id + """ + ) diff --git a/l10n_eu_product_adr/models/__init__.py b/l10n_eu_product_adr/models/__init__.py index f7b4ec741..c1cc759f9 100644 --- a/l10n_eu_product_adr/models/__init__.py +++ b/l10n_eu_product_adr/models/__init__.py @@ -3,6 +3,7 @@ from . import adr_label from . import adr_packing_instruction from . import common +from . import product_product from . import product_template from . import stock_move from . import stock_picking diff --git a/l10n_eu_product_adr/models/product_product.py b/l10n_eu_product_adr/models/product_product.py new file mode 100644 index 000000000..4a531eea9 --- /dev/null +++ b/l10n_eu_product_adr/models/product_product.py @@ -0,0 +1,66 @@ +# Copyright 2019 Iryna Vyshnevska (Camptocamp) +# Copyright 2021 Opener B.V. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import api, fields, models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + is_dangerous = fields.Boolean(help="This product belongs to a dangerous class") + adr_goods_id = fields.Many2one("adr.goods", "Dangerous Goods") + adr_class_id = fields.Many2one( + "adr.class", related="adr_goods_id.class_id", readonly=True + ) + adr_classification_code = fields.Char( + related="adr_goods_id.classification_code", readonly=True + ) + adr_label_ids = fields.Many2many( + "adr.label", related="adr_goods_id.label_ids", readonly=True + ) + adr_limited_quantity = fields.Float( + related="adr_goods_id.limited_quantity", + readonly=True, + ) + adr_limited_quantity_uom_id = fields.Many2one( + related="adr_goods_id.limited_quantity_uom_id", + readonly=True, + ) + adr_packing_instruction_ids = fields.Many2many( + "adr.packing.instruction", + related="adr_goods_id.packing_instruction_ids", + readonly=True, + ) + adr_transport_category = fields.Selection( + related="adr_goods_id.transport_category", readonly=True + ) + adr_tunnel_restriction_code = fields.Selection( + related="adr_goods_id.tunnel_restriction_code", readonly=True + ) + + @api.onchange("is_dangerous") + def onchange_is_dangerous(self): + """Remove the dangerous goods attribute from the product + + (when is_dangerous is deselected) + """ + if not self.is_dangerous and self.adr_goods_id: + self.adr_goods_id = False + + @api.model_create_multi + def create(self, vals_list): + """Propagate the template's adr settings when creating variants""" + for vals in vals_list: + if ( + "product_tmpl_id" in vals + and "adr_goods_id" not in vals + and "is_dangerous" not in vals + ): + template = self.env["product.template"].browse(vals["product_tmpl_id"]) + vals.update( + { + "adr_goods_id": template.adr_goods_id.id, + "is_dangerous": template.is_dangerous, + } + ) + return super().create(vals_list) diff --git a/l10n_eu_product_adr/models/product_template.py b/l10n_eu_product_adr/models/product_template.py index 152c8c56a..42b93cfb9 100644 --- a/l10n_eu_product_adr/models/product_template.py +++ b/l10n_eu_product_adr/models/product_template.py @@ -1,43 +1,86 @@ # Copyright 2019 Iryna Vyshnevska (Camptocamp) # Copyright 2021 Opener B.V. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError class ProductTemplate(models.Model): _inherit = "product.template" - is_dangerous = fields.Boolean(help="This product belongs to a dangerous class") - adr_goods_id = fields.Many2one("adr.goods", "Dangerous Goods") + adr_goods_on_variants = fields.Boolean( + compute="_compute_adr_goods_on_variants", + help="Indicates whether the adr configuration is different for each variant.", + ) + is_dangerous = fields.Boolean( + related="product_variant_ids.is_dangerous", + help="This product belongs to a dangerous class", + ) + adr_goods_id = fields.Many2one( + "adr.goods", "Dangerous Goods", related="product_variant_ids.adr_goods_id" + ) adr_class_id = fields.Many2one( - "adr.class", related="adr_goods_id.class_id", readonly=True + "adr.class", related="product_variant_ids.adr_goods_id.class_id", readonly=True ) adr_classification_code = fields.Char( related="adr_goods_id.classification_code", readonly=True ) adr_label_ids = fields.Many2many( - "adr.label", related="adr_goods_id.label_ids", readonly=True + "adr.label", related="product_variant_ids.adr_goods_id.label_ids", readonly=True ) adr_limited_quantity = fields.Float( - related="adr_goods_id.limited_quantity", + related="product_variant_ids.adr_goods_id.limited_quantity", readonly=True, ) adr_limited_quantity_uom_id = fields.Many2one( - related="adr_goods_id.limited_quantity_uom_id", + related="product_variant_ids.adr_goods_id.limited_quantity_uom_id", readonly=True, ) adr_packing_instruction_ids = fields.Many2many( "adr.packing.instruction", - related="adr_goods_id.packing_instruction_ids", + related="product_variant_ids.adr_goods_id.packing_instruction_ids", readonly=True, ) adr_transport_category = fields.Selection( - related="adr_goods_id.transport_category", readonly=True + related="product_variant_ids.adr_goods_id.transport_category", readonly=True ) adr_tunnel_restriction_code = fields.Selection( - related="adr_goods_id.tunnel_restriction_code", readonly=True + related="product_variant_ids.adr_goods_id.tunnel_restriction_code", + readonly=True, ) + @api.depends("product_variant_ids.adr_goods_id") + def _compute_adr_goods_on_variants(self): + for template in self: + template.adr_goods_on_variants = not all( + product.adr_goods_id == (template.product_variant_ids[0].adr_goods_id) + for product in template.product_variant_ids[1:] + ) + + def write(self, values): + """Delegate dangerous goods fields to variants + + while preventing a sweeping change over variants with different settings + """ + values = values.copy() + variant_vals = {} + for field in ("is_dangerous", "adr_goods_id"): + if field in values: + variant_vals[field] = values.pop(field) + res = super().write(values) + if variant_vals: + if any(template.adr_goods_on_variants for template in self): + raise UserError( + _( + "There are different dangerous goods configured on " + "this product's variant, so you cannot update the " + "dangerous goods from here. Please reconfigure each " + "variant separately." + ) + ) + self.mapped("product_variant_ids").write(variant_vals) + return res + @api.onchange("is_dangerous") def onchange_is_dangerous(self): """Remove the dangerous goods attribute from the product @@ -46,3 +89,16 @@ def onchange_is_dangerous(self): """ if not self.is_dangerous and self.adr_goods_id: self.adr_goods_id = False + + @api.model_create_multi + def create(self, vals_list): + """Propagate the template's adr settings on the created variants""" + res = super().create(vals_list) + for template, vals in zip(res, vals_list): + variant_vals = {} + for field in ("is_dangerous", "adr_goods_id"): + if field in vals: + variant_vals[field] = vals[field] + if variant_vals: + template.product_variant_ids.write(variant_vals) + return res diff --git a/l10n_eu_product_adr/readme/CONFIGURE.rst b/l10n_eu_product_adr/readme/CONFIGURE.rst index 04ab4336b..21d73dd41 100644 --- a/l10n_eu_product_adr/readme/CONFIGURE.rst +++ b/l10n_eu_product_adr/readme/CONFIGURE.rst @@ -4,6 +4,8 @@ goods that applies to this particular product, out of a list of dangerous goods that represents Table A from chapter 3 of the ADR specifications document. +It is possible to specify a different, or no goods per product variant. + The data in this module is generated based on a spreadsheet from https://cepa.be. This spreadsheet, just like the specifications themselves sometimes contain multiple options for each dangerous good, or additional diff --git a/l10n_eu_product_adr/tests/test_adr_goods.py b/l10n_eu_product_adr/tests/test_adr_goods.py index 2914037b1..e96a4e10d 100644 --- a/l10n_eu_product_adr/tests/test_adr_goods.py +++ b/l10n_eu_product_adr/tests/test_adr_goods.py @@ -1,6 +1,11 @@ # Copyright 2021 Opener B.V. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -from odoo.exceptions import ValidationError +import logging +import os + +from odoo.exceptions import UserError, ValidationError +from odoo.modules import get_module_resource +from odoo.modules.migration import load_script from odoo.tests import SavepointCase @@ -9,6 +14,18 @@ class TestAdrModels(SavepointCase): def setUpClass(cls): super().setUpClass() cls.env.user.lang = "en_US" + cls.size_attr = cls.env["product.attribute"].create({"name": "Size"}) + cls.value_s = cls.env["product.attribute.value"].create( + {"name": "S", "attribute_id": cls.size_attr.id} + ) + cls.value_m = cls.env["product.attribute.value"].create( + {"name": "M", "attribute_id": cls.size_attr.id} + ) + cls.value_l = cls.env["product.attribute.value"].create( + {"name": "L", "attribute_id": cls.size_attr.id} + ) + cls.goods1 = cls.env.ref("l10n_eu_product_adr.adr_goods_0065") + cls.goods2 = cls.env.ref("l10n_eu_product_adr.adr_goods_0066") def test_01_adr_class(self): """Test adr.class name_search and name_get""" @@ -71,9 +88,132 @@ def test_02_adr_goods(self): def test_03_adr_label(self): """Labels that are in use cannot be deleted""" label = self.env.ref("l10n_eu_product_adr.adr_label_1").copy() - goods = self.env.ref("l10n_eu_product_adr.adr_goods_0066") - goods.label_ids += label + self.goods1.label_ids += label with self.assertRaisesRegex(ValidationError, "in use"): label.unlink() - goods.label_ids -= label + self.goods1.label_ids -= label label.unlink() + + def test_04_product_variant_write(self): + """It is possible to configure the adr settings per variant""" + template = self.env["product.template"].create( + { + "name": "Sofa", + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": self.size_attr.id, + "value_ids": [(4, self.value_s.id), (4, self.value_m.id)], + }, + ), + ], + } + ) + var1, var2 = template.product_variant_ids + self.assertFalse(template.adr_goods_on_variants) + var1.adr_goods_id = self.goods1 + self.assertTrue(template.adr_goods_on_variants) + self.assertFalse(var2.adr_goods_id) + var2.adr_goods_id = self.goods2 + self.assertTrue(template.adr_goods_on_variants) + self.assertEqual(var1.adr_classification_code, "1.1D") + self.assertEqual(var2.adr_classification_code, "1.4G") + var1.adr_goods_id = self.goods2 + self.assertFalse(template.adr_goods_on_variants) + template.adr_goods_id = self.goods1 + self.assertEqual(var1.adr_goods_id, self.goods1) + self.assertEqual(var2.adr_goods_id, self.goods1) + var2.adr_goods_id = False + self.assertTrue(template.adr_goods_on_variants) + with self.assertRaisesRegex(UserError, "variant"): + template.adr_goods_id = self.goods1 + var1.adr_goods_id = False + template.adr_goods_id = self.goods1 + template.adr_goods_id = False + self.assertFalse(var1.adr_goods_id) + self.assertFalse(var2.adr_goods_id) + + def test_05_product_variant_create(self): + """Variants are initialized with the settings of the template""" + template = self.env["product.template"].create( + { + "name": "Sofa", + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": self.size_attr.id, + "value_ids": [(4, self.value_s.id), (4, self.value_m.id)], + }, + ), + ], + "adr_goods_id": self.goods1.id, + "is_dangerous": True, + } + ) + var1, var2 = template.product_variant_ids + self.assertEqual(var1.adr_goods_id, self.goods1) + self.assertEqual(var2.adr_goods_id, self.goods1) + + template.attribute_line_ids.value_ids += self.value_l + self.assertEqual(len(template.product_variant_ids), 3) + self.assertTrue( + var.adr_goods_id == self.goods1 for var in template.product_variant_ids + ) + + def test_06_product_variant_migration(self): + """Test the migration of the adr fields from template to product""" + try: + from openupgradelib import openupgrade # noqa: F401 + except ImportError: + logging.getLogger("odoo.addons.l10n_eu_product_adr.tests").info( + "OpenUpgrade not found, skip test" + ) + return + template = self.env["product.template"].create( + { + "name": "Sofa", + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": self.size_attr.id, + "value_ids": [(4, self.value_s.id), (4, self.value_m.id)], + }, + ), + ], + } + ) + self.env.cr.execute( + """ + ALTER TABLE product_template + ADD COLUMN adr_goods_id INTEGER, + ADD COLUMN is_dangerous BOOLEAN; + ALTER TABLE product_product + DROP COLUMN adr_goods_id, + DROP COLUMN is_dangerous; + """ + ) + + self.env.cr.execute( + """UPDATE product_template + SET is_dangerous = TRUE, + adr_goods_id = %s + WHERE id = %s; + """, + (self.goods1.id, template.id), + ) + pyfile = get_module_resource( + "l10n_eu_product_adr", "migrations", "14.0.1.1.0", "pre-migration.py" + ) + name, ext = os.path.splitext(os.path.basename(pyfile)) + mod = load_script(pyfile, name) + mod.migrate(self.env.cr, "14.0.1.0.0") + template.refresh() + var1, var2 = template.product_variant_ids + self.assertEqual(var1.adr_goods_id, self.goods1) + self.assertTrue(var2.is_dangerous) diff --git a/l10n_eu_product_adr/views/product_product_views.xml b/l10n_eu_product_adr/views/product_product_views.xml new file mode 100644 index 000000000..6f47c1166 --- /dev/null +++ b/l10n_eu_product_adr/views/product_product_views.xml @@ -0,0 +1,20 @@ + + + + Dangerous goods settings on variants + product.product + + + + + {'invisible': ['|', ('type', '!=', 'product'), ('is_dangerous', '=', False)]} + + + + + + + + diff --git a/l10n_eu_product_adr/views/product_template_views.xml b/l10n_eu_product_adr/views/product_template_views.xml index 624835048..ed77a7442 100644 --- a/l10n_eu_product_adr/views/product_template_views.xml +++ b/l10n_eu_product_adr/views/product_template_views.xml @@ -15,10 +15,21 @@ - + +
Dangerous goods settings differ per variant of this product. Please configure the dangerous goods settings on each variant.
+
+ +