diff --git a/base_custom_info/README.rst b/base_custom_info/README.rst index 60eecd504..6d6c57177 100644 --- a/base_custom_info/README.rst +++ b/base_custom_info/README.rst @@ -6,8 +6,126 @@ Base Custom Info ================ -This module allows to create custom fields in models without altering model's -structure. +This module allows you to attach custom information to records without the need +to alter the database structure too much. + +Definitions +=========== + +This module defines several concepts that you have to understand. + +Templates +--------- + +A *template* is a collection of *properties* that a record should have. +*Templates* always apply to a given model, and then you can choose among the +current templates for the model you are using when you edit a record of that +model. + +I.e., This addon includes a demo template called "Smart partners", that applies +to the model ``res.partner``, so if you edit any partner, you can choose that +template and get its properties autofilled. + +Properties +---------- + +A *property* is the "name" of the field. *Templates* can have any amount of +*properties*, and when you apply a *template* to a record, it automatically +gets all of its *properties* filled, empty (unless they have a *Default +value*), ready to assign *values*. + +You can set a property to as *required* to force it have a value, although you +should keep in mind that for yes/no properties, this would mean that only *yes* +can be selected, and for numeric properties, zero would be forbidden. + +Also you can set *Minimum* and *Maximum* limits for every *property*, but those +limits are only used when the data type is text (to constrain its length) or +number. To skip this constraint, just set a maximum smaller than the minimum. + +*Properties* always belong to a template, and as such, to a model. + +*Properties* define the data type (text, number, yes/no...), and when the type +is "Selection", then you can define what *options* are available. + +I.e., the "Smart partners" *template* has the following *properties*: + +- Name of his/her teacher +- Amount of people that hates him/her for being so smart +- Average note on all subjects +- Does he/she believe he/she is the smartest person on earth? +- What weaknesses does he/she have? + +When you set that template to any partner, you will then be able to fill these +*properties* with *values*. + +Categories +---------- + +*Properties* can also belong to a *category*, which allows you to sort them in +a logical way, and makes further development easier. + +For example, the ``website_sale_custom_info`` addon uses these to display a +technical datasheet per product in your online shop, sorted and separated by +category. + +You are not required to give a *category* to every *property*. + +Options +------- + +When a *property*'s type is "Selection", then you define the *options* +available, so the *value* must be one of these *options*. + +I.e., the "What weaknesses does he/she have?" *property* has some options: + +- Loves junk food +- Needs videogames +- Huge glasses + +The *value* will always be one of these. + +Value +----- + +When you assign a *template* to a partner, and then you get the *properties* it +should have, you still have to set a *value* for each property. + +*Values* can be of different types (whole numbers, constrained selection, +booleans...), depending on how the *property* was defined. However, there is +always the ``value`` field, that is a text string, and converts automatically +to/from the correct type. + +Why would I need this? +~~~~~~~~~~~~~~~~~~~~~~ + +Imagine you have some partners that are foreign, and that for those partners +you need some extra information that is not needed for others, and you do not +want to fill the partners model with a lot of fields that will be empty most of +the time. + +In this case, you could define a *template* called "Foreign partners", which +will be applied to ``res.partner`` objects, and defines some *properties* that +these are expected to have. + +Then you could assign that *template* to a partner, and automatically you will +get a subtable of all the properties it should have, with tools to fill their +*values* correctly. + +Does this work with any model? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Yes and no. + +Yes, because this is a base module that provides the tools to make this work +with any model. + +No, because, although the tools are provided, they are only applied to the +``res.partner`` model. This is by design, because different models can have +different needs, and we don't want to depend on every possible model. + +So, if you want to apply this to other models, you will have to develop a +little additional addon that depends on this one. If you are a developer, refer +to the *Development* section below. Installation ============ @@ -18,6 +136,17 @@ concrete models. This module is a technical dependency and is to be installed in parallel to other modules. +Configuration +============= + +To enable the main *Custom Info* menu: + +#. Enable *Settings > General Settings > Manage custom information*. + +To enable partner's custom info tab: + +#. Enable *Settings > General Settings > Edit custom information in partners*. + Usage ===== @@ -26,7 +155,7 @@ expected for a given record. To define a template, you need to: -* Go to *Settings > Custom Info > Templates*. +* Go to *Custom Info > Templates*. * Create one. * Add some *Properties* to it. @@ -35,11 +164,19 @@ properties. To manage the properties, you need to: -* Go to *Settings > Custom Info > Properties*. +* Go to *Custom Info > Properties*. + +To manage the property categories, you need to: + +* Go to *Custom Info > Categories*. + +Some properties can have a number of options to choose, to manage them: + +* Go to *Custom Info > Options*. To manage their values, you need to: -* Go to *Settings > Custom Info > Values*. +* Go to *Custom Info > Values*. .. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas :alt: Try me on Runbot @@ -51,10 +188,19 @@ Development To create a module that supports custom information, just depend on this module and inherit from the ``custom.info`` model. +See an example in the ``product_custom_info`` addon. + Known issues / Roadmap ====================== -* All data types of custom information values are text strings. +* Custom properties cannot be shared among templates. +* You get an error if you press *Save & New* when setting property values in + partner form. +* `There seems to be a bug in the web client that makes subviews appear empty + after an onchange + `_. + This module includes a workaround for that, but the bug should be fixed and + the workaround removed. Bug Tracker =========== diff --git a/base_custom_info/__init__.py b/base_custom_info/__init__.py index cc6e9affa..a518dce55 100644 --- a/base_custom_info/__init__.py +++ b/base_custom_info/__init__.py @@ -3,4 +3,4 @@ # © 2015 Antiun Ingeniería S.L. - Carlos Dauden # License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html -from . import models +from . import models, wizard diff --git a/base_custom_info/__manifest__.py b/base_custom_info/__manifest__.py index 603362e2f..907468d1e 100644 --- a/base_custom_info/__manifest__.py +++ b/base_custom_info/__manifest__.py @@ -8,16 +8,27 @@ 'name': "Base Custom Info", 'summary': "Add custom field in models", 'category': 'Tools', - 'version': '9.0.1.0.0', + 'version': '9.0.2.0.0', 'depends': [ - 'base', + 'base_setup', ], 'data': [ + 'security/ir.model.access.csv', + 'security/res_groups.xml', + 'views/custom_info_category_view.xml', + 'views/custom_info_option_view.xml', 'views/custom_info_template_view.xml', 'views/custom_info_property_view.xml', 'views/custom_info_value_view.xml', 'views/menu.xml', - 'security/ir.model.access.csv', + 'views/res_partner_view.xml', + 'wizard/base_config_settings_view.xml', + ], + 'demo': [ + 'demo/custom.info.template.csv', + 'demo/custom.info.property.csv', + 'demo/custom.info.option.csv', + 'demo/res_groups.xml', ], "images": [ "images/menu.png", @@ -29,7 +40,7 @@ 'Incaser Informatica S.L., ' 'Tecnativa, ' 'Odoo Community Association (OCA)', - 'website': 'http://www.antiun.com', + 'website': 'https://www.tecnativa.com', 'license': 'AGPL-3', 'installable': True, } diff --git a/base_custom_info/demo/custom.info.option.csv b/base_custom_info/demo/custom.info.option.csv new file mode 100644 index 000000000..aa072dc80 --- /dev/null +++ b/base_custom_info/demo/custom.info.option.csv @@ -0,0 +1,4 @@ +id,name,property_ids:id +opt_food,Loves junk food,prop_weaknesses +opt_videogames,Needs videogames,prop_weaknesses +opt_glasses,Huge glasses,prop_weaknesses diff --git a/base_custom_info/demo/custom.info.property.csv b/base_custom_info/demo/custom.info.property.csv new file mode 100644 index 000000000..64c3a3066 --- /dev/null +++ b/base_custom_info/demo/custom.info.property.csv @@ -0,0 +1,6 @@ +id,name,template_id:id,field_type,default_value,required +prop_teacher,Name of his/her teacher,tpl_smart,str,, +prop_haters,Amount of people that hates him/her for being so smart,tpl_smart,int,, +prop_avg_note,Average note on all subjects,tpl_smart,float,,True +prop_smartypants,Does he/she believe he/she is the smartest person on earth?,tpl_smart,bool,, +prop_weaknesses,What weaknesses does he/she have?,tpl_smart,id,Huge glasses, diff --git a/base_custom_info/demo/custom.info.template.csv b/base_custom_info/demo/custom.info.template.csv new file mode 100644 index 000000000..1ec9e2bfd --- /dev/null +++ b/base_custom_info/demo/custom.info.template.csv @@ -0,0 +1,2 @@ +id,name,model +tpl_smart,Smart partners,res.partner diff --git a/base_custom_info/demo/res_groups.xml b/base_custom_info/demo/res_groups.xml new file mode 100644 index 000000000..be6dcbc2f --- /dev/null +++ b/base_custom_info/demo/res_groups.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/base_custom_info/migrations/9.0.2.0.0/pre-migrate.py b/base_custom_info/migrations/9.0.2.0.0/pre-migrate.py new file mode 100644 index 000000000..574e261dd --- /dev/null +++ b/base_custom_info/migrations/9.0.2.0.0/pre-migrate.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Jairo Llopis +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# pragma: no-cover + + +def migrate(cr, version): + """Update database from previous versions, before updating module.""" + cr.execute( + "ALTER TABLE custom_info_value RENAME COLUMN value TO value_str") diff --git a/base_custom_info/models/__init__.py b/base_custom_info/models/__init__.py index 4ce49af98..e0593f285 100644 --- a/base_custom_info/models/__init__.py +++ b/base_custom_info/models/__init__.py @@ -1,6 +1,15 @@ # -*- coding: utf-8 -*- # © 2015 Antiun Ingeniería S.L. - Sergio Teruel # © 2015 Antiun Ingeniería S.L. - Carlos Dauden +# © 2016 Jairo Llopis # License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html -from . import custom_info +from . import ( + custom_info_template, + custom_info_property, + custom_info_category, + custom_info_option, + custom_info_value, + custom_info, + res_partner, +) diff --git a/base_custom_info/models/custom_info.py b/base_custom_info/models/custom_info.py index 297a25ade..7ef469e3a 100644 --- a/base_custom_info/models/custom_info.py +++ b/base_custom_info/models/custom_info.py @@ -1,121 +1,61 @@ # -*- coding: utf-8 -*- # © 2015 Antiun Ingeniería S.L. - Sergio Teruel # © 2015 Antiun Ingeniería S.L. - Carlos Dauden -# © 2015 Antiun Ingeniería S.L. - Jairo Llopis +# © 2016 Jairo Llopis # License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html from openerp import api, fields, models -class CustomInfoModelLink(models.AbstractModel): - _description = "A model that gets its ``ir.model`` computed" - _name = "custom.info.model_link" - - model = fields.Char( - index=True, - readonly=True, - required=True) - model_id = fields.Many2one( - 'ir.model', - 'Model', - compute="_compute_model_id", - store=True) - - @api.multi - @api.depends("model") - def _compute_model_id(self): - """Get a related model from its name, for better UI.""" - for s in self: - s.model_id = self.env["ir.model"].search([("model", "=", s.model)]) - - -class CustomInfoTemplate(models.Model): - """Defines custom properties expected for a given database object.""" - _description = "Custom information template" - _name = "custom.info.template" - _inherit = "custom.info.model_link" - _sql_constraints = [ - ("name_model", - "UNIQUE (name, model)", - "Another template with that name exists for that model."), - ] - - name = fields.Char(required=True, translate=True) - info_ids = fields.One2many( - 'custom.info.property', - 'template_id', - 'Properties') - - -class CustomInfoProperty(models.Model): - """Name of the custom information property.""" - _description = "Custom information property" - _name = "custom.info.property" - _sql_constraints = [ - ("name_template", - "UNIQUE (name, template_id)", - "Another property with that name exists for that template."), - ] - - name = fields.Char(required=True, translate=True) - template_id = fields.Many2one( - comodel_name='custom.info.template', - string='Template', - required=True) - info_value_ids = fields.One2many( - comodel_name="custom.info.value", - inverse_name="property_id", - string="Property Values") - - -class CustomInfoValue(models.Model): - _description = "Custom information value" - _name = "custom.info.value" - _inherit = "custom.info.model_link" - _rec_name = 'value' - _sql_constraints = [ - ("property_model_res", - "UNIQUE (property_id, model, res_id)", - "Another property with that name exists for that resource."), - ] - - res_id = fields.Integer("Resource ID", index=True, required=True) - property_id = fields.Many2one( - comodel_name='custom.info.property', - required=True, - string='Property') - name = fields.Char(related='property_id.name', readonly=True) - value = fields.Char(translate=True, index=True) +class CustomInfo(models.AbstractModel): + """Models that inherit this one will get custom information for free! + They will probably want to declare a default model in the context of the + :attr:`custom_info_template_id` field. -class CustomInfo(models.AbstractModel): + See example in :mod:`res_partner`. + """ _description = "Inheritable abstract model to add custom info in any model" _name = "custom.info" custom_info_template_id = fields.Many2one( comodel_name='custom.info.template', + domain=lambda self: [("model", "=", self._name)], string='Custom Information Template') custom_info_ids = fields.One2many( comodel_name='custom.info.value', inverse_name='res_id', domain=lambda self: [("model", "=", self._name)], + context={"embed": True}, auto_join=True, string='Custom Properties') @api.multi @api.onchange('custom_info_template_id') def _onchange_custom_info_template_id(self): - if not self.custom_info_template_id: - self.custom_info_ids = False - else: - info_list = self.custom_info_ids.mapped('property_id') - for info_name in self.custom_info_template_id.info_ids: - if info_name not in info_list: - self.custom_info_ids |= self.custom_info_ids.new({ - 'model': self._name, - 'property_id': info_name.id, - "res_id": self.id, - }) + new = [(5, False, False)] + for prop in self.custom_info_template_id.property_ids: + new += [(0, False, { + "property_id": prop.id, + "res_id": self.id, + "model": self._name, + })] + self.custom_info_ids = new + self.custom_info_ids._onchange_property_set_default_value() + self.custom_info_ids._inverse_value() + self.custom_info_ids._compute_value() + + # HACK https://github.com/OCA/server-tools/pull/492#issuecomment-237594285 + @api.multi + def onchange(self, values, field_name, field_onchange): + """Add custom info children values that will be probably changed.""" + subfields = ("category_id", "field_type", "required", "property_id", + "res_id", "model", "value", "value_str", "value_int", + "value_float", "value_bool", "value_id") + for subfield in subfields: + field_onchange.setdefault("custom_info_ids." + subfield, "") + return super(CustomInfo, self).onchange( + values, field_name, field_onchange) @api.multi def unlink(self): @@ -124,3 +64,13 @@ class CustomInfo(models.AbstractModel): if res: info_values.unlink() return res + + @api.multi + @api.returns("custom.info.value") + def get_custom_info_value(self, properties): + """Get ``custom.info.value`` records for the given property.""" + return self.env["custom.info.value"].search([ + ("model", "=", self._name), + ("res_id", "in", self.ids), + ("property_id", "in", properties.ids), + ]) diff --git a/base_custom_info/models/custom_info_category.py b/base_custom_info/models/custom_info_category.py new file mode 100644 index 000000000..f48551471 --- /dev/null +++ b/base_custom_info/models/custom_info_category.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# © 2016 Jairo Llopis +# License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html + +from openerp import api, fields, models + + +class CustomInfoCategory(models.Model): + _description = "Categorize custom info properties" + _name = "custom.info.category" + _order = "sequence, name" + + name = fields.Char(index=True, translate=True, required=True) + sequence = fields.Integer(index=True) + property_ids = fields.One2many( + comodel_name="custom.info.property", + inverse_name="category_id", + string="Properties", + help="Properties in this category.", + ) + + @api.multi + def check_access_rule(self, operation): + """You access a category if you access at least one property.""" + last = None + for prop in self.mapped("property_ids"): + try: + prop.check_access_rule(operation) + return + except Exception as last: + pass + if last: + raise last + return super(CustomInfoCategory, self).check_access_rule(operation) diff --git a/base_custom_info/models/custom_info_option.py b/base_custom_info/models/custom_info_option.py new file mode 100644 index 000000000..08d812c41 --- /dev/null +++ b/base_custom_info/models/custom_info_option.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# © 2016 Jairo Llopis +# License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html + +from openerp import api, fields, models + + +class CustomInfoOption(models.Model): + _description = "Available options for a custom property" + _name = "custom.info.option" + _order = "name" + + name = fields.Char(index=True, translate=True, required=True) + property_ids = fields.Many2many( + comodel_name="custom.info.property", + string="Properties", + help="Properties where this option is enabled.", + ) + value_ids = fields.One2many( + comodel_name="custom.info.value", + inverse_name="value_id", + string="Values", + help="Values that have set this option.", + ) + + @api.multi + def check_access_rule(self, operation): + """You access an option if you access at least one property.""" + last = None + for prop in self.mapped("property_ids"): + try: + prop.check_access_rule(operation) + return + except Exception as last: + pass + if last: + raise last + return super(CustomInfoOption, self).check_access_rule(operation) diff --git a/base_custom_info/models/custom_info_property.py b/base_custom_info/models/custom_info_property.py new file mode 100644 index 000000000..67917cff3 --- /dev/null +++ b/base_custom_info/models/custom_info_property.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# © 2016 Jairo Llopis +# License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html +from openerp import _, api, fields, models +from openerp.exceptions import UserError, ValidationError + + +class CustomInfoProperty(models.Model): + """Name of the custom information property.""" + _description = "Custom information property" + _name = "custom.info.property" + _order = "template_id, category_sequence, category_id, sequence, id" + _sql_constraints = [ + ("name_template", + "UNIQUE (name, template_id)", + "Another property with that name exists for that template."), + ] + + name = fields.Char(required=True, translate=True) + sequence = fields.Integer(index=True) + category_id = fields.Many2one( + comodel_name="custom.info.category", + string="Category", + ) + category_sequence = fields.Integer( + related="category_id.sequence", + store=True, + readonly=True, + ) + template_id = fields.Many2one( + comodel_name='custom.info.template', + string='Template', + required=True) + model = fields.Char( + related="template_id.model", + readonly=True, + auto_join=True, + ) + info_value_ids = fields.One2many( + comodel_name="custom.info.value", + inverse_name="property_id", + string="Property Values") + default_value = fields.Char( + translate=True, + help="Will be applied by default to all custom values of this " + "property. This is a char field, so you have to enter some value " + "that can be converted to the field type you choose.", + ) + required = fields.Boolean() + minimum = fields.Float( + help="For numeric fields, it means the minimum possible value; " + "for text fields, it means the minimum possible length. " + "If it is bigger than the maximum, then this check is skipped", + ) + maximum = fields.Float( + default=-1, + help="For numeric fields, it means the maximum possible value; " + "for text fields, it means the maximum possible length. " + "If it is smaller than the minimum, then this check is skipped", + ) + field_type = fields.Selection( + selection=[ + ("str", "Text"), + ("int", "Whole number"), + ("float", "Decimal number"), + ("bool", "Yes/No"), + ("id", "Selection"), + ], + default="str", + required=True, + help="Type of information that can be stored in the property.", + ) + option_ids = fields.Many2many( + comodel_name="custom.info.option", + string="Options", + help="When the field type is 'selection', choose the available " + "options here.", + ) + + @api.multi + def check_access_rule(self, operation): + """You access a property if you access its template.""" + self.mapped("template_id").check_access_rule(operation) + return super(CustomInfoProperty, self).check_access_rule(operation) + + @api.one + @api.constrains("default_value", "field_type") + def _check_default_value(self): + """Ensure the default value is valid.""" + if self.default_value: + try: + self.env["custom.info.value"]._transform_value( + self.default_value, self.field_type, self) + except ValueError: + selection = dict( + self._fields["field_type"].get_description(self.env) + ["selection"]) + raise ValidationError( + _("Default value %s cannot be converted to type %s.") % + (self.default_value, selection[self.field_type])) + + @api.multi + @api.onchange("required", "field_type") + def _onchange_required_warn(self): + """Warn if the required flag implies a possible weird behavior.""" + if self.required: + if self.field_type == "bool": + raise UserError( + _("If you require a Yes/No field, you can only set Yes.")) + if self.field_type in {"int", "float"}: + raise UserError( + _("If you require a numeric field, you cannot set it to " + "zero.")) diff --git a/base_custom_info/models/custom_info_template.py b/base_custom_info/models/custom_info_template.py new file mode 100644 index 000000000..16e814e0e --- /dev/null +++ b/base_custom_info/models/custom_info_template.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# © 2016 Jairo Llopis +# License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html + +from openerp import _, api, fields, models +from openerp.exceptions import ValidationError + + +class CustomInfoTemplate(models.Model): + """Defines custom properties expected for a given database object.""" + _description = "Custom information template" + _name = "custom.info.template" + _order = "model_id, name" + _sql_constraints = [ + ("name_model", + "UNIQUE (name, model)", + "Another template with that name exists for that model."), + ] + + name = fields.Char(required=True, translate=True) + model = fields.Char( + index=True, + readonly=True, + required=True) + model_id = fields.Many2one( + 'ir.model', + 'Model', + compute="_compute_model_id", + store=True, + ondelete="cascade", + ) + property_ids = fields.One2many( + 'custom.info.property', + 'template_id', + 'Properties', + oldname="info_ids", + context={"embed": True}, + ) + + @api.multi + @api.depends("model") + def _compute_model_id(self): + """Get a related model from its name, for better UI.""" + for s in self: + s.model_id = self.env["ir.model"].search([("model", "=", s.model)]) + + @api.multi + @api.constrains("model") + def _check_model(self): + """Ensure model exists.""" + for s in self: + if s.model not in self.env: + raise ValidationError(_("Model does not exist.")) + # Avoid error when updating base module and a submodule extends a + # model that falls out of this one's dependency graph + with self.env.norecompute(): + oldmodels = set(s.mapped("property_ids.info_value_ids.model")) + if oldmodels and {s.model} != oldmodels: + raise ValidationError( + _("You cannot change the model because it is in use.")) + + @api.multi + def check_access_rule(self, operation): + """You access a template if you access its model.""" + for s in self: + model = self.env[s.model] + model.check_access_rights(operation) + model.check_access_rule(operation) + return super(CustomInfoTemplate, self).check_access_rule(operation) diff --git a/base_custom_info/models/custom_info_value.py b/base_custom_info/models/custom_info_value.py new file mode 100644 index 000000000..321b25f22 --- /dev/null +++ b/base_custom_info/models/custom_info_value.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +# © 2016 Jairo Llopis +# License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html +from openerp import _, api, fields, models, SUPERUSER_ID +from openerp.exceptions import ValidationError +from openerp.tools.safe_eval import safe_eval + + +class CustomInfoValue(models.Model): + _description = "Custom information value" + _name = "custom.info.value" + _rec_name = 'value' + _order = ("model, res_id, category_sequence, category_id, " + "property_sequence, property_id") + _sql_constraints = [ + ("property_owner", + "UNIQUE (property_id, model, res_id)", + "Another property with that name exists for that resource."), + ] + + model = fields.Char( + related="property_id.model", + index=True, + readonly=True, + auto_join=True, + store=True, + ) + owner_id = fields.Reference( + selection="_selection_owner_id", + string="Owner", + compute="_compute_owner_id", + inverse="_inverse_owner_id", + help="Record that owns this custom value.", + ) + res_id = fields.Integer( + "Resource ID", + required=True, + index=True, + store=True, + ondelete="cascade", + ) + property_id = fields.Many2one( + comodel_name='custom.info.property', + required=True, + string='Property') + property_sequence = fields.Integer( + related="property_id.sequence", + store=True, + index=True, + readonly=True, + ) + category_sequence = fields.Integer( + related="property_id.category_id.sequence", + store=True, + readonly=True, + ) + category_id = fields.Many2one( + related="property_id.category_id", + store=True, + readonly=True, + ) + name = fields.Char(related='property_id.name', readonly=True) + field_type = fields.Selection(related="property_id.field_type") + field_name = fields.Char( + compute="_compute_field_name", + help="Technical name of the field where the value is stored.", + ) + required = fields.Boolean(related="property_id.required") + value = fields.Char( + compute="_compute_value", + inverse="_inverse_value", + search="_search_value", + help="Value, always converted to/from the typed field.", + ) + value_str = fields.Char( + string="Text value", + translate=True, + index=True, + ) + value_int = fields.Integer( + string="Whole number value", + index=True, + ) + value_float = fields.Float( + string="Decimal number value", + index=True, + ) + value_bool = fields.Boolean( + string="Yes/No value", + index=True, + ) + value_id = fields.Many2one( + comodel_name="custom.info.option", + string="Selection value", + ondelete="cascade", + domain="[('property_ids', 'in', [property_id])]", + ) + + @api.multi + def check_access_rule(self, operation): + """You access a value if you access its property and owner record.""" + if self.env.uid == SUPERUSER_ID: + return + for s in self: + s.property_id.check_access_rule(operation) + s.owner_id.check_access_rights(operation) + s.owner_id.check_access_rule(operation) + return super(CustomInfoValue, self).check_access_rule(operation) + + @api.model + def create(self, vals): + """Skip constrains in 1st lap.""" + # HACK https://github.com/odoo/odoo/pull/13439 + if "value" in vals: + self.env.context.skip_required = True + return super(CustomInfoValue, self).create(vals) + + @api.model + def _selection_owner_id(self): + """You can choose among models linked to a template.""" + models = self.env["ir.model.fields"].search([ + ("ttype", "=", "many2one"), + ("relation", "=", "custom.info.template"), + ("model_id.transient", "=", False), + "!", ("model", "=like", "custom.info.%"), + ]).mapped("model_id") + models = models.search([("id", "in", models.ids)], order="name") + return [(m.model, m.name) for m in models + if m.model in self.env and self.env[m.model]._auto] + + @api.multi + @api.depends("property_id.field_type") + def _compute_field_name(self): + """Get the technical name where the real typed value is stored.""" + for s in self: + s.field_name = "value_{!s}".format(s.property_id.field_type) + + @api.multi + @api.depends("res_id", "model") + def _compute_owner_id(self): + """Get the id from the linked record.""" + for s in self: + s.owner_id = "{},{}".format(s.model, s.res_id) + + @api.multi + def _inverse_owner_id(self): + """Store the owner according to the model and ID.""" + for s in self: + s.model = s.owner_id._name + s.res_id = s.owner_id.id + + @api.multi + @api.depends("property_id.field_type", "field_name", "value_str", + "value_int", "value_float", "value_bool", "value_id") + def _compute_value(self): + """Get the value as a string, from the original field.""" + for s in self: + if s.field_type == "id": + s.value = ", ".join(s.value_id.mapped("display_name")) + elif s.field_type == "bool": + s.value = _("Yes") if s.value_bool else _("No") + else: + s.value = getattr(s, s.field_name, False) + + @api.multi + def _inverse_value(self): + """Write the value correctly converted in the typed field.""" + for s in self: + s[s.field_name] = self._transform_value( + s.value, s.field_type, s.property_id) + + @api.one + @api.constrains("required", "field_name", "value_str", "value_int", + "value_float", "value_bool", "value_id") + def _check_required(self): + """Ensure required fields are filled""" + # HACK https://github.com/odoo/odoo/pull/13439 + try: + del self.env.context.skip_required + except AttributeError: + if self.required and not self[self.field_name]: + raise ValidationError( + _("Property %s is required.") % + self.property_id.display_name) + + @api.one + @api.constrains("property_id", "field_type", "field_name", + "value_str", "value_int", "value_float") + def _check_min_max_limits(self): + """Ensure value falls inside the property's stablished limits.""" + minimum, maximum = self.property_id.minimum, self.property_id.maximum + if minimum <= maximum: + value = self[self.field_name] + if not value: + # This is a job for :meth:`.~_check_required` + return + if self.field_type == "str": + number = len(self.value_str) + message = _( + "Length for %(prop)s is %(val)s, but it should be " + "between %(min)d and %(max)d.") + elif self.field_type in {"int", "float"}: + number = value + if self.field_type == "int": + message = _( + "Value for %(prop)s is %(val)s, but it should be " + "between %(min)d and %(max)d.") + else: + message = _( + "Value for %(prop)s is %(val)s, but it should be " + "between %(min)f and %(max)f.") + else: + return + if not minimum <= number <= maximum: + raise ValidationError(message % { + "prop": self.property_id.display_name, + "val": number, + "min": minimum, + "max": maximum, + }) + + @api.multi + @api.onchange("property_id") + def _onchange_property_set_default_value(self): + """Load default value for this property.""" + for record in self: + if not record.value and record.property_id.default_value: + record.value = record.property_id.default_value + + @api.model + def _transform_value(self, value, format_, properties=None): + """Transforms a text value to the expected format. + + :param str/bool value: + Custom value in raw string. + + :param str format_: + Target conversion format for the value. Must be available among + ``custom.info.property`` options. + + :param recordset properties: + Useful when :param:`format_` is ``id``, as it helps to ensure the + option is available in these properties. If :param:`format_` is + ``id`` and :param:`properties` is ``None``, no transformation will + be made for :param:`value`. + """ + if not value: + value = False + elif format_ == "id" and properties: + value = self.env["custom.info.option"].search( + [("property_ids", "in", properties.ids), + ("name", "=ilike", value)]) + value.ensure_one() + elif format_ == "bool": + value = value.strip().lower() not in { + "0", "false", "", "no", "off", _("No").lower()} + elif format_ not in {"str", "id"}: + value = safe_eval("{!s}({!r})".format(format_, value)) + return value + + @api.model + def _search_value(self, operator, value): + """Search from the stored field directly.""" + options = ( + o[0] for o in + self.property_id._fields["field_type"] + .get_description(self.env)["selection"]) + domain = [] + for fmt in options: + try: + _value = (self._transform_value(value, fmt) + if not isinstance(value, list) else + [self._transform_value(v, fmt) for v in value]) + except ValueError: + # If you are searching something that cannot be casted, then + # your property is probably from another type + continue + domain += [ + "&", + ("field_type", "=", fmt), + ("value_" + fmt, operator, _value), + ] + return ["|"] * (len(domain) / 3 - 1) + domain diff --git a/base_custom_info/models/res_partner.py b/base_custom_info/models/res_partner.py new file mode 100644 index 000000000..cc431bfbf --- /dev/null +++ b/base_custom_info/models/res_partner.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Jairo Llopis +# License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html + +from openerp import fields, models + + +class ResPartner(models.Model): + """Implement custom information for partners. + + Besides adding some visible feature to the module, this is useful for + testing and example purposes. + """ + _name = "res.partner" + _inherit = [_name, "custom.info"] + + custom_info_template_id = fields.Many2one( + context={"default_model": _name}, + ) + custom_info_ids = fields.One2many( + context={"default_model": _name}, + ) diff --git a/base_custom_info/security/ir.model.access.csv b/base_custom_info/security/ir.model.access.csv index d285e7021..ee65f8e15 100644 --- a/base_custom_info/security/ir.model.access.csv +++ b/base_custom_info/security/ir.model.access.csv @@ -1,7 +1,6 @@ -"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" -"access_custom_info_template_user","custom.info.template.user","model_custom_info_template","base.group_user",1,0,0,0 -"access_custom_info_property_user","custom.info.template.line.user","model_custom_info_property","base.group_user",1,0,0,0 -"access_custom_info_value_user","custom.info.value.user","model_custom_info_value","base.group_user",1,0,0,0 -"access_custom_info_template_sale_manager","custom.info.template.salemanager","model_custom_info_template","base.group_system",1,1,1,1 -"access_custom_info_property_sale_manager","custom.info.template.line.salemanager","model_custom_info_property","base.group_system",1,1,1,1 -"access_custom_info_value_sale_manager","custom.info.value.salemanager","model_custom_info_value","base.group_system",1,1,1,1 +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_template,Access custom info templates,model_custom_info_template,,1,1,1,1 +access_property,Access custom info properties,model_custom_info_property,,1,1,1,1 +access_value,Access custom info values,model_custom_info_value,,1,1,1,1 +access_option,Access custom info options,model_custom_info_option,,1,1,1,1 +access_category,Access custom info categories,model_custom_info_category,,1,1,1,1 diff --git a/base_custom_info/security/res_groups.xml b/base_custom_info/security/res_groups.xml new file mode 100644 index 000000000..684c141c8 --- /dev/null +++ b/base_custom_info/security/res_groups.xml @@ -0,0 +1,31 @@ + + + + + + + Custom Information + + + + Display in partner form + + Will be able to edit custom information from partner's form. + + + + Basic management + + The user will be able to manage basic custom information. + + + + Advanced management + + The user will be able to manage advanced custom information. + + + + + diff --git a/base_custom_info/tests/__init__.py b/base_custom_info/tests/__init__.py new file mode 100644 index 000000000..18f016dd7 --- /dev/null +++ b/base_custom_info/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Jairo Llopis +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import test_partner, test_value_conversion diff --git a/base_custom_info/tests/test_partner.py b/base_custom_info/tests/test_partner.py new file mode 100644 index 000000000..ee39778ce --- /dev/null +++ b/base_custom_info/tests/test_partner.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Jairo Llopis +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from openerp.exceptions import AccessError, ValidationError +from openerp.tests.common import TransactionCase + + +class PartnerCase(TransactionCase): + def setUp(self, *args, **kwargs): + super(PartnerCase, self).setUp(*args, **kwargs) + self.agrolait = self.env.ref("base.res_partner_2") + self.tpl = self.env.ref("base_custom_info.tpl_smart") + self.demouser = self.env.ref("base.user_demo") + + def set_custom_info_for_agrolait(self): + """Used when you need to use some created custom info.""" + self.agrolait.custom_info_template_id = self.tpl + self.env["custom.info.value"].create({ + "res_id": self.agrolait.id, + "property_id": self.env.ref("base_custom_info.prop_haters").id, + "value_int": 5, + }) + + def test_access_granted(self): + """Access to the model implies access to custom info.""" + # Demo user has contact creation permissions by default + agrolait = self.agrolait.sudo(self.demouser) + agrolait.custom_info_template_id = self.tpl + agrolait.env["custom.info.value"].create({ + "res_id": agrolait.id, + "property_id": + agrolait.env.ref("base_custom_info.prop_weaknesses").id, + "value_id": agrolait.env.ref("base_custom_info.opt_food").id, + }) + agrolait.custom_info_template_id.property_ids[0].name = "Changed!" + agrolait.env.ref("base_custom_info.opt_food").name = "Changed!" + + def test_access_denied(self): + """Forbidden access to the model forbids it to custom info.""" + # Remove permissions to demo user + self.demouser.groups_id = self.env.ref("base.group_portal") + + agrolait = self.agrolait.sudo(self.demouser) + with self.assertRaises(AccessError): + agrolait.custom_info_template_id = self.tpl + + with self.assertRaises(AccessError): + agrolait.env["custom.info.value"].create({ + "res_id": agrolait.id, + "property_id": + agrolait.env.ref("base_custom_info.prop_weaknesses").id, + "value_id": agrolait.env.ref("base_custom_info.opt_food").id, + }) + + with self.assertRaises(AccessError): + agrolait.custom_info_template_id.property_ids[0].name = "Changed!" + + with self.assertRaises(AccessError): + agrolait.env.ref("base_custom_info.opt_food").name = "Changed!" + + def test_apply_unapply_template(self): + """(Un)apply a template to a owner and it gets filled.""" + # Applying a template autofills the values + self.agrolait.custom_info_template_id = self.tpl + with self.env.do_in_onchange(): + self.agrolait._onchange_custom_info_template_id() + self.assertEqual( + len(self.agrolait.custom_info_ids), + len(self.tpl.property_ids)) + self.assertEqual( + self.agrolait.custom_info_ids.mapped("property_id"), + self.tpl.property_ids) + + # Unapplying a template empties the values + self.agrolait.custom_info_template_id = False + self.agrolait._onchange_custom_info_template_id() + self.assertFalse(self.agrolait.custom_info_template_id) + + def test_template_model_and_model_id_match(self): + """Template's model and model_id fields match.""" + self.assertEqual(self.tpl.model, self.tpl.model_id.model) + self.tpl.model = "res.users" + self.assertEqual(self.tpl.model, self.tpl.model_id.model) + + def test_template_model_must_exist(self): + """Cannot create templates for unexisting models.""" + with self.assertRaises(ValidationError): + self.tpl.model = "yabadabaduu" + + def test_change_used_model_fails(self): + """If a template's model is already used, you cannot change it.""" + self.set_custom_info_for_agrolait() + with self.assertRaises(ValidationError): + self.tpl.model = "res.users" + + def test_owners_selection(self): + """Owners selection includes only the required matches.""" + choices = dict(self.env["custom.info.value"]._selection_owner_id()) + self.assertIn("res.partner", choices) + self.assertNotIn("ir.model", choices) + self.assertNotIn("custom.info.property", choices) + self.assertNotIn("custom.info", choices) + + def test_owner_id(self): + """Check the computed owner id for a value.""" + self.set_custom_info_for_agrolait() + self.assertEqual(self.agrolait.custom_info_ids.owner_id, self.agrolait) + + def test_get_custom_info_value(self): + """Check the custom info getter helper works fine.""" + self.set_custom_info_for_agrolait() + result = self.agrolait.get_custom_info_value( + self.env.ref("base_custom_info.prop_haters")) + self.assertEqual(result.field_type, "int") + self.assertEqual(result.field_name, "value_int") + self.assertEqual(result[result.field_name], 5) + self.assertEqual(result.value_int, 5) + self.assertEqual(result.value, "5") diff --git a/base_custom_info/tests/test_value_conversion.py b/base_custom_info/tests/test_value_conversion.py new file mode 100644 index 000000000..1f18c2dcf --- /dev/null +++ b/base_custom_info/tests/test_value_conversion.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Jairo Llopis +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import logging + +from openerp.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + + +class ValueConversionCase(TransactionCase): + def setUp(self): + super(ValueConversionCase, self).setUp() + self.agrolait = self.env.ref("base.res_partner_2") + self.tpl = self.env.ref("base_custom_info.tpl_smart") + self.prop_str = self.env.ref("base_custom_info.prop_teacher") + self.prop_int = self.env.ref("base_custom_info.prop_haters") + self.prop_float = self.env.ref("base_custom_info.prop_avg_note") + self.prop_bool = self.env.ref("base_custom_info.prop_smartypants") + self.prop_id = self.env.ref("base_custom_info.prop_weaknesses") + + def create_value(self, prop, value, field="value"): + """Create a custom info value.""" + _logger.info( + "Creating. prop: %s; value: %s; field: %s", prop, value, field) + self.agrolait.custom_info_template_id = self.tpl + if field == "value": + value = str(value) + self.value = self.env["custom.info.value"].create({ + "res_id": self.agrolait.id, + "property_id": prop.id, + field: value, + }) + + def creation_found(self, value): + """Ensure you can search what you just created.""" + prop = self.value.property_id + _logger.info( + "Searching. prop: %s; value: %s", prop, value) + self.assertEqual( + self.value.search([ + ("property_id", "=", prop.id), + ("value", "=", value)]), + self.value) + self.assertEqual( + self.value.search([ + ("property_id", "=", prop.id), + ("value", "in", [value])]), + self.value) + self.assertIs( + self.value.search([ + ("property_id", "=", prop.id), + ("value", "not in", [value])]).id, + False) + + def test_to_str(self): + """Conversion to text.""" + self.create_value(self.prop_str, "Mr. Einstein") + self.creation_found("Mr. Einstein") + self.assertEqual(self.value.value, self.value.value_str) + + def test_from_str(self): + """Conversion from text.""" + self.create_value(self.prop_str, "Mr. Einstein", "value_str") + self.creation_found("Mr. Einstein") + self.assertEqual(self.value.value, self.value.value_str) + + def test_to_int(self): + """Conversion to whole number.""" + self.create_value(self.prop_int, 5) + self.creation_found("5") + self.assertEqual(int(self.value.value), self.value.value_int) + + def test_from_int(self): + """Conversion from whole number.""" + self.create_value(self.prop_int, 5, "value_int") + self.creation_found("5") + self.assertEqual(int(self.value.value), self.value.value_int) + + def test_to_float(self): + """Conversion to decimal number.""" + self.create_value(self.prop_float, 10.5) + self.creation_found("10.5") + self.assertEqual(float(self.value.value), self.value.value_float) + + def test_from_float(self): + """Conversion from decimal number.""" + self.create_value(self.prop_float, 10.5, "value_float") + self.creation_found("10.5") + self.assertEqual(float(self.value.value), self.value.value_float) + + def test_to_bool_true(self): + """Conversion to yes.""" + self.create_value(self.prop_bool, "True") + self.creation_found("True") + self.assertEqual(self.value.with_context(lang="en_US").value, "Yes") + self.assertIs(self.value.value_bool, True) + + def test_from_bool_true(self): + """Conversion from yes.""" + self.create_value(self.prop_bool, "True", "value_bool") + self.creation_found("True") + self.assertEqual(self.value.with_context(lang="en_US").value, "Yes") + self.assertIs(self.value.value_bool, True) + + def test_to_bool_false(self): + """Conversion to no.""" + self.create_value(self.prop_bool, "False") + self.assertEqual(self.value.with_context(lang="en_US").value, "No") + self.assertIs(self.value.value_bool, False) + + def test_from_bool_false(self): + """Conversion from no.""" + self.create_value(self.prop_bool, False, "value_bool") + self.assertEqual(self.value.with_context(lang="en_US").value, "No") + self.assertIs(self.value.value_bool, False) + + def test_to_id(self): + """Conversion to selection.""" + self.create_value(self.prop_id, "Needs videogames") + self.creation_found("Needs videogames") + self.assertEqual(self.value.value, self.value.value_id.name) + + def test_from_id(self): + """Conversion from selection.""" + self.create_value( + self.prop_id, + self.env.ref("base_custom_info.opt_videogames").id, + "value_id") + self.creation_found("Needs videogames") + self.assertEqual(self.value.value, self.value.value_id.name) diff --git a/base_custom_info/views/custom_info_category_view.xml b/base_custom_info/views/custom_info_category_view.xml new file mode 100644 index 000000000..d9b5a7355 --- /dev/null +++ b/base_custom_info/views/custom_info_category_view.xml @@ -0,0 +1,53 @@ + + + + + + Custom Info Category Tree + custom.info.category + + + + + + + + + + + Custom Info Category Form + custom.info.category + +
+ + + + + + + +
+
+
+ + + Custom Info Category Search + custom.info.category + + + + + + + + + + Categories + ir.actions.act_window + custom.info.category + tree,form + form + + +
diff --git a/base_custom_info/views/custom_info_option_view.xml b/base_custom_info/views/custom_info_option_view.xml new file mode 100644 index 000000000..dfad88e3e --- /dev/null +++ b/base_custom_info/views/custom_info_option_view.xml @@ -0,0 +1,52 @@ + + + + + + Custom Info Option Tree + custom.info.option + + + + + + + + + + Custom Info Option Form + custom.info.option + +
+ + + + + + + +
+
+
+ + + Custom Info Option Search + custom.info.option + + + + + + + + + + Options + ir.actions.act_window + custom.info.option + tree,form + form + + +
diff --git a/base_custom_info/views/custom_info_property_view.xml b/base_custom_info/views/custom_info_property_view.xml index 72bda5437..21d447ec6 100644 --- a/base_custom_info/views/custom_info_property_view.xml +++ b/base_custom_info/views/custom_info_property_view.xml @@ -1,36 +1,77 @@ - + + - - base.custom.info.property.tree + + Custom Info Property Tree custom.info.property - + + - + + + + + - - base.custom.info.property.form + + Custom Info Property Form custom.info.property
- + + + + + + + - + +
- + + Custom Info Property Search + custom.info.property + + + + + + + + + + + + + + + + + Properties ir.actions.act_window custom.info.property @@ -38,4 +79,4 @@ form -
+ diff --git a/base_custom_info/views/custom_info_template_view.xml b/base_custom_info/views/custom_info_template_view.xml index 3e9ff7cbd..d8500fe96 100644 --- a/base_custom_info/views/custom_info_template_view.xml +++ b/base_custom_info/views/custom_info_template_view.xml @@ -1,21 +1,23 @@ - + + - base.custom.info.template.tree + Custom Info Template Tree custom.info.template - + - base.custom.info.template.form + Custom Info Template Form custom.info.template
@@ -26,17 +28,30 @@ - - - - - +
+ + Custom Info Template Search + custom.info.template + + + + + + + + + + + + Templates ir.actions.act_window @@ -49,10 +64,9 @@

Click to define a new custom info template.

- You must define a custom info template for each - product properties group. + You must define a custom info template for each properties group.

-
+ diff --git a/base_custom_info/views/custom_info_value_view.xml b/base_custom_info/views/custom_info_value_view.xml index 53ed922dd..519721095 100644 --- a/base_custom_info/views/custom_info_value_view.xml +++ b/base_custom_info/views/custom_info_value_view.xml @@ -1,19 +1,87 @@ - + + - base.custom.info.value.tree + Custom Info Value Tree custom.info.value - + + + - - + + Custom Info Value Form + custom.info.value + +
+ + + + + + + + + + + + + + + +
+ Warning! + You might see no changes in parent form until you save it. +
+
+
+
+
+ + + Custom Info Value Search + custom.info.value + + + + + + + + + + + + + + + + Values ir.actions.act_window @@ -22,4 +90,4 @@ form -
+ diff --git a/base_custom_info/views/menu.xml b/base_custom_info/views/menu.xml index 8f4b2220c..f69e82584 100644 --- a/base_custom_info/views/menu.xml +++ b/base_custom_info/views/menu.xml @@ -1,23 +1,41 @@ - + + - + + groups="base_custom_info.group_basic"/> + + + + - - - - + parent="menu_basic" sequence="10"/> - + parent="menu_basic" sequence="20"/> + + + + + + - + diff --git a/base_custom_info/views/res_partner_view.xml b/base_custom_info/views/res_partner_view.xml new file mode 100644 index 000000000..5f64e41f0 --- /dev/null +++ b/base_custom_info/views/res_partner_view.xml @@ -0,0 +1,28 @@ + + + + + + Custom info in partners form + res.partner + + + + + + + + + + + + + + diff --git a/base_custom_info/wizard/__init__.py b/base_custom_info/wizard/__init__.py new file mode 100644 index 000000000..d3a343ecb --- /dev/null +++ b/base_custom_info/wizard/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# © 2015 Antiun Ingeniería S.L. - Sergio Teruel +# © 2015 Antiun Ingeniería S.L. - Carlos Dauden +# License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html + +from . import base_config_settings diff --git a/base_custom_info/wizard/base_config_settings.py b/base_custom_info/wizard/base_config_settings.py new file mode 100644 index 000000000..6d78f1209 --- /dev/null +++ b/base_custom_info/wizard/base_config_settings.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Jairo Llopis +# License LGPL-3 - See http://www.gnu.org/licenses/lgpl-3.0.html + +from openerp import fields, models + + +class BaseConfigSettings(models.TransientModel): + _inherit = "base.config.settings" + + group_custom_info_manager = fields.Boolean( + string="Manage custom information", + implied_group="base_custom_info.group_basic", + help="Allow all employees to manage custom information", + ) + group_custom_info_partner = fields.Boolean( + string="Edit custom information in partners", + implied_group="base_custom_info.group_partner", + help="Add a tab in the partners form to edit custom information", + ) diff --git a/base_custom_info/wizard/base_config_settings_view.xml b/base_custom_info/wizard/base_config_settings_view.xml new file mode 100644 index 000000000..095b60943 --- /dev/null +++ b/base_custom_info/wizard/base_config_settings_view.xml @@ -0,0 +1,33 @@ + + + + + + Allow to enable partners custom info + base.config.settings + + + + + + + + + + + +