From b08c1bc4c254056642ac72c68bcd4aebb12e65a2 Mon Sep 17 00:00:00 2001 From: Kevin Khao Date: Mon, 16 Dec 2019 18:31:43 +0100 Subject: [PATCH] [MIG] product_profile: Migration to 12.0 --- product_profile/README.rst | 5 +- product_profile/__init__.py | 1 - product_profile/__manifest__.py | 35 +-- product_profile/models/__init__.py | 1 - product_profile/models/config.py | 10 +- product_profile/models/product.py | 311 +++++++++++-------- product_profile/security/group.xml | 11 +- product_profile/security/ir.model.access.csv | 3 +- product_profile/views/config_view.xml | 32 +- product_profile/views/product_view.xml | 82 ++--- 10 files changed, 280 insertions(+), 211 deletions(-) diff --git a/product_profile/README.rst b/product_profile/README.rst index d8b3863515dc..d99178b2569d 100644 --- a/product_profile/README.rst +++ b/product_profile/README.rst @@ -85,9 +85,7 @@ Configuration In this case 'categ_id' field (from product.template) is populated with 'profile_default_categ_id' value but can be updated manually by the user. - Careful: each time you change profile, the default value is also populated - whatever the previous value. Custom value is only keep if don't - change the profile. + The default value is only used once on the product's create() call. 4. Insert data (xml or csv) and define values for each field defined above for each configuration scenario @@ -128,6 +126,7 @@ Contributors * David BEAL * Sébastien BEAU * Abdessamad HILALI +* Kevin Khao Iconography ----------- diff --git a/product_profile/__init__.py b/product_profile/__init__.py index f7a2a9924c28..19d563ef687d 100644 --- a/product_profile/__init__.py +++ b/product_profile/__init__.py @@ -1,4 +1,3 @@ -# coding: utf-8 # © 2015 David BEAL @ Akretion # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). diff --git a/product_profile/__manifest__.py b/product_profile/__manifest__.py index 97351a79924b..245b01d92453 100644 --- a/product_profile/__manifest__.py +++ b/product_profile/__manifest__.py @@ -1,26 +1,21 @@ -# coding: utf-8 # © 2015 David BEAL @ Akretion # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { - 'name': 'Product Profile', - 'version': '10.0.1.0.0', - 'author': 'Akretion, Odoo Community Association (OCA)', - 'summary': "Allow to configure a product in 1 click", - 'category': 'product', - 'depends': [ - 'sale', + "name": "Product Profile", + "version": "12.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "summary": "Allow to configure a product in 1 click", + "category": "product", + "depends": ["sale_management"], + "website": "https://github.com/oca/product-attribute", + "data": [ + "security/group.xml", + "views/product_view.xml", + "views/config_view.xml", + "security/ir.model.access.csv", ], - 'website': 'http://www.akretion.com/', - 'data': [ - 'security/group.xml', - 'views/product_view.xml', - 'views/config_view.xml', - 'security/ir.model.access.csv', - ], - 'demo': [ - 'demo/product.profile.csv', - ], - 'installable': True, - 'license': 'AGPL-3', + "demo": ["demo/product.profile.csv"], + "installable": True, + "license": "AGPL-3", } diff --git a/product_profile/models/__init__.py b/product_profile/models/__init__.py index d9e8d256a432..cb571880b01f 100644 --- a/product_profile/models/__init__.py +++ b/product_profile/models/__init__.py @@ -1,4 +1,3 @@ -# coding: utf-8 # © 2015 David BEAL @ Akretion # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). diff --git a/product_profile/models/config.py b/product_profile/models/config.py index 2434618d19c9..a2a05addb590 100644 --- a/product_profile/models/config.py +++ b/product_profile/models/config.py @@ -1,4 +1,3 @@ -# coding: utf-8 # © 2015 David BEAL @ Akretion # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). @@ -6,11 +5,12 @@ from .product import PROFILE_MENU -class BaseConfigSettings(models.TransientModel): - _inherit = 'base.config.settings' +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" group_product_profile = fields.Boolean( string="Display Product Profile fields", - implied_group='product_profile.group_product_profile', + implied_group="product_profile.group_product_profile_user", help="Display fields computed by product profile " - "module.\nFor debugging purpose see menu\n%s" % PROFILE_MENU) + "module.\nFor debugging purpose see menu\n%s" % PROFILE_MENU, + ) diff --git a/product_profile/models/product.py b/product_profile/models/product.py index c2f0108f3728..c0a6e7bbccd0 100644 --- a/product_profile/models/product.py +++ b/product_profile/models/product.py @@ -1,7 +1,7 @@ -# coding: utf-8 # © 2015 David BEAL @ Akretion # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from copy import deepcopy import logging from odoo import models, fields, api, _ from odoo.osv import orm @@ -9,11 +9,13 @@ from lxml import etree -PROFILE_MENU = (_("Sales > Configuration \n> Product Categories and Attributes" - "\n> Product Profiles")) +PROFILE_MENU = _( + "Sales > Configuration \n> Product Categories and Attributes" + "\n> Product Profiles" +) # Prefix name of profile fields setting a default value, # not an immutable value according to profile -PROF_DEFAULT_STR = 'profile_default_' +PROF_DEFAULT_STR = "profile_default_" LEN_DEF_STR = len(PROF_DEFAULT_STR) _logger = logging.getLogger(__name__) @@ -22,13 +24,15 @@ def format_except_message(error, field, self): value = self.profile_id[field] model = type(self)._name - message = (_("Issue\n------\n" - "%s\n'%s' value can't be applied to '%s' field." - "\nThere is no matching value between 'Product Profiles' " - "\nand '%s' models for this field.\n\n" - "Resolution\n----------\n" - "Check your settings on Profile model:\n%s" - % (error, value, field, model, PROFILE_MENU))) + message = _( + "Issue\n------\n" + "%s\n'%s' value can't be applied to '%s' field." + "\nThere is no matching value between 'Product Profiles' " + "\nand '%s' models for this field.\n\n" + "Resolution\n----------\n" + "Check your settings on Profile model:\n%s" + % (error, value, field, model, PROFILE_MENU) + ) return message @@ -36,58 +40,77 @@ def get_profile_fields_to_exclude(): # These fields must not be synchronized between product.profile # and product.template/product return models.MAGIC_COLUMNS + [ - 'name', 'explanation', 'sequence', - 'display_name', '__last_update'] + "name", + "explanation", + "sequence", + "id", + "display_name", + "__last_update", + ] class ProductProfile(models.Model): - _name = 'product.profile' - _order = 'sequence, name' + _name = "product.profile" + _order = "sequence, name" + _description = "Product Profile" name = fields.Char( required=True, help="Profile name displayed on product template\n" - "(not synchronized with product.template fields)") + "(not synchronized with product.template fields)", + ) sequence = fields.Integer( help="Defines the order of the entries of profile_id field\n" - "(not synchronized with product.template fields)") + "(not synchronized with product.template fields)" + ) explanation = fields.Text( required=True, - oldname='description', + oldname="description", help="An explanation on the selected profile\n" - "(not synchronized with product.template fields)") + "(not synchronized with product.template fields)", + ) type = fields.Selection( - selection=[('consu', 'Consumable'), ('service', 'Service')], + selection=[("consu", "Consumable"), ("service", "Service")], required=True, - help="See 'type' field in product.template") + help="See 'type' field in product.template", + ) - @api.multi def write(self, vals): - """ Profile update can impact products: we take care - to propagate ad hoc changes """ - new_vals = vals.copy() + """ Profile update can impact products: we take care to propagate + ad hoc changes. If on the profile at least one field has changed, + re-apply its values on relevant products""" excludable_fields = get_profile_fields_to_exclude() + values_to_keep = deepcopy(vals) for key in vals: - if (key.startswith(PROF_DEFAULT_STR) or - key in excludable_fields or - self.check_useless_key_in_vals(new_vals, key)): - new_vals.pop(key) - # super call must be after check_useless_key_in_vals() call - # because we compare value before and after write - res = super(ProductProfile, self).write(new_vals) - if new_vals: - for rec in self: - products = self.env['product.product'].search( - [('profile_id', '=', rec.id)]) - if products: - _logger.info( - " >>> %s Products updating after updated '%s' pro" - "duct profile" % (len(products), rec.name)) - data = products._get_vals_from_profile( - {'profile_id': rec.id}) - products.write(data) + discard_value = ( + key.startswith(PROF_DEFAULT_STR) + or key in excludable_fields + or self.check_useless_key_in_vals(vals, key) + ) + if discard_value: + values_to_keep.pop(key) + if values_to_keep: + self._refresh_products_vals() + res = super(ProductProfile, self).write(vals) return res + def _refresh_products_vals(self): + """Reapply profile values on products""" + for rec in self: + products = self.env["product.product"].search( + [("profile_id", "=", rec.id)] + ) + if products: + _logger.info( + " >>> %s Products updating after updated '%s' pro" + "duct profile" % (len(products), rec.name) + ) + data = products._get_vals_from_profile( + {"profile_id": rec.id}, + ignore_defaults=True + ) + products.write(data) + @api.model def check_useless_key_in_vals(self, vals, key): """ If replacing values are the same than in db, we remove them. @@ -100,69 +123,90 @@ def check_useless_key_in_vals(self, vals, key): we remove field from vals to minimize impact on products """ comparison_value = self[key] - if self._fields[key].type == 'many2one': + if self._fields[key].type == "many2one": comparison_value = self[key].id - elif self._fields[key].type == 'many2many': - comparison_value = [(6, False, self[key].ids), ] + elif self._fields[key].type == "many2many": + comparison_value = [ + (6, False, self[key].ids), + ] return vals[key] == comparison_value @api.model - def fields_view_get(self, view_id=None, view_type='form', - toolbar=False, submenu=False): + def fields_view_get( + self, view_id=None, view_type="form", toolbar=False, submenu=False + ): """ Display a warning for end user if edit record """ res = super(ProductProfile, self).fields_view_get( - view_id=view_id, view_type=view_type, toolbar=toolbar, - submenu=submenu) - if view_type == 'form': - style = 'alert alert-warning oe_text_center oe_edit_only' - alert = etree.Element('h2', {'class': style}) - alert.text = (_("If you update this profile, all products " - "using this profile could also be updated. " - "Changes can take a while.")) - doc = etree.XML(res['arch']) + view_id=view_id, + view_type=view_type, + toolbar=toolbar, + submenu=submenu, + ) + if view_type == "form": + style = "alert alert-warning oe_text_center oe_edit_only" + alert = etree.Element("h2", {"class": style}) + alert.text = _( + "If you update this profile, all products " + "using this profile could also be updated. " + "Changes can take a while." + ) + doc = etree.XML(res["arch"]) doc[0].addprevious(alert) - res['arch'] = etree.tostring(doc, pretty_print=True) + res["arch"] = etree.tostring(doc, pretty_print=True) return res class ProductMixinProfile(models.AbstractModel): - _name = 'product.mixin.profile' + _name = "product.mixin.profile" + _description = "Product Profile Mixin" @api.model def _get_profile_fields(self): fields_to_exclude = set(get_profile_fields_to_exclude()) - return [field for field in self.env['product.profile']._fields.keys() - if field not in fields_to_exclude] + return [ + field + for field in self.env["product.profile"]._fields.keys() + if field not in fields_to_exclude + ] @api.model - def _get_vals_from_profile(self, values): - profile_obj = self.env['product.profile'] + def _get_vals_from_profile(self, product_values, ignore_defaults=False): + res = {} + profile_obj = self.env["product.profile"] fields = self._get_profile_fields() - vals = profile_obj.browse(values['profile_id']).read(fields)[0] - vals.pop('id') - for field, value in vals.items(): - if value and profile_obj._fields[field].type == 'many2one': + profile_vals = profile_obj.browse(product_values["profile_id"]).read(fields)[0] + profile_vals.pop("id") + profile_vals = self._reformat_relationals(profile_vals) + for key, val in profile_vals.items(): + if key[:LEN_DEF_STR] == PROF_DEFAULT_STR: + if not ignore_defaults: + destination_field = key[LEN_DEF_STR:] + res[destination_field] = val + else: + res[key] = val + return res + + def _reformat_relationals(self, profile_vals): + res = deepcopy(profile_vals) + profile_obj = self.env['product.profile'] + for key, value in profile_vals.items(): + if value and profile_obj._fields[key].type == "many2one": # m2o value is a tuple - vals[field] = value[0] - if profile_obj._fields[field].type == 'many2many': - vals[field] = [(6, 0, value)] - if PROF_DEFAULT_STR == field[:LEN_DEF_STR]: - if field[LEN_DEF_STR:] not in values: - # we only put the default profile value - # if their is no matching in default data - vals[field[LEN_DEF_STR:]] = vals[field] - # prefixed fields must be removed from dict - # because they are in profile not in product - vals.pop(field) - return vals - - @api.onchange('profile_id') + res[key] = value[0] + if profile_obj._fields[key].type == "many2many": + res[key] = [(6, 0, value)] + return res + + @api.onchange("profile_id") def _onchange_from_profile(self): """ Update product fields with product.profile corresponding fields """ self.ensure_one() if self.profile_id: + ignore_defaults = True if self._origin.profile_id else False values = self._get_vals_from_profile( - {'profile_id': self.profile_id.id}) + {"profile_id": self.profile_id.id}, + ignore_defaults=ignore_defaults + ) for field, value in values.items(): try: self[field] = value @@ -171,37 +215,42 @@ def _onchange_from_profile(self): @api.model def create(self, vals): - if vals.get('profile_id'): - vals.update(self._get_vals_from_profile(vals)) + if vals.get("profile_id"): + vals.update(self._get_vals_from_profile(vals, ignore_defaults=False)) return super(ProductMixinProfile, self).create(vals) - @api.multi def write(self, vals): - if vals.get('profile_id'): - vals.update(self._get_vals_from_profile(vals)) + profile_changed = vals.get("profile_id") + if profile_changed: + recs_has_profile = self.filtered(lambda r: r.profile_id) + recs_no_profile = self - recs_has_profile + recs_has_profile.write(self._get_vals_from_profile(vals, ignore_defaults=True)) + recs_no_profile.write(self._get_vals_from_profile(vals, ignore_defaults=False)) return super(ProductMixinProfile, self).write(vals) @api.model def _get_default_profile_fields(self): " Get profile fields with prefix PROF_DEFAULT_STR " - return [x for x in self.env['product.profile']._fields.keys() - if x[:LEN_DEF_STR] == PROF_DEFAULT_STR] + return [ + x + for x in self.env["product.profile"]._fields.keys() + if x[:LEN_DEF_STR] == PROF_DEFAULT_STR + ] @api.model def _customize_view(self, res, view_type): - profile_group = self.env.ref('product_profile.group_product_profile') + profile_group = self.env.ref("product_profile.group_product_profile_user") users_in_profile_group = [user.id for user in profile_group.users] default_fields = self._get_default_profile_fields() - if view_type == 'form': - doc = etree.XML(res['arch']) + if view_type == "form": + doc = etree.XML(res["arch"]) fields = self._get_profile_fields() fields_def = self.fields_get(allfields=fields) if self.env.uid not in users_in_profile_group: - attrs = {'invisible': [('profile_id', '!=', False)]} + attrs = {"invisible": [("profile_id", "!=", False)]} else: - attrs = {'readonly': [('profile_id', '!=', False)]} - paths = ["//field[@name='%s']", - "//label[@for='%s']"] + attrs = {"readonly": [("profile_id", "!=", False)]} + paths = ["//field[@name='%s']", "//label[@for='%s']"] for field in fields: if field not in default_fields: # default fields shouldn't be modified @@ -209,69 +258,81 @@ def _customize_view(self, res, view_type): node = doc.xpath(path % field) if node: for current_node in node: - current_node.set('attrs', str(attrs)) - orm.setup_modifiers(current_node, - fields_def[field]) - res['arch'] = etree.tostring(doc, pretty_print=True) - elif view_type == 'search': + current_node.set("attrs", str(attrs)) + orm.setup_modifiers( + current_node, fields_def[field] + ) + res["arch"] = etree.tostring(doc, pretty_print=True) + elif view_type == "search": # Allow to dynamically create search filters for each profile filters_to_create = self._get_profiles_to_filter() - doc = etree.XML(res['arch']) + doc = etree.XML(res["arch"]) node = doc.xpath("//filter[1]") if node: for my_filter in filters_to_create: elm = etree.Element( - 'filter', **self._customize_profile_filters(my_filter)) + "filter", **self._customize_profile_filters(my_filter) + ) node[0].addprevious(elm) - res['arch'] = etree.tostring(doc, pretty_print=True) + res["arch"] = etree.tostring(doc, pretty_print=True) return res @api.model def _get_profiles_to_filter(self): """ Inherit if you want that some profiles doesn't have a filter """ - return [(x.id, x.name) for x in self.env['product.profile'].search([])] + return [(x.id, x.name) for x in self.env["product.profile"].search([])] @api.model def _customize_profile_filters(self, my_filter): """ Inherit if you to customize search filter display""" return { - 'string': "%s" % my_filter[1], - 'help': 'Filtering by Product Profile', - 'domain': "[('profile_id','=', %s)]" % my_filter[0]} + "string": "%s" % my_filter[1], + "help": "Filtering by Product Profile", + "domain": "[('profile_id','=', %s)]" % my_filter[0], + } class ProductTemplate(models.Model): - _inherit = ['product.template', 'product.mixin.profile'] - _name = 'product.template' + _inherit = ["product.template", "product.mixin.profile"] + _name = "product.template" profile_id = fields.Many2one( - comodel_name='product.profile', string='Profile') + comodel_name="product.profile", string="Profile" + ) profile_explanation = fields.Text( - related='profile_id.explanation', - readonly=True) + related="profile_id.explanation", readonly=True + ) @api.model - def fields_view_get(self, view_id=None, view_type='form', - toolbar=False, submenu=False): + def fields_view_get( + self, view_id=None, view_type="form", toolbar=False, submenu=False + ): """ fields_view_get comes from Model (not AbstractModel) """ res = super(ProductTemplate, self).fields_view_get( - view_id=view_id, view_type=view_type, toolbar=toolbar, - submenu=submenu) + view_id=view_id, + view_type=view_type, + toolbar=toolbar, + submenu=submenu, + ) return self._customize_view(res, view_type) class ProductProduct(models.Model): - _inherit = ['product.product', 'product.mixin.profile'] - _name = 'product.product' + _inherit = ["product.product", "product.mixin.profile"] + _name = "product.product" @api.model - def fields_view_get(self, view_id=None, view_type='form', - toolbar=False, submenu=False): - view = self.env['ir.ui.view'].browse(view_id) + def fields_view_get( + self, view_id=None, view_type="form", toolbar=False, submenu=False + ): + view = self.env["ir.ui.view"].browse(view_id) res = super(ProductProduct, self).fields_view_get( - view_id=view_id, view_type=view_type, toolbar=toolbar, - submenu=submenu) + view_id=view_id, + view_type=view_type, + toolbar=toolbar, + submenu=submenu, + ) # This is a simplified view for which the customization do not apply - if view.name == 'product.product.view.form.easy': + if view.name == "product.product.view.form.easy": return res return self._customize_view(res, view_type) diff --git a/product_profile/security/group.xml b/product_profile/security/group.xml index 24731d85690e..e5a492cf851e 100644 --- a/product_profile/security/group.xml +++ b/product_profile/security/group.xml @@ -1,9 +1,14 @@ - - Product Profile - + + Display Product Profile fields + + + + + Manage Product Profiles + diff --git a/product_profile/security/ir.model.access.csv b/product_profile/security/ir.model.access.csv index a2878718f6b9..5a5b2f91ed60 100644 --- a/product_profile/security/ir.model.access.csv +++ b/product_profile/security/ir.model.access.csv @@ -1,2 +1,3 @@ "id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" -"access_product_brand_product_manager","product.profile","model_product_profile","base.group_user",1,0,0,0 +"access_product_profile_manager","product.profile","model_product_profile","product_profile.group_product_profile_manager",1,1,1,1 +"access_product_profile_select","product.profile","model_product_profile","base.group_user",1,0,0,0 diff --git a/product_profile/views/config_view.xml b/product_profile/views/config_view.xml index 1fac98d1c89a..9e581b6bea98 100644 --- a/product_profile/views/config_view.xml +++ b/product_profile/views/config_view.xml @@ -1,16 +1,26 @@ - - base.config.settings - - - - - - - - - + + res.config.settings + + + +
+
+ +
+
+
+
+
+
+
diff --git a/product_profile/views/product_view.xml b/product_profile/views/product_view.xml index 6fa69a5ce168..f16db36b9cda 100644 --- a/product_profile/views/product_view.xml +++ b/product_profile/views/product_view.xml @@ -1,51 +1,51 @@ - - product.template - - - - - - - - - + + product.template + + + + + + + + + + - - + - - product.template - - - - - - - + + product.template + + + + + + + - - product.profile - - - - - - - - - + + product.profile + + + + + + + + + - - Product Profiles - product.profile - tree,form - + + Product Profiles + product.profile + tree,form + - +