Initial commit: OCA Technical packages (595 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:03 +02:00
commit 2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions

View file

@ -0,0 +1,250 @@
=======================
Track record changesets
=======================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:a0553f512eea126df69773c8a4597af8d63d3427fa46a6bee3090761e8f7b869
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
:target: https://odoo-community.org/page/development-status
:alt: Alpha
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github
:target: https://github.com/OCA/server-tools/tree/16.0/base_changeset
:alt: OCA/server-tools
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-base_changeset
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=16.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
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.
.. IMPORTANT::
This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
`More details on development status <https://odoo-community.org/page/development-status>`_
**Table of contents**
.. contents::
:local:
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 ``Configuration >
Record Changesets > Fields Rules``.
* Configuration of rules
.. image:: https://raw.githubusercontent.com/OCA/server-tools/16.0/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.
Usage
=====
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:: https://raw.githubusercontent.com/OCA/server-tools/16.0/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:: https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge.png
When you click on it:
* Clicking the badge: red button to reject, green one to apply
.. image:: https://raw.githubusercontent.com/OCA/server-tools/16.0/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.
Notes on security
-----------------
Note that by default, changeset users see all changes on all configured
rules. This circumvents read restrictions on the original records, so if you
have restrictions on models with changeset rules, changeset users will still
see all changes of all records, and applying a change on an inaccessible record
will fail.
Known issues / Roadmap
======================
* Only a subset of the type of fields is actually supported
* Multicompany not fully supported
* The popover widget indicating the number of pending changes is not shown for
fields without a label at the moment. The approach was already failing in 15.0
(in the case of inline fields such as the partner address fields)
and even in 14.0 (in the case of fields for which no value was set yet).
Or, for a more flexible approach, implement a kind of view preprocessing that
allows a developer to indicate where the widget needs to go (analogous to
`<label for="field_name" />`).
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/server-tools/issues/new?body=module:%20base_changeset%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Onestein
* Camptocamp
Contributors
~~~~~~~~~~~~
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
* Denis Leemann <denis.leemann@camptocamp.com>
* Yannick Vaucher <yannick.vaucher@camptocamp.com>
* Dennis Sluijk <d.sluijk@onestein.nl>
* Andrea Stirpe <a.stirpe@onestein.nl>
* Holger Brunn <mail@hunki-enterprises.com>
* Mark Schuit <mark@gig.solutions>
* Stefan Rijnhart <stefan@opener.amsterdam>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
.. |maintainer-astirpe| image:: https://github.com/astirpe.png?size=40px
:target: https://github.com/astirpe
:alt: astirpe
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|maintainer-astirpe|
This module is part of the `OCA/server-tools <https://github.com/OCA/server-tools/tree/16.0/base_changeset>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import models

View file

@ -0,0 +1,33 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Track record changesets",
"version": "16.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",
"views/record_changeset_views.xml",
"views/record_changeset_change_views.xml",
"views/changeset_field_rule_views.xml",
"views/menu.xml",
],
"assets": {
"web.assets_backend": [
"base_changeset/static/src/components/form_label.*",
"base_changeset/static/src/components/changeset_popover.*",
"base_changeset/static/src/components/record.esm.js",
],
},
"demo": ["demo/changeset_field_rule.xml"],
"installable": True,
}

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record model="changeset.field.rule" id="changeset_field_rule_name">
<field name="field_id" ref="base.field_res_partner__name" />
<field name="action">auto</field>
</record>
<record model="changeset.field.rule" id="changeset_field_rule_street">
<field name="field_id" ref="base.field_res_partner__street" />
<field name="action">auto</field>
</record>
<record model="changeset.field.rule" id="changeset_field_rule_street2">
<field name="field_id" ref="base.field_res_partner__street2" />
<field name="action">validate</field>
</record>
<record model="changeset.field.rule" id="changeset_field_rule_zip">
<field name="field_id" ref="base.field_res_partner__zip" />
<field name="action">validate</field>
</record>
<record model="changeset.field.rule" id="changeset_field_rule_city">
<field name="field_id" ref="base.field_res_partner__city" />
<field name="action">validate</field>
</record>
<record model="changeset.field.rule" id="changeset_field_rule_email">
<field name="field_id" ref="base.field_res_partner__email" />
<field name="action">never</field>
</record>
<record model="changeset.field.rule" id="changeset_field_rule_ref">
<field name="field_id" ref="base.field_res_partner__ref" />
<field name="action">validate</field>
</record>
<record model="changeset.field.rule" id="changeset_field_rule_country_id">
<field name="field_id" ref="base.field_res_partner__country_id" />
<field name="action">auto</field>
</record>
<record model="changeset.field.rule" id="changeset_field_rule_partner_latitude">
<field name="field_id" ref="base.field_res_partner__partner_latitude" />
<field name="action">auto</field>
</record>
</odoo>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -0,0 +1,214 @@
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
from lxml import etree
from odoo import _, api, fields, models
from odoo.tools import config, ormcache
# put this object into context key '__no_changeset' to disable changeset
# functionality
disable_changeset = object()
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_changesets = fields.Integer(
compute="_compute_count_pending_changesets",
help="The overall number of changesets of this record",
)
count_pending_changesets = fields.Integer(
compute="_compute_count_pending_changesets",
help="The number of pending changesets of this record",
)
count_pending_changeset_changes = fields.Integer(
compute="_compute_count_pending_changesets",
help="The number of pending changes of this record",
)
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:
rec.count_changesets = len(rec.changeset_ids)
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_changesets = 0
rec.count_pending_changesets = 0
rec.count_pending_changeset_changes = 0
@api.model
@ormcache(skiparg=1)
def models_to_track_changeset(self):
"""Models to be tracked for changes
:args:
:returns: list of models
"""
models = (
self.env["changeset.field.rule"].sudo().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
@api.model_create_multi
def create(self, vals_list):
result = super().create(vals_list)
if self._changeset_disabled():
return result
for this, vals in zip(result, vals_list):
local_vals = self.env["record.changeset"].add_changeset(
this, vals, create=True
)
local_vals = {
key: value for key, value in local_vals.items() if vals[key] != value
}
if local_vals:
this.with_context(
__no_changeset=disable_changeset,
tracking_disable=True,
).write(local_vals)
return result
def write(self, values):
if self._changeset_disabled():
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 True
def _changeset_disabled(self):
if self.env.context.get("__no_changeset") == disable_changeset:
return True
# To avoid conflicts with tests of other modules
if config["test_enable"] and not self.env.context.get("test_record_changeset"):
return True
if self._name not in self.models_to_track_changeset():
return True
return False
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 get_view(self, view_id=None, view_type="form", **options):
"""Insert the pending changes smart button in the form view of tracked models."""
res = super().get_view(view_id=view_id, view_type=view_type, **options)
if (
view_type == "form"
and self._name in self.models_to_track_changeset()
and self._user_can_see_changeset()
):
button_label = _("Changes")
doc = etree.XML(res["arch"])
for node in doc.xpath("//div[@name='button_box']"):
field_count_changesets = etree.Element(
"field",
{
"name": "count_changesets",
"modifiers": json.dumps({"invisible": True}),
},
)
field_count_pending_changeset_changes = 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}",
"modifiers": json.dumps(
{"invisible": [("count_changesets", "=", 0)]}
),
},
)
xml_button.insert(0, field_count_pending_changeset_changes)
xml_button.insert(0, field_count_changesets)
node.insert(0, xml_button)
res["arch"] = etree.tostring(doc, encoding="unicode")
return res
@api.model
def _user_can_see_changeset(self):
"""Return if the current user has changeset access"""
return self.env.is_superuser() or self.user_has_groups(
"base_changeset.group_changeset_user"
)
def _compute_user_can_see_changeset(self):
user_can_see_changeset = self._user_can_see_changeset()
for rec in self:
rec.user_can_see_changeset = user_can_see_changeset

View file

@ -0,0 +1,181 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models, tools
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",
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)
prevent_self_validation = fields.Boolean(default=False)
expression = fields.Text(
help="Use this rule only on records where this is true. "
"Available variables: object, user",
)
validator_group_ids = fields.Many2many(
"res.groups",
"changeset_field_rule_validator_group_rel",
string="Validator Groups",
default=lambda self: self.env.ref(
"base_changeset.group_changeset_user",
raise_if_not_found=False,
)
or self.env["res.groups"],
)
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")]
@api.constrains("expression")
def _check_expression(self):
for this in self:
this._evaluate_expression(self.env[this.model_id.model].new({}))
@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.sudo().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
def _evaluate_expression(self, record):
"""Evaluate expression if set"""
self.ensure_one()
return not self.expression or tools.safe_eval.safe_eval(
self.expression, {"object": record, "user": self.env.user}
)
@api.model_create_multi
def create(self, vals_list):
record = super().create(vals_list)
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

View file

@ -0,0 +1,184 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# 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.display_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, create=False):
"""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
:param create: in create mode, no check is made to see if the field
value consitutes a change.
:type creatie: boolean
: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
or not rule._evaluate_expression(record)
or (create and not values[field])
):
continue
if field in values:
if not create and not change_model._has_field_changed(
record, field, values[field]
):
continue
change, pop_value = change_model._prepare_changeset_change(
record,
rule,
field,
values[field],
create=create,
)
if pop_value:
write_values.pop(field)
if create:
# overwrite with null value for new records
write_values[field] = (
# but create some default for required text fields
record._fields[field].required
and record._fields[field].type in ("char", "text")
and "/"
or record._fields[field].null(record)
)
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 {
# newly created records are passed as newid records with the id in ref
"res_id": record.id or record.id.ref,
"model": record._name,
"company_id": company.id,
"change_ids": [(0, 0, vals) for vals in changes],
"date": fields.Datetime.now(),
"source": source,
}

View file

@ -0,0 +1,436 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# 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
from .base import disable_changeset
# 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,
ondelete="cascade",
)
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):
"""Get all model names from ir.model.
Requires sudo, as ir.model is only readable for ERP managers.
"""
models = self.sudo().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", "html"),
"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()
field_names = [
field_name
for field_name in self._fields.keys()
if field_name.startswith("origin_value_")
and field_name != "origin_value_display"
]
for rec in self:
field_name = rec.get_field_for_type(rec.field_id, "origin")
for fname in field_names:
if fname == field_name:
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, fname, value)
else:
setattr(rec, fname, False)
@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.sudo().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=disable_changeset).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=disable_changeset).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, create=False):
"""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
"""
field = rule.sudo().field_id
new_field_name = self.get_field_for_type(field, "new")
new_value = self._value_for_changeset(record, field_name, value=value)
change = {
new_field_name: new_value,
"field_id": field.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 create or 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(field, "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, value=False if create else _NO_VALUE
)
change[old_field_name] = origin_value
return change, pop_value
@api.model
def get_changeset_changes_by_field(self, model, res_id):
"""Return changes grouped by field.
:returns: dictionary with field names as keys and lists of dictionaries
describing changes as keys.
:rtype: dict
"""
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 {
field_name: list(changes)
for (field_name, changes) in groupby(
self.search_read(domain, fields), lambda vals: vals["field_name"]
)
}
@api.depends_context("user")
def _compute_user_can_validate_changeset(self):
is_superuser = self.env.is_superuser()
has_group = self.user_has_groups("base_changeset.group_changeset_user")
user_groups = self.env.user.groups_id
for rec in self:
can_validate = rec._is_change_pending() and (
is_superuser
or rec.rule_id.validator_group_ids & user_groups
or has_group
)
if rec.rule_id.prevent_self_validation:
can_validate = can_validate and rec.modified_by_id != self.env.user
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()

View file

@ -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:: ../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.

View file

@ -0,0 +1,8 @@
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
* Denis Leemann <denis.leemann@camptocamp.com>
* Yannick Vaucher <yannick.vaucher@camptocamp.com>
* Dennis Sluijk <d.sluijk@onestein.nl>
* Andrea Stirpe <a.stirpe@onestein.nl>
* Holger Brunn <mail@hunki-enterprises.com>
* Mark Schuit <mark@gig.solutions>
* Stefan Rijnhart <stefan@opener.amsterdam>

View file

@ -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.

View file

@ -0,0 +1,9 @@
* Only a subset of the type of fields is actually supported
* Multicompany not fully supported
* The popover widget indicating the number of pending changes is not shown for
fields without a label at the moment. The approach was already failing in 15.0
(in the case of inline fields such as the partner address fields)
and even in 14.0 (in the case of fields for which no value was set yet).
Or, for a more flexible approach, implement a kind of view preprocessing that
allows a developer to indicate where the widget needs to go (analogous to
`<label for="field_name" />`).

View file

@ -0,0 +1,75 @@
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:: ../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:: ../static/src/img/badge.png
When you click on it:
* Clicking the badge: red button to reject, green one to apply
.. image:: ../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.
Notes on security
-----------------
Note that by default, changeset users see all changes on all configured
rules. This circumvents read restrictions on the original records, so if you
have restrictions on models with changeset rules, changeset users will still
see all changes of all records, and applying a change on an inaccessible record
will fail.

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<record id="group_changeset_manager" model="res.groups">
<field name="name">Changeset Configuration</field>
<field
name="comment"
>The user will have an access to the configuration of the changeset rules.</field>
</record>
<record id="group_changeset_user" model="res.groups">
<field name="name">Changeset Validations</field>
<field
name="comment"
>The user will be able to apply or reject changes.</field>
</record>
</data>
<data noupdate="1">
<record id="group_changeset_manager" model="res.groups">
<field
name="users"
eval="[Command.link(ref('base.user_root')), Command.link(ref('base.user_admin'))]"
/>
<field
name="implied_ids"
eval="[Command.link(ref('group_changeset_user'))]"
/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,10 @@
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
access_ir_model_fields_changeset,ir_model_fields changeset,base.model_ir_model_fields,group_changeset_user,1,0,0,0
access_ir_model_changeset,ir_model_changeset,base.model_ir_model,group_changeset_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_record_changeset access_record_changeset model_record_changeset base.group_user 1 1 1 1
3 access_record_changeset_change access_record_changeset_change model_record_changeset_change base.group_user 1 1 1 1
4 access_changeset_field_rule access_changeset_field_rule model_changeset_field_rule base.group_user 1 1 1 1
5 access_view_record_changeset_user changeset for changeset users model_record_changeset group_changeset_user 1 1 1 0
6 access_view_record_changeset_change_user changeset change for changeset users model_record_changeset_change group_changeset_user 1 1 1 0
7 access_view_record_changeset_manager changeset for changeset managers model_record_changeset group_changeset_manager 1 1 1 1
8 access_view_record_changeset_change_manager changeset change for changeset managers model_record_changeset_change group_changeset_manager 1 1 1 1
9 access_ir_model_fields_changeset ir_model_fields changeset base.model_ir_model_fields group_changeset_user 1 0 0 0
10 access_ir_model_changeset ir_model_changeset base.model_ir_model group_changeset_user 1 0 0 0

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="changeset_field_rule_rule" model="ir.rule">
<field name="name">Changeset Field Rules</field>
<field name="model_id" ref="model_changeset_field_rule" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
<record id="record_changeset_rule" model="ir.rule">
<field name="name">Record Changeset</field>
<field name="model_id" ref="model_record_changeset" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_record_changeset_change_base_user" model="ir.rule">
<field name="name">Restrict changeset change access for users</field>
<field name="model_id" ref="model_record_changeset_change" />
<field name="groups" eval="[(4, ref('base.group_user'))]" />
<field name="domain_force">[('create_uid', '=', user.id)]</field>
</record>
<record id="rule_record_changeset_change_user" model="ir.rule">
<field name="name">Allow changeset change access for changeset users</field>
<field name="model_id" ref="model_record_changeset_change" />
<field name="groups" eval="[(4, ref('group_changeset_user'))]" />
<field
name="domain_force"
>[('rule_id.validator_group_ids', 'in', user.groups_id.ids)]</field>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -0,0 +1,569 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Track record changesets</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="track-record-changesets">
<h1 class="title">Track record changesets</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:a0553f512eea126df69773c8a4597af8d63d3427fa46a6bee3090761e8f7b869
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Alpha" src="https://img.shields.io/badge/maturity-Alpha-red.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/server-tools/tree/16.0/base_changeset"><img alt="OCA/server-tools" src="https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-base_changeset"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/server-tools&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>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.</p>
<div class="section" id="what-is-a-changeset">
<h1>What is a changeset</h1>
<p>A changeset is a list of changes made on a record.</p>
<p>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.</p>
<div class="admonition important">
<p class="first admonition-title">Important</p>
<p class="last">This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
<a class="reference external" href="https://odoo-community.org/page/development-status">More details on development status</a></p>
</div>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
</ul>
</div>
<div class="section" id="configuration">
<h2><a class="toc-backref" href="#toc-entry-1">Configuration</a></h2>
</div>
</div>
<div class="section" id="access-rights">
<h1>Access Rights</h1>
<p>The changesets rules must be edited by users with the group <tt class="docutils literal">Changesets
Configuration</tt>. The changesets can be applied or canceled only by users
with the group <tt class="docutils literal">Changesets Validations</tt></p>
</div>
<div class="section" id="changesets-rules">
<h1>Changesets Rules</h1>
<p>The changesets rules can be configured in <tt class="docutils literal">Configuration &gt;
Record Changesets &gt; Fields Rules</tt>.</p>
<ul>
<li><p class="first">Configuration of rules</p>
<img alt="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/rules.png" src="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/rules.png" />
</li>
</ul>
<p>For each record field, an action can be defined:</p>
<ul class="simple">
<li>Auto: the changes made on this field are always applied</li>
<li>Validate: the changes made on this field must be manually confirmed by
a Changesets User user</li>
<li>Never: the changes made on this field are always refused</li>
</ul>
<p>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.</p>
<p>The supported fields are:</p>
<ul class="simple">
<li>Char</li>
<li>Text</li>
<li>Date</li>
<li>Datetime</li>
<li>Integer</li>
<li>Float</li>
<li>Monetary</li>
<li>Boolean</li>
<li>Many2one</li>
</ul>
<p>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).</p>
<p>If a field has no rule, it is written to the record without changeset.</p>
<div class="section" id="usage">
<h2>Usage</h2>
</div>
</div>
<div class="section" id="changeset-rules">
<h1>Changeset rules</h1>
<p>The first step is to configure the changeset rules. Once that done, writes on
records will be created as changesets.</p>
</div>
<div class="section" id="handling-changesets">
<h1>Handling changesets</h1>
<p>The list of all the changesets is in <tt class="docutils literal">Configuration &gt; Record
Changesets &gt; Changesets</tt>.</p>
<p>By default, only the pending changesets (waiting for validation) are shown.
Remove the “Pending” filter to show all the changesets.</p>
<ul>
<li><p class="first">Changeset waiting for validation</p>
<img alt="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/changeset.png" src="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/changeset.png" />
</li>
</ul>
<p>The changes view shows the name of the records 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.</p>
<p>A button on a changeset allows to apply or reject all the changes at
once.</p>
</div>
<div class="section" id="handling-single-changes">
<h1>Handling single changes</h1>
<p>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:</p>
<ul>
<li><p class="first">Badge with the number of pending changes</p>
<img alt="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge.png" src="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge.png" />
</li>
</ul>
<p>When you click on it:</p>
<ul>
<li><p class="first">Clicking the badge: red button to reject, green one to apply</p>
<img alt="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge_click.png" src="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge_click.png" />
</li>
</ul>
<p>Click the red button to reject the change, click the green one to apply it.</p>
</div>
<div class="section" id="custom-source-rules-in-your-addon">
<h1>Custom source rules in your addon</h1>
<p>Addons wanting to create changeset with their own rules should pass the
following keys in the context when they write on the record:</p>
<ul class="simple">
<li><tt class="docutils literal">__changeset_rules_source_model</tt>: name of the model which asks for
the change</li>
<li><tt class="docutils literal">__changeset_rules_source_id</tt>: id of the record which asks for the
change</li>
</ul>
<p>Also, they should extend the selection in
<tt class="docutils literal">ChangesetFieldRule._domain_source_models</tt> to add their model (the
same that is passed in <tt class="docutils literal">__changeset_rules_source_model</tt>).</p>
<p>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.</p>
</div>
<div class="section" id="notes-on-security">
<h1>Notes on security</h1>
<p>Note that by default, changeset users see all changes on all configured
rules. This circumvents read restrictions on the original records, so if you
have restrictions on models with changeset rules, changeset users will still
see all changes of all records, and applying a change on an inaccessible record
will fail.</p>
<div class="section" id="known-issues-roadmap">
<h2>Known issues / Roadmap</h2>
<ul class="simple">
<li>Only a subset of the type of fields is actually supported</li>
<li>Multicompany not fully supported</li>
<li>The popover widget indicating the number of pending changes is not shown for
fields without a label at the moment. The approach was already failing in 15.0
(in the case of inline fields such as the partner address fields)
and even in 14.0 (in the case of fields for which no value was set yet).
Or, for a more flexible approach, implement a kind of view preprocessing that
allows a developer to indicate where the widget needs to go (analogous to
<cite>&lt;label for=”field_name” /&gt;</cite>).</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h2>Bug Tracker</h2>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/server-tools/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/server-tools/issues/new?body=module:%20base_changeset%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h2>Credits</h2>
<div class="section" id="authors">
<h3>Authors</h3>
<ul class="simple">
<li>Onestein</li>
<li>Camptocamp</li>
</ul>
</div>
<div class="section" id="contributors">
<h3>Contributors</h3>
<ul class="simple">
<li>Guewen Baconnier &lt;<a class="reference external" href="mailto:guewen.baconnier&#64;camptocamp.com">guewen.baconnier&#64;camptocamp.com</a>&gt;</li>
<li>Denis Leemann &lt;<a class="reference external" href="mailto:denis.leemann&#64;camptocamp.com">denis.leemann&#64;camptocamp.com</a>&gt;</li>
<li>Yannick Vaucher &lt;<a class="reference external" href="mailto:yannick.vaucher&#64;camptocamp.com">yannick.vaucher&#64;camptocamp.com</a>&gt;</li>
<li>Dennis Sluijk &lt;<a class="reference external" href="mailto:d.sluijk&#64;onestein.nl">d.sluijk&#64;onestein.nl</a>&gt;</li>
<li>Andrea Stirpe &lt;<a class="reference external" href="mailto:a.stirpe&#64;onestein.nl">a.stirpe&#64;onestein.nl</a>&gt;</li>
<li>Holger Brunn &lt;<a class="reference external" href="mailto:mail&#64;hunki-enterprises.com">mail&#64;hunki-enterprises.com</a>&gt;</li>
<li>Mark Schuit &lt;<a class="reference external" href="mailto:mark&#64;gig.solutions">mark&#64;gig.solutions</a>&gt;</li>
<li>Stefan Rijnhart &lt;<a class="reference external" href="mailto:stefan&#64;opener.amsterdam">stefan&#64;opener.amsterdam</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h3>Maintainers</h3>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>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.</p>
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p>
<p><a class="reference external image-reference" href="https://github.com/astirpe"><img alt="astirpe" src="https://github.com/astirpe.png?size=40px" /></a></p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/server-tools/tree/16.0/base_changeset">OCA/server-tools</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,53 @@
/** @odoo-module */
import {Component} from "@odoo/owl";
import {FormLabel} from "@web/views/form/form_label";
import Popover from "web.Popover";
export class BaseChangesetPopover extends Popover {
/*
Call the ORM to accept the change and refresh the form view
to update the field value.
*/
async applyChange(change_id) {
await this.props.record.model.orm.call(
"record.changeset.change",
"apply",
[[change_id]],
{
context: {set_change_by_ui: true},
}
);
this._close();
// Save the record first to prevent losing unsaved data on load.
await this.props.record.save();
await this.props.record.load();
await this.props.record.model.notify();
}
/*
Call the ORM to reject the change and only update the record's pending changes.
*/
async rejectChange(change_id) {
await this.props.record.model.orm.call(
"record.changeset.change",
"cancel",
[[change_id]],
{
context: {set_change_by_ui: true},
}
);
this._close();
this.props.record.changesetChanges =
await this.props.record.fetchChangesetChanges();
this.props.record.model.notify();
}
}
BaseChangesetPopover.template = "base_changeset.ChangesetPopover";
BaseChangesetPopover.props = ["fieldName", "popoverClass", "record", "title"];
export class BaseChangesetPopoverWrapper extends Component {}
BaseChangesetPopoverWrapper.components = {BaseChangesetPopover};
BaseChangesetPopoverWrapper.template = "base_changeset.ChangesetPopoverWrapper";
FormLabel.components = FormLabel.components || {};
Object.assign(FormLabel.components, {BaseChangesetPopoverWrapper});

View file

@ -0,0 +1,6 @@
.o_changeset_popover {
background-color: $o-view-background-color;
}
span.o_changeset_popover_wrapper > div {
display: inline;
}

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="base_changeset.ChangesetPopoverWrapper" owl="1">
<!--
The popover button has to be set to inline display using a wrapper around
the inherited Popover template. Otherwise, if instead we modify the top
level div element of the Popover template, the component loses its `el`.
-->
<span class="o_changeset_popover_wrapper">
<BaseChangesetPopover
record="props.record"
fieldName="props.fieldName"
title="'Pending Changes'"
popoverClass="'o_changeset_popover'"
t-if="props.record.changesetChanges ? props.record.changesetChanges[props.fieldName] : 0"
/>
</span>
</t>
<t
t-name="base_changeset.ChangesetPopover"
owl="1"
t-inherit="web.Popover"
t-inherit-mode="primary"
>
<t t-portal="'body'" position="before">
<a
class="o_ChangesetPopoverView badge rounded-pill text-bg-warning mx-3 align-self-center"
t-esc="props.record.changesetChanges[props.fieldName].length"
role="button"
/>
</t>
<t t-slot="opened" position="replace">
<table class="pb-4">
<tr
t-foreach="props.record.changesetChanges[props.fieldName]"
t-as="change"
t-key="change.id"
>
<td>
<t t-esc="change.origin_value_display" />
</td>
<td class="pl-2 pr-2">
<i class="fa fa-arrow-right" />
</td>
<td>
<t t-esc="change.new_value_display" />
</td>
<td class="pl-4" t-if="change.user_can_validate_changeset">
<div class="btn-group">
<button
class="btn btn-danger base_changeset_reject btn-sm"
t-attf-data-id="#{change.id}"
t-on-click.synthetic="() => this.rejectChange(change.id)"
>
<i class="fa fa-times" />
</button>
<button
class="btn btn-success base_changeset_apply btn-sm"
t-attf-data-id="#{change.id}"
t-on-click.synthetic="() => this.applyChange(change.id)"
>
<i class="fa fa-check" />
</button>
</div>
</td>
</tr>
</table>
</t>
</t>
</templates>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-inherit="web.FormLabel" t-inherit-mode="extension">
<xpath expr="//label" position="inside">
<BaseChangesetPopoverWrapper record="props.record" fieldName="props.id" />
</xpath>
</t>
</templates>

View file

@ -0,0 +1,31 @@
/* @odoo-module */
import {Record} from "@web/views/basic_relational_model";
import {patch} from "@web/core/utils/patch";
patch(Record.prototype, "base_changeset.Record", {
/* Call the ORM to get this record's changeset changes */
async fetchChangesetChanges() {
return this.model.orm.call(
"record.changeset.change",
"get_changeset_changes_by_field",
[this.resModel, this.resId]
);
},
/* After loading the form's record data, fetch the changeset changes */
async load() {
await this._super(...arguments);
if (this.__viewType === "form" && this.resId) {
this.changesetChanges = await this.fetchChangesetChanges();
}
},
/* Call the ORM to get this record's changeset changes after the form is modified */
async save() {
const isSaved = await this._super(...arguments);
if (this.__viewType === "form" && this.resId) {
this.changesetChanges = await this.fetchChangesetChanges();
this.model.notify();
}
return isSaved;
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View file

@ -0,0 +1,5 @@
from . import test_changeset_flow
from . import test_changeset_field_type
from . import test_changeset_origin
from . import test_changeset_field_rule
from . import test_changeset_security

View file

@ -0,0 +1,86 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
class ChangesetTestCommon(object):
def assert_changeset(self, record, expected_source, expected_changes):
"""Check if a changeset has been created according to expected values
The record should have no prior changeset than the one created in the
test (so it has exactly 1 changeset).
The expected changes are tuples with (field, origin_value,
new_value, state)
:param record: record of record having a changeset
:param expected_changes: contains tuples with the changes
:type expected_changes: list(tuple))
"""
changeset = self.env["record.changeset"].search(
[("model", "=", record._name), ("res_id", "=", record.id)]
)
self.assertEqual(
len(changeset), 1, "1 changeset expected, got {}".format(changeset)
)
self.assertEqual(changeset.source, expected_source)
changes = changeset.change_ids
missing = []
for expected_change in expected_changes:
for change in changes:
if (
change.field_id,
change.get_origin_value(),
change.get_new_value(),
change.state,
) == expected_change:
changes -= change
break
else:
missing.append(expected_change)
message = ""
for field, origin_value, new_value, state in missing:
message += (
"- field: '%s', origin_value: '%s', "
"new_value: '%s', state: '%s'\n"
% (field.name, origin_value, new_value, state)
)
for change in changes:
message += (
"+ field: '%s', origin_value: '%s', "
"new_value: '%s', state: '%s'\n"
% (
change.field_id.name,
change.get_origin_value(),
change.get_new_value(),
change.state,
)
)
if message:
raise AssertionError("Changes do not match\n\n:%s" % message)
def _create_changeset(self, record, changes):
"""Create a changeset and its associated changes
:param record: 'record' record
:param changes: list of changes [(field, new value, state)]
:returns: 'record.changeset' record
"""
ChangesetChange = self.env["record.changeset.change"]
get_field = ChangesetChange.get_field_for_type
change_values = []
for field, value, state in changes:
change = {
"field_id": field.id,
# write in the field of the appropriate type for the
# origin field (char, many2one, ...)
get_field(field, "new"): value,
"state": state,
}
change_values.append((0, 0, change))
values = {
"model": record._name,
"res_id": record.id,
"change_ids": change_values,
}
return self.env["record.changeset"].create(values)

View file

@ -0,0 +1,73 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tests import common
class TestChangesetFieldRule(common.TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company_model_id = cls.env.ref("base.model_res_company").id
cls.field_name = cls.env.ref("base.field_res_partner__name")
cls.field_street = cls.env.ref("base.field_res_partner__street")
def test_get_rules(self):
ChangesetFieldRule = self.env["changeset.field.rule"]
ChangesetFieldRule.search([]).unlink()
rule1 = ChangesetFieldRule.create(
{"field_id": self.field_name.id, "action": "validate"}
)
rule2 = ChangesetFieldRule.create(
{"field_id": self.field_street.id, "action": "never"}
)
get_rules = ChangesetFieldRule.get_rules(None, "res.partner")
self.assertEqual(get_rules, {"name": rule1, "street": rule2})
def test_get_rules_source(self):
ChangesetFieldRule = self.env["changeset.field.rule"]
ChangesetFieldRule.search([]).unlink()
rule1 = ChangesetFieldRule.create(
{"field_id": self.field_name.id, "action": "validate"}
)
rule2 = ChangesetFieldRule.create(
{"field_id": self.field_street.id, "action": "never"}
)
rule3 = ChangesetFieldRule.create(
{
"source_model_id": self.company_model_id,
"field_id": self.field_street.id,
"action": "never",
}
)
model = ChangesetFieldRule
rules = model.get_rules(None, "res.partner")
self.assertEqual(rules, {"name": rule1, "street": rule2})
rules = model.get_rules("res.company", "res.partner")
self.assertEqual(rules, {"name": rule1, "street": rule3})
def test_get_rules_cache(self):
ChangesetFieldRule = self.env["changeset.field.rule"]
ChangesetFieldRule.search([]).unlink()
rule = ChangesetFieldRule.create(
{"field_id": self.field_name.id, "action": "validate"}
)
self.assertEqual(
ChangesetFieldRule.get_rules(None, "res.partner")["name"].action, "validate"
)
# Write on cursor to bypass the cache invalidation for the
# matter of the test
self.env.cr.execute(
"UPDATE changeset_field_rule " "SET action = 'never' " "WHERE id = %s",
(rule.id,),
)
self.assertEqual(
ChangesetFieldRule.get_rules(None, "res.partner")["name"].action, "validate"
)
rule.action = "auto"
self.assertEqual(
ChangesetFieldRule.get_rules(None, "res.partner")["name"].action, "auto"
)
rule.unlink()
self.assertFalse(ChangesetFieldRule.get_rules(None, "res.partner"))

View file

@ -0,0 +1,301 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from markupsafe import Markup
from odoo import fields
from odoo.tests.common import TransactionCase
from ..models.base import disable_changeset
from .common import ChangesetTestCommon
class TestChangesetFieldType(ChangesetTestCommon, TransactionCase):
"""Check that changeset changes are stored expectingly to their types"""
@classmethod
def _setup_rules(cls):
ChangesetFieldRule = cls.env["changeset.field.rule"]
ChangesetFieldRule.search([]).unlink()
fields = (
("char", "ref"),
("text", "comment"),
("boolean", "is_company"),
("date", "date"),
("integer", "color"),
("float", "partner_latitude"),
("selection", "type"),
("many2one", "country_id"),
("many2many", "category_id"),
("one2many", "user_ids"),
("binary", "image_1920"),
)
for field_type, field in fields:
attr_name = "field_%s" % field_type
field_record = cls.env["ir.model.fields"].search(
[("model", "=", "res.partner"), ("name", "=", field)]
)
cls.assertTrue(field_record, "Field %s not available" % field)
# set attribute such as 'self.field_char' is a
# ir.model.fields record of the field res_partner.ref
setattr(cls, attr_name, field_record)
ChangesetFieldRule.create(
{"field_id": field_record.id, "action": "validate"}
)
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._setup_rules()
cls.partner = cls.env["res.partner"].create(
{"name": "Original Name", "street": "Original Street"}
)
# Add context for this test for compatibility with other modules' tests
cls.partner = cls.partner.with_context(test_record_changeset=True)
def test_new_changeset_char(self):
"""Add a new changeset on a Char field"""
self.partner.write({self.field_char.name: "New value"})
self.assert_changeset(
self.partner,
self.env.user,
[
(
self.field_char,
self.partner[self.field_char.name],
"New value",
"draft",
)
],
)
def test_new_changeset_text(self):
"""Add a new changeset on a Text field"""
self.partner.write({self.field_text.name: "New comment\non 2 lines"})
self.assert_changeset(
self.partner,
self.env.user,
[
(
self.field_text,
self.partner[self.field_text.name],
"New comment\non 2 lines",
"draft",
)
],
)
def test_new_changeset_boolean(self):
"""Add a new changeset on a Boolean field"""
# ensure the changeset has to change the value
self.partner.with_context(__no_changeset=disable_changeset).write(
{self.field_boolean.name: False}
)
self.partner.write({self.field_boolean.name: True})
self.assert_changeset(
self.partner,
self.env.user,
[
(
self.field_boolean,
self.partner[self.field_boolean.name],
True,
"draft",
)
],
)
def test_new_changeset_date(self):
"""Add a new changeset on a Date field"""
self.partner.write({self.field_date.name: "2015-09-15"})
self.assert_changeset(
self.partner,
self.env.user,
[
(
self.field_date,
self.partner[self.field_date.name],
fields.Date.from_string("2015-09-15"),
"draft",
)
],
)
def test_new_changeset_integer(self):
"""Add a new changeset on a Integer field"""
self.partner.write({self.field_integer.name: 42})
self.assert_changeset(
self.partner,
self.env.user,
[(self.field_integer, self.partner[self.field_integer.name], 42, "draft")],
)
def test_new_changeset_float(self):
"""Add a new changeset on a Float field"""
self.partner.write({self.field_float.name: 3.1415})
self.assert_changeset(
self.partner,
self.env.user,
[(self.field_float, self.partner[self.field_float.name], 3.1415, "draft")],
)
def test_new_changeset_selection(self):
"""Add a new changeset on a Selection field"""
self.partner.write({self.field_selection.name: "delivery"})
self.assert_changeset(
self.partner,
self.env.user,
[
(
self.field_selection,
self.partner[self.field_selection.name],
"delivery",
"draft",
)
],
)
def test_new_changeset_many2one(self):
"""Add a new changeset on a Many2one field"""
self.partner.with_context(__no_changeset=disable_changeset).write(
{self.field_many2one.name: self.env.ref("base.fr").id}
)
self.partner.write({self.field_many2one.name: self.env.ref("base.ch").id})
self.assert_changeset(
self.partner,
self.env.user,
[
(
self.field_many2one,
self.partner[self.field_many2one.name],
self.env.ref("base.ch"),
"draft",
)
],
)
def test_new_changeset_many2many(self):
"""Add a new changeset on a Many2many field is not supported"""
with self.assertRaises(NotImplementedError):
self.partner.write(
{self.field_many2many.name: [self.env.ref("base.ch").id]}
)
def test_new_changeset_one2many(self):
"""Add a new changeset on a One2many field is not supported"""
with self.assertRaises(NotImplementedError):
self.partner.write(
{self.field_one2many.name: [self.env.ref("base.user_root").id]}
)
def test_new_changeset_binary(self):
"""Add a new changeset on a Binary field is not supported"""
with self.assertRaises(NotImplementedError):
self.partner.write({self.field_binary.name: "xyz"})
def test_apply_char(self):
"""Apply a change on a Char field"""
changes = [(self.field_char, "New Ref", "draft")]
changeset = self._create_changeset(self.partner, changes)
changeset.change_ids.apply()
self.assertEqual(self.partner[self.field_char.name], "New Ref")
def test_apply_text(self):
"""Apply a change on a Text field"""
changes = [(self.field_text, "New comment\non 2 lines", "draft")]
changeset = self._create_changeset(self.partner, changes)
changeset.change_ids.apply()
self.assertEqual(
self.partner[self.field_text.name], Markup("<p>New comment\non 2 lines</p>")
)
def test_apply_boolean(self):
"""Apply a change on a Boolean field"""
# ensure the changeset has to change the value
self.partner.write({self.field_boolean.name: False})
changes = [(self.field_boolean, True, "draft")]
changeset = self._create_changeset(self.partner, changes)
changeset.change_ids.apply()
self.assertEqual(self.partner[self.field_boolean.name], True)
# Cannot do this while it is on the same transaction. The cache may not
# be updated
# changes = [(self.field_boolean, False, 'draft')]
# changeset = self._create_changeset(self.partner, changes)
# changeset.change_ids.apply()
# self.assertEqual(self.partner[self.field_boolean.name], False)
def test_apply_date(self):
"""Apply a change on a Date field"""
changes = [(self.field_date, "2015-09-15", "draft")]
changeset = self._create_changeset(self.partner, changes)
changeset.change_ids.apply()
self.assertAlmostEqual(
self.partner[self.field_date.name], fields.Date.from_string("2015-09-15")
)
def test_apply_integer(self):
"""Apply a change on a Integer field"""
changes = [(self.field_integer, 42, "draft")]
changeset = self._create_changeset(self.partner, changes)
changeset.change_ids.apply()
self.assertAlmostEqual(self.partner[self.field_integer.name], 42)
def test_apply_float(self):
"""Apply a change on a Float field"""
changes = [(self.field_float, 52.47, "draft")]
changeset = self._create_changeset(self.partner, changes)
changeset.change_ids.apply()
self.assertAlmostEqual(self.partner[self.field_float.name], 52.47)
def test_apply_selection(self):
"""Apply a change on a Selection field"""
changes = [(self.field_selection, "delivery", "draft")]
changeset = self._create_changeset(self.partner, changes)
changeset.change_ids.apply()
self.assertAlmostEqual(self.partner[self.field_selection.name], "delivery")
def test_apply_many2one(self):
"""Apply a change on a Many2one field"""
self.partner.with_context(__no_changeset=disable_changeset).write(
{self.field_many2one.name: self.env.ref("base.fr").id}
)
changes = [
(
self.field_many2one,
"res.country,%d" % self.env.ref("base.ch").id,
"draft",
)
]
changeset = self._create_changeset(self.partner, changes)
changeset.change_ids.apply()
self.assertEqual(
self.partner[self.field_many2one.name], self.env.ref("base.ch")
)
def test_apply_many2many(self):
"""Apply a change on a Many2many field is not supported"""
changes = [(self.field_many2many, self.env.ref("base.ch").id, "draft")]
with self.assertRaises(NotImplementedError):
self._create_changeset(self.partner, changes)
def test_apply_one2many(self):
"""Apply a change on a One2many field is not supported"""
changes = [
(
self.field_one2many,
[self.env.ref("base.user_root").id, self.env.ref("base.user_demo").id],
"draft",
)
]
with self.assertRaises(NotImplementedError):
self._create_changeset(self.partner, changes)
def test_apply_binary(self):
"""Apply a change on a Binary field is not supported"""
changes = [(self.field_one2many, "", "draft")]
with self.assertRaises(NotImplementedError):
self._create_changeset(self.partner, changes)

View file

@ -0,0 +1,485 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from datetime import datetime, timedelta
from lxml import etree
from odoo import fields
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase
from ..models.base import disable_changeset
from .common import ChangesetTestCommon
class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
"""Check how changeset are generated and applied based on the rules.
We do not really care about the types of the fields in this test
suite, so we only use 'char' fields. We have to ensure that the
general changeset flows work as expected, that is:
* create a changeset when a manual/system write is made on partner
* create a changeset according to the changeset rules when a source model
is specified
* apply a changeset change writes the value on the partner
* apply a whole changeset writes all the changes' values on the partner
* changes in state 'cancel' or 'done' do not write on the partner
* when all the changes are either 'cancel' or 'done', the changeset
becomes 'done'
"""
@classmethod
def _setup_rules(cls):
ChangesetFieldRule = cls.env["changeset.field.rule"]
ChangesetFieldRule.search([]).unlink()
cls.field_name = cls.env.ref("base.field_res_partner__name")
cls.field_street = cls.env.ref("base.field_res_partner__street")
cls.field_street2 = cls.env.ref("base.field_res_partner__street2")
ChangesetFieldRule.create({"field_id": cls.field_name.id, "action": "auto"})
ChangesetFieldRule.create(
{"field_id": cls.field_street.id, "action": "validate"}
)
ChangesetFieldRule.create({"field_id": cls.field_street2.id, "action": "never"})
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._setup_rules()
cls.demo_user = cls.env.ref("base.user_demo")
cls.partner = (
cls.env["res.partner"]
.with_user(cls.demo_user)
.create({"name": "X", "street": "street X", "street2": "street2 X"})
)
# Add context for this test for compatibility with other modules' tests
cls.partner = cls.partner.with_context(test_record_changeset=True)
def test_get_view(self):
"""For privileged users, the smart button is present on the form"""
view = self.env.ref("base.view_partner_form")
def get_nodes(user):
arch = etree.XML(
self.env["res.partner"]
.with_user(user)
.get_view(view_id=view.id)["arch"]
)
return len(
arch.xpath(
"//div[@name='button_box']"
"/button[@name='action_record_changeset_change_view']"
)
)
self.assertTrue(get_nodes(self.env.ref("base.user_admin")))
self.assertFalse(get_nodes(self.env.ref("base.user_demo")))
def test_new_changeset(self):
"""Add a new changeset on a partner
A new changeset is created when we write on a partner
"""
self.partner.write({"name": "Y", "street": "street Y", "street2": "street2 Y"})
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(self.partner.count_pending_changeset_changes, 1)
self.assert_changeset(
self.partner,
self.demo_user,
[
(self.field_name, "X", "Y", "done"),
(self.field_street, "street X", "street Y", "draft"),
(self.field_street2, "street2 X", "street2 Y", "cancel"),
],
)
self.assertEqual(self.partner.name, "Y")
self.assertEqual(self.partner.street, "street X")
self.assertEqual(self.partner.street2, "street2 X")
# Pending Changes widget can be rendered for the unprivileged user
self.env.invalidate_all()
self.env["record.changeset.change"].with_user(
self.demo_user
).get_changeset_changes_by_field(self.partner._name, self.partner.id)
def test_create_new_changeset(self):
"""Create a new partner with a changeset"""
new = (
self.env["res.partner"]
.with_context(test_record_changeset=True)
.create(
{
"name": "partner",
"street": "street",
"street2": "street2",
}
)
)
new._compute_changeset_ids()
new._compute_count_pending_changesets()
self.assertEqual(new.count_pending_changesets, 1)
self.assert_changeset(
new,
self.env.user,
[
(self.field_name, False, "partner", "done"),
(self.field_street, False, "street", "draft"),
(self.field_street2, False, "street2", "cancel"),
],
)
self.assertEqual(new.name, "partner")
self.assertFalse(new.street)
self.assertFalse(new.street2)
def test_create_new_changeset_empty_value(self):
"""No change is created for empty values on create"""
new = (
self.env["res.partner"]
.with_context(test_record_changeset=True)
.create(
{
"name": "partner",
"street": "street",
"street2": False,
}
)
)
new._compute_changeset_ids()
new._compute_count_pending_changesets()
self.assertEqual(new.count_pending_changesets, 1)
self.assert_changeset(
new,
self.env.user,
[
(self.field_name, False, "partner", "done"),
(self.field_street, False, "street", "draft"),
],
)
self.assertEqual(new.name, "partner")
self.assertFalse(new.street)
self.assertFalse(new.street2)
def test_new_changeset_empty_value(self):
"""Create a changeset change that empty a value"""
self.partner.write({"street": False})
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assert_changeset(
self.partner,
self.demo_user,
[(self.field_street, "street X", False, "draft")],
)
def test_no_changeset_empty_value_both_sides(self):
"""No changeset created when both sides have an empty value"""
# we have to ensure that even if we write '' to a False field, we won't
# write a changeset
self.partner.with_context(__no_changeset=disable_changeset).write(
{"street": False}
)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.partner.write({"street": ""})
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertFalse(self.partner.changeset_ids)
def test_apply_change(self):
"""Apply a changeset change on a partner"""
changes = [(self.field_name, "Y", "draft")]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
for change in changeset.change_ids:
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
changeset.change_ids.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(self.partner.name, "Y")
self.assertEqual(changeset.change_ids.state, "done")
# All computed fields are assigned
changeset.change_ids.read()
def test_apply_change_with_prevent_self_validation(self):
"""Don't apply a changeset change and prevent self validation"""
self.partner.write({"street": "street Z"})
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(self.partner.count_pending_changeset_changes, 1)
self.partner.changeset_ids.change_ids.rule_id.prevent_self_validation = True
with self.assertRaises(
UserError, msg="You don't have the rights to reject the changes."
):
self.partner.changeset_ids.change_ids.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(self.partner.count_pending_changeset_changes, 1)
self.assertEqual(self.partner.street, "street X")
self.assertEqual(self.partner.changeset_ids.change_ids.state, "draft")
# Copy the user to have another user with similar rights, so that
# self validation prevention doesn't kick in.
other_demo_user = self.demo_user.copy()
other_demo_user.groups_id += self.env.ref("base_changeset.group_changeset_user")
self.partner.changeset_ids.change_ids.with_user(other_demo_user).apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(self.partner.count_pending_changeset_changes, 0)
self.assertEqual(self.partner.street, "street Z")
self.assertEqual(self.partner.changeset_ids.change_ids.state, "done")
def test_apply_done_change(self):
"""Done changes do not apply (already applied)"""
changes = [(self.field_name, "Y", "done")]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
with self.assertRaises(UserError):
changeset.change_ids.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(self.partner.name, "X")
changeset.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(self.partner.name, "X")
def test_apply_cancel_change(self):
"""Cancel changes do not apply"""
changes = [(self.field_name, "Y", "cancel")]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
with self.assertRaises(UserError):
changeset.change_ids.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(self.partner.name, "X")
changeset.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(self.partner.name, "X")
def test_apply_empty_value(self):
"""Apply a change that empty a value"""
changes = [(self.field_street, False, "draft")]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
for change in changeset.change_ids:
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
changeset.change_ids.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertFalse(self.partner.street)
def test_apply_change_loop(self):
"""Test multiple changes"""
changes = [
(self.field_name, "Y", "draft"),
(self.field_street, "street Y", "draft"),
(self.field_street2, "street2 Y", "draft"),
]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
for change in changeset.change_ids:
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
changeset.change_ids.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(self.partner.name, "Y")
self.assertEqual(self.partner.street, "street Y")
self.assertEqual(self.partner.street2, "street2 Y")
def test_apply(self):
"""Apply a full changeset on a partner"""
changes = [
(self.field_name, "Y", "draft"),
(self.field_street, "street Y", "draft"),
(self.field_street2, "street2 Y", "draft"),
]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(self.partner.count_pending_changeset_changes, 3)
for change in changeset.change_ids:
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
changeset.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(self.partner.count_pending_changeset_changes, 0)
self.assertEqual(self.partner.name, "Y")
self.assertEqual(self.partner.street, "street Y")
self.assertEqual(self.partner.street2, "street2 Y")
def test_changeset_state_on_done(self):
"""Check that changeset state becomes done when changes are done"""
changes = [(self.field_name, "Y", "draft")]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(changeset.state, "draft")
changeset.change_ids.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(changeset.state, "done")
def test_changeset_state_on_cancel(self):
"""Check that rev. state becomes done when changes are canceled"""
changes = [(self.field_name, "Y", "draft")]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(changeset.state, "draft")
changeset.change_ids.cancel()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(changeset.state, "done")
def test_changeset_state(self):
"""Check that changeset state becomes done with multiple changes"""
changes = [
(self.field_name, "Y", "draft"),
(self.field_street, "street Y", "draft"),
(self.field_street2, "street2 Y", "draft"),
]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(self.partner.count_pending_changeset_changes, 3)
self.assertEqual(changeset.state, "draft")
changeset.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 0)
self.assertEqual(self.partner.count_pending_changeset_changes, 0)
self.assertEqual(changeset.state, "done")
def test_apply_changeset_with_other_pending(self):
"""Error when applying when previous pending changesets exist"""
changes = [(self.field_name, "Y", "draft")]
old_changeset = self._create_changeset(self.partner, changes)
# if the date is the same, both changeset can be applied
to_string = fields.Datetime.to_string
old_changeset.date = to_string(datetime.now() - timedelta(days=1))
changes = [(self.field_name, "Z", "draft")]
changeset = self._create_changeset(self.partner, changes)
with self.assertRaises(UserError):
changeset.change_ids.with_context(
require_previous_changesets_done=True
).apply()
changeset.change_ids.apply()
def test_apply_different_changesets(self):
"""Apply different changesets at once"""
partner2 = self.env["res.partner"].create({"name": "P2"})
changes = [
(self.field_name, "Y", "draft"),
(self.field_street, "street Y", "draft"),
(self.field_street2, "street2 Y", "draft"),
]
changeset = self._create_changeset(self.partner, changes)
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(self.partner.count_pending_changeset_changes, 3)
for change in changeset.change_ids:
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
changeset2 = self._create_changeset(partner2, changes)
partner2._compute_changeset_ids()
partner2._compute_count_pending_changesets()
self.assertEqual(changeset.state, "draft")
self.assertEqual(changeset2.state, "draft")
self.assertEqual(partner2.count_pending_changesets, 1)
self.assertEqual(partner2.count_pending_changeset_changes, 3)
for change in changeset2.change_ids:
change.get_changeset_changes_by_field(changeset2.model, changeset2.res_id)
(changeset + changeset2).apply()
self.assertEqual(self.partner.name, "Y")
self.assertEqual(self.partner.street, "street Y")
self.assertEqual(self.partner.street2, "street2 Y")
self.assertEqual(partner2.name, "Y")
self.assertEqual(partner2.street, "street Y")
self.assertEqual(partner2.street2, "street2 Y")
self.assertEqual(changeset.state, "done")
self.assertEqual(changeset2.state, "done")
def test_new_changeset_source(self):
"""Source is the user who made the change"""
self.partner.write({"street": False})
self.partner._compute_changeset_ids()
changeset = self.partner.changeset_ids
self.assertEqual(changeset.source, self.demo_user)
def test_new_changeset_source_other_model(self):
"""Define source from another model"""
company = self.env.ref("base.main_company")
keys = {
"force_changeset_for_partners": True,
"__changeset_rules_source_model": "res.company",
"__changeset_rules_source_id": company.id,
}
self.partner.with_context(**keys).write({"street": False})
self.partner._compute_changeset_ids()
changeset = self.partner.changeset_ids
self.assertEqual(changeset.source, company)
def test_name_get(self):
"""Test the name_get of a changeset for a model without name field"""
self.env["changeset.field.rule"].create(
{
"field_id": self.env.ref("base.field_res_partner_bank__active").id,
"action": "validate",
}
)
bank = self.env.ref("base.bank_partner_demo").with_context(
test_record_changeset=True
)
bank.active = False
self.assertTrue(bank.changeset_ids)
self.assertIn(bank.acc_number, bank.changeset_ids.name_get()[0][1])
def test_new_changeset_expression(self):
"""Test that rules can be conditional"""
self.env["changeset.field.rule"].search(
[
("field_id", "=", self.field_street.id),
]
).expression = "object.street != 'street X'"
self.partner.street = "street Y"
self.partner.invalidate_recordset()
self.assertEqual(self.partner.street, "street Y")
self.assertFalse(self.partner.changeset_ids)
self.partner.street = "street Z"
self.partner.invalidate_recordset()
self.assertTrue(self.partner.changeset_ids)
self.assertEqual(self.partner.street, "street Y")

View file

@ -0,0 +1,139 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tests.common import Form, TransactionCase
from ..models.base import disable_changeset
from .common import ChangesetTestCommon
class TestChangesetOrigin(ChangesetTestCommon, TransactionCase):
"""Check that origin - old fields are stored as expected.
'origin' fields dynamically read fields from the partner when the state
of the change is 'draft'. Once a change becomes 'done' or 'cancel', the
'old' field copies the value from the partner and then the 'origin' field
displays the 'old' value.
"""
@classmethod
def _setup_rules(cls):
ChangesetFieldRule = cls.env["changeset.field.rule"]
ChangesetFieldRule.search([]).unlink()
cls.field_name = cls.env.ref("base.field_res_partner__name")
ChangesetFieldRule.create({"field_id": cls.field_name.id, "action": "validate"})
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._setup_rules()
cls.partner = cls.env["res.partner"].create({"name": "X"})
# Add context for this test for compatibility with other modules' tests
cls.partner = cls.partner.with_context(test_record_changeset=True)
def test_origin_value_of_change_with_apply(self):
"""Origin field is read from the parter or 'old' - with apply
According to the state of the change.
"""
with Form(self.partner) as partner_form:
partner_form.name = "Y"
self.assertEqual(self.partner.count_pending_changesets, 1)
changeset = self.partner.changeset_ids
change = changeset.change_ids
self.assertEqual(self.partner.name, "X")
self.assertEqual(change.origin_value_char, "X")
self.assertEqual(change.origin_value_display, "X")
with Form(
self.partner.with_context(__no_changeset=disable_changeset)
) as partner_form:
partner_form.name = "A"
self.assertEqual(change.origin_value_char, "A")
self.assertEqual(change.origin_value_display, "A")
change.apply()
self.assertEqual(change.origin_value_char, "A")
self.assertEqual(change.origin_value_display, "A")
with Form(
self.partner.with_context(__no_changeset=disable_changeset)
) as partner_form:
partner_form.name = "B"
self.assertEqual(change.origin_value_char, "A")
self.assertEqual(change.origin_value_display, "A")
self.assertEqual(self.partner.count_pending_changesets, 0)
def test_origin_value_of_change_with_cancel(self):
"""Origin field is read from the parter or 'old' - with cancel
According to the state of the change.
"""
with Form(self.partner) as partner_form:
partner_form.name = "Y"
self.assertEqual(self.partner.count_pending_changesets, 1)
changeset = self.partner.changeset_ids
change = changeset.change_ids
self.assertEqual(self.partner.name, "X")
self.assertEqual(change.origin_value_char, "X")
self.assertEqual(change.origin_value_display, "X")
with Form(
self.partner.with_context(__no_changeset=disable_changeset)
) as partner_form:
partner_form.name = "A"
self.assertEqual(change.origin_value_char, "A")
self.assertEqual(change.origin_value_display, "A")
change.cancel()
self.assertEqual(change.origin_value_char, "A")
self.assertEqual(change.origin_value_display, "A")
with Form(
self.partner.with_context(__no_changeset=disable_changeset)
) as partner_form:
partner_form.name = "B"
self.assertEqual(change.origin_value_char, "A")
self.assertEqual(change.origin_value_display, "A")
self.assertEqual(self.partner.count_pending_changesets, 0)
def test_old_field_of_change_with_apply(self):
"""Old field is stored when the change is applied"""
with Form(self.partner) as partner_form:
partner_form.name = "Y"
self.assertEqual(self.partner.count_pending_changesets, 1)
changeset = self.partner.changeset_ids
change = changeset.change_ids
self.assertEqual(self.partner.name, "X")
self.assertFalse(change.old_value_char)
with Form(
self.partner.with_context(__no_changeset=disable_changeset)
) as partner_form:
partner_form.name = "A"
self.assertFalse(change.old_value_char)
change.apply()
self.assertEqual(change.old_value_char, "A")
with Form(
self.partner.with_context(__no_changeset=disable_changeset)
) as partner_form:
partner_form.name = "B"
self.assertEqual(change.old_value_char, "A")
self.assertEqual(self.partner.count_pending_changesets, 0)
def test_old_field_of_change_with_cancel(self):
"""Old field is stored when the change is canceled"""
with Form(self.partner) as partner_form:
partner_form.name = "Y"
self.assertEqual(self.partner.count_pending_changesets, 1)
changeset = self.partner.changeset_ids
change = changeset.change_ids
self.assertEqual(self.partner.name, "X")
self.assertFalse(change.old_value_char)
with Form(
self.partner.with_context(__no_changeset=disable_changeset)
) as partner_form:
partner_form.name = "A"
self.assertFalse(change.old_value_char)
change.cancel()
self.assertEqual(change.old_value_char, "A")
with Form(
self.partner.with_context(__no_changeset=disable_changeset)
) as partner_form:
partner_form.name = "B"
self.assertEqual(change.old_value_char, "A")
self.assertEqual(self.partner.count_pending_changesets, 0)

View file

@ -0,0 +1,38 @@
# Copyright 2021 Hunki Enterprises BV (<https://hunki-enterprises.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tests.common import TransactionCase
from .common import ChangesetTestCommon
class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
"""Check that changesets don't leak information"""
@classmethod
def setUp(cls):
super().setUpClass()
cls.env["changeset.field.rule"].search([]).unlink()
cls.rule = cls.env["changeset.field.rule"].create(
{
"model_id": cls.env.ref("base.model_ir_config_parameter").id,
"field_id": cls.env.ref("base.field_ir_config_parameter__key").id,
"action": "auto",
}
)
def test_change_unprivileged_user(self):
"""
Check that unprivileged users can't see changesets they didn't create
"""
user = self.env.ref("base.user_demo")
self.env["ir.config_parameter"].with_context(
test_record_changeset=True,
).set_param("hello", "world")
changeset = self.env["record.changeset.change"].search(
[
("rule_id", "=", self.rule.id),
]
)
self.assertTrue(changeset)
self.assertFalse(changeset.with_user(user).search([("id", "=", changeset.id)]))

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_changeset_field_rule_tree" model="ir.ui.view">
<field name="model">changeset.field.rule</field>
<field name="arch" type="xml">
<tree>
<field name="model_id" />
<field name="field_id" options="{'no_create': True}" />
<field name="source_model_id" />
<field name="expression" />
<field name="validator_group_ids" />
<field
name="company_id"
groups="base.group_multi_company"
options="{'no_create': True}"
/>
<field name="action" />
</tree>
</field>
</record>
<record id="view_changeset_field_rule_form" model="ir.ui.view">
<field name="model">changeset.field.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="model_id" options="{'no_open': True}" />
<field
name="field_id"
options="{'no_create_edit': True, 'no_open': True}"
domain="[('ttype', 'in', ('char', 'selection', 'date', 'datetime', 'float', 'monetary', 'integer', 'text', 'boolean', 'many2one')),
('readonly', '=', False)]"
/>
</group>
<group>
<field
name="company_id"
groups="base.group_multi_company"
options="{'no_create': True}"
/>
<field name="action" />
<field name="source_model_id" widget="selection" />
<field name="prevent_self_validation" />
<field name="expression" placeholder="True" />
<field name="validator_group_ids" widget="many2many_tags" />
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_changeset_field_rule_search" model="ir.ui.view">
<field name="model">changeset.field.rule</field>
<field name="arch" type="xml">
<search>
<field name="field_id" />
<field name="source_model_id" />
<field name="action" />
<field name="validator_group_ids" />
<group string="Group By" name="groupby">
<filter
name="model_groupby"
string="Model"
context="{'group_by': 'model_id'}"
/>
</group>
</search>
</field>
</record>
<record id="action_changeset_field_rule_view" model="ir.actions.act_window">
<field name="name">Changeset Fields Rules</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">changeset.field.rule</field>
<field name="view_mode">tree,form</field>
<field name="context" eval="{'search_default_model_groupby': 1}" />
<field name="search_view_id" ref="view_changeset_field_rule_search" />
</record>
</odoo>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<menuitem
id="menu_changeset"
name="Record Changesets"
groups="group_changeset_user,group_changeset_manager"
parent="base.menu_administration"
sequence="20"
/>
<menuitem
id="menu_changeset_field_rule"
parent="menu_changeset"
name="Field Rules"
groups="group_changeset_manager"
sequence="20"
action="action_changeset_field_rule_view"
/>
<menuitem
id="menu_record_changeset"
parent="menu_changeset"
sequence="20"
name="Changesets"
groups="group_changeset_user"
action="action_record_changeset_view"
/>
<menuitem
id="menu_record_changeset_change"
parent="menu_changeset"
sequence="20"
name="Changes"
groups="group_changeset_user"
action="action_record_changeset_change_view"
/>
</odoo>

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_record_changeset_change_tree" model="ir.ui.view">
<field name="model">record.changeset.change</field>
<field name="arch" type="xml">
<tree delete="false" create="false">
<field name="record_id" />
<field name="changeset_id" optional="hidden" />
<field name="field_id" />
<field name="field_type" />
<field name="origin_value_display" />
<field name="new_value_display" />
<field name="date" />
<field name="modified_by_id" />
<field name="verified_on_date" />
<field name="verified_by_id" />
<field name="state" />
<field name="user_can_validate_changeset" invisible="1" />
<button
name="apply"
string="Apply"
type="object"
icon="fa-plus-circle"
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
/>
<button
name="cancel"
string="Reject"
type="object"
icon="fa-times"
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
/>
</tree>
</field>
</record>
<record id="view_record_changeset_change_form" model="ir.ui.view">
<field name="model">record.changeset.change</field>
<field name="arch" type="xml">
<form delete="false" create="false">
<header>
<button
name="apply"
string="Apply"
type="object"
class="oe_highlight"
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
/>
<button
name="cancel"
string="Reject"
type="object"
class="oe_highlight"
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
/>
<field
name="state"
widget="statusbar"
statusbar_visible="draft,done"
/>
</header>
<sheet>
<group name="main">
<group>
<field name="record_id" />
<field name="field_id" options="{'no_open': True}" />
<field name="field_type" />
<field name="date" />
<field name="modified_by_id" />
<field name="verified_on_date" />
<field name="verified_by_id" />
<field name="user_can_validate_changeset" invisible="1" />
</group>
<group>
<field name="origin_value_display" />
<field name="new_value_display" />
<field name="changeset_id" />
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_record_changeset_change_search" model="ir.ui.view">
<field name="model">record.changeset.change</field>
<field name="arch" type="xml">
<search>
<field name="field_name" />
<filter
string="Pending"
name="pending"
domain="[('state','=','draft')]"
/>
<filter string="Done" name="done" domain="[('state','=','done')]" />
<group expand="0" string="Group By">
<filter
string="State"
name="groupby_state"
context="{'group_by': 'state'}"
/>
</group>
</search>
</field>
</record>
<record id="action_record_changeset_change_view" model="ir.actions.act_window">
<field name="name">Changes</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">record.changeset.change</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_pending': 1}</field>
<field name="search_view_id" ref="view_record_changeset_change_search" />
</record>
</odoo>

View file

@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_record_changeset_tree" model="ir.ui.view">
<field name="model">record.changeset</field>
<field name="arch" type="xml">
<tree delete="false" create="false">
<field name="record_id" />
<field name="model" />
<field name="res_id" widget="integer" />
<field name="date" />
<field name="modified_by_id" />
<field name="state" />
<button
name="apply"
string="Apply"
type="object"
icon="fa-plus-circle"
states="draft"
/>
<button
name="cancel"
string="Reject"
type="object"
icon="fa-times"
states="draft"
/>
</tree>
</field>
</record>
<record id="view_record_changeset_form" model="ir.ui.view">
<field name="model">record.changeset</field>
<field name="arch" type="xml">
<form delete="false" create="false">
<header>
<button
name="apply"
string="Apply pending changes"
type="object"
class="oe_highlight"
states="draft"
/>
<button
name="cancel"
string="Reject pending changes"
type="object"
class="oe_highlight"
states="draft"
/>
<field
name="state"
widget="statusbar"
statusbar_visible="draft,done"
/>
</header>
<sheet>
<group>
<group>
<field name="record_id" />
<field name="model" />
<field name="res_id" widget="integer" />
</group>
<group>
<field
name="company_id"
groups="base.group_multi_company"
options="{'no_create': True}"
/>
<field name="source" />
<field name="date" />
<field name="modified_by_id" />
</group>
</group>
<group string="Changes">
<field name="change_ids" nolabel="1">
<tree>
<field name="field_id" context="{'no_open': True}" />
<field name="field_type" invisible="1" />
<field name="origin_value_display" string="Previous" />
<field name="new_value_display" />
<field name="state" />
<field
name="user_can_validate_changeset"
invisible="1"
/>
<button
name="apply"
string="Apply"
type="object"
icon="fa-plus-circle"
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
/>
<button
name="cancel"
string="Reject"
type="object"
icon="fa-times"
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
/>
</tree>
</field>
</group>
<group>
<field name="note" />
</group>
</sheet>
</form>
</field>
</record>
<record id="view_record_changeset_search" model="ir.ui.view">
<field name="model">record.changeset</field>
<field name="arch" type="xml">
<search>
<field name="model" />
<filter
string="Pending"
name="pending"
domain="[('state','=','draft')]"
/>
<filter string="Done" name="done" domain="[('state','=','done')]" />
<group expand="0" string="Group By">
<filter
string="State"
name="groupby_state"
context="{'group_by': 'state'}"
/>
</group>
</search>
</field>
</record>
<record id="action_record_changeset_view" model="ir.actions.act_window">
<field name="name">Record Changeset</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">record.changeset</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_pending': 1}</field>
<field name="search_view_id" ref="view_record_changeset_search" />
</record>
</odoo>