From f0a716023b1bd33f3a016025986b42af3faa3c72 Mon Sep 17 00:00:00 2001 From: clementmbr Date: Mon, 18 May 2020 17:44:11 -0300 Subject: [PATCH 01/47] [REF] rename modules based on attribute_set and pim New organization : - attribute_set (former base_custom_attribute) - product_attribute_set (former pim_custom_attribute but without menus) - pim (The "PIM application" former pim_base) - pim_attrubute_set (depends on product_attribute_set and adds menus in the PIM application) --- attribute_set/README.rst | 15 + attribute_set/__init__.py | 2 + attribute_set/__manifest__.py | 20 + attribute_set/models/__init__.py | 5 + attribute_set/models/attribute_attribute.py | 478 ++++++++++++++++++ attribute_set/models/attribute_group.py | 25 + attribute_set/models/attribute_option.py | 50 ++ attribute_set/models/attribute_set.py | 24 + attribute_set/models/attribute_set_owner.py | 88 ++++ attribute_set/security/attribute_security.xml | 7 + attribute_set/security/ir.model.access.csv | 13 + attribute_set/tests/__init__.py | 2 + attribute_set/tests/models.py | 11 + attribute_set/tests/test_build_view.py | 265 ++++++++++ attribute_set/tests/test_custom_attribute.py | 64 +++ .../views/attribute_attribute_view.xml | 193 +++++++ attribute_set/views/attribute_group_view.xml | 38 ++ attribute_set/views/attribute_option_view.xml | 101 ++++ attribute_set/views/attribute_set_view.xml | 71 +++ attribute_set/views/menu_view.xml | 9 + attribute_set/wizard/__init__.py | 1 + .../wizard/attribute_option_wizard.py | 88 ++++ .../wizard/attribute_option_wizard_view.xml | 25 + 23 files changed, 1595 insertions(+) create mode 100644 attribute_set/README.rst create mode 100644 attribute_set/__init__.py create mode 100644 attribute_set/__manifest__.py create mode 100644 attribute_set/models/__init__.py create mode 100644 attribute_set/models/attribute_attribute.py create mode 100644 attribute_set/models/attribute_group.py create mode 100644 attribute_set/models/attribute_option.py create mode 100644 attribute_set/models/attribute_set.py create mode 100644 attribute_set/models/attribute_set_owner.py create mode 100644 attribute_set/security/attribute_security.xml create mode 100644 attribute_set/security/ir.model.access.csv create mode 100644 attribute_set/tests/__init__.py create mode 100644 attribute_set/tests/models.py create mode 100644 attribute_set/tests/test_build_view.py create mode 100644 attribute_set/tests/test_custom_attribute.py create mode 100644 attribute_set/views/attribute_attribute_view.xml create mode 100644 attribute_set/views/attribute_group_view.xml create mode 100644 attribute_set/views/attribute_option_view.xml create mode 100644 attribute_set/views/attribute_set_view.xml create mode 100644 attribute_set/views/menu_view.xml create mode 100644 attribute_set/wizard/__init__.py create mode 100644 attribute_set/wizard/attribute_option_wizard.py create mode 100644 attribute_set/wizard/attribute_option_wizard_view.xml diff --git a/attribute_set/README.rst b/attribute_set/README.rst new file mode 100644 index 000000000..5dd7267ed --- /dev/null +++ b/attribute_set/README.rst @@ -0,0 +1,15 @@ +Attribute Set +====================== + +This module allows to create attributes on a chosen model. +An attribute is a field that can be serialized. +If the attribute is serialized it will not create a new column in the database. + +Credits +======= + +Contributors +------------ +* Benoît Guillot +* Akretion Raphaël VALYI +* David Dufresne diff --git a/attribute_set/__init__.py b/attribute_set/__init__.py new file mode 100644 index 000000000..9b4296142 --- /dev/null +++ b/attribute_set/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/attribute_set/__manifest__.py b/attribute_set/__manifest__.py new file mode 100644 index 000000000..45470d173 --- /dev/null +++ b/attribute_set/__manifest__.py @@ -0,0 +1,20 @@ +{ + "name": "attribute_set", + "version": "12.0.0.0.1", + "category": "Generic Modules/Others", + "license": "AGPL-3", + "author": "Akretion", + "website": "https://akretion.com", + "depends": ["base", "base_sparse_field"], + "data": [ + "security/ir.model.access.csv", + "security/attribute_security.xml", + "views/menu_view.xml", + "views/attribute_attribute_view.xml", + "views/attribute_group_view.xml", + "views/attribute_option_view.xml", + "views/attribute_set_view.xml", + "wizard/attribute_option_wizard_view.xml", + ], + "external_dependencies": {"python": ["unidecode"]}, +} diff --git a/attribute_set/models/__init__.py b/attribute_set/models/__init__.py new file mode 100644 index 000000000..a4d368306 --- /dev/null +++ b/attribute_set/models/__init__.py @@ -0,0 +1,5 @@ +from . import attribute_attribute +from . import attribute_option +from . import attribute_set +from . import attribute_set_owner +from . import attribute_group diff --git a/attribute_set/models/attribute_attribute.py b/attribute_set/models/attribute_attribute.py new file mode 100644 index 000000000..2aea88503 --- /dev/null +++ b/attribute_set/models/attribute_attribute.py @@ -0,0 +1,478 @@ +# Copyright 2011 Akretion (http://www.akretion.com). +# @author Benoît GUILLOT +# @author Raphaël VALYI +# Copyright 2015 Savoir-faire Linux +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import ast +import logging +import re + +from lxml import etree + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.osv.orm import setup_modifiers + +_logger = logging.getLogger(__name__) + +try: + from unidecode import unidecode +except ImportError as err: + _logger.debug(err) + + +def safe_column_name(string): + """ Prevent portability problem in database column name + with other DBMS system + Use case : if you synchronise attributes with other applications """ + string = unidecode(string.replace(" ", "_").lower()) + return re.sub(r"[^0-9a-z_]", "", string) + + +class AttributeAttribute(models.Model): + _name = "attribute.attribute" + _description = "Attribute" + _inherits = {"ir.model.fields": "field_id"} + _order = "sequence_group,sequence,name" + + def _set_attrs(self): + return { + "invisible": [("attribute_set_id", "not in", self.attribute_set_ids.ids)] + } + + @api.model + def _build_attribute_field(self, attribute_egroup): + """Add an etree 'field' subelement (related to the current attribute 'self') + to attribute_egroup, with a conditional invisibility based on its + attribute sets.""" + self.ensure_one() + kwargs = {"name": "%s" % self.name} + kwargs["attrs"] = str(self._set_attrs()) + kwargs["required"] = str(self.required or self.required_on_views) + + if self.ttype == "many2many": + # TODO use an attribute field instead + # to let user specify the widget. For now it fixes: + # https://github.com/shopinvader/odoo-pim/issues/2 + kwargs["widget"] = "many2many_tags" + + if self.ttype in ["many2one", "many2many"]: + if self.relation_model_id: + # TODO update related attribute.option in cascade to allow + # attribute.option creation from the field. + kwargs["options"] = "{'no_create': True}" + # attribute.domain is a string, it may be an empty list + try: + domain = ast.literal_eval(self.domain) + except ValueError: + domain = None + if domain: + kwargs["domain"] = self.domain + else: + # Display only options linked to an existing object + ids = [op.value_ref.id for op in self.option_ids if op.value_ref] + kwargs["domain"] = "[('id', 'in', %s)]" % ids + # Add color options if the attribute's Relational Model + # has a color field + relation_model_obj = self.env[self.relation_model_id.model] + if "color" in relation_model_obj.fields_get().keys(): + kwargs["options"] = "{'color_field': 'color', 'no_create': True}" + else: + # Define field's domain and context with attribute's id to go along with + # Attribute Options search and creation + kwargs["domain"] = "[('attribute_id', '=', %s)]" % (self.id) + kwargs["context"] = "{'default_attribute_id': %s}" % (self.id) + + if self.ttype == "text": + # Display field label above his value + field_title = etree.SubElement( + attribute_egroup, "b", colspan="2", attrs=kwargs["attrs"] + ) + field_title.text = self.field_description + kwargs["nolabel"] = "1" + kwargs["colspan"] = "2" + setup_modifiers(field_title) + efield = etree.SubElement(attribute_egroup, "field", **kwargs) + setup_modifiers(efield) + + def _build_attribute_eview(self): + """Return an 'attribute_eview' including all the Attributes (in the current + recorset 'self') distributed in different 'attribute_egroup' for each + Attribute's group. + """ + attribute_eview = etree.Element("group", name="attributes_group", col="4") + groups = [] + + for attribute in self: + att_group = attribute.attribute_group_id + att_group_name = att_group.name.capitalize() + if att_group in groups: + xpath = ".//group[@string='{}']".format(att_group_name) + attribute_egroup = attribute_eview.find(xpath) + else: + att_set_ids = [] + for att in att_group.attribute_ids: + att_set_ids += att.attribute_set_ids.ids + # Hide the Group if none of its attributes are in + # the destination object's Attribute set + hide_domain = "[('attribute_set_id', 'not in', {})]".format( + list(set(att_set_ids)) + ) + attribute_egroup = etree.SubElement( + attribute_eview, + "group", + string=att_group_name, + colspan="2", + attrs="{{'invisible' : {} }}".format(hide_domain), + ) + groups.append(att_group) + + setup_modifiers(attribute_egroup) + attribute._build_attribute_field(attribute_egroup) + + return attribute_eview + + field_id = fields.Many2one( + "ir.model.fields", "Ir Model Fields", required=True, ondelete="cascade" + ) + + attribute_nature = fields.Selection( + [("custom", "Custom"), ("native", "Native")], + string="Attribute Nature", + required=True, + default="custom", + store=True, + ) + + attribute_type = fields.Selection( + [ + ("char", "Char"), + ("text", "Text"), + ("select", "Select"), + ("multiselect", "Multiselect"), + ("boolean", "Boolean"), + ("integer", "Integer"), + ("date", "Date"), + ("datetime", "Datetime"), + ("binary", "Binary"), + ("float", "Float"), + ], + ) + + serialized = fields.Boolean( + "Serialized", + help="""If serialized, the attribute's field will be stored in the serialization + field 'x_custom_json_attrs' (i.e. a JSON containing all the serialized fields + values) instead of creating a new SQL column for this attribute's field. + Useful to increase speed requests if creating a high number of attributes.""", + ) + + option_ids = fields.One2many( + "attribute.option", "attribute_id", "Attribute Options" + ) + + create_date = fields.Datetime("Created date", readonly=True) + + relation_model_id = fields.Many2one("ir.model", "Relational Model") + + required_on_views = fields.Boolean( + "Required (on views)", + help="If activated, the attribute will be mandatory on the views, " + "but not in the database", + ) + + attribute_set_ids = fields.Many2many( + comodel_name="attribute.set", + string="Attribute Sets", + relation="rel_attribute_set", + column1="attribute_id", + column2="attribute_set_id", + ) + + attribute_group_id = fields.Many2one( + "attribute.group", "Attribute Group", required=True, ondelete="cascade" + ) + + sequence_group = fields.Integer( + "Sequence of the Group", + related="attribute_group_id.sequence", + help="The sequence of the group", + store="True", + ) + + sequence = fields.Integer( + "Sequence in Group", help="The attribute's order in his group" + ) + + @api.onchange("model_id") + def onchange_model_id(self): + return {"domain": {"field_id": [("model_id", "=", self.model_id.id)]}} + + @api.onchange("field_description") + def onchange_field_description(self): + if self.field_description and not self.create_date: + self.name = unidecode("x_" + safe_column_name(self.field_description)) + + @api.onchange("name") + def onchange_name(self): + name = self.name + if not name.startswith("x_"): + self.name = "x_%s" % name + + @api.onchange("relation_model_id") + def relation_model_id_change(self): + "Remove selected options as they would be inconsistent" + self.option_ids = [(5, 0)] + + @api.onchange("domain") + def domain_change(self): + if self.domain not in ["", False]: + try: + ast.literal_eval(self.domain) + except ValueError: + raise ValidationError( + _( + """ "{}" is an unvalid Domain name.\n + Specify a Python expression defining a list of triplets.\ + For example : "[('color', '=', 'red')]" """.format( + self.domain + ) + ) + ) + # Remove selected options as the domain will predominate on actual options + if self.domain != "[]": + self.option_ids = [(5, 0)] + + @api.multi + def button_add_options(self): + self.ensure_one() + # Before adding another option delete the ones which are linked + # to a deleted object + for option in self.option_ids: + if not option.value_ref: + option.unlink() + # Then open the Options Wizard which will display an 'opt_ids' m2m field related + # to the 'relation_model_id' model + return { + "context": "{'attribute_id': %s}" % (self.id), + "name": _("Options Wizard"), + "view_type": "form", + "view_mode": "form", + "res_model": "attribute.option.wizard", + "type": "ir.actions.act_window", + "target": "new", + } + + @api.model + def create(self, vals): + """ Create an attribute.attribute + + - In case of a new "custom" attribute, a new field object 'ir.model.fields' will + be created as this model "_inherits" 'ir.model.fields'. + So we need to add here the mandatory 'ir.model.fields' instance's attributes to + the new 'attribute.attribute'. + + - In case of a new "native" attribute, it will be linked to an existing + field object 'ir.model.fields' (through "field_id") that cannot be modified. + That's why we remove all the 'ir.model.fields' instance's attributes values + from `vals` before creating our new 'attribute.attribute'. + + """ + if vals.get("attribute_nature") == "native": + + field_obj = self.env["ir.model.fields"] + if vals.get("serialized"): + raise ValidationError( + _("Error"), + _( + "Can't create a serialized attribute on " + "an existing ir.model.fields (%s)" + ) + % field_obj.browse(vals["field_id"]).name, + ) + + # Remove all the values that can modify the related native field + # before creating the new 'attribute.attribute' + for key in list(vals.keys()): + if key in field_obj.fields_get().keys(): + del vals[key] + return super().create(vals) + + if vals.get("relation_model_id"): + model = self.env["ir.model"].browse(vals["relation_model_id"]) + relation = model.model + else: + relation = "attribute.option" + + attr_type = vals.get("attribute_type") + + if attr_type == "select": + vals["ttype"] = "many2one" + vals["relation"] = relation + + elif attr_type == "multiselect": + vals["ttype"] = "many2many" + vals["relation"] = relation + # Specify the relation_table's name in case of m2m not serialized + # to avoid creating the same default relation_table name for any attribute + # linked to the same attribute.option or relation_model_id's model. + if not vals.get("serialized"): + att_model_id = self.env["ir.model"].browse(vals["model_id"]) + table_name = ( + "x_" + + att_model_id.model.replace(".", "_") + + "_" + + vals["name"] + + "_" + + relation.replace(".", "_") + + "_rel" + ) + # avoid too long relation_table names + vals["relation_table"] = table_name[0:60] + + else: + vals["ttype"] = attr_type + + if vals.get("serialized"): + field_obj = self.env["ir.model.fields"] + + serialized_fields = field_obj.search( + [ + ("ttype", "=", "serialized"), + ("model_id", "=", vals["model_id"]), + ("name", "=", "x_custom_json_attrs"), + ] + ) + + if serialized_fields: + vals["serialization_field_id"] = serialized_fields[0].id + + else: + f_vals = { + "name": "x_custom_json_attrs", + "field_description": "Serialized JSON Attributes", + "ttype": "serialized", + "model_id": vals["model_id"], + } + + vals["serialization_field_id"] = ( + field_obj.with_context({"manual": True}).create(f_vals).id + ) + + vals["state"] = "manual" + return super().create(vals) + + def _delete_related_option_wizard(self, option_vals): + """ Delete the attribute's options wizards related to the attribute's options + deleted after the write""" + self.ensure_one() + for option_change in option_vals: + if option_change[0] == 2: + self.env["attribute.option.wizard"].search( + [("attribute_id", "=", self.id)] + ).unlink() + + def _delete_old_fields_options(self, options): + """Delete attribute's field values in the objects using our attribute + as a field, if these values are not in the new Domain or Options list + """ + self.ensure_one() + custom_field = self.name + for obj in self.env[self.model].search([]): + if obj.fields_get(custom_field): + for value in obj[custom_field]: + if value not in options: + if self.attribute_type == "select": + obj.write({custom_field: False}) + elif self.attribute_type == "multiselect": + obj.write({custom_field: [(3, value.id, 0)]}) + + @api.multi + def write(self, vals): + # Prevent from changing Attribute's type + if "attribute_type" in list(vals.keys()): + if self.search( + [ + ("attribute_type", "!=", vals["attribute_type"]), + ("id", "in", self.ids), + ] + ): + raise ValidationError( + _( + "Can't change the type of an attribute. " + "Please create a new one." + ) + ) + else: + vals.pop("attribute_type") + # Prevent from changing relation_model_id for multiselect Attributes + # as the values of the existing many2many Attribute fields won't be + # deleted if changing relation_model_id + if "relation_model_id" in list(vals.keys()): + if self.search( + [ + ("relation_model_id", "!=", vals["relation_model_id"]), + ("id", "in", self.ids), + ] + ): + raise ValidationError( + _( + """Can't change the attribute's Relational Model in order to + avoid conflicts with existing objects using this attribute. + Please create a new one.""" + ) + ) + # Prevent from changing 'JSON Field' + if "serialized" in list(vals.keys()): + if self.search( + [("serialized", "!=", vals["serialized"]), ("id", "in", self.ids)] + ): + raise ValidationError( + _( + """It is not allowed to change the boolean 'JSON Field'. + A serialized field can not be change to non-serialized \ + and vice versa.""" + ) + ) + # Set the new values to self + res = super(AttributeAttribute, self).write(vals) + + for att in self: + options = att.option_ids + if self.relation_model_id: + options = self.env[att.relation_model_id.model] + if "option_ids" in list(vals.keys()): + # Delete related attribute.option.wizard if an attribute.option + # has been deleted + att._delete_related_option_wizard(vals["option_ids"]) + # If there is still some attribute.option available, override + # 'options' with the objects they are refering to. + options = options.search( + [("id", "in", [op.value_ref.id for op in att.option_ids])] + ) + if "domain" in list(vals.keys()): + try: + domain = ast.literal_eval(att.domain) + except ValueError: + domain = [] + if domain: + # If there is a Valid domain not null, it means that there is + # no more attribute.option. + options = options.search(domain) + # Delete attribute's field values in the objects using our attribute + # as a field, if these values are not in the new Domain or Options list + if {"option_ids", "domain"} & set(vals.keys()): + att._delete_old_fields_options(options) + + return res + + @api.multi + def unlink(self): + """ Delete the Attribute's related field when deleting a custom Attribute""" + fields_to_remove = self.filtered( + lambda s: s.attribute_nature == "custom" + ).mapped("field_id") + res = super(AttributeAttribute, self).unlink() + fields_to_remove.unlink() + return res diff --git a/attribute_set/models/attribute_group.py b/attribute_set/models/attribute_group.py new file mode 100644 index 000000000..1d5801d3b --- /dev/null +++ b/attribute_set/models/attribute_group.py @@ -0,0 +1,25 @@ +# Copyright 2011 Akretion (http://www.akretion.com). +# @author Benoît GUILLOT +# @author Raphaël VALYI +# Copyright 2015 Savoir-faire Linux +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AttributeGroup(models.Model): + _name = "attribute.group" + _description = "Attribute Group" + _order = "sequence" + + name = fields.Char("Name", size=128, required=True, translate=True) + + sequence = fields.Integer( + "Sequence in Set", help="The Group order in his attribute's Set" + ) + + attribute_ids = fields.One2many( + "attribute.attribute", "attribute_group_id", "Attributes" + ) + + model_id = fields.Many2one("ir.model", "Model", required=True) diff --git a/attribute_set/models/attribute_option.py b/attribute_set/models/attribute_option.py new file mode 100644 index 000000000..ebed4b209 --- /dev/null +++ b/attribute_set/models/attribute_option.py @@ -0,0 +1,50 @@ +# Copyright 2011 Akretion (http://www.akretion.com). +# @author Benoît GUILLOT +# @author Raphaël VALYI +# Copyright 2015 Savoir-faire Linux +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class AttributeOption(models.Model): + _name = "attribute.option" + _description = "Attribute Option" + _order = "sequence" + + @api.model + def _get_model_list(self): + models = self.env["ir.model"].search([]) + return [(m.model, m.name) for m in models] + + name = fields.Char("Name", translate=True, required=True) + + value_ref = fields.Reference(_get_model_list, "Reference") + + attribute_id = fields.Many2one( + "attribute.attribute", "Product Attribute", required=True, ondelete="cascade", + ) + + relation_model_id = fields.Many2one( + "ir.model", "Relational Model", related="attribute_id.relation_model_id", + ) + + sequence = fields.Integer("Sequence") + + @api.onchange("name") + def name_change(self): + """Prevent the user from adding manually an option to m2o or m2m Attributes + linked to another model (through 'relation_model_id')""" + if self.attribute_id.relation_model_id: + warning = { + "title": _("Error!"), + "message": _( + """Use the 'Load Attribute Options' button or specify a Domain + in order to define the available Options linked to the Relational\ + Model. + + If the button is not visible, you need to erase the Domain value\ + and Save first.""" + ), + } + return {"warning": warning} diff --git a/attribute_set/models/attribute_set.py b/attribute_set/models/attribute_set.py new file mode 100644 index 000000000..3029478b4 --- /dev/null +++ b/attribute_set/models/attribute_set.py @@ -0,0 +1,24 @@ +# Copyright 2011 Akretion (http://www.akretion.com). +# @author Benoît GUILLOT +# @author Raphaël VALYI +# Copyright 2015 Savoir-faire Linux +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AttributeSet(models.Model): + _name = "attribute.set" + _description = "Attribute Set" + + name = fields.Char("Name", required=True, translate=True) + + attribute_ids = fields.Many2many( + comodel_name="attribute.attribute", + string="Attributes", + relation="rel_attribute_set", + column1="attribute_set_id", + column2="attribute_id", + ) + + model_id = fields.Many2one("ir.model", "Model", required=True) diff --git a/attribute_set/models/attribute_set_owner.py b/attribute_set/models/attribute_set_owner.py new file mode 100644 index 000000000..d3ef91db6 --- /dev/null +++ b/attribute_set/models/attribute_set_owner.py @@ -0,0 +1,88 @@ +# Copyright 2020 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from lxml import etree + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class AttributeSetOwnerMixin(models.AbstractModel): + """Override the '_inheriting' model's fields_view_get() and replace + the 'attributes_placeholder' by the fields related to the '_inheriting' model's + Attributes. + Each Attribute's field will have a conditional invisibility depending on its + Attribute Sets. + """ + + _name = "attribute.set.owner.mixin" + _description = "Attribute set owner mixin" + + attribute_set_id = fields.Many2one("attribute.set", "Attribute Set") + + @api.model + def _build_attribute_eview(self): + """Override Attribute's method _build_attribute_eview() to build an + attribute eview with the mixin model's attributes""" + domain = [ + ("model_id.model", "=", self._name), + ("attribute_set_ids", "!=", False), + ] + if not self._context.get("include_native_attribute"): + domain.append(("attribute_nature", "=", "custom")) + + attributes = self.env["attribute.attribute"].search(domain) + return attributes._build_attribute_eview() + + @api.model + def remove_native_fields(self, eview): + """Remove native fields related to native attributes from eview""" + native_attrs = self.env["attribute.attribute"].search( + [ + ("model_id.model", "=", self._name), + ("attribute_set_ids", "!=", False), + ("attribute_nature", "=", "native"), + ] + ) + for attr in native_attrs: + efield = eview.xpath("//field[@name='{}']".format(attr.name))[0] + efield.getparent().remove(efield) + + def _insert_attribute(self, arch): + """Insert the model's Attributes related fields into the arch's view form + at the placeholder's place.""" + eview = etree.fromstring(arch) + form_name = eview.get("string") + placeholder = eview.xpath("//separator[@name='attributes_placeholder']") + + if len(placeholder) != 1: + raise ValidationError( + _( + """It is impossible to add Attributes on "{}" xml view as there is + not one "" in it. + """.format( + form_name + ) + ) + ) + + if self._context.get("include_native_attribute"): + self.remove_native_fields(eview) + attribute_eview = self._build_attribute_eview() + + # Insert the Attributes view + placeholder[0].getparent().replace(placeholder[0], attribute_eview) + return etree.tostring(eview, pretty_print=True) + + @api.model + def fields_view_get( + self, view_id=None, view_type="form", toolbar=False, submenu=False + ): + result = super(AttributeSetOwnerMixin, self).fields_view_get( + view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu, + ) + if view_type == "form": + result["arch"] = self._insert_attribute(result["arch"]) + return result diff --git a/attribute_set/security/attribute_security.xml b/attribute_set/security/attribute_security.xml new file mode 100644 index 000000000..98826e3b8 --- /dev/null +++ b/attribute_set/security/attribute_security.xml @@ -0,0 +1,7 @@ + + + + Advanced Attribute Option + + + diff --git a/attribute_set/security/ir.model.access.csv b/attribute_set/security/ir.model.access.csv new file mode 100644 index 000000000..f4f3c1a4b --- /dev/null +++ b/attribute_set/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_attribute_set_attribute_set_erpmanager,attribute_set_attribute_set,attribute_set.model_attribute_set,base.group_erp_manager,1,1,1,1 +access_attribute_set_attribute_group_erpmanager,attribute_set_attribute_group,attribute_set.model_attribute_group,base.group_erp_manager,1,1,1,1 +access_attribute_set_attribute_attribute_erpmanager,attribute_set_product_attribute,attribute_set.model_attribute_attribute,base.group_erp_manager,1,1,1,1 +access_attribute_set_attribute_option_erpmanager,attribute_set_attribute_option,attribute_set.model_attribute_option,base.group_erp_manager,1,1,1,1 +access_attribute_set_attribute_set_manager,attribute_set_attribute_set,attribute_set.model_attribute_set,base.group_no_one,1,1,1,1 +access_attribute_set_attribute_group_manager,attribute_set_attribute_group,attribute_set.model_attribute_group,base.group_no_one,1,1,1,1 +access_attribute_set_attribute_attribute_manager,attribute_set_attribute_attribute,attribute_set.model_attribute_attribute,base.group_no_one,1,1,1,1 +access_attribute_set_attribute_option_manager,attribute_set_attribute_option,attribute_set.model_attribute_option,base.group_no_one,1,1,1,1 +access_attribute_set_attribute_set_user,attribute_set_attribute_set,attribute_set.model_attribute_set,base.group_user,1,0,0,0 +access_attribute_set_attribute_group_user,attribute_set_attribute_group,attribute_set.model_attribute_group,base.group_user,1,0,0,0 +access_attribute_set_attribute_attribute_user,attribute_set_attribute_attribute,attribute_set.model_attribute_attribute,base.group_user,1,0,0,0 +access_attribute_set_attribute_option_user,attribute_set_attribute_option,attribute_set.model_attribute_option,base.group_user,1,0,0,0 diff --git a/attribute_set/tests/__init__.py b/attribute_set/tests/__init__.py new file mode 100644 index 000000000..884bf61d4 --- /dev/null +++ b/attribute_set/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_custom_attribute +from . import test_build_view diff --git a/attribute_set/tests/models.py b/attribute_set/tests/models.py new file mode 100644 index 000000000..38a85caae --- /dev/null +++ b/attribute_set/tests/models.py @@ -0,0 +1,11 @@ +# Copyright 2020 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import models + + +class ResPartner(models.Model): + _inherit = ["res.partner", "attribute.set.owner.mixin"] + _name = "res.partner" diff --git a/attribute_set/tests/test_build_view.py b/attribute_set/tests/test_build_view.py new file mode 100644 index 000000000..62a108029 --- /dev/null +++ b/attribute_set/tests/test_build_view.py @@ -0,0 +1,265 @@ +# Copyright 2020 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +import ast + +from lxml import etree +from odoo_test_helper import FakeModelLoader + +from odoo.tests import SavepointCase + + +class BuildViewCase(SavepointCase): + @classmethod + def _create_set(cls, name): + return cls.env["attribute.set"].create({"name": name, "model_id": cls.model_id}) + + @classmethod + def _create_group(cls, vals): + vals["model_id"] = cls.model_id + return cls.env["attribute.group"].create(vals) + + @classmethod + def _create_attribute(cls, vals): + vals["model_id"] = cls.model_id + return cls.env["attribute.attribute"].create(vals) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import ResPartner + + cls.loader.update_registry((ResPartner,)) + + # Create a new inherited view with the 'attributes' placeholder. + cls.view = cls.env["ir.ui.view"].create( + { + "name": "res.partner.form.test", + "model": "res.partner", + "inherit_id": cls.env.ref("base.view_partner_form").id, + "arch": """ + + + + + + """, + } + ) + # Create some attributes + cls.model_id = cls.env.ref("base.model_res_partner").id + cls.set_1 = cls._create_set("Set 1") + cls.set_2 = cls._create_set("Set 2") + cls.group_1 = cls._create_group({"name": "Group 1", "sequence": 1}) + cls.group_2 = cls._create_group({"name": "Group 2", "sequence": 2}) + cls.group_native = cls._create_group({"name": "Group native", "sequence": 3}) + cls.attr_1 = cls._create_attribute( + { + "attribute_nature": "custom", + "name": "x_attr_1", + "attribute_type": "char", + "sequence": 1, + "attribute_group_id": cls.group_1.id, + "attribute_set_ids": [(6, 0, [cls.set_1.id])], + } + ) + cls.attr_2 = cls._create_attribute( + { + "attribute_nature": "custom", + "name": "x_attr_2", + "attribute_type": "text", + "sequence": 2, + "attribute_group_id": cls.group_1.id, + "attribute_set_ids": [(6, 0, [cls.set_1.id])], + } + ) + cls.attr_3 = cls._create_attribute( + { + "attribute_nature": "custom", + "name": "x_attr_3", + "attribute_type": "boolean", + "sequence": 1, + "attribute_group_id": cls.group_2.id, + "attribute_set_ids": [(6, 0, [cls.set_1.id, cls.set_2.id])], + } + ) + cls.attr_4 = cls._create_attribute( + { + "attribute_nature": "custom", + "name": "x_attr_4", + "attribute_type": "date", + "sequence": 2, + "attribute_group_id": cls.group_2.id, + "attribute_set_ids": [(6, 0, [cls.set_1.id, cls.set_2.id])], + } + ) + cls.attr_native = cls._create_attribute( + { + "attribute_nature": "native", + "field_id": cls.env.ref("base.field_res_partner__category_id").id, + "attribute_group_id": cls.group_native.id, + "attribute_set_ids": [(6, 0, [cls.set_1.id, cls.set_2.id])], + } + ) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super(BuildViewCase, cls).tearDownClass() + + def _check_attrset_visiblility(self, attrs, set_ids): + attrs = ast.literal_eval(attrs) + self.assertIn("invisible", attrs) + domain = attrs["invisible"][0] + self.assertEqual("attribute_set_id", domain[0]) + self.assertEqual("not in", domain[1]) + self.assertEqual( + set(set_ids), + set(domain[2]), + "Expected {}, get {}".format(set(set_ids), set(domain[2])), + ) + + def _get_attr_element(self, name): + eview = self.env["res.partner"]._build_attribute_eview() + return eview.find("group/field[@name='{}']".format(name)) + + def test_group_order(self): + eview = self.env["res.partner"]._build_attribute_eview() + groups = [g.get("string") for g in eview.getchildren()] + self.assertEqual(groups, ["Group 1", "Group 2"]) + + self.group_2.sequence = 0 + eview = self.env["res.partner"]._build_attribute_eview() + groups = [g.get("string") for g in eview.getchildren()] + self.assertEqual(groups, ["Group 2", "Group 1"]) + + def test_group_visibility(self): + eview = self.env["res.partner"]._build_attribute_eview() + group = eview.getchildren()[0] + self._check_attrset_visiblility(group.get("attrs"), [self.set_1.id]) + + self.attr_1.attribute_set_ids += self.set_2 + eview = self.env["res.partner"]._build_attribute_eview() + group = eview.getchildren()[0] + self._check_attrset_visiblility( + group.get("attrs"), [self.set_1.id, self.set_2.id] + ) + + def test_attribute_order(self): + eview = self.env["res.partner"]._build_attribute_eview() + attrs = [ + item.get("name") + for item in eview.getchildren()[0].getchildren() + if item.tag == "field" + ] + self.assertEqual(attrs, ["x_attr_1", "x_attr_2"]) + + self.attr_1.sequence = 3 + eview = self.env["res.partner"]._build_attribute_eview() + attrs = [ + item.get("name") + for item in eview.getchildren()[0].getchildren() + if item.tag == "field" + ] + self.assertEqual(attrs, ["x_attr_2", "x_attr_1"]) + + def test_attr_visibility(self): + attrs = self._get_attr_element("x_attr_1").get("attrs") + self._check_attrset_visiblility(attrs, [self.set_1.id]) + + self.attr_1.attribute_set_ids += self.set_2 + attrs = self._get_attr_element("x_attr_1").get("attrs") + self._check_attrset_visiblility(attrs, [self.set_1.id, self.set_2.id]) + + def test_attr_required(self): + required = self._get_attr_element("x_attr_1").get("required") + self.assertEqual(required, "False") + + self.attr_1.required_on_views = True + required = self._get_attr_element("x_attr_1").get("required") + self.assertEqual(required, "True") + + def test_render_all_field_type(self): + field = self.env["attribute.attribute"]._fields["attribute_type"] + for attr_type, _name in field.selection: + name = "x_test_render_{}".format(attr_type) + self._create_attribute( + { + "attribute_nature": "custom", + "name": name, + "attribute_type": attr_type, + "sequence": 1, + "attribute_group_id": self.group_1.id, + "attribute_set_ids": [(6, 0, [self.set_1.id])], + } + ) + attr = self._get_attr_element(name) + self.assertIsNotNone(attr) + if attr_type == "text": + self.assertTrue(attr.get("nolabel")) + previous = attr.getprevious() + self.assertEqual(previous.tag, "b") + else: + self.assertFalse(attr.get("nolabel", False)) + + # TEST on NATIVE ATTRIBUTES + def test_include_native_attr(self): + # Run fields_view_get on the test view with "include_native_attribute" context + fields_view = ( + self.env["res.partner"] + .with_context({"include_native_attribute": True}) + .fields_view_get( + view_id=self.view.id, view_type="form", toolbar=False, submenu=False + ) + ) + eview = etree.fromstring(fields_view["arch"]) + attr = eview.xpath("//field[@name='{}']".format(self.attr_native.name)) + + # Only one field with this name + self.assertEqual(len(attr), 1) + # The moved field is inside page "partner_attributes" + self.assertEqual(attr[0].xpath("../../..")[0].get("name"), "partner_attributes") + # It has the given visibility by its related attribute sets. + self._check_attrset_visiblility( + attr[0].get("attrs"), [self.set_1.id, self.set_2.id] + ) + + def test_no_include_native_attr(self): + # Run fields_view_get on the test view with no "include_native_attribute" + # context + fields_view = ( + self.env["res.partner"] + .with_context({"include_native_attribute": False}) + .fields_view_get( + view_id=self.view.id, view_type="form", toolbar=False, submenu=False + ) + ) + eview = etree.fromstring(fields_view["arch"]) + attr = eview.xpath("//field[@name='{}']".format(self.attr_native.name)) + + # Only one field with this name + self.assertEqual(len(attr), 1) + # And it is not in page "partner_attributes" + self.assertFalse( + eview.xpath( + "//page[@name='partner_attributes']//field[@name='{}']".format( + self.attr_native.name + ) + ) + ) + + def test_unlink_custom_attribute(self): + attr_1_field_id = self.attr_1.field_id.id + self.attr_1.unlink() + self.assertFalse(self.env["ir.model.fields"].browse([attr_1_field_id]).exists()) + + def test_unlink_native_attribute(self): + attr_native_field_id = self.attr_native.field_id.id + self.attr_native.unlink() + self.assertTrue( + self.env["ir.model.fields"].browse([attr_native_field_id]).exists() + ) diff --git a/attribute_set/tests/test_custom_attribute.py b/attribute_set/tests/test_custom_attribute.py new file mode 100644 index 000000000..014213624 --- /dev/null +++ b/attribute_set/tests/test_custom_attribute.py @@ -0,0 +1,64 @@ +# Copyright 2011 Akretion (http://www.akretion.com). +# @author Benoît GUILLOT +# @author Raphaël VALYI +# Copyright 2015 Savoir-faire Linux +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import mock + +from odoo.tests import common + + +class TestCustomAttribute(common.TransactionCase): + def setUp(self): + super(TestCustomAttribute, self).setUp() + self.model_id = self.env.ref("base.model_res_partner").id + self.group = self.env["attribute.group"].create( + {"name": "My Group", "model_id": self.model_id} + ) + # Do not commit + self.env.cr.commit = mock.Mock() + + def _create_attribute(self, vals): + vals.update( + { + "attribute_nature": "custom", + "model_id": self.model_id, + "field_description": "Attribute %s" % vals["attribute_type"], + "name": "x_%s" % vals["attribute_type"], + "attribute_group_id": self.group.id, + } + ) + return self.env["attribute.attribute"].create(vals) + + def test_create_attribute_char(self): + attribute = self._create_attribute({"attribute_type": "char"}) + self.assertEqual(attribute.ttype, "char") + + def test_create_attribute_selection(self): + attribute = self._create_attribute( + { + "attribute_type": "select", + "option_ids": [ + (0, 0, {"name": "Value 1"}), + (0, 0, {"name": "Value 2"}), + ], + } + ) + + self.assertEqual(attribute.ttype, "many2one") + self.assertEqual(attribute.relation, "attribute.option") + + def test_create_attribute_multiselect(self): + attribute = self._create_attribute( + { + "attribute_type": "multiselect", + "option_ids": [ + (0, 0, {"name": "Value 1"}), + (0, 0, {"name": "Value 2"}), + ], + } + ) + + self.assertEqual(attribute.ttype, "many2many") + self.assertEqual(attribute.relation, "attribute.option") diff --git a/attribute_set/views/attribute_attribute_view.xml b/attribute_set/views/attribute_attribute_view.xml new file mode 100644 index 000000000..a080b447b --- /dev/null +++ b/attribute_set/views/attribute_attribute_view.xml @@ -0,0 +1,193 @@ + + + + attribute.attribute.form + attribute.attribute + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +