diff --git a/base_changeset/README.rst b/base_changeset/README.rst new file mode 100644 index 000000000..d7b1df849 --- /dev/null +++ b/base_changeset/README.rst @@ -0,0 +1,169 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +================== +Record Changesets +================== + +This module extends the functionality of records. It allows to create +changesets that must be validated when a record is modified instead of direct +modifications. Rules allow to configure which field must be validated. + +Configuration +============= + +Access Rights +------------- + +The changesets rules must be edited by users with the group ``Changesets +Configuration``. The changesets can be applied or canceled only by users +with the group ``Changesets Validations`` + +Changesets Rules +---------------- + +The changesets rules can be configured in ``Sales > Configuration > +Record Changesets > Fields Rules``. For each record field, an +action can be defined: + +* Auto: the changes made on this field are always applied +* Validate: the changes made on this field must be manually confirmed by + a 'Changesets User' user +* Never: the changes made on this field are always refused + +In any case, all the changes made by the users are always applied +directly on the users, but a 'validated' changeset is created for the +history. + +The supported fields are: + +* Char +* Text +* Date +* Datetime +* Integer +* Float +* Boolean +* Many2one + +Rules can be global (no source model) or configured by source model. +Rules by source model have the priority. If a field is not configured +for the source model, it will use the global rule (if existing). + +If a field has no rule, it is written to the record without changeset. + +Usage +===== + +General case +------------ + +The first step is to create the changeset rules, once that done, writes on +records will be created as changesets. + +Finding changesets +------------------ + +A menu lists all the changesets in ``Sales > Configuration > Record +Changesets > Changesets``. + +However, it is more convenient to access them directly from the +records. Pending changesets can be accessed directly from the top right +of the records' view. A new filter on the records shows the records +having at least one pending changeset. + +Handling changesets +------------------- + +A changeset shows the list of the changes made on a record. Some of the +changes may be 'Pending', some 'Accepted' or 'Rejected' according to the +changeset rules. The only changes that need an action from the user are +'Pending' changes. When a change is accepted, the value is written on +the user. + +The changes view shows the name of the record's field, the Origin value +and the New value alongside the state of the change. By clicking on the +change in some cases a more detailed view is displayed, for instance, +links for relations can be clicked on. + +A button on a changeset allows to apply or reject all the changes at +once. + +Custom source rules in your addon +--------------------------------- + +Addons wanting to create changeset with their own rules should pass the +following keys in the context when they write on the record: + +* ``__changeset_rules_source_model``: name of the model which asks for + the change +* ``__changeset_rules_source_id``: id of the record which asks for the + change + +Also, they should extend the selection in +``ChangesetFieldRule._domain_source_models`` to add their model (the +same that is passed in ``__changeset_rules_source_model``). + +The source is used for the application of the rules, allowing to have a +different rule for a different source. It is also stored on the changeset for +information. + +Screenshot: +----------- + +* Configuration of rules + + .. image:: base_changeset/static/src/img/rules.png + +* Changeset waiting for validation + + .. image:: base_changeset/static/src/img/changeset.png + + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/134/10.0 + +Known issues / Roadmap +====================== + +* Only a subset of the type of fields is actually supported + +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 + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Guewen Baconnier +* Denis Leemann +* Yannick Vaucher + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +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. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/base_changeset/__init__.py b/base_changeset/__init__.py new file mode 100644 index 000000000..69f7babdf --- /dev/null +++ b/base_changeset/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/base_changeset/__manifest__.py b/base_changeset/__manifest__.py new file mode 100644 index 000000000..02c9f1ddb --- /dev/null +++ b/base_changeset/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2015-2017 Camptocamp SA +# Copyright 2020 Onestein () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Track record changesets", + "version": "13.0.1.0.0", + "development_status": "Alpha", + "author": "Onestein, Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["astirpe"], + "license": "AGPL-3", + "category": "Tools", + "depends": ["web"], + "website": "https://github.com/OCA/server-tools/", + "data": [ + "security/groups.xml", + "security/ir.model.access.csv", + "security/rules.xml", + "templates/assets.xml", + "views/record_changeset_views.xml", + "views/record_changeset_change_views.xml", + "views/changeset_field_rule_views.xml", + "views/menu.xml", + ], + "demo": ["demo/changeset_field_rule.xml"], + "qweb": ["static/src/xml/backend.xml"], + "installable": True, +} diff --git a/base_changeset/demo/changeset_field_rule.xml b/base_changeset/demo/changeset_field_rule.xml new file mode 100644 index 000000000..522ea6a0c --- /dev/null +++ b/base_changeset/demo/changeset_field_rule.xml @@ -0,0 +1,39 @@ + + + + + auto + + + + auto + + + + validate + + + + validate + + + + validate + + + + never + + + + validate + + + + auto + + + + auto + + diff --git a/base_changeset/models/__init__.py b/base_changeset/models/__init__.py new file mode 100644 index 000000000..b2c343024 --- /dev/null +++ b/base_changeset/models/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import base +from . import record_changeset +from . import record_changeset_change +from . import changeset_field_rule diff --git a/base_changeset/models/base.py b/base_changeset/models/base.py new file mode 100644 index 000000000..4f2ef55a3 --- /dev/null +++ b/base_changeset/models/base.py @@ -0,0 +1,161 @@ +# Copyright 2020 Onestein () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from lxml import etree + +from odoo import _, api, fields, models +from odoo.tools import config + + +class Base(models.AbstractModel): + _inherit = "base" + + changeset_ids = fields.One2many( + comodel_name="record.changeset", + compute="_compute_changeset_ids", + string="Changesets", + ) + changeset_change_ids = fields.One2many( + comodel_name="record.changeset.change", + compute="_compute_changeset_ids", + string="Changeset Changes", + ) + count_pending_changesets = fields.Integer( + compute="_compute_count_pending_changesets" + ) + count_pending_changeset_changes = fields.Integer( + compute="_compute_count_pending_changesets" + ) + user_can_see_changeset = fields.Boolean(compute="_compute_user_can_see_changeset") + + def _compute_changeset_ids(self): + model_name = self._name + for record in self: + changesets = self.env["record.changeset"].search( + [("model", "=", model_name), ("res_id", "=", record.id)] + ) + record.changeset_ids = changesets + record.changeset_change_ids = changesets.mapped("change_ids") + + def _compute_count_pending_changesets(self): + model_name = self._name + if model_name in self.models_to_track_changeset(): + for rec in self: + changesets = rec.changeset_ids.filtered( + lambda rev: rev.state == "draft" + and rev.res_id == rec.id + and rev.model == model_name + ) + changes = changesets.mapped("change_ids") + changes = changes.filtered( + lambda c: c.state in c.get_pending_changes_states() + ) + rec.count_pending_changesets = len(changesets) + rec.count_pending_changeset_changes = len(changes) + else: + for rec in self: + rec.count_pending_changesets = 0.0 + rec.count_pending_changeset_changes = 0.0 + + @api.model + def models_to_track_changeset(self): + """Models to be tracked for changes + :args: + :returns: list of models + """ + models = self.env["changeset.field.rule"].search([]).mapped("model_id.model") + if config["test_enable"] and self.env.context.get("test_record_changeset"): + if "res.partner" not in models: + models += ["res.partner"] # Used in tests + return models + + def write(self, values): + if self.env.context.get("__no_changeset"): + return super().write(values) + + # To avoid conflicts with tests of other modules + if config["test_enable"] and not self.env.context.get("test_record_changeset"): + return super().write(values) + + if self._name not in self.models_to_track_changeset(): + return super().write(values) + + for record in self: + local_values = self.env["record.changeset"].add_changeset(record, values) + super(Base, record).write(local_values) + return self + + def action_record_changeset_change_view(self): + self.ensure_one() + res = { + "type": "ir.actions.act_window", + "res_model": "record.changeset.change", + "view_mode": "tree", + "views": [ + [ + self.env.ref("base_changeset.view_record_changeset_change_tree").id, + "list", + ] + ], + "context": self.env.context, + "name": _("Record Changes"), + "search_view_id": [ + self.env.ref("base_changeset.view_record_changeset_change_search").id, + "search", + ], + } + record_id = self.env.context.get("search_default_record_id") + if record_id: + res.update( + { + "domain": [ + ("model", "=", self._name), + ("changeset_id.res_id", "=", record_id), + ] + } + ) + return res + + @api.model + def _fields_view_get( + self, view_id=None, view_type="form", toolbar=False, submenu=False + ): + res = super()._fields_view_get( + view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu + ) + to_track_changeset = self._name in self.models_to_track_changeset() + can_see = len(self) == 1 and self.user_can_see_changeset + button_label = _("Changes") + if to_track_changeset and can_see and view_type == "form": + doc = etree.XML(res["arch"]) + for node in doc.xpath("//div[@name='button_box']"): + xml_field = etree.Element( + "field", + { + "name": "count_pending_changeset_changes", + "string": button_label, + "widget": "statinfo", + }, + ) + xml_button = etree.Element( + "button", + { + "type": "object", + "name": "action_record_changeset_change_view", + "icon": "fa-code-fork", + "context": "{'search_default_draft': 1, " + "'search_default_record_id': active_id}", + }, + ) + xml_button.insert(0, xml_field) + node.insert(0, xml_button) + res["arch"] = etree.tostring(doc, encoding="unicode") + return res + + def _compute_user_can_see_changeset(self): + is_superuser = self.env.is_superuser() + has_changeset_group = self.user_has_groups( + "base_changeset.group_changeset_user" + ) + for rec in self: + rec.user_can_see_changeset = is_superuser or has_changeset_group diff --git a/base_changeset/models/changeset_field_rule.py b/base_changeset/models/changeset_field_rule.py new file mode 100644 index 000000000..b3ebfe303 --- /dev/null +++ b/base_changeset/models/changeset_field_rule.py @@ -0,0 +1,156 @@ +# Copyright 2015-2017 Camptocamp SA +# Copyright 2020 Onestein () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.tools.cache import ormcache + + +class ChangesetFieldRule(models.Model): + _name = "changeset.field.rule" + _description = "Changeset Field Rules" + _rec_name = "field_id" + + model_id = fields.Many2one(related="field_id.model_id", store=True) + field_id = fields.Many2one( + comodel_name="ir.model.fields", ondelete="cascade", required=True + ) + action = fields.Selection( + selection="_selection_action", + string="Action", + required=True, + help="Auto: always apply a change.\n" + "Validate: manually applied by an administrator.\n" + "Never: change never applied.", + ) + source_model_id = fields.Many2one( + comodel_name="ir.model", + string="Source Model", + ondelete="cascade", + domain=lambda self: [("id", "in", self._domain_source_models().ids)], + help="If a source model is defined, the rule will be applied only " + "when the change is made from this origin. " + "Rules without source model are global and applies to all " + "backends.\n" + "Rules with a source model have precedence over global rules, " + "but if a field has no rule with a source model, the global rule " + "is used.", + ) + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + active = fields.Boolean(default=True) + + def init(self): + """Ensure there is at most one rule with source_model_id NULL. + """ + self.env.cr.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS source_model_null_field_uniq + ON %s (field_id) + WHERE source_model_id IS NULL + """ + % self._table + ) + + _sql_constraints = [ + ( + "model_field_uniq", + "unique (source_model_id, field_id)", + "A rule already exists for this field.", + ) + ] + + @api.model + def _domain_source_models(self): + """ Returns the models for which we can define rules. + + Example for submodules (replace by the xmlid of the model): + + :: + models = super()._domain_source_models() + return models | self.env.ref('base.model_res_users') + + Rules without model are global and apply for all models. + + """ + return self.env.ref("base.model_res_users") + + @api.model + def _selection_action(self): + return [("auto", "Auto"), ("validate", "Validate"), ("never", "Never")] + + @ormcache(skiparg=1) + @api.model + def _get_rules(self, source_model_name, record_model_name): + """ Cache rules + + Keep only the id of the rules, because if we keep the recordsets + in the ormcache, we won't be able to browse them once their + cursor is closed. + + The public method ``get_rules`` return the rules with the recordsets + when called. + + """ + domain = self._get_rules_search_domain(record_model_name, source_model_name) + model_rules = self.search( + domain, + # using 'ASC' means that 'NULLS LAST' is the default + order="source_model_id ASC", + ) + # model's rules have precedence over global ones so we iterate + # over rules which have a source model first, then we complete + # them with the global rules + result = {} + for rule in model_rules: + # we already have a model's rule + if result.get(rule.field_id.name): + continue + result[rule.field_id.name] = rule.id + return result + + def _get_rules_search_domain(self, record_model_name, source_model_name): + return [ + ("model_id.model", "=", record_model_name), + "|", + ("source_model_id.model", "=", source_model_name), + ("source_model_id", "=", False), + ] + + @api.model + def get_rules(self, source_model_name, record_model_name): + """ Return the rules for a model + + When a model is specified, it will return the rules for this + model. Fields that have no rule for this model will use the + global rules (those without source). + + The source model is the model which ask for a change, it will be + for instance ``res.users``, ``lefac.backend`` or + ``magellan.backend``. + + The second argument (``source_model_name``) is optional but + cannot be an optional keyword argument otherwise it would not be + in the key for the cache. The callers have to pass ``None`` if + they want only global rules. + """ + rules = {} + cached_rules = self._get_rules(source_model_name, record_model_name) + for field, rule_id in cached_rules.items(): + rules[field] = self.browse(rule_id) + return rules + + @api.model + def create(self, vals): + record = super().create(vals) + self.clear_caches() + return record + + def write(self, vals): + result = super().write(vals) + self.clear_caches() + return result + + def unlink(self): + result = super().unlink() + self.clear_caches() + return result diff --git a/base_changeset/models/record_changeset.py b/base_changeset/models/record_changeset.py new file mode 100644 index 000000000..dcc91d1e3 --- /dev/null +++ b/base_changeset/models/record_changeset.py @@ -0,0 +1,161 @@ +# Copyright 2015-2017 Camptocamp SA +# Copyright 2020 Onestein () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class RecordChangeset(models.Model): + _name = "record.changeset" + _description = "Record Changeset" + _order = "date desc" + _rec_name = "date" + + model = fields.Char(index=True, required=True, readonly=True) + res_id = fields.Many2oneReference( + string="Record ID", + index=True, + required=True, + readonly=True, + model_field="model", + ) + change_ids = fields.One2many( + comodel_name="record.changeset.change", + inverse_name="changeset_id", + string="Changes", + readonly=True, + ) + date = fields.Datetime( + string="Modified on", default=fields.Datetime.now(), index=True, readonly=True + ) + modified_by_id = fields.Many2one( + "res.users", default=lambda self: self.env.user, readonly=True + ) + state = fields.Selection( + compute="_compute_state", + selection=[("draft", "Pending"), ("done", "Done")], + store=True, + ) + note = fields.Text() + source = fields.Reference( + string="Source of the change", selection="_reference_models", readonly=True + ) + company_id = fields.Many2one("res.company") + record_id = fields.Reference( + selection="_reference_models", compute="_compute_resource_record", readonly=True + ) + + @api.depends("model", "res_id") + def _compute_resource_record(self): + for changeset in self: + changeset.record_id = "{},{}".format(changeset.model, changeset.res_id or 0) + + @api.model + def _reference_models(self): + models = self.env["ir.model"].sudo().search([]) + return [(model.model, model.name) for model in models] + + @api.depends("change_ids", "change_ids.state") + def _compute_state(self): + for rec in self: + changes = rec.mapped("change_ids") + if all(change.state in ("done", "cancel") for change in changes): + rec.state = "done" + else: + rec.state = "draft" + + def name_get(self): + result = [] + for changeset in self: + name = "{} ({})".format(changeset.date, changeset.record_id.name) + result.append((changeset.id, name)) + return result + + def apply(self): + self.with_context(skip_pending_status_check=True).mapped("change_ids").apply() + + def cancel(self): + self.with_context(skip_pending_status_check=True).mapped("change_ids").cancel() + + @api.model + def add_changeset(self, record, values): + """ Add a changeset on a record + + By default, when a record is modified by a user or by the + system, the the changeset will follow the rules configured for + the global rules. + + A caller should pass the following keys in the context: + + * ``__changeset_rules_source_model``: name of the model which + asks for the change + * ``__changeset_rules_source_id``: id of the record which asks + for the change + + When the source model and id are not defined, the current user + is considered as the origin of the change. + + Should be called before the execution of ``write`` on the record + so we can keep track of the existing value and also because the + returned values should be used for ``write`` as some of the + values may have been removed. + + :param values: the values being written on the record + :type values: dict + + :returns: dict of values that should be wrote on the record + (fields with a 'Validate' or 'Never' rule are excluded) + + """ + record.ensure_one() + + source_model = self.env.context.get("__changeset_rules_source_model") + source_id = self.env.context.get("__changeset_rules_source_id") + if not source_model: + # if the changes source is not defined, log the user who + # made the change + source_model = "res.users" + if not source_id: + source_id = self.env.uid + if source_model and source_id: + source = "{},{}".format(source_model, source_id) + else: + source = False + + change_model = self.env["record.changeset.change"] + write_values = values.copy() + changes = [] + rules = self.env["changeset.field.rule"].get_rules( + source_model_name=source_model, record_model_name=record._name + ) + for field in values: + rule = rules.get(field) + if not rule: + continue + if field in values: + if not change_model._has_field_changed(record, field, values[field]): + continue + change, pop_value = change_model._prepare_changeset_change( + record, rule, field, values[field] + ) + if pop_value: + write_values.pop(field) + changes.append(change) + if changes: + changeset_vals = self._prepare_changeset_vals(changes, record, source) + self.env["record.changeset"].create(changeset_vals) + return write_values + + @api.model + def _prepare_changeset_vals(self, changes, record, source): + has_company = "company_id" in self.env[record._name]._fields + has_company = has_company and record.company_id + company = record.company_id if has_company else self.env.company + return { + "res_id": record.id, + "model": record._name, + "company_id": company.id, + "change_ids": [(0, 0, vals) for vals in changes], + "date": fields.Datetime.now(), + "source": source, + } diff --git a/base_changeset/models/record_changeset_change.py b/base_changeset/models/record_changeset_change.py new file mode 100644 index 000000000..698f65f7a --- /dev/null +++ b/base_changeset/models/record_changeset_change.py @@ -0,0 +1,393 @@ +# Copyright 2015-2017 Camptocamp SA +# Copyright 2020 Onestein () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from itertools import groupby +from operator import attrgetter + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +# sentinel object to be sure that no empty value was passed to +# RecordChangesetChange._value_for_changeset +_NO_VALUE = object() + + +class RecordChangesetChange(models.Model): + """ Store the change of one field for one changeset on one record + + This model is composed of 3 sets of fields: + + * 'origin' + * 'old' + * 'new' + + The 'new' fields contain the value that needs to be validated. + The 'old' field copies the actual value of the record when the + change is either applied either canceled. This field is used as a storage + place but never shown by itself. + The 'origin' fields is a related field towards the actual values of + the record until the change is either applied either canceled, past + that it shows the 'old' value. + The reason behind this is that the values may change on a record between + the moment when the changeset is created and when it is applied. + + On the views, we show the origin fields which represent the actual + record values or the old values and we show the new fields. + + The 'origin' and 'new_value_display' are displayed on + the tree view where we need a unique of field, the other fields are + displayed on the form view so we benefit from their widgets. + + """ + + _name = "record.changeset.change" + _description = "Record Changeset Change" + _rec_name = "field_id" + + changeset_id = fields.Many2one( + comodel_name="record.changeset", + required=True, + ondelete="cascade", + readonly=True, + ) + field_id = fields.Many2one( + comodel_name="ir.model.fields", required=True, readonly=True + ) + field_name = fields.Char(related="field_id.name", readonly=True) + field_type = fields.Selection(related="field_id.ttype", readonly=True) + model = fields.Char(related="field_id.model", readonly=True, store=True) + origin_value_display = fields.Char( + string="Previous", compute="_compute_value_display" + ) + new_value_display = fields.Char(string="New", compute="_compute_value_display") + + # Fields showing the origin record's value or the 'old' value if + # the change is applied or canceled. + origin_value_char = fields.Char(compute="_compute_origin_values", readonly=True) + origin_value_date = fields.Date(compute="_compute_origin_values", readonly=True) + origin_value_datetime = fields.Datetime( + compute="_compute_origin_values", readonly=True + ) + origin_value_float = fields.Float(compute="_compute_origin_values", readonly=True) + origin_value_monetary = fields.Float( + compute="_compute_origin_values", readonly=True + ) + origin_value_integer = fields.Integer( + compute="_compute_origin_values", readonly=True + ) + origin_value_text = fields.Text(compute="_compute_origin_values", readonly=True) + origin_value_boolean = fields.Boolean( + compute="_compute_origin_values", readonly=True + ) + origin_value_reference = fields.Reference( + compute="_compute_origin_values", selection="_reference_models", readonly=True + ) + + # Fields storing the previous record's values (saved when the + # changeset is applied) + old_value_char = fields.Char(readonly=True) + old_value_date = fields.Date(readonly=True) + old_value_datetime = fields.Datetime(readonly=True) + old_value_float = fields.Float(readonly=True) + old_value_monetary = fields.Float(readonly=True) + old_value_integer = fields.Integer(readonly=True) + old_value_text = fields.Text(readonly=True) + old_value_boolean = fields.Boolean(readonly=True) + old_value_reference = fields.Reference(selection="_reference_models", readonly=True) + + # Fields storing the value applied on the record + new_value_char = fields.Char(readonly=True) + new_value_date = fields.Date(readonly=True) + new_value_datetime = fields.Datetime(readonly=True) + new_value_float = fields.Float(readonly=True) + new_value_monetary = fields.Float(readonly=True) + new_value_integer = fields.Integer(readonly=True) + new_value_text = fields.Text(readonly=True) + new_value_boolean = fields.Boolean(readonly=True) + new_value_reference = fields.Reference(selection="_reference_models", readonly=True) + + state = fields.Selection( + selection=[("draft", "Pending"), ("done", "Approved"), ("cancel", "Rejected")], + required=True, + default="draft", + readonly=True, + ) + record_id = fields.Reference(related="changeset_id.record_id") + rule_id = fields.Many2one("changeset.field.rule", readonly=True) + user_can_validate_changeset = fields.Boolean( + compute="_compute_user_can_validate_changeset" + ) + date = fields.Datetime(related="changeset_id.date") + modified_by_id = fields.Many2one(related="changeset_id.modified_by_id") + verified_on_date = fields.Datetime(string="Verified on", readonly=True) + verified_by_id = fields.Many2one("res.users", readonly=True) + + @api.model + def _reference_models(self): + models = self.env["ir.model"].search([]) + return [(model.model, model.name) for model in models] + + _suffix_to_types = { + "char": ("char", "selection"), + "date": ("date",), + "datetime": ("datetime",), + "float": ("float",), + "monetary": ("monetary",), + "integer": ("integer",), + "text": ("text",), + "boolean": ("boolean",), + "reference": ("many2one",), + } + + _type_to_suffix = { + ftype: suffix for suffix, ftypes in _suffix_to_types.items() for ftype in ftypes + } + + _origin_value_fields = ["origin_value_%s" % suffix for suffix in _suffix_to_types] + _old_value_fields = ["old_value_%s" % suffix for suffix in _suffix_to_types] + _new_value_fields = ["new_value_%s" % suffix for suffix in _suffix_to_types] + _value_fields = _origin_value_fields + _old_value_fields + _new_value_fields + + @api.depends("changeset_id.res_id", "changeset_id.model") + def _compute_origin_values(self): + states = self.get_pending_changes_states() + for rec in self: + field_name = rec.get_field_for_type(rec.field_id, "origin") + if rec.state in states: + value = rec.record_id[rec.field_id.name] + else: + old_field = rec.get_field_for_type(rec.field_id, "old") + value = rec[old_field] + setattr(rec, field_name, value) + + @api.depends(lambda self: self._value_fields) + def _compute_value_display(self): + for rec in self: + for prefix in ("origin", "new"): + value = getattr(rec, "get_%s_value" % prefix)() + if rec.field_id.ttype == "many2one" and value: + value = value.display_name + setattr(rec, "%s_value_display" % prefix, value) + + @api.model + def get_field_for_type(self, field, prefix): + assert prefix in ("origin", "old", "new") + field_type = self._type_to_suffix.get(field.ttype) + if not field_type: + raise NotImplementedError("field type %s is not supported" % field_type) + return "{}_value_{}".format(prefix, field_type) + + def get_origin_value(self): + self.ensure_one() + field_name = self.get_field_for_type(self.field_id, "origin") + return self[field_name] + + def get_new_value(self): + self.ensure_one() + field_name = self.get_field_for_type(self.field_id, "new") + return self[field_name] + + def set_old_value(self): + """ Copy the value of the record to the 'old' field """ + for change in self: + # copy the existing record's value for the history + old_value_for_write = self._value_for_changeset( + change.record_id, change.field_id.name + ) + old_field_name = self.get_field_for_type(change.field_id, "old") + change.write({old_field_name: old_value_for_write}) + + def apply(self): + """ Apply the change on the changeset's record + + It is optimized thus that it makes only one write on the record + per changeset if many changes are applied at once. + """ + for change in self: + if not change.user_can_validate_changeset: + raise UserError(_("You don't have the rights to apply the changes.")) + changes_ok = self.browse() + key = attrgetter("changeset_id") + for changeset, changes in groupby( + self.with_context(__no_changeset=True).sorted(key=key), key=key + ): + values = {} + for change in changes: + if change.state in ("cancel", "done"): + continue + + field = change.field_id + new_value = change.get_new_value() + value_for_write = change._convert_value_for_write(new_value) + values[field.name] = value_for_write + + change.set_old_value() + + changes_ok |= change + + if not values: + continue + + self._check_previous_changesets(changeset) + + changeset.record_id.with_context(__no_changeset=True).write(values) + + changes_ok._finalize_change_approval() + + def _check_previous_changesets(self, changeset): + if self.env.context.get("require_previous_changesets_done"): + states = self.get_pending_changes_states() + previous_changesets = self.env["record.changeset"].search( + [ + ("date", "<", changeset.date), + ("state", "in", states), + ("model", "=", changeset.model), + ("res_id", "=", changeset.res_id), + ], + limit=1, + ) + if previous_changesets: + raise UserError( + _( + "This change cannot be applied because a previous " + "changeset for the same record is pending.\n" + "Apply all the anterior changesets before applying " + "this one." + ) + ) + + def cancel(self): + """ Reject the change """ + for change in self: + if not change.user_can_validate_changeset: + raise UserError(_("You don't have the rights to reject the changes.")) + if any(change.state == "done" for change in self): + raise UserError(_("This change has already be applied.")) + self.set_old_value() + self._finalize_change_rejection() + + def _finalize_change_approval(self): + self.write( + { + "state": "done", + "verified_by_id": self.env.user.id, + "verified_on_date": fields.Datetime.now(), + } + ) + + def _finalize_change_rejection(self): + self.write( + { + "state": "cancel", + "verified_by_id": self.env.user.id, + "verified_on_date": fields.Datetime.now(), + } + ) + + @api.model + def _has_field_changed(self, record, field, value): + field_def = record._fields[field] + current_value = field_def.convert_to_write(record[field], record) + if not (current_value or value): + return False + return current_value != value + + def _convert_value_for_write(self, value): + if not value: + return value + model = self.env[self.field_id.model_id.model] + model_field_def = model._fields[self.field_id.name] + return model_field_def.convert_to_write(value, self.record_id) + + @api.model + def _value_for_changeset(self, record, field_name, value=_NO_VALUE): + """ Return a value from the record ready to write in a changeset field + + :param record: modified record + :param field_name: name of the modified field + :param value: if no value is given, it is read from the record + """ + field_def = record._fields[field_name] + if value is _NO_VALUE: + # when the value is read from the record, we need to prepare + # it for the write (e.g. extract .id from a many2one record) + value = field_def.convert_to_write(record[field_name], record) + if field_def.type == "many2one": + # store as 'reference' + comodel = field_def.comodel_name + return "{},{}".format(comodel, value) if value else False + else: + return value + + @api.model + def _prepare_changeset_change(self, record, rule, field_name, value): + """ Prepare data for a changeset change + + It returns a dict of the values to write on the changeset change + and a boolean that indicates if the value should be popped out + of the values to write on the model. + + :returns: dict of values, boolean + """ + new_field_name = self.get_field_for_type(rule.field_id, "new") + new_value = self._value_for_changeset(record, field_name, value=value) + change = { + new_field_name: new_value, + "field_id": rule.field_id.id, + "rule_id": rule.id, + } + if rule.action == "auto": + change["state"] = "done" + pop_value = False + elif rule.action == "validate": + change["state"] = "draft" + pop_value = True # change to apply manually + elif rule.action == "never": + change["state"] = "cancel" + pop_value = True # change never applied + + if change["state"] in ("cancel", "done"): + # Normally the 'old' value is set when we use the 'apply' + # button, but since we short circuit the 'apply', we + # directly set the 'old' value here + old_field_name = self.get_field_for_type(rule.field_id, "old") + # get values ready to write as expected by the changeset + # (for instance, a many2one is written in a reference + # field) + origin_value = self._value_for_changeset(record, field_name) + change[old_field_name] = origin_value + + return change, pop_value + + @api.model + def get_fields_changeset_changes(self, model, res_id): + fields = [ + "new_value_display", + "origin_value_display", + "field_name", + "user_can_validate_changeset", + ] + states = self.get_pending_changes_states() + domain = [ + ("changeset_id.model", "=", model), + ("changeset_id.res_id", "=", res_id), + ("state", "in", states), + ] + return self.search_read(domain, fields) + + def _compute_user_can_validate_changeset(self): + is_superuser = self.env.is_superuser() + has_group = self.user_has_groups("base_changeset.group_changeset_user") + for rec in self: + can_validate = rec._is_change_pending() and (is_superuser or has_group) + rec.user_can_validate_changeset = can_validate + + @api.model + def get_pending_changes_states(self): + return ["draft"] + + def _is_change_pending(self): + self.ensure_one() + skip_status_check = self.env.context.get("skip_pending_status_check") + return skip_status_check or self.state in self.get_pending_changes_states() diff --git a/base_changeset/readme/CONFIGURE.rst b/base_changeset/readme/CONFIGURE.rst new file mode 100644 index 000000000..df7cccdea --- /dev/null +++ b/base_changeset/readme/CONFIGURE.rst @@ -0,0 +1,45 @@ +Access Rights +------------- + +The changesets rules must be edited by users with the group ``Changesets +Configuration``. The changesets can be applied or canceled only by users +with the group ``Changesets Validations`` + +Changesets Rules +---------------- + +The changesets rules can be configured in ``Configuration > +Record Changesets > Fields Rules``. + +* Configuration of rules + + .. image:: base_changeset/static/src/img/rules.png + +For each record field, an action can be defined: + +* Auto: the changes made on this field are always applied +* Validate: the changes made on this field must be manually confirmed by + a 'Changesets User' user +* Never: the changes made on this field are always refused + +In any case, all the changes made by the users are always applied +directly on the users, but a 'validated' changeset is created for the +history. + +The supported fields are: + +* Char +* Text +* Date +* Datetime +* Integer +* Float +* Monetary +* Boolean +* Many2one + +Rules can be global (no source model) or configured by source model. +Rules by source model have the priority. If a field is not configured +for the source model, it will use the global rule (if existing). + +If a field has no rule, it is written to the record without changeset. diff --git a/base_changeset/readme/CONTRIBUTORS.rst b/base_changeset/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..820b74693 --- /dev/null +++ b/base_changeset/readme/CONTRIBUTORS.rst @@ -0,0 +1,5 @@ +* Guewen Baconnier +* Denis Leemann +* Yannick Vaucher +* Dennis Sluijk +* Andrea Stirpe diff --git a/base_changeset/readme/DESCRIPTION.rst b/base_changeset/readme/DESCRIPTION.rst new file mode 100644 index 000000000..c7ad89d7b --- /dev/null +++ b/base_changeset/readme/DESCRIPTION.rst @@ -0,0 +1,13 @@ +This module extends the functionality of records. It allows to create +changesets that must be validated when a record is modified instead of direct +modifications. Rules allow to configure which field must be validated. + +What is a changeset +------------------- + +A changeset is a list of changes made on a record. + +Some of the changes may be 'Pending', some 'Accepted' or 'Rejected' according +to the changeset rules. The 'Pending' changes require an interaction by the +approver user: only when that change is approved, its value is written on +the record. diff --git a/base_changeset/readme/ROADMAP.rst b/base_changeset/readme/ROADMAP.rst new file mode 100644 index 000000000..569a462b0 --- /dev/null +++ b/base_changeset/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* Only a subset of the type of fields is actually supported +* Multicompany not fully supported diff --git a/base_changeset/readme/USAGE.rst b/base_changeset/readme/USAGE.rst new file mode 100644 index 000000000..71c38e153 --- /dev/null +++ b/base_changeset/readme/USAGE.rst @@ -0,0 +1,66 @@ +Changeset rules +--------------- + +The first step is to configure the changeset rules. Once that done, writes on +records will be created as changesets. + +Handling changesets +------------------- + +The list of all the changesets is in ``Configuration > Record +Changesets > Changesets``. + +By default, only the pending changesets (waiting for validation) are shown. +Remove the "Pending" filter to show all the changesets. + +* Changeset waiting for validation + + .. image:: base_changeset/static/src/img/changeset.png + +The changes view shows the name of the record's field, the Origin value +and the New value alongside the state of the change. By clicking on the +change in some cases a more detailed view is displayed, for instance, +links for relations can be clicked on. + +A button on a changeset allows to apply or reject all the changes at +once. + +Handling single changes +----------------------- + +Accessing the changesets gives the full overview of all the changes made. +However, it is more convenient to access the single changes directly from the +records. When there is a pending change for a field you get a badge with the +number of pending changes next to it like this: + +* Badge with the number of pending changes + + .. image:: base_changeset/static/src/img/badge.png + +When you click on it: + +* Clicking the badge: red button to reject, green one to apply + + .. image:: base_changeset/static/src/img/badge_click.png + +Click the red button to reject the change, click the green one to apply it. + + +Custom source rules in your addon +--------------------------------- + +Addons wanting to create changeset with their own rules should pass the +following keys in the context when they write on the record: + +* ``__changeset_rules_source_model``: name of the model which asks for + the change +* ``__changeset_rules_source_id``: id of the record which asks for the + change + +Also, they should extend the selection in +``ChangesetFieldRule._domain_source_models`` to add their model (the +same that is passed in ``__changeset_rules_source_model``). + +The source is used for the application of the rules, allowing to have a +different rule for a different source. It is also stored on the changeset for +information. diff --git a/base_changeset/security/groups.xml b/base_changeset/security/groups.xml new file mode 100644 index 000000000..852751849 --- /dev/null +++ b/base_changeset/security/groups.xml @@ -0,0 +1,26 @@ + + + + + Changeset Configuration + The user will have an access to the configuration of the changeset rules. + + + Changeset Validations + The user will be able to apply or reject changes. + + + + + + + + + + + + diff --git a/base_changeset/security/ir.model.access.csv b/base_changeset/security/ir.model.access.csv new file mode 100644 index 000000000..313916c63 --- /dev/null +++ b/base_changeset/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_record_changeset,access_record_changeset,model_record_changeset,base.group_user,1,1,1,1 +access_record_changeset_change,access_record_changeset_change,model_record_changeset_change,base.group_user,1,1,1,1 +access_changeset_field_rule,access_changeset_field_rule,model_changeset_field_rule,base.group_user,1,1,1,1 +access_view_record_changeset_user,changeset for changeset users,model_record_changeset,group_changeset_user,1,1,1,0 +access_view_record_changeset_change_user,changeset change for changeset users,model_record_changeset_change,group_changeset_user,1,1,1,0 +access_view_record_changeset_manager,changeset for changeset managers,model_record_changeset,group_changeset_manager,1,1,1,1 +access_view_record_changeset_change_manager,changeset change for changeset managers,model_record_changeset_change,group_changeset_manager,1,1,1,1 diff --git a/base_changeset/security/rules.xml b/base_changeset/security/rules.xml new file mode 100644 index 000000000..670568065 --- /dev/null +++ b/base_changeset/security/rules.xml @@ -0,0 +1,19 @@ + + + + Changeset Field Rules + + + ['|',('company_id','=',False),('company_id', 'in', company_ids)] + + + Record Changeset + + + ['|',('company_id','=',False),('company_id', 'in', company_ids)] + + diff --git a/base_changeset/static/description/icon.png b/base_changeset/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/base_changeset/static/description/icon.png differ diff --git a/base_changeset/static/src/img/badge.png b/base_changeset/static/src/img/badge.png new file mode 100644 index 000000000..4896d1ed4 Binary files /dev/null and b/base_changeset/static/src/img/badge.png differ diff --git a/base_changeset/static/src/img/badge_click.png b/base_changeset/static/src/img/badge_click.png new file mode 100644 index 000000000..2f44a8d1f Binary files /dev/null and b/base_changeset/static/src/img/badge_click.png differ diff --git a/base_changeset/static/src/img/changeset.png b/base_changeset/static/src/img/changeset.png new file mode 100644 index 000000000..c117da412 Binary files /dev/null and b/base_changeset/static/src/img/changeset.png differ diff --git a/base_changeset/static/src/img/rules.png b/base_changeset/static/src/img/rules.png new file mode 100644 index 000000000..37b9626f0 Binary files /dev/null and b/base_changeset/static/src/img/rules.png differ diff --git a/base_changeset/static/src/js/backend.js b/base_changeset/static/src/js/backend.js new file mode 100644 index 000000000..19cbe2298 --- /dev/null +++ b/base_changeset/static/src/js/backend.js @@ -0,0 +1,160 @@ +odoo.define("base_changeset", function(require) { + "use strict"; + + var FormRenderer = require("web.FormRenderer"); + var FormController = require("web.FormController"); + var BasicModel = require("web.BasicModel"); + var core = require("web.core"); + var qweb = core.qweb; + + FormController.include({ + start: function() { + return this._super + .apply(this, arguments) + .then(this._updateChangeset.bind(this)); + }, + + update: function() { + var self = this; + var res = this._super.apply(this, arguments); + res.then(function() { + self._updateChangeset(); + }); + return res; + }, + + _updateChangeset: function() { + var self = this; + var state = this.model.get(this.handle); + this.model + .getChangeset(state.model, state.data.id) + .then(function(changeset) { + self.renderer.renderChangesetPopovers(changeset); + }); + }, + + applyChange: function(id) { + this.model.applyChange(id).then(this.reload.bind(this)); + }, + + rejectChange: function(id) { + this.model.rejectChange(id).then(this.reload.bind(this)); + }, + }); + + FormRenderer.include({ + renderChangesetPopovers: function(changeset) { + var self = this; + _.each(changeset, function(changes, fieldName) { + var labelId = self._getIDForLabel(fieldName); + var $label = self.$el.find(_.str.sprintf('label[for="%s"]', labelId)); + if (!$label.length) { + var widgets = _.filter( + self.allFieldWidgets[self.state.id], + function(widget) { + return widget.name === fieldName; + } + ); + if (widgets.length === 1) { + var widget = widgets[0]; + $label = widget.$el; + } else { + return; + } + } + self._renderChangesetPopover($label, changes); + }); + }, + + _renderChangesetPopover: function($el, changes) { + var self = this; + if (this.mode !== "readonly") { + return; + } + var $button = $( + qweb.render("ChangesetButton", { + count: changes.length, + }) + ); + + $el.append($button); + + var options = { + content: function() { + var $content = $( + qweb.render("ChangesetPopover", { + changes: changes, + }) + ); + $content.find(".base_changeset_apply").on("click", function() { + self._applyClicked($(this)); + }); + $content.find(".base_changeset_reject").on("click", function() { + self._rejectClicked($(this)); + }); + return $content; + }, + html: true, + placement: "bottom", + title: "Pending Changes", + trigger: "focus", + delay: {show: 0, hide: 100}, + template: qweb.render("ChangesetTemplate"), + }; + + $button.popover(options); + }, + + _applyClicked: function($el) { + var id = parseInt($el.data("id"), 10); + this.getParent().applyChange(id); + }, + + _rejectClicked: function($el) { + var id = parseInt($el.data("id"), 10); + this.getParent().rejectChange(id); + }, + }); + + BasicModel.include({ + applyChange: function(id) { + return this._rpc({ + model: "record.changeset.change", + method: "apply", + args: [[id]], + context: _.extend({}, this.context, {set_change_by_ui: true}), + }); + }, + + rejectChange: function(id) { + return this._rpc({ + model: "record.changeset.change", + method: "cancel", + args: [[id]], + context: _.extend({}, this.context, {set_change_by_ui: true}), + }); + }, + + getChangeset: function(modelName, resId) { + var self = this; + return new Promise(function(resolve) { + return self + ._rpc({ + model: "record.changeset.change", + method: "get_fields_changeset_changes", + args: [modelName, resId], + }) + .then(function(changeset) { + var res = {}; + _.each(changeset, function(changesetChange) { + if (!_.contains(_.keys(res), changesetChange.field_name)) { + res[changesetChange.field_name] = []; + } + res[changesetChange.field_name].push(changesetChange); + }); + resolve(res); + }); + }); + }, + }); +}); diff --git a/base_changeset/static/src/scss/backend.scss b/base_changeset/static/src/scss/backend.scss new file mode 100644 index 000000000..d85a517ce --- /dev/null +++ b/base_changeset/static/src/scss/backend.scss @@ -0,0 +1,12 @@ +.base_changeset_reject, +.base_changeset_apply { + width: 25px; +} + +.base_changeset_button { + cursor: pointer; +} + +.base_changeset_popover { + max-width: 100%; +} diff --git a/base_changeset/static/src/xml/backend.xml b/base_changeset/static/src/xml/backend.xml new file mode 100644 index 000000000..75f4d7a28 --- /dev/null +++ b/base_changeset/static/src/xml/backend.xml @@ -0,0 +1,53 @@ + + + + + + + + + + +
+ + + + + + +
+ + +
+
+
+ + + + + + +