diff --git a/attribute_set/README.rst b/attribute_set/README.rst new file mode 100644 index 00000000..9683020a --- /dev/null +++ b/attribute_set/README.rst @@ -0,0 +1,109 @@ +============= +Attribute Set +============= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fodoo--pim-lightgray.png?logo=github + :target: https://github.com/OCA/odoo-pim/tree/15.0/attribute_set + :alt: OCA/odoo-pim +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/odoo-pim-15-0/odoo-pim-15-0-attribute_set + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/295/15.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows the user to create Attributes to any model. +This is a basic module in the way that **it does not provide views to display these new Attributes.** + +Each Attribute created will be related to an **existing field** (in case of a *"native"* Attribute) or to a newly **created field** (in case of a *"custom"* Attribute). + +A *"custom"* Attribute can be of any type : Char, Text, Boolean, Date, Binary... but also Many2one or Many2many. + +In case of m2o or m2m, these attributes can be related to **custom options** created for the Attribute, or to **existing Odoo objects** from other models. + +Last but not least an Attribute can be **serialized** using the Odoo SA module `base_sparse_field `_ . +It means that all the serialized attributes will be stored in a single "JSON serialization field" and will not create new columns in the database (and better, it will not create new SQL tables in case of Many2many Attributes), **increasing significantly the requests speed** when dealing with thousands of Attributes. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Even if this module does not provide views to display some model's Attributes, it provides however a Technical menu in *Settings > Technical > Database Structure > Attributes* to **create new Attributes**. + +An Attribute is related to both an Attribute Group and an Attribute Set : + +- The **Attribute Set** is related to the *"model's category"*, i.e. all the model's instances which will display the same Attributes. +- The **Attribute Group** is related to the *"attribute's category"*. All the attributes from the same Attribute Set and Attribute Group will be displayed under the same field's Group in the model's view. + + + 🔎 In order to create a custom Attribute many2one or many2many related to **other Odoo model**, you need to activate the Technical Setting **"Advanced Attribute Set settings"** (:code:`group_advanced_attribute_set`). + +----- + +If you want to create a module displaying some specific model's Attributes : + +1. Your model must **\_inherit the mixin** :code:`"attribute.set.owner.mixin"` +2. You need to **add a placeholder** :code:`` at the desired location in the model's form view. +3. Finally, **add a context** :code:`{"include_native_attribute": True}` on the action leading to this form view if the model's view needs to display attributes related to native fields together with the other "custom" attributes. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* Sébastien BEAU +* Clément Mombereau +* Benoît Guillot +* Akretion Raphaël VALYI +* David Dufresne +* Denis Roussel + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/odoo-pim `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/attribute_set/__init__.py b/attribute_set/__init__.py new file mode 100644 index 00000000..da04c4f0 --- /dev/null +++ b/attribute_set/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import wizard +from . import utils diff --git a/attribute_set/__manifest__.py b/attribute_set/__manifest__.py new file mode 100644 index 00000000..abc1a2e9 --- /dev/null +++ b/attribute_set/__manifest__.py @@ -0,0 +1,21 @@ +{ + "name": "Attribute Set", + "version": "16.0.1.0.0", + "category": "Generic Modules/Others", + "license": "AGPL-3", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/odoo-pim", + "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"]}, + "installable": True, +} diff --git a/attribute_set/i18n/attribute_set.pot b/attribute_set/i18n/attribute_set.pot new file mode 100644 index 00000000..06046b85 --- /dev/null +++ b/attribute_set/i18n/attribute_set.pot @@ -0,0 +1,731 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * attribute_set +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: attribute_set +#: code:addons/attribute_set/models/attribute_attribute.py:0 +#, python-format +msgid "" +" \"{}\" is an unvalid Domain name.\n" +"\n" +" Specify a Python expression defining a list of triplets. For example : \"[('color', '=', 'red')]\" " +msgstr "" + +#. module: attribute_set +#: model:res.groups,name:attribute_set.group_advanced_attribute_set +msgid "Advanced Attribute Set settings" +msgstr "" + +#. module: attribute_set +#: model:ir.model,name:attribute_set.model_attribute_attribute +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_attribute_form_view +msgid "Attribute" +msgstr "" + +#. module: attribute_set +#: model:ir.model,name:attribute_set.model_attribute_group +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__attribute_group_id +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_group_form_view +msgid "Attribute Group" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__nature +msgid "Attribute Nature" +msgstr "" + +#. module: attribute_set +#: model:ir.model,name:attribute_set.model_attribute_option +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_option_form_popup_view +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_option_form_view +msgid "Attribute Option" +msgstr "" + +#. module: attribute_set +#: model:ir.actions.act_window,name:attribute_set.attribute_option_form_action +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__option_ids +#: model:ir.ui.menu,name:attribute_set.menu_attribute_option_action +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_attribute_form_view +msgid "Attribute Options" +msgstr "" + +#. module: attribute_set +#: model:ir.model,name:attribute_set.model_attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set_owner_mixin__attribute_set_id +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_set_form_view +msgid "Attribute Set" +msgstr "" + +#. module: attribute_set +#: model:ir.actions.act_window,name:attribute_set.attribute_set_form_action +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__attribute_set_ids +#: model:ir.ui.menu,name:attribute_set.menu_attribute_set_action +msgid "Attribute Sets" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__attribute_type +msgid "Attribute Type" +msgstr "" + +#. module: attribute_set +#: model:ir.model,name:attribute_set.model_attribute_set_owner_mixin +msgid "Attribute set owner mixin" +msgstr "" + +#. module: attribute_set +#: model:ir.actions.act_window,name:attribute_set.attribute_attribute_form_action +#: model:ir.actions.act_window,name:attribute_set.attribute_attribute_sort_action +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__attribute_ids +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set__attribute_ids +#: model:ir.ui.menu,name:attribute_set.menu_attribute_attribute_action +#: model:ir.ui.menu,name:attribute_set.menu_attribute_in_admin +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_set_form_view +msgid "Attributes" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__binary +msgid "Binary" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__boolean +msgid "Boolean" +msgstr "" + +#. module: attribute_set +#: code:addons/attribute_set/models/attribute_attribute.py:0 +#, python-format +msgid "" +"Can't change the attribute's Relational Model in order to\n" +" avoid conflicts with existing objects using this attribute.\n" +" Please create a new one." +msgstr "" + +#. module: attribute_set +#: code:addons/attribute_set/models/attribute_attribute.py:0 +#, python-format +msgid "Can't change the type of an attribute. Please create a new one." +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_option_wizard_form_view +msgid "Cancel" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__char +msgid "Char" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__compute +msgid "" +"Code to compute the value of the field.\n" +"Iterate on the recordset 'self' and assign the field's value:\n" +"\n" +" for record in self:\n" +" record['size'] = len(record.name)\n" +"\n" +"Modules time, datetime, dateutil are available." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__column1 +msgid "Column 1" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__column2 +msgid "Column 2" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__column2 +msgid "Column referring to the record in the comodel table" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__column1 +msgid "Column referring to the record in the model table" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__complete_name +msgid "Complete Name" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__compute +msgid "Compute" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__copied +msgid "Copied" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__create_uid +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__create_uid +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__create_uid +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option_wizard__create_uid +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set__create_uid +msgid "Created by" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__create_date +msgid "Created date" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__create_date +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__create_date +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option_wizard__create_date +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set__create_date +msgid "Created on" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__nature__custom +msgid "Custom" +msgstr "" + +#. module: attribute_set +#: model:ir.model,name:attribute_set.model_attribute_option_wizard +msgid "Custom Attributes Option" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__date +msgid "Date" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__datetime +msgid "Datetime" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__depends +msgid "Dependencies" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__depends +msgid "" +"Dependencies of compute method; a list of comma-separated field names, like\n" +"\n" +" name, partner_id.name" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__display_name +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__display_name +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__display_name +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option_wizard__display_name +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set__display_name +msgid "Display Name" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__domain +msgid "Domain" +msgstr "" + +#. module: attribute_set +#: code:addons/attribute_set/models/attribute_option.py:0 +#, python-format +msgid "Error!" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__group_expand +msgid "Expand Groups" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__help +msgid "Field Help" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__field_description +msgid "Field Label" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__name +msgid "Field Name" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__ttype +msgid "Field Type" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__float +msgid "Float" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__relation_field +msgid "" +"For one2many fields, the field on the target model that implement the " +"opposite many2one relationship" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__relation +msgid "For relationship fields, the technical name of the target model" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_group_form_view +msgid "Group name" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__groups +msgid "Groups" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__id +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__id +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__id +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option_wizard__id +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set__id +msgid "ID" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__required_on_views +msgid "" +"If activated, the attribute will be mandatory on the views, but not in the " +"database" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__group_expand +msgid "" +"If checked, all the records of the target model will be included\n" +"in a grouped result (e.g. 'Group By' filters, Kanban columns, etc.).\n" +"Note that it can significantly reduce performance if the target model\n" +"of the field contains a lot of records; usually used on models with\n" +"few records (e.g. Stages, Job Positions, Event Types, etc.)." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__serialized +msgid "" +"If serialized, the attribute's field will be stored in the serialization\n" +" field 'x_custom_json_attrs' (i.e. a JSON containing all the serialized\n" +" fields values) instead of creating a new SQL column for this\n" +" attribute's field. Useful to increase speed requests if creating a\n" +" high number of attributes." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__serialization_field_id +msgid "" +"If set, this field will be stored in the sparse structure of the " +"serialization field, instead of having its own database column. This cannot " +"be changed after creation." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__modules +msgid "In Apps" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__index +msgid "Indexed" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__integer +msgid "Integer" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__field_id +msgid "Ir Model Fields" +msgstr "" + +#. module: attribute_set +#: code:addons/attribute_set/models/attribute_set_owner.py:0 +#, python-format +msgid "" +"It is impossible to add Attributes on \"{}\" xml\n" +" view as there is\n" +" not one \"\" in it.\n" +" " +msgstr "" + +#. module: attribute_set +#: code:addons/attribute_set/models/attribute_attribute.py:0 +#, python-format +msgid "" +"It is not allowed to change the boolean 'Serialized'.\n" +" A serialized field can not be change to non-serialized and vice versa." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute____last_update +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group____last_update +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option____last_update +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option_wizard____last_update +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set____last_update +msgid "Last Modified on" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__write_uid +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__write_uid +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__write_uid +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option_wizard__write_uid +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__write_date +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__write_date +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__write_date +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option_wizard__write_date +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set__write_date +msgid "Last Updated on" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__modules +msgid "List of modules in which the field is defined" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_attribute_form_view +msgid "Load Attribute Options" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__model_id +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__model_id +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set__model_id +msgid "Model" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__model +msgid "Model Name" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__multiselect +msgid "Multiselect" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__name +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__name +#: model:ir.model.fields,field_description:attribute_set.field_attribute_set__name +msgid "Name" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__nature__native +msgid "Native" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__on_delete +msgid "On Delete" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__on_delete +msgid "On delete property for many2one fields" +msgstr "" + +#. module: attribute_set +#: code:addons/attribute_set/models/attribute_attribute.py:0 +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_option_wizard_form_view +#, python-format +msgid "Options Wizard" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__attribute_id +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option_wizard__attribute_id +msgid "Product Attribute" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__readonly +msgid "Readonly" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__value_ref +msgid "Reference" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__related +msgid "Related Field" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__relation +msgid "Related Model" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__related_field_id +msgid "Related field" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_attribute_form_view +msgid "Related native field" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__relation_field +msgid "Relation Field" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__relation_table +msgid "Relation Table" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__relation_field_id +msgid "Relation field" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__relation_model_id +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__relation_model_id +msgid "Relational Model" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__required +msgid "Required" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__required_on_views +msgid "Required (on views)" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.view_attribute_option_search +msgid "Search Attribute Options" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.view_attribute_set_search +msgid "Search Attribute Sets" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_group_search_view +#: model_terms:ir.ui.view,arch_db:attribute_set.view_attribute_attribute_search +msgid "Search Attributes" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__select +msgid "Select" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__selectable +msgid "Selectable" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__selection_ids +msgid "Selection Options" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__selection +msgid "Selection Options (Deprecated)" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_option__sequence +msgid "Sequence" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__sequence +msgid "Sequence in Group" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_group__sequence +msgid "Sequence in Set" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__sequence_group +msgid "Sequence of the Group" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__serialization_field_id +msgid "Serialization Field" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__serialized +msgid "Serialized" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__size +msgid "Size" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_group_tree_view +msgid "Sort Attribute" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__widget +msgid "Specify widget to add to the field on the views." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__store +msgid "Stored" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields.selection,name:attribute_set.selection__attribute_attribute__attribute_type__text +msgid "Text" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_group__sequence +msgid "The Group order in his attribute's Set" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__sequence +msgid "The attribute's order in his group" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__related +msgid "" +"The corresponding related field, if any. This must be a dot-separated list " +"of field names." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__model_id +msgid "The model this field belongs to" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__domain +msgid "" +"The optional domain to restrict possible values for relationship fields, " +"specified as a Python expression defining a list of triplets. For example: " +"[('color','=','red')]" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__sequence_group +msgid "The sequence of the group" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__model +msgid "The technical name of the model this field belongs to" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__translate +msgid "Translatable" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__state +msgid "Type" +msgstr "" + +#. module: attribute_set +#: code:addons/attribute_set/models/attribute_option.py:0 +#, python-format +msgid "" +"Use the 'Load Attribute Options' button or specify a Domain\n" +" in order to define the available Options linked to the Relational Model.\n" +"\n" +" If the button is not visible, you need to erase the Domain value and Save first." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__relation_table +msgid "" +"Used for custom many2many fields to define a custom relation table name" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_option_wizard_form_view +msgid "Validate" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__copied +msgid "Whether the value is copied when duplicating a record." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__store +msgid "Whether the value is stored in the database." +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,help:attribute_set.field_attribute_attribute__translate +msgid "" +"Whether values for this field can be translated (enables the translation " +"mechanism for that field)" +msgstr "" + +#. module: attribute_set +#: model:ir.model.fields,field_description:attribute_set.field_attribute_attribute__widget +msgid "Widget" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_option_wizard_form_view +msgid "options_placeholder" +msgstr "" + +#. module: attribute_set +#: model_terms:ir.ui.view,arch_db:attribute_set.attribute_option_wizard_form_view +msgid "or" +msgstr "" diff --git a/attribute_set/models/__init__.py b/attribute_set/models/__init__.py new file mode 100644 index 00000000..a4d36830 --- /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 00000000..55ba7446 --- /dev/null +++ b/attribute_set/models/attribute_attribute.py @@ -0,0 +1,482 @@ +# 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 ..utils.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" + + field_id = fields.Many2one( + "ir.model.fields", "Ir Model Fields", required=True, ondelete="cascade" + ) + + 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( + 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", ondelete="cascade" + ) + + widget = fields.Char(help="Specify widget to add to the field on the views.") + + 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" + ) + + def _get_attrs(self): + attrs = { + "invisible": [("attribute_set_id", "not in", self.attribute_set_ids.ids)] + } + if self.required or self.required_on_views: + attrs.update( + {"required": [("attribute_set_id", "in", self.attribute_set_ids.ids)]} + ) + return attrs + + @api.model + def _build_attribute_field(self, attribute_egroup): + """Add field into given attribute group. + + Conditional invisibility based on its attribute sets. + """ + self.ensure_one() + kwargs = {"name": "%s" % self.name} + kwargs["attrs"] = str(self._get_attrs()) + if self.widget: + kwargs["widget"] = self.widget + + if self.readonly: + kwargs["readonly"] = str(True) + + 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}" + elif self.nature == "custom": + # 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) + elif self.nature != "custom": + kwargs["context"] = self._get_native_field_context() + + 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 _get_native_field_context(self): + return str(self.env[self.field_id.model]._fields[self.field_id.name].context) + + def _build_attribute_eview(self): + """Generate group element for all attributes in the current recordset. + + 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 + + @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("attribute_type") + def onchange_attribute_type(self): + if self.attribute_type == "multiselect": + self.widget = "many2many_tags" + + @api.onchange("relation_model_id") + def _onchange_relation_model_id(self): + """Remove selected options as they would be inconsistent""" + self.option_ids = [(5, 0)] + + @api.onchange("domain") + def _onchange_domain(self): + if self.domain not in ["", False]: + try: + ast.literal_eval(self.domain) + except ValueError: + raise ValidationError( + _( + "`%(domain)s` is an invalid Domain name.\n" + "Specify a Python expression defining a list of triplets.\n" + "For example : `[('color', '=', 'red')]`", + domain=self.domain, + ) + ) from ValueError + # Remove selected options as the domain will predominate on actual options + if self.domain != "[]": + self.option_ids = [(5, 0)] + + 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": dict(self.env.context, attribute_id=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_create_multi + def create(self, vals_list): + """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'. + + """ + for vals in vals_list: + if vals.get("nature") == "native": + # Remove all the values that can modify the related native field + # before creating the new 'attribute.attribute' + for key in set(vals).intersection(self.env["ir.model.fields"]._fields): + del vals[key] + continue + + 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_list) + + def _delete_related_option_wizard(self, option_vals): + """Delete related attribute's options wizards.""" + 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() + break + + def _delete_old_fields_options(self, options): + """Delete outdated attribute's field values on existing records.""" + 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)]}) + + def write(self, vals): + # Prevent from changing Attribute's type + if "attribute_type" in list(vals.keys()): + if self.search_count( + [ + ("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_count( + [ + ("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 'Serialized' + if "serialized" in list(vals.keys()): + if self.search_count( + [("serialized", "!=", vals["serialized"]), ("id", "in", self.ids)] + ): + raise ValidationError( + _( + """It is not allowed to change the boolean 'Serialized'. + A serialized field can not be change to non-serialized \ + and vice versa.""" + ) + ) + # Set the new values to self + res = super().write(vals) + + for att in self: + options = att.option_ids + if att.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 + + def unlink(self): + """Delete the Attribute's related field when deleting a custom Attribute""" + fields_to_remove = self.filtered(lambda s: s.nature == "custom").mapped( + "field_id" + ) + res = super().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 00000000..b079a2a1 --- /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(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, ondelete="cascade") diff --git a/attribute_set/models/attribute_option.py b/attribute_set/models/attribute_option.py new file mode 100644 index 00000000..ae1e4bb7 --- /dev/null +++ b/attribute_set/models/attribute_option.py @@ -0,0 +1,59 @@ +# 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 _selection_model_list(self): + models = self.env["ir.model"].search([]) + return [(m.model, m.name) for m in models] + + name = fields.Char(translate=True, required=True) + + value_ref = fields.Reference(selection="_selection_model_list", string="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", + ondelete="cascade", + ) + + sequence = fields.Integer() + + @api.onchange("name") + def _onchange_name(self): + """Prevent improper linking of attributes. + + The user could add 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 00000000..c686a05d --- /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(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, ondelete="cascade") diff --git a/attribute_set/models/attribute_set_owner.py b/attribute_set/models/attribute_set_owner.py new file mode 100644 index 00000000..c016d296 --- /dev/null +++ b/attribute_set/models/attribute_set_owner.py @@ -0,0 +1,82 @@ +# 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): + """Mixin for consumers of 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(("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), + ("nature", "=", "native"), + ] + ) + for attr in native_attrs: + efield = eview.xpath("//field[@name='{}']".format(attr.name)) + if len(efield): + efield[0].getparent().remove(efield[0]) + + def _insert_attribute(self, arch): + """Replace attributes' placeholders with real fields in form view arch.""" + 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 "%(name)s" xml + view as there is + not one "" in it. + """, + name=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 get_views(self, views, options=None): + result = super().get_views(views, options=options) + form_arch = result.get("views", {}).get("form", {}).get("arch") + if form_arch: + result["views"]["form"]["arch"] = self._insert_attribute( + result["views"]["form"]["arch"] + ) + return result diff --git a/attribute_set/readme/CONTRIBUTORS.rst b/attribute_set/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..85d78a19 --- /dev/null +++ b/attribute_set/readme/CONTRIBUTORS.rst @@ -0,0 +1,6 @@ +* Sébastien BEAU +* Clément Mombereau +* Benoît Guillot +* Akretion Raphaël VALYI +* David Dufresne +* Denis Roussel diff --git a/attribute_set/readme/DESCRIPTION.rst b/attribute_set/readme/DESCRIPTION.rst new file mode 100644 index 00000000..046d326e --- /dev/null +++ b/attribute_set/readme/DESCRIPTION.rst @@ -0,0 +1,11 @@ +This module allows the user to create Attributes to any model. +This is a basic module in the way that **it does not provide views to display these new Attributes.** + +Each Attribute created will be related to an **existing field** (in case of a *"native"* Attribute) or to a newly **created field** (in case of a *"custom"* Attribute). + +A *"custom"* Attribute can be of any type : Char, Text, Boolean, Date, Binary... but also Many2one or Many2many. + +In case of m2o or m2m, these attributes can be related to **custom options** created for the Attribute, or to **existing Odoo objects** from other models. + +Last but not least an Attribute can be **serialized** using the Odoo SA module `base_sparse_field `_ . +It means that all the serialized attributes will be stored in a single "JSON serialization field" and will not create new columns in the database (and better, it will not create new SQL tables in case of Many2many Attributes), **increasing significantly the requests speed** when dealing with thousands of Attributes. diff --git a/attribute_set/readme/ROADMAP.rst b/attribute_set/readme/ROADMAP.rst new file mode 100644 index 00000000..e69de29b diff --git a/attribute_set/readme/USAGE.rst b/attribute_set/readme/USAGE.rst new file mode 100644 index 00000000..7e3cfa44 --- /dev/null +++ b/attribute_set/readme/USAGE.rst @@ -0,0 +1,17 @@ +Even if this module does not provide views to display some model's Attributes, it provides however a Technical menu in *Settings > Technical > Database Structure > Attributes* to **create new Attributes**. + +An Attribute is related to both an Attribute Group and an Attribute Set : + +- The **Attribute Set** is related to the *"model's category"*, i.e. all the model's instances which will display the same Attributes. +- The **Attribute Group** is related to the *"attribute's category"*. All the attributes from the same Attribute Set and Attribute Group will be displayed under the same field's Group in the model's view. + + + 🔎 In order to create a custom Attribute many2one or many2many related to **other Odoo model**, you need to activate the Technical Setting **"Advanced Attribute Set settings"** (:code:`group_advanced_attribute_set`). + +----- + +If you want to create a module displaying some specific model's Attributes : + +1. Your model must **\_inherit the mixin** :code:`"attribute.set.owner.mixin"` +2. You need to **add a placeholder** :code:`` at the desired location in the model's form view. +3. Finally, **add a context** :code:`{"include_native_attribute": True}` on the action leading to this form view if the model's view needs to display attributes related to native fields together with the other "custom" attributes. diff --git a/attribute_set/security/attribute_security.xml b/attribute_set/security/attribute_security.xml new file mode 100644 index 00000000..edb23d1c --- /dev/null +++ b/attribute_set/security/attribute_security.xml @@ -0,0 +1,8 @@ + + + + Advanced Attribute Set settings + + + + diff --git a/attribute_set/security/ir.model.access.csv b/attribute_set/security/ir.model.access.csv new file mode 100644 index 00000000..c0cd4a5b --- /dev/null +++ b/attribute_set/security/ir.model.access.csv @@ -0,0 +1,11 @@ +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_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 +access_attribute_set_attribute_option_wizard_user,attribute_set_attribute_option_wizard,attribute_set.model_attribute_option_wizard,base.group_user,1,0,0,0 +access_attribute_set_attribute_option_wizard_erpmanager,attribute_set_attribute_option_wizard,attribute_set.model_attribute_option_wizard,base.group_erp_manager,1,1,1,1 diff --git a/attribute_set/static/description/icon.png b/attribute_set/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/attribute_set/static/description/icon.png differ diff --git a/attribute_set/static/description/index.html b/attribute_set/static/description/index.html new file mode 100644 index 00000000..96440d91 --- /dev/null +++ b/attribute_set/static/description/index.html @@ -0,0 +1,452 @@ + + + + + + +Attribute Set + + + +
+

Attribute Set

+ + +

Beta License: AGPL-3 OCA/odoo-pim Translate me on Weblate Try me on Runbot

+

This module allows the user to create Attributes to any model. +This is a basic module in the way that it does not provide views to display these new Attributes.

+

Each Attribute created will be related to an existing field (in case of a “native” Attribute) or to a newly created field (in case of a “custom” Attribute).

+

A “custom” Attribute can be of any type : Char, Text, Boolean, Date, Binary… but also Many2one or Many2many.

+

In case of m2o or m2m, these attributes can be related to custom options created for the Attribute, or to existing Odoo objects from other models.

+

Last but not least an Attribute can be serialized using the Odoo SA module base_sparse_field . +It means that all the serialized attributes will be stored in a single “JSON serialization field” and will not create new columns in the database (and better, it will not create new SQL tables in case of Many2many Attributes), increasing significantly the requests speed when dealing with thousands of Attributes.

+

Table of contents

+ +
+

Usage

+

Even if this module does not provide views to display some model’s Attributes, it provides however a Technical menu in Settings > Technical > Database Structure > Attributes to create new Attributes.

+

An Attribute is related to both an Attribute Group and an Attribute Set :

+
    +
  • The Attribute Set is related to the “model’s category”, i.e. all the model’s instances which will display the same Attributes.

    +
  • +
  • The Attribute Group is related to the “attribute’s category”. All the attributes from the same Attribute Set and Attribute Group will be displayed under the same field’s Group in the model’s view.

    +
    +

    🔎 In order to create a custom Attribute many2one or many2many related to other Odoo model, you need to activate the Technical Setting “Advanced Attribute Set settings” (group_advanced_attribute_set).

    +
    +
  • +
+
+

If you want to create a module displaying some specific model’s Attributes :

+
    +
  1. Your model must _inherit the mixin "attribute.set.owner.mixin"
  2. +
  3. You need to add a placeholder <separator name="attributes_placeholder" /> at the desired location in the model’s form view.
  4. +
  5. Finally, add a context {"include_native_attribute": True} on the action leading to this form view if the model’s view needs to display attributes related to native fields together with the other “custom” attributes.
  6. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/odoo-pim project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/attribute_set/tests/__init__.py b/attribute_set/tests/__init__.py new file mode 100644 index 00000000..884bf61d --- /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 00000000..38a85caa --- /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 00000000..425063d5 --- /dev/null +++ b/attribute_set/tests/test_build_view.py @@ -0,0 +1,306 @@ +# 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 TransactionCase + + +class BuildViewCase(TransactionCase): + @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.partner = cls.env.ref("base.res_partner_12") + 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.attr_1 = cls._create_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( + { + "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( + { + "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( + { + "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_select = cls._create_attribute( + { + "nature": "custom", + "name": "x_attr_select", + "attribute_type": "select", + "attribute_group_id": cls.group_2.id, + "attribute_set_ids": [(6, 0, [cls.set_1.id])], + } + ) + cls.attr_select_option = cls.env["attribute.option"].create( + {"name": "Option 1", "attribute_id": cls.attr_select.id} + ) + cls.attr_native = cls._create_attribute( + { + "nature": "native", + "field_id": cls.env.ref("base.field_res_partner__category_id").id, + "attribute_group_id": cls.group_2.id, + "attribute_set_ids": [(6, 0, [cls.set_1.id, cls.set_2.id])], + } + ) + cls.attr_native_readonly = cls._create_attribute( + { + "nature": "native", + "field_id": cls.env.ref("base.field_res_partner__create_uid").id, + "attribute_group_id": cls.group_2.id, + "attribute_set_ids": [(6, 0, [cls.set_1.id, cls.set_2.id])], + } + ) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + return super(BuildViewCase, cls).tearDownClass() + + # TEST write on attributes + def test_write_attribute_values_text(self): + self.partner.write({"x_attr_2": "abcd"}) + self.assertEqual(self.partner.x_attr_2, "abcd") + + def test_write_attribute_values_select(self): + self.partner.write({"x_attr_select": self.attr_select_option.id}) + self.assertEqual(self.partner.x_attr_select, self.attr_select_option) + + # TEST render partner's view with attribute's place_holder + 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 _check_attrset_required(self, attrs, set_ids): + attrs = ast.literal_eval(attrs) + self.assertIn("required", attrs) + domain = attrs["required"][0] + self.assertEqual("attribute_set_id", domain[0]) + self.assertEqual("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): + attrs = self._get_attr_element("x_attr_1").get("attrs") + attrs = ast.literal_eval(attrs) + self.assertNotIn("required", attrs) + + self.attr_1.required_on_views = True + attrs = self._get_attr_element("x_attr_1").get("attrs") + self._check_attrset_required(attrs, [self.set_1.id]) + + 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( + { + "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 _get_eview_from_get_views(self, include_native_attribute=True): + result = ( + self.env["res.partner"] + .with_context(include_native_attribute=include_native_attribute) + .get_views([(self.view.id, "form")]) + ) + return etree.fromstring(result["views"]["form"]["arch"]) + + def test_include_native_attr(self): + eview = self._get_eview_from_get_views() + 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_native_readonly(self): + eview = self._get_eview_from_get_views() + attr = eview.xpath("//field[@name='{}']".format(self.attr_native_readonly.name)) + self.assertTrue(attr[0].get("readonly")) + + def test_no_include_native_attr(self): + # Run get_views on the test view with no "include_native_attribute" + eview = self._get_eview_from_get_views(include_native_attribute=False) + 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 + ) + ) + ) + + # TESTS UNLINK + 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 00000000..e21dbd22 --- /dev/null +++ b/attribute_set/tests/test_custom_attribute.py @@ -0,0 +1,65 @@ +# 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 unittest import mock + +from odoo.tests import common + + +class TestAttributeSet(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.model_id = cls.env.ref("base.model_res_partner").id + cls.group = cls.env["attribute.group"].create( + {"name": "My Group", "model_id": cls.model_id} + ) + # Do not commit + cls.env.cr.commit = mock.Mock() + + def _create_attribute(self, vals): + vals.update( + { + "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/utils/__init__.py b/attribute_set/utils/__init__.py new file mode 100644 index 00000000..e14e446d --- /dev/null +++ b/attribute_set/utils/__init__.py @@ -0,0 +1 @@ +from . import orm diff --git a/attribute_set/utils/orm.py b/attribute_set/utils/orm.py new file mode 100644 index 00000000..1b5b8dab --- /dev/null +++ b/attribute_set/utils/orm.py @@ -0,0 +1,94 @@ +# Copyright 2020 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json + +from odoo.tools.safe_eval import safe_eval + + +def transfer_field_to_modifiers(field, modifiers): + default_values = {} + state_exceptions = {} + for attr in ("invisible", "readonly", "required"): + state_exceptions[attr] = [] + default_values[attr] = bool(field.get(attr)) + for state, modifs in field.get("states", {}).items(): + for modif in modifs: + if default_values[modif[0]] != modif[1]: + state_exceptions[modif[0]].append(state) + + for attr, default_value in default_values.items(): + if state_exceptions[attr]: + modifiers[attr] = [ + ("state", "not in" if default_value else "in", state_exceptions[attr]) + ] + else: + modifiers[attr] = default_value + + +# Don't deal with groups, it is done by check_group(). +# Need the context to evaluate the invisible attribute on tree views. +# For non-tree views, the context shouldn't be given. +def transfer_node_to_modifiers(node, modifiers, context=None, in_tree_view=False): + if node.get("attrs"): + modifiers.update(safe_eval(node.get("attrs"))) + + if node.get("states"): + if "invisible" in modifiers and isinstance(modifiers["invisible"], list): + # TODO combine with AND or OR, use implicit AND for now. + modifiers["invisible"].append( + ("state", "not in", node.get("states").split(",")) + ) + else: + modifiers["invisible"] = [ + ("state", "not in", node.get("states").split(",")) + ] + + for a in ("invisible", "readonly", "required"): + if node.get(a): + v = bool(safe_eval(node.get(a), {"context": context or {}})) + if in_tree_view and a == "invisible": + # Invisible in a tree view has a specific meaning, make it a + # new key in the modifiers attribute. + modifiers["column_invisible"] = v + elif v or (a not in modifiers or not isinstance(modifiers[a], list)): + # Don't set the attribute to False if a dynamic value was + # provided (i.e. a domain from attrs or states). + modifiers[a] = v + + +def simplify_modifiers(modifiers): + for a in ("invisible", "readonly", "required"): + if a in modifiers and not modifiers[a]: + del modifiers[a] + + +def transfer_modifiers_to_node(modifiers, node): + if modifiers: + simplify_modifiers(modifiers) + node.set("modifiers", json.dumps(modifiers)) + + +def setup_modifiers(node, field=None, context=None, in_tree_view=False): + """Generate ``modifiers`` from node attributes and fields descriptors. + Alters its first argument in-place. + :param node: ``field`` node from an OpenERP view + :type node: lxml.etree._Element + :param dict field: field descriptor corresponding to the provided node + :param dict context: execution context used to evaluate node attributes + :param bool in_tree_view: triggers the ``column_invisible`` code + path (separate from ``invisible``): in + tree view there are two levels of + invisibility, cell content (a column is + present but the cell itself is not + displayed) with ``invisible`` and column + invisibility (the whole column is + hidden) with ``column_invisible``. + :returns: None + """ + modifiers = {} + if field is not None: + transfer_field_to_modifiers(field, modifiers) + transfer_node_to_modifiers( + node, modifiers, context=context, in_tree_view=in_tree_view + ) + transfer_modifiers_to_node(modifiers, node) diff --git a/attribute_set/views/attribute_attribute_view.xml b/attribute_set/views/attribute_attribute_view.xml new file mode 100644 index 00000000..c1bb7042 --- /dev/null +++ b/attribute_set/views/attribute_attribute_view.xml @@ -0,0 +1,191 @@ + + + + attribute.attribute.form + attribute.attribute + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +