[13.0][ADD] base_changeset
parent
b594a825fe
commit
c1e3fa0f19
|
@ -0,0 +1,169 @@
|
||||||
|
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
|
||||||
|
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||||
|
:alt: License: AGPL-3
|
||||||
|
|
||||||
|
==================
|
||||||
|
Record Changesets
|
||||||
|
==================
|
||||||
|
|
||||||
|
This module extends the functionality of records. It allows to create
|
||||||
|
changesets that must be validated when a record is modified instead of direct
|
||||||
|
modifications. Rules allow to configure which field must be validated.
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
|
Access Rights
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The changesets rules must be edited by users with the group ``Changesets
|
||||||
|
Configuration``. The changesets can be applied or canceled only by users
|
||||||
|
with the group ``Changesets Validations``
|
||||||
|
|
||||||
|
Changesets Rules
|
||||||
|
----------------
|
||||||
|
|
||||||
|
The changesets rules can be configured in ``Sales > Configuration >
|
||||||
|
Record Changesets > Fields Rules``. For each record field, an
|
||||||
|
action can be defined:
|
||||||
|
|
||||||
|
* Auto: the changes made on this field are always applied
|
||||||
|
* Validate: the changes made on this field must be manually confirmed by
|
||||||
|
a 'Changesets User' user
|
||||||
|
* Never: the changes made on this field are always refused
|
||||||
|
|
||||||
|
In any case, all the changes made by the users are always applied
|
||||||
|
directly on the users, but a 'validated' changeset is created for the
|
||||||
|
history.
|
||||||
|
|
||||||
|
The supported fields are:
|
||||||
|
|
||||||
|
* Char
|
||||||
|
* Text
|
||||||
|
* Date
|
||||||
|
* Datetime
|
||||||
|
* Integer
|
||||||
|
* Float
|
||||||
|
* Boolean
|
||||||
|
* Many2one
|
||||||
|
|
||||||
|
Rules can be global (no source model) or configured by source model.
|
||||||
|
Rules by source model have the priority. If a field is not configured
|
||||||
|
for the source model, it will use the global rule (if existing).
|
||||||
|
|
||||||
|
If a field has no rule, it is written to the record without changeset.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
General case
|
||||||
|
------------
|
||||||
|
|
||||||
|
The first step is to create the changeset rules, once that done, writes on
|
||||||
|
records will be created as changesets.
|
||||||
|
|
||||||
|
Finding changesets
|
||||||
|
------------------
|
||||||
|
|
||||||
|
A menu lists all the changesets in ``Sales > Configuration > Record
|
||||||
|
Changesets > Changesets``.
|
||||||
|
|
||||||
|
However, it is more convenient to access them directly from the
|
||||||
|
records. Pending changesets can be accessed directly from the top right
|
||||||
|
of the records' view. A new filter on the records shows the records
|
||||||
|
having at least one pending changeset.
|
||||||
|
|
||||||
|
Handling changesets
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
A changeset shows the list of the changes made on a record. Some of the
|
||||||
|
changes may be 'Pending', some 'Accepted' or 'Rejected' according to the
|
||||||
|
changeset rules. The only changes that need an action from the user are
|
||||||
|
'Pending' changes. When a change is accepted, the value is written on
|
||||||
|
the user.
|
||||||
|
|
||||||
|
The changes view shows the name of the record's field, the Origin value
|
||||||
|
and the New value alongside the state of the change. By clicking on the
|
||||||
|
change in some cases a more detailed view is displayed, for instance,
|
||||||
|
links for relations can be clicked on.
|
||||||
|
|
||||||
|
A button on a changeset allows to apply or reject all the changes at
|
||||||
|
once.
|
||||||
|
|
||||||
|
Custom source rules in your addon
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
Addons wanting to create changeset with their own rules should pass the
|
||||||
|
following keys in the context when they write on the record:
|
||||||
|
|
||||||
|
* ``__changeset_rules_source_model``: name of the model which asks for
|
||||||
|
the change
|
||||||
|
* ``__changeset_rules_source_id``: id of the record which asks for the
|
||||||
|
change
|
||||||
|
|
||||||
|
Also, they should extend the selection in
|
||||||
|
``ChangesetFieldRule._domain_source_models`` to add their model (the
|
||||||
|
same that is passed in ``__changeset_rules_source_model``).
|
||||||
|
|
||||||
|
The source is used for the application of the rules, allowing to have a
|
||||||
|
different rule for a different source. It is also stored on the changeset for
|
||||||
|
information.
|
||||||
|
|
||||||
|
Screenshot:
|
||||||
|
-----------
|
||||||
|
|
||||||
|
* Configuration of rules
|
||||||
|
|
||||||
|
.. image:: base_changeset/static/src/img/rules.png
|
||||||
|
|
||||||
|
* Changeset waiting for validation
|
||||||
|
|
||||||
|
.. image:: base_changeset/static/src/img/changeset.png
|
||||||
|
|
||||||
|
|
||||||
|
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
|
||||||
|
:alt: Try me on Runbot
|
||||||
|
:target: https://runbot.odoo-community.org/runbot/134/10.0
|
||||||
|
|
||||||
|
Known issues / Roadmap
|
||||||
|
======================
|
||||||
|
|
||||||
|
* Only a subset of the type of fields is actually supported
|
||||||
|
|
||||||
|
Bug Tracker
|
||||||
|
===========
|
||||||
|
|
||||||
|
Bugs are tracked on `GitHub Issues
|
||||||
|
<https://github.com/OCA/record-contact/issues>`_. In case of trouble, please
|
||||||
|
check there if your issue has already been reported. If you spotted it first,
|
||||||
|
help us smashing it by providing a detailed and welcomed feedback
|
||||||
|
|
||||||
|
Credits
|
||||||
|
=======
|
||||||
|
|
||||||
|
Images
|
||||||
|
------
|
||||||
|
|
||||||
|
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_.
|
||||||
|
|
||||||
|
Contributors
|
||||||
|
------------
|
||||||
|
|
||||||
|
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
|
||||||
|
* Denis Leemann <denis.leemann@camptocamp.com>
|
||||||
|
* Yannick Vaucher <yannick.vaucher@camptocamp.com>
|
||||||
|
|
||||||
|
Maintainer
|
||||||
|
----------
|
||||||
|
|
||||||
|
.. image:: https://odoo-community.org/logo.png
|
||||||
|
:alt: Odoo Community Association
|
||||||
|
:target: https://odoo-community.org
|
||||||
|
|
||||||
|
This module is maintained by the OCA.
|
||||||
|
|
||||||
|
OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||||
|
mission is to support the collaborative development of Odoo features and
|
||||||
|
promote its widespread use.
|
||||||
|
|
||||||
|
To contribute to this module, please visit https://odoo-community.org.
|
|
@ -0,0 +1,3 @@
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
|
from . import models
|
|
@ -0,0 +1,28 @@
|
||||||
|
# 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": "13.0.1.0.0",
|
||||||
|
"development_status": "Alpha",
|
||||||
|
"author": "Onestein, Camptocamp, Odoo Community Association (OCA)",
|
||||||
|
"maintainers": ["astirpe"],
|
||||||
|
"license": "AGPL-3",
|
||||||
|
"category": "Tools",
|
||||||
|
"depends": ["web"],
|
||||||
|
"website": "https://github.com/OCA/server-tools/",
|
||||||
|
"data": [
|
||||||
|
"security/groups.xml",
|
||||||
|
"security/ir.model.access.csv",
|
||||||
|
"security/rules.xml",
|
||||||
|
"templates/assets.xml",
|
||||||
|
"views/record_changeset_views.xml",
|
||||||
|
"views/record_changeset_change_views.xml",
|
||||||
|
"views/changeset_field_rule_views.xml",
|
||||||
|
"views/menu.xml",
|
||||||
|
],
|
||||||
|
"demo": ["demo/changeset_field_rule.xml"],
|
||||||
|
"qweb": ["static/src/xml/backend.xml"],
|
||||||
|
"installable": True,
|
||||||
|
}
|
|
@ -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_credit_limit">
|
||||||
|
<field name="field_id" ref="base.field_res_partner__credit_limit" />
|
||||||
|
<field name="action">auto</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
|
@ -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
|
|
@ -0,0 +1,161 @@
|
||||||
|
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.tools import config
|
||||||
|
|
||||||
|
|
||||||
|
class Base(models.AbstractModel):
|
||||||
|
_inherit = "base"
|
||||||
|
|
||||||
|
changeset_ids = fields.One2many(
|
||||||
|
comodel_name="record.changeset",
|
||||||
|
compute="_compute_changeset_ids",
|
||||||
|
string="Changesets",
|
||||||
|
)
|
||||||
|
changeset_change_ids = fields.One2many(
|
||||||
|
comodel_name="record.changeset.change",
|
||||||
|
compute="_compute_changeset_ids",
|
||||||
|
string="Changeset Changes",
|
||||||
|
)
|
||||||
|
count_pending_changesets = fields.Integer(
|
||||||
|
compute="_compute_count_pending_changesets"
|
||||||
|
)
|
||||||
|
count_pending_changeset_changes = fields.Integer(
|
||||||
|
compute="_compute_count_pending_changesets"
|
||||||
|
)
|
||||||
|
user_can_see_changeset = fields.Boolean(compute="_compute_user_can_see_changeset")
|
||||||
|
|
||||||
|
def _compute_changeset_ids(self):
|
||||||
|
model_name = self._name
|
||||||
|
for record in self:
|
||||||
|
changesets = self.env["record.changeset"].search(
|
||||||
|
[("model", "=", model_name), ("res_id", "=", record.id)]
|
||||||
|
)
|
||||||
|
record.changeset_ids = changesets
|
||||||
|
record.changeset_change_ids = changesets.mapped("change_ids")
|
||||||
|
|
||||||
|
def _compute_count_pending_changesets(self):
|
||||||
|
model_name = self._name
|
||||||
|
if model_name in self.models_to_track_changeset():
|
||||||
|
for rec in self:
|
||||||
|
changesets = rec.changeset_ids.filtered(
|
||||||
|
lambda rev: rev.state == "draft"
|
||||||
|
and rev.res_id == rec.id
|
||||||
|
and rev.model == model_name
|
||||||
|
)
|
||||||
|
changes = changesets.mapped("change_ids")
|
||||||
|
changes = changes.filtered(
|
||||||
|
lambda c: c.state in c.get_pending_changes_states()
|
||||||
|
)
|
||||||
|
rec.count_pending_changesets = len(changesets)
|
||||||
|
rec.count_pending_changeset_changes = len(changes)
|
||||||
|
else:
|
||||||
|
for rec in self:
|
||||||
|
rec.count_pending_changesets = 0.0
|
||||||
|
rec.count_pending_changeset_changes = 0.0
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def models_to_track_changeset(self):
|
||||||
|
"""Models to be tracked for changes
|
||||||
|
:args:
|
||||||
|
:returns: list of models
|
||||||
|
"""
|
||||||
|
models = self.env["changeset.field.rule"].search([]).mapped("model_id.model")
|
||||||
|
if config["test_enable"] and self.env.context.get("test_record_changeset"):
|
||||||
|
if "res.partner" not in models:
|
||||||
|
models += ["res.partner"] # Used in tests
|
||||||
|
return models
|
||||||
|
|
||||||
|
def write(self, values):
|
||||||
|
if self.env.context.get("__no_changeset"):
|
||||||
|
return super().write(values)
|
||||||
|
|
||||||
|
# To avoid conflicts with tests of other modules
|
||||||
|
if config["test_enable"] and not self.env.context.get("test_record_changeset"):
|
||||||
|
return super().write(values)
|
||||||
|
|
||||||
|
if self._name not in self.models_to_track_changeset():
|
||||||
|
return super().write(values)
|
||||||
|
|
||||||
|
for record in self:
|
||||||
|
local_values = self.env["record.changeset"].add_changeset(record, values)
|
||||||
|
super(Base, record).write(local_values)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def action_record_changeset_change_view(self):
|
||||||
|
self.ensure_one()
|
||||||
|
res = {
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "record.changeset.change",
|
||||||
|
"view_mode": "tree",
|
||||||
|
"views": [
|
||||||
|
[
|
||||||
|
self.env.ref("base_changeset.view_record_changeset_change_tree").id,
|
||||||
|
"list",
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"context": self.env.context,
|
||||||
|
"name": _("Record Changes"),
|
||||||
|
"search_view_id": [
|
||||||
|
self.env.ref("base_changeset.view_record_changeset_change_search").id,
|
||||||
|
"search",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
record_id = self.env.context.get("search_default_record_id")
|
||||||
|
if record_id:
|
||||||
|
res.update(
|
||||||
|
{
|
||||||
|
"domain": [
|
||||||
|
("model", "=", self._name),
|
||||||
|
("changeset_id.res_id", "=", record_id),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fields_view_get(
|
||||||
|
self, view_id=None, view_type="form", toolbar=False, submenu=False
|
||||||
|
):
|
||||||
|
res = super()._fields_view_get(
|
||||||
|
view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu
|
||||||
|
)
|
||||||
|
to_track_changeset = self._name in self.models_to_track_changeset()
|
||||||
|
can_see = len(self) == 1 and self.user_can_see_changeset
|
||||||
|
button_label = _("Changes")
|
||||||
|
if to_track_changeset and can_see and view_type == "form":
|
||||||
|
doc = etree.XML(res["arch"])
|
||||||
|
for node in doc.xpath("//div[@name='button_box']"):
|
||||||
|
xml_field = etree.Element(
|
||||||
|
"field",
|
||||||
|
{
|
||||||
|
"name": "count_pending_changeset_changes",
|
||||||
|
"string": button_label,
|
||||||
|
"widget": "statinfo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
xml_button = etree.Element(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"name": "action_record_changeset_change_view",
|
||||||
|
"icon": "fa-code-fork",
|
||||||
|
"context": "{'search_default_draft': 1, "
|
||||||
|
"'search_default_record_id': active_id}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
xml_button.insert(0, xml_field)
|
||||||
|
node.insert(0, xml_button)
|
||||||
|
res["arch"] = etree.tostring(doc, encoding="unicode")
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _compute_user_can_see_changeset(self):
|
||||||
|
is_superuser = self.env.is_superuser()
|
||||||
|
has_changeset_group = self.user_has_groups(
|
||||||
|
"base_changeset.group_changeset_user"
|
||||||
|
)
|
||||||
|
for rec in self:
|
||||||
|
rec.user_can_see_changeset = is_superuser or has_changeset_group
|
|
@ -0,0 +1,156 @@
|
||||||
|
# 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
|
||||||
|
from odoo.tools.cache import ormcache
|
||||||
|
|
||||||
|
|
||||||
|
class ChangesetFieldRule(models.Model):
|
||||||
|
_name = "changeset.field.rule"
|
||||||
|
_description = "Changeset Field Rules"
|
||||||
|
_rec_name = "field_id"
|
||||||
|
|
||||||
|
model_id = fields.Many2one(related="field_id.model_id", store=True)
|
||||||
|
field_id = fields.Many2one(
|
||||||
|
comodel_name="ir.model.fields", ondelete="cascade", required=True
|
||||||
|
)
|
||||||
|
action = fields.Selection(
|
||||||
|
selection="_selection_action",
|
||||||
|
string="Action",
|
||||||
|
required=True,
|
||||||
|
help="Auto: always apply a change.\n"
|
||||||
|
"Validate: manually applied by an administrator.\n"
|
||||||
|
"Never: change never applied.",
|
||||||
|
)
|
||||||
|
source_model_id = fields.Many2one(
|
||||||
|
comodel_name="ir.model",
|
||||||
|
string="Source Model",
|
||||||
|
ondelete="cascade",
|
||||||
|
domain=lambda self: [("id", "in", self._domain_source_models().ids)],
|
||||||
|
help="If a source model is defined, the rule will be applied only "
|
||||||
|
"when the change is made from this origin. "
|
||||||
|
"Rules without source model are global and applies to all "
|
||||||
|
"backends.\n"
|
||||||
|
"Rules with a source model have precedence over global rules, "
|
||||||
|
"but if a field has no rule with a source model, the global rule "
|
||||||
|
"is used.",
|
||||||
|
)
|
||||||
|
company_id = fields.Many2one("res.company", default=lambda self: self.env.company)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
"""Ensure there is at most one rule with source_model_id NULL.
|
||||||
|
"""
|
||||||
|
self.env.cr.execute(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS source_model_null_field_uniq
|
||||||
|
ON %s (field_id)
|
||||||
|
WHERE source_model_id IS NULL
|
||||||
|
"""
|
||||||
|
% self._table
|
||||||
|
)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
(
|
||||||
|
"model_field_uniq",
|
||||||
|
"unique (source_model_id, field_id)",
|
||||||
|
"A rule already exists for this field.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _domain_source_models(self):
|
||||||
|
""" Returns the models for which we can define rules.
|
||||||
|
|
||||||
|
Example for submodules (replace by the xmlid of the model):
|
||||||
|
|
||||||
|
::
|
||||||
|
models = super()._domain_source_models()
|
||||||
|
return models | self.env.ref('base.model_res_users')
|
||||||
|
|
||||||
|
Rules without model are global and apply for all models.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self.env.ref("base.model_res_users")
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _selection_action(self):
|
||||||
|
return [("auto", "Auto"), ("validate", "Validate"), ("never", "Never")]
|
||||||
|
|
||||||
|
@ormcache(skiparg=1)
|
||||||
|
@api.model
|
||||||
|
def _get_rules(self, source_model_name, record_model_name):
|
||||||
|
""" Cache rules
|
||||||
|
|
||||||
|
Keep only the id of the rules, because if we keep the recordsets
|
||||||
|
in the ormcache, we won't be able to browse them once their
|
||||||
|
cursor is closed.
|
||||||
|
|
||||||
|
The public method ``get_rules`` return the rules with the recordsets
|
||||||
|
when called.
|
||||||
|
|
||||||
|
"""
|
||||||
|
domain = self._get_rules_search_domain(record_model_name, source_model_name)
|
||||||
|
model_rules = self.search(
|
||||||
|
domain,
|
||||||
|
# using 'ASC' means that 'NULLS LAST' is the default
|
||||||
|
order="source_model_id ASC",
|
||||||
|
)
|
||||||
|
# model's rules have precedence over global ones so we iterate
|
||||||
|
# over rules which have a source model first, then we complete
|
||||||
|
# them with the global rules
|
||||||
|
result = {}
|
||||||
|
for rule in model_rules:
|
||||||
|
# we already have a model's rule
|
||||||
|
if result.get(rule.field_id.name):
|
||||||
|
continue
|
||||||
|
result[rule.field_id.name] = rule.id
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _get_rules_search_domain(self, record_model_name, source_model_name):
|
||||||
|
return [
|
||||||
|
("model_id.model", "=", record_model_name),
|
||||||
|
"|",
|
||||||
|
("source_model_id.model", "=", source_model_name),
|
||||||
|
("source_model_id", "=", False),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_rules(self, source_model_name, record_model_name):
|
||||||
|
""" Return the rules for a model
|
||||||
|
|
||||||
|
When a model is specified, it will return the rules for this
|
||||||
|
model. Fields that have no rule for this model will use the
|
||||||
|
global rules (those without source).
|
||||||
|
|
||||||
|
The source model is the model which ask for a change, it will be
|
||||||
|
for instance ``res.users``, ``lefac.backend`` or
|
||||||
|
``magellan.backend``.
|
||||||
|
|
||||||
|
The second argument (``source_model_name``) is optional but
|
||||||
|
cannot be an optional keyword argument otherwise it would not be
|
||||||
|
in the key for the cache. The callers have to pass ``None`` if
|
||||||
|
they want only global rules.
|
||||||
|
"""
|
||||||
|
rules = {}
|
||||||
|
cached_rules = self._get_rules(source_model_name, record_model_name)
|
||||||
|
for field, rule_id in cached_rules.items():
|
||||||
|
rules[field] = self.browse(rule_id)
|
||||||
|
return rules
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def create(self, vals):
|
||||||
|
record = super().create(vals)
|
||||||
|
self.clear_caches()
|
||||||
|
return record
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
result = super().write(vals)
|
||||||
|
self.clear_caches()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
result = super().unlink()
|
||||||
|
self.clear_caches()
|
||||||
|
return result
|
|
@ -0,0 +1,161 @@
|
||||||
|
# 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.name)
|
||||||
|
result.append((changeset.id, name))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def apply(self):
|
||||||
|
self.with_context(skip_pending_status_check=True).mapped("change_ids").apply()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.with_context(skip_pending_status_check=True).mapped("change_ids").cancel()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def add_changeset(self, record, values):
|
||||||
|
""" Add a changeset on a record
|
||||||
|
|
||||||
|
By default, when a record is modified by a user or by the
|
||||||
|
system, the the changeset will follow the rules configured for
|
||||||
|
the global rules.
|
||||||
|
|
||||||
|
A caller should pass the following keys in the context:
|
||||||
|
|
||||||
|
* ``__changeset_rules_source_model``: name of the model which
|
||||||
|
asks for the change
|
||||||
|
* ``__changeset_rules_source_id``: id of the record which asks
|
||||||
|
for the change
|
||||||
|
|
||||||
|
When the source model and id are not defined, the current user
|
||||||
|
is considered as the origin of the change.
|
||||||
|
|
||||||
|
Should be called before the execution of ``write`` on the record
|
||||||
|
so we can keep track of the existing value and also because the
|
||||||
|
returned values should be used for ``write`` as some of the
|
||||||
|
values may have been removed.
|
||||||
|
|
||||||
|
:param values: the values being written on the record
|
||||||
|
:type values: dict
|
||||||
|
|
||||||
|
:returns: dict of values that should be wrote on the record
|
||||||
|
(fields with a 'Validate' or 'Never' rule are excluded)
|
||||||
|
|
||||||
|
"""
|
||||||
|
record.ensure_one()
|
||||||
|
|
||||||
|
source_model = self.env.context.get("__changeset_rules_source_model")
|
||||||
|
source_id = self.env.context.get("__changeset_rules_source_id")
|
||||||
|
if not source_model:
|
||||||
|
# if the changes source is not defined, log the user who
|
||||||
|
# made the change
|
||||||
|
source_model = "res.users"
|
||||||
|
if not source_id:
|
||||||
|
source_id = self.env.uid
|
||||||
|
if source_model and source_id:
|
||||||
|
source = "{},{}".format(source_model, source_id)
|
||||||
|
else:
|
||||||
|
source = False
|
||||||
|
|
||||||
|
change_model = self.env["record.changeset.change"]
|
||||||
|
write_values = values.copy()
|
||||||
|
changes = []
|
||||||
|
rules = self.env["changeset.field.rule"].get_rules(
|
||||||
|
source_model_name=source_model, record_model_name=record._name
|
||||||
|
)
|
||||||
|
for field in values:
|
||||||
|
rule = rules.get(field)
|
||||||
|
if not rule:
|
||||||
|
continue
|
||||||
|
if field in values:
|
||||||
|
if not change_model._has_field_changed(record, field, values[field]):
|
||||||
|
continue
|
||||||
|
change, pop_value = change_model._prepare_changeset_change(
|
||||||
|
record, rule, field, values[field]
|
||||||
|
)
|
||||||
|
if pop_value:
|
||||||
|
write_values.pop(field)
|
||||||
|
changes.append(change)
|
||||||
|
if changes:
|
||||||
|
changeset_vals = self._prepare_changeset_vals(changes, record, source)
|
||||||
|
self.env["record.changeset"].create(changeset_vals)
|
||||||
|
return write_values
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _prepare_changeset_vals(self, changes, record, source):
|
||||||
|
has_company = "company_id" in self.env[record._name]._fields
|
||||||
|
has_company = has_company and record.company_id
|
||||||
|
company = record.company_id if has_company else self.env.company
|
||||||
|
return {
|
||||||
|
"res_id": record.id,
|
||||||
|
"model": record._name,
|
||||||
|
"company_id": company.id,
|
||||||
|
"change_ids": [(0, 0, vals) for vals in changes],
|
||||||
|
"date": fields.Datetime.now(),
|
||||||
|
"source": source,
|
||||||
|
}
|
|
@ -0,0 +1,393 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# sentinel object to be sure that no empty value was passed to
|
||||||
|
# RecordChangesetChange._value_for_changeset
|
||||||
|
_NO_VALUE = object()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordChangesetChange(models.Model):
|
||||||
|
""" Store the change of one field for one changeset on one record
|
||||||
|
|
||||||
|
This model is composed of 3 sets of fields:
|
||||||
|
|
||||||
|
* 'origin'
|
||||||
|
* 'old'
|
||||||
|
* 'new'
|
||||||
|
|
||||||
|
The 'new' fields contain the value that needs to be validated.
|
||||||
|
The 'old' field copies the actual value of the record when the
|
||||||
|
change is either applied either canceled. This field is used as a storage
|
||||||
|
place but never shown by itself.
|
||||||
|
The 'origin' fields is a related field towards the actual values of
|
||||||
|
the record until the change is either applied either canceled, past
|
||||||
|
that it shows the 'old' value.
|
||||||
|
The reason behind this is that the values may change on a record between
|
||||||
|
the moment when the changeset is created and when it is applied.
|
||||||
|
|
||||||
|
On the views, we show the origin fields which represent the actual
|
||||||
|
record values or the old values and we show the new fields.
|
||||||
|
|
||||||
|
The 'origin' and 'new_value_display' are displayed on
|
||||||
|
the tree view where we need a unique of field, the other fields are
|
||||||
|
displayed on the form view so we benefit from their widgets.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
_name = "record.changeset.change"
|
||||||
|
_description = "Record Changeset Change"
|
||||||
|
_rec_name = "field_id"
|
||||||
|
|
||||||
|
changeset_id = fields.Many2one(
|
||||||
|
comodel_name="record.changeset",
|
||||||
|
required=True,
|
||||||
|
ondelete="cascade",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
field_id = fields.Many2one(
|
||||||
|
comodel_name="ir.model.fields", required=True, readonly=True
|
||||||
|
)
|
||||||
|
field_name = fields.Char(related="field_id.name", readonly=True)
|
||||||
|
field_type = fields.Selection(related="field_id.ttype", readonly=True)
|
||||||
|
model = fields.Char(related="field_id.model", readonly=True, store=True)
|
||||||
|
origin_value_display = fields.Char(
|
||||||
|
string="Previous", compute="_compute_value_display"
|
||||||
|
)
|
||||||
|
new_value_display = fields.Char(string="New", compute="_compute_value_display")
|
||||||
|
|
||||||
|
# Fields showing the origin record's value or the 'old' value if
|
||||||
|
# the change is applied or canceled.
|
||||||
|
origin_value_char = fields.Char(compute="_compute_origin_values", readonly=True)
|
||||||
|
origin_value_date = fields.Date(compute="_compute_origin_values", readonly=True)
|
||||||
|
origin_value_datetime = fields.Datetime(
|
||||||
|
compute="_compute_origin_values", readonly=True
|
||||||
|
)
|
||||||
|
origin_value_float = fields.Float(compute="_compute_origin_values", readonly=True)
|
||||||
|
origin_value_monetary = fields.Float(
|
||||||
|
compute="_compute_origin_values", readonly=True
|
||||||
|
)
|
||||||
|
origin_value_integer = fields.Integer(
|
||||||
|
compute="_compute_origin_values", readonly=True
|
||||||
|
)
|
||||||
|
origin_value_text = fields.Text(compute="_compute_origin_values", readonly=True)
|
||||||
|
origin_value_boolean = fields.Boolean(
|
||||||
|
compute="_compute_origin_values", readonly=True
|
||||||
|
)
|
||||||
|
origin_value_reference = fields.Reference(
|
||||||
|
compute="_compute_origin_values", selection="_reference_models", readonly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fields storing the previous record's values (saved when the
|
||||||
|
# changeset is applied)
|
||||||
|
old_value_char = fields.Char(readonly=True)
|
||||||
|
old_value_date = fields.Date(readonly=True)
|
||||||
|
old_value_datetime = fields.Datetime(readonly=True)
|
||||||
|
old_value_float = fields.Float(readonly=True)
|
||||||
|
old_value_monetary = fields.Float(readonly=True)
|
||||||
|
old_value_integer = fields.Integer(readonly=True)
|
||||||
|
old_value_text = fields.Text(readonly=True)
|
||||||
|
old_value_boolean = fields.Boolean(readonly=True)
|
||||||
|
old_value_reference = fields.Reference(selection="_reference_models", readonly=True)
|
||||||
|
|
||||||
|
# Fields storing the value applied on the record
|
||||||
|
new_value_char = fields.Char(readonly=True)
|
||||||
|
new_value_date = fields.Date(readonly=True)
|
||||||
|
new_value_datetime = fields.Datetime(readonly=True)
|
||||||
|
new_value_float = fields.Float(readonly=True)
|
||||||
|
new_value_monetary = fields.Float(readonly=True)
|
||||||
|
new_value_integer = fields.Integer(readonly=True)
|
||||||
|
new_value_text = fields.Text(readonly=True)
|
||||||
|
new_value_boolean = fields.Boolean(readonly=True)
|
||||||
|
new_value_reference = fields.Reference(selection="_reference_models", readonly=True)
|
||||||
|
|
||||||
|
state = fields.Selection(
|
||||||
|
selection=[("draft", "Pending"), ("done", "Approved"), ("cancel", "Rejected")],
|
||||||
|
required=True,
|
||||||
|
default="draft",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
record_id = fields.Reference(related="changeset_id.record_id")
|
||||||
|
rule_id = fields.Many2one("changeset.field.rule", readonly=True)
|
||||||
|
user_can_validate_changeset = fields.Boolean(
|
||||||
|
compute="_compute_user_can_validate_changeset"
|
||||||
|
)
|
||||||
|
date = fields.Datetime(related="changeset_id.date")
|
||||||
|
modified_by_id = fields.Many2one(related="changeset_id.modified_by_id")
|
||||||
|
verified_on_date = fields.Datetime(string="Verified on", readonly=True)
|
||||||
|
verified_by_id = fields.Many2one("res.users", readonly=True)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _reference_models(self):
|
||||||
|
models = self.env["ir.model"].search([])
|
||||||
|
return [(model.model, model.name) for model in models]
|
||||||
|
|
||||||
|
_suffix_to_types = {
|
||||||
|
"char": ("char", "selection"),
|
||||||
|
"date": ("date",),
|
||||||
|
"datetime": ("datetime",),
|
||||||
|
"float": ("float",),
|
||||||
|
"monetary": ("monetary",),
|
||||||
|
"integer": ("integer",),
|
||||||
|
"text": ("text",),
|
||||||
|
"boolean": ("boolean",),
|
||||||
|
"reference": ("many2one",),
|
||||||
|
}
|
||||||
|
|
||||||
|
_type_to_suffix = {
|
||||||
|
ftype: suffix for suffix, ftypes in _suffix_to_types.items() for ftype in ftypes
|
||||||
|
}
|
||||||
|
|
||||||
|
_origin_value_fields = ["origin_value_%s" % suffix for suffix in _suffix_to_types]
|
||||||
|
_old_value_fields = ["old_value_%s" % suffix for suffix in _suffix_to_types]
|
||||||
|
_new_value_fields = ["new_value_%s" % suffix for suffix in _suffix_to_types]
|
||||||
|
_value_fields = _origin_value_fields + _old_value_fields + _new_value_fields
|
||||||
|
|
||||||
|
@api.depends("changeset_id.res_id", "changeset_id.model")
|
||||||
|
def _compute_origin_values(self):
|
||||||
|
states = self.get_pending_changes_states()
|
||||||
|
for rec in self:
|
||||||
|
field_name = rec.get_field_for_type(rec.field_id, "origin")
|
||||||
|
if rec.state in states:
|
||||||
|
value = rec.record_id[rec.field_id.name]
|
||||||
|
else:
|
||||||
|
old_field = rec.get_field_for_type(rec.field_id, "old")
|
||||||
|
value = rec[old_field]
|
||||||
|
setattr(rec, field_name, value)
|
||||||
|
|
||||||
|
@api.depends(lambda self: self._value_fields)
|
||||||
|
def _compute_value_display(self):
|
||||||
|
for rec in self:
|
||||||
|
for prefix in ("origin", "new"):
|
||||||
|
value = getattr(rec, "get_%s_value" % prefix)()
|
||||||
|
if rec.field_id.ttype == "many2one" and value:
|
||||||
|
value = value.display_name
|
||||||
|
setattr(rec, "%s_value_display" % prefix, value)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_field_for_type(self, field, prefix):
|
||||||
|
assert prefix in ("origin", "old", "new")
|
||||||
|
field_type = self._type_to_suffix.get(field.ttype)
|
||||||
|
if not field_type:
|
||||||
|
raise NotImplementedError("field type %s is not supported" % field_type)
|
||||||
|
return "{}_value_{}".format(prefix, field_type)
|
||||||
|
|
||||||
|
def get_origin_value(self):
|
||||||
|
self.ensure_one()
|
||||||
|
field_name = self.get_field_for_type(self.field_id, "origin")
|
||||||
|
return self[field_name]
|
||||||
|
|
||||||
|
def get_new_value(self):
|
||||||
|
self.ensure_one()
|
||||||
|
field_name = self.get_field_for_type(self.field_id, "new")
|
||||||
|
return self[field_name]
|
||||||
|
|
||||||
|
def set_old_value(self):
|
||||||
|
""" Copy the value of the record to the 'old' field """
|
||||||
|
for change in self:
|
||||||
|
# copy the existing record's value for the history
|
||||||
|
old_value_for_write = self._value_for_changeset(
|
||||||
|
change.record_id, change.field_id.name
|
||||||
|
)
|
||||||
|
old_field_name = self.get_field_for_type(change.field_id, "old")
|
||||||
|
change.write({old_field_name: old_value_for_write})
|
||||||
|
|
||||||
|
def apply(self):
|
||||||
|
""" Apply the change on the changeset's record
|
||||||
|
|
||||||
|
It is optimized thus that it makes only one write on the record
|
||||||
|
per changeset if many changes are applied at once.
|
||||||
|
"""
|
||||||
|
for change in self:
|
||||||
|
if not change.user_can_validate_changeset:
|
||||||
|
raise UserError(_("You don't have the rights to apply the changes."))
|
||||||
|
changes_ok = self.browse()
|
||||||
|
key = attrgetter("changeset_id")
|
||||||
|
for changeset, changes in groupby(
|
||||||
|
self.with_context(__no_changeset=True).sorted(key=key), key=key
|
||||||
|
):
|
||||||
|
values = {}
|
||||||
|
for change in changes:
|
||||||
|
if change.state in ("cancel", "done"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
field = change.field_id
|
||||||
|
new_value = change.get_new_value()
|
||||||
|
value_for_write = change._convert_value_for_write(new_value)
|
||||||
|
values[field.name] = value_for_write
|
||||||
|
|
||||||
|
change.set_old_value()
|
||||||
|
|
||||||
|
changes_ok |= change
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._check_previous_changesets(changeset)
|
||||||
|
|
||||||
|
changeset.record_id.with_context(__no_changeset=True).write(values)
|
||||||
|
|
||||||
|
changes_ok._finalize_change_approval()
|
||||||
|
|
||||||
|
def _check_previous_changesets(self, changeset):
|
||||||
|
if self.env.context.get("require_previous_changesets_done"):
|
||||||
|
states = self.get_pending_changes_states()
|
||||||
|
previous_changesets = self.env["record.changeset"].search(
|
||||||
|
[
|
||||||
|
("date", "<", changeset.date),
|
||||||
|
("state", "in", states),
|
||||||
|
("model", "=", changeset.model),
|
||||||
|
("res_id", "=", changeset.res_id),
|
||||||
|
],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if previous_changesets:
|
||||||
|
raise UserError(
|
||||||
|
_(
|
||||||
|
"This change cannot be applied because a previous "
|
||||||
|
"changeset for the same record is pending.\n"
|
||||||
|
"Apply all the anterior changesets before applying "
|
||||||
|
"this one."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
""" Reject the change """
|
||||||
|
for change in self:
|
||||||
|
if not change.user_can_validate_changeset:
|
||||||
|
raise UserError(_("You don't have the rights to reject the changes."))
|
||||||
|
if any(change.state == "done" for change in self):
|
||||||
|
raise UserError(_("This change has already be applied."))
|
||||||
|
self.set_old_value()
|
||||||
|
self._finalize_change_rejection()
|
||||||
|
|
||||||
|
def _finalize_change_approval(self):
|
||||||
|
self.write(
|
||||||
|
{
|
||||||
|
"state": "done",
|
||||||
|
"verified_by_id": self.env.user.id,
|
||||||
|
"verified_on_date": fields.Datetime.now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finalize_change_rejection(self):
|
||||||
|
self.write(
|
||||||
|
{
|
||||||
|
"state": "cancel",
|
||||||
|
"verified_by_id": self.env.user.id,
|
||||||
|
"verified_on_date": fields.Datetime.now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _has_field_changed(self, record, field, value):
|
||||||
|
field_def = record._fields[field]
|
||||||
|
current_value = field_def.convert_to_write(record[field], record)
|
||||||
|
if not (current_value or value):
|
||||||
|
return False
|
||||||
|
return current_value != value
|
||||||
|
|
||||||
|
def _convert_value_for_write(self, value):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
model = self.env[self.field_id.model_id.model]
|
||||||
|
model_field_def = model._fields[self.field_id.name]
|
||||||
|
return model_field_def.convert_to_write(value, self.record_id)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _value_for_changeset(self, record, field_name, value=_NO_VALUE):
|
||||||
|
""" Return a value from the record ready to write in a changeset field
|
||||||
|
|
||||||
|
:param record: modified record
|
||||||
|
:param field_name: name of the modified field
|
||||||
|
:param value: if no value is given, it is read from the record
|
||||||
|
"""
|
||||||
|
field_def = record._fields[field_name]
|
||||||
|
if value is _NO_VALUE:
|
||||||
|
# when the value is read from the record, we need to prepare
|
||||||
|
# it for the write (e.g. extract .id from a many2one record)
|
||||||
|
value = field_def.convert_to_write(record[field_name], record)
|
||||||
|
if field_def.type == "many2one":
|
||||||
|
# store as 'reference'
|
||||||
|
comodel = field_def.comodel_name
|
||||||
|
return "{},{}".format(comodel, value) if value else False
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _prepare_changeset_change(self, record, rule, field_name, value):
|
||||||
|
""" Prepare data for a changeset change
|
||||||
|
|
||||||
|
It returns a dict of the values to write on the changeset change
|
||||||
|
and a boolean that indicates if the value should be popped out
|
||||||
|
of the values to write on the model.
|
||||||
|
|
||||||
|
:returns: dict of values, boolean
|
||||||
|
"""
|
||||||
|
new_field_name = self.get_field_for_type(rule.field_id, "new")
|
||||||
|
new_value = self._value_for_changeset(record, field_name, value=value)
|
||||||
|
change = {
|
||||||
|
new_field_name: new_value,
|
||||||
|
"field_id": rule.field_id.id,
|
||||||
|
"rule_id": rule.id,
|
||||||
|
}
|
||||||
|
if rule.action == "auto":
|
||||||
|
change["state"] = "done"
|
||||||
|
pop_value = False
|
||||||
|
elif rule.action == "validate":
|
||||||
|
change["state"] = "draft"
|
||||||
|
pop_value = True # change to apply manually
|
||||||
|
elif rule.action == "never":
|
||||||
|
change["state"] = "cancel"
|
||||||
|
pop_value = True # change never applied
|
||||||
|
|
||||||
|
if change["state"] in ("cancel", "done"):
|
||||||
|
# Normally the 'old' value is set when we use the 'apply'
|
||||||
|
# button, but since we short circuit the 'apply', we
|
||||||
|
# directly set the 'old' value here
|
||||||
|
old_field_name = self.get_field_for_type(rule.field_id, "old")
|
||||||
|
# get values ready to write as expected by the changeset
|
||||||
|
# (for instance, a many2one is written in a reference
|
||||||
|
# field)
|
||||||
|
origin_value = self._value_for_changeset(record, field_name)
|
||||||
|
change[old_field_name] = origin_value
|
||||||
|
|
||||||
|
return change, pop_value
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_fields_changeset_changes(self, model, res_id):
|
||||||
|
fields = [
|
||||||
|
"new_value_display",
|
||||||
|
"origin_value_display",
|
||||||
|
"field_name",
|
||||||
|
"user_can_validate_changeset",
|
||||||
|
]
|
||||||
|
states = self.get_pending_changes_states()
|
||||||
|
domain = [
|
||||||
|
("changeset_id.model", "=", model),
|
||||||
|
("changeset_id.res_id", "=", res_id),
|
||||||
|
("state", "in", states),
|
||||||
|
]
|
||||||
|
return self.search_read(domain, fields)
|
||||||
|
|
||||||
|
def _compute_user_can_validate_changeset(self):
|
||||||
|
is_superuser = self.env.is_superuser()
|
||||||
|
has_group = self.user_has_groups("base_changeset.group_changeset_user")
|
||||||
|
for rec in self:
|
||||||
|
can_validate = rec._is_change_pending() and (is_superuser or has_group)
|
||||||
|
rec.user_can_validate_changeset = can_validate
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_pending_changes_states(self):
|
||||||
|
return ["draft"]
|
||||||
|
|
||||||
|
def _is_change_pending(self):
|
||||||
|
self.ensure_one()
|
||||||
|
skip_status_check = self.env.context.get("skip_pending_status_check")
|
||||||
|
return skip_status_check or self.state in self.get_pending_changes_states()
|
|
@ -0,0 +1,45 @@
|
||||||
|
Access Rights
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The changesets rules must be edited by users with the group ``Changesets
|
||||||
|
Configuration``. The changesets can be applied or canceled only by users
|
||||||
|
with the group ``Changesets Validations``
|
||||||
|
|
||||||
|
Changesets Rules
|
||||||
|
----------------
|
||||||
|
|
||||||
|
The changesets rules can be configured in ``Configuration >
|
||||||
|
Record Changesets > Fields Rules``.
|
||||||
|
|
||||||
|
* Configuration of rules
|
||||||
|
|
||||||
|
.. image:: base_changeset/static/src/img/rules.png
|
||||||
|
|
||||||
|
For each record field, an action can be defined:
|
||||||
|
|
||||||
|
* Auto: the changes made on this field are always applied
|
||||||
|
* Validate: the changes made on this field must be manually confirmed by
|
||||||
|
a 'Changesets User' user
|
||||||
|
* Never: the changes made on this field are always refused
|
||||||
|
|
||||||
|
In any case, all the changes made by the users are always applied
|
||||||
|
directly on the users, but a 'validated' changeset is created for the
|
||||||
|
history.
|
||||||
|
|
||||||
|
The supported fields are:
|
||||||
|
|
||||||
|
* Char
|
||||||
|
* Text
|
||||||
|
* Date
|
||||||
|
* Datetime
|
||||||
|
* Integer
|
||||||
|
* Float
|
||||||
|
* Monetary
|
||||||
|
* Boolean
|
||||||
|
* Many2one
|
||||||
|
|
||||||
|
Rules can be global (no source model) or configured by source model.
|
||||||
|
Rules by source model have the priority. If a field is not configured
|
||||||
|
for the source model, it will use the global rule (if existing).
|
||||||
|
|
||||||
|
If a field has no rule, it is written to the record without changeset.
|
|
@ -0,0 +1,5 @@
|
||||||
|
* 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>
|
|
@ -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.
|
|
@ -0,0 +1,2 @@
|
||||||
|
* Only a subset of the type of fields is actually supported
|
||||||
|
* Multicompany not fully supported
|
|
@ -0,0 +1,66 @@
|
||||||
|
Changeset rules
|
||||||
|
---------------
|
||||||
|
|
||||||
|
The first step is to configure the changeset rules. Once that done, writes on
|
||||||
|
records will be created as changesets.
|
||||||
|
|
||||||
|
Handling changesets
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
The list of all the changesets is in ``Configuration > Record
|
||||||
|
Changesets > Changesets``.
|
||||||
|
|
||||||
|
By default, only the pending changesets (waiting for validation) are shown.
|
||||||
|
Remove the "Pending" filter to show all the changesets.
|
||||||
|
|
||||||
|
* Changeset waiting for validation
|
||||||
|
|
||||||
|
.. image:: base_changeset/static/src/img/changeset.png
|
||||||
|
|
||||||
|
The changes view shows the name of the record's field, the Origin value
|
||||||
|
and the New value alongside the state of the change. By clicking on the
|
||||||
|
change in some cases a more detailed view is displayed, for instance,
|
||||||
|
links for relations can be clicked on.
|
||||||
|
|
||||||
|
A button on a changeset allows to apply or reject all the changes at
|
||||||
|
once.
|
||||||
|
|
||||||
|
Handling single changes
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Accessing the changesets gives the full overview of all the changes made.
|
||||||
|
However, it is more convenient to access the single changes directly from the
|
||||||
|
records. When there is a pending change for a field you get a badge with the
|
||||||
|
number of pending changes next to it like this:
|
||||||
|
|
||||||
|
* Badge with the number of pending changes
|
||||||
|
|
||||||
|
.. image:: base_changeset/static/src/img/badge.png
|
||||||
|
|
||||||
|
When you click on it:
|
||||||
|
|
||||||
|
* Clicking the badge: red button to reject, green one to apply
|
||||||
|
|
||||||
|
.. image:: base_changeset/static/src/img/badge_click.png
|
||||||
|
|
||||||
|
Click the red button to reject the change, click the green one to apply it.
|
||||||
|
|
||||||
|
|
||||||
|
Custom source rules in your addon
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
Addons wanting to create changeset with their own rules should pass the
|
||||||
|
following keys in the context when they write on the record:
|
||||||
|
|
||||||
|
* ``__changeset_rules_source_model``: name of the model which asks for
|
||||||
|
the change
|
||||||
|
* ``__changeset_rules_source_id``: id of the record which asks for the
|
||||||
|
change
|
||||||
|
|
||||||
|
Also, they should extend the selection in
|
||||||
|
``ChangesetFieldRule._domain_source_models`` to add their model (the
|
||||||
|
same that is passed in ``__changeset_rules_source_model``).
|
||||||
|
|
||||||
|
The source is used for the application of the rules, allowing to have a
|
||||||
|
different rule for a different source. It is also stored on the changeset for
|
||||||
|
information.
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?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="[(4, ref('base.user_root'))]" />
|
||||||
|
</record>
|
||||||
|
<record id="group_changeset_user" model="res.groups">
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_changeset_manager'))]" />
|
||||||
|
<field name="users" eval="[(4, ref('base.user_root'))]" />
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
|
@ -0,0 +1,8 @@
|
||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_record_changeset,access_record_changeset,model_record_changeset,base.group_user,1,1,1,1
|
||||||
|
access_record_changeset_change,access_record_changeset_change,model_record_changeset_change,base.group_user,1,1,1,1
|
||||||
|
access_changeset_field_rule,access_changeset_field_rule,model_changeset_field_rule,base.group_user,1,1,1,1
|
||||||
|
access_view_record_changeset_user,changeset for changeset users,model_record_changeset,group_changeset_user,1,1,1,0
|
||||||
|
access_view_record_changeset_change_user,changeset change for changeset users,model_record_changeset_change,group_changeset_user,1,1,1,0
|
||||||
|
access_view_record_changeset_manager,changeset for changeset managers,model_record_changeset,group_changeset_manager,1,1,1,1
|
||||||
|
access_view_record_changeset_change_manager,changeset change for changeset managers,model_record_changeset_change,group_changeset_manager,1,1,1,1
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?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>
|
||||||
|
</odoo>
|
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
|
@ -0,0 +1,160 @@
|
||||||
|
odoo.define("base_changeset", function(require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var FormRenderer = require("web.FormRenderer");
|
||||||
|
var FormController = require("web.FormController");
|
||||||
|
var BasicModel = require("web.BasicModel");
|
||||||
|
var core = require("web.core");
|
||||||
|
var qweb = core.qweb;
|
||||||
|
|
||||||
|
FormController.include({
|
||||||
|
start: function() {
|
||||||
|
return this._super
|
||||||
|
.apply(this, arguments)
|
||||||
|
.then(this._updateChangeset.bind(this));
|
||||||
|
},
|
||||||
|
|
||||||
|
update: function() {
|
||||||
|
var self = this;
|
||||||
|
var res = this._super.apply(this, arguments);
|
||||||
|
res.then(function() {
|
||||||
|
self._updateChangeset();
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateChangeset: function() {
|
||||||
|
var self = this;
|
||||||
|
var state = this.model.get(this.handle);
|
||||||
|
this.model
|
||||||
|
.getChangeset(state.model, state.data.id)
|
||||||
|
.then(function(changeset) {
|
||||||
|
self.renderer.renderChangesetPopovers(changeset);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
applyChange: function(id) {
|
||||||
|
this.model.applyChange(id).then(this.reload.bind(this));
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectChange: function(id) {
|
||||||
|
this.model.rejectChange(id).then(this.reload.bind(this));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
FormRenderer.include({
|
||||||
|
renderChangesetPopovers: function(changeset) {
|
||||||
|
var self = this;
|
||||||
|
_.each(changeset, function(changes, fieldName) {
|
||||||
|
var labelId = self._getIDForLabel(fieldName);
|
||||||
|
var $label = self.$el.find(_.str.sprintf('label[for="%s"]', labelId));
|
||||||
|
if (!$label.length) {
|
||||||
|
var widgets = _.filter(
|
||||||
|
self.allFieldWidgets[self.state.id],
|
||||||
|
function(widget) {
|
||||||
|
return widget.name === fieldName;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (widgets.length === 1) {
|
||||||
|
var widget = widgets[0];
|
||||||
|
$label = widget.$el;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self._renderChangesetPopover($label, changes);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderChangesetPopover: function($el, changes) {
|
||||||
|
var self = this;
|
||||||
|
if (this.mode !== "readonly") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var $button = $(
|
||||||
|
qweb.render("ChangesetButton", {
|
||||||
|
count: changes.length,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$el.append($button);
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
content: function() {
|
||||||
|
var $content = $(
|
||||||
|
qweb.render("ChangesetPopover", {
|
||||||
|
changes: changes,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
$content.find(".base_changeset_apply").on("click", function() {
|
||||||
|
self._applyClicked($(this));
|
||||||
|
});
|
||||||
|
$content.find(".base_changeset_reject").on("click", function() {
|
||||||
|
self._rejectClicked($(this));
|
||||||
|
});
|
||||||
|
return $content;
|
||||||
|
},
|
||||||
|
html: true,
|
||||||
|
placement: "bottom",
|
||||||
|
title: "Pending Changes",
|
||||||
|
trigger: "focus",
|
||||||
|
delay: {show: 0, hide: 100},
|
||||||
|
template: qweb.render("ChangesetTemplate"),
|
||||||
|
};
|
||||||
|
|
||||||
|
$button.popover(options);
|
||||||
|
},
|
||||||
|
|
||||||
|
_applyClicked: function($el) {
|
||||||
|
var id = parseInt($el.data("id"), 10);
|
||||||
|
this.getParent().applyChange(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
_rejectClicked: function($el) {
|
||||||
|
var id = parseInt($el.data("id"), 10);
|
||||||
|
this.getParent().rejectChange(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
BasicModel.include({
|
||||||
|
applyChange: function(id) {
|
||||||
|
return this._rpc({
|
||||||
|
model: "record.changeset.change",
|
||||||
|
method: "apply",
|
||||||
|
args: [[id]],
|
||||||
|
context: _.extend({}, this.context, {set_change_by_ui: true}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectChange: function(id) {
|
||||||
|
return this._rpc({
|
||||||
|
model: "record.changeset.change",
|
||||||
|
method: "cancel",
|
||||||
|
args: [[id]],
|
||||||
|
context: _.extend({}, this.context, {set_change_by_ui: true}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getChangeset: function(modelName, resId) {
|
||||||
|
var self = this;
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
return self
|
||||||
|
._rpc({
|
||||||
|
model: "record.changeset.change",
|
||||||
|
method: "get_fields_changeset_changes",
|
||||||
|
args: [modelName, resId],
|
||||||
|
})
|
||||||
|
.then(function(changeset) {
|
||||||
|
var res = {};
|
||||||
|
_.each(changeset, function(changesetChange) {
|
||||||
|
if (!_.contains(_.keys(res), changesetChange.field_name)) {
|
||||||
|
res[changesetChange.field_name] = [];
|
||||||
|
}
|
||||||
|
res[changesetChange.field_name].push(changesetChange);
|
||||||
|
});
|
||||||
|
resolve(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,12 @@
|
||||||
|
.base_changeset_reject,
|
||||||
|
.base_changeset_apply {
|
||||||
|
width: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base_changeset_button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base_changeset_popover {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="ChangesetPopover">
|
||||||
|
<table class="pb-4">
|
||||||
|
<tr t-foreach="changes" t-as="change">
|
||||||
|
<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}"
|
||||||
|
>
|
||||||
|
<i class="fa fa-times" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-success base_changeset_apply btn-sm"
|
||||||
|
t-attf-data-id="#{change.id}"
|
||||||
|
>
|
||||||
|
<i class="fa fa-check" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</t>
|
||||||
|
<t t-name="ChangesetButton">
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Pending Changes"
|
||||||
|
title="Pending Changes"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
class="badge badge-warning badge-pill base_changeset_button ml-2"
|
||||||
|
>
|
||||||
|
<t t-esc="count" />
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
<t t-name="ChangesetTemplate">
|
||||||
|
<div class="popover base_changeset_popover" role="tooltip">
|
||||||
|
<div class="arrow" />
|
||||||
|
<h3 class="popover-header" />
|
||||||
|
<div class="popover-body" />
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<odoo>
|
||||||
|
<template id="assets_backend" inherit_id="web.assets_backend">
|
||||||
|
<xpath expr=".">
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="/base_changeset/static/src/js/backend.js"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="/base_changeset/static/src/scss/backend.scss"
|
||||||
|
type="text/scss"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
|
@ -0,0 +1,4 @@
|
||||||
|
from . import test_changeset_flow
|
||||||
|
from . import test_changeset_field_type
|
||||||
|
from . import test_changeset_origin
|
||||||
|
from . import test_changeset_field_rule
|
|
@ -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)
|
|
@ -0,0 +1,72 @@
|
||||||
|
# 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):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.company_model_id = self.env.ref("base.model_res_company").id
|
||||||
|
self.field_name = self.env.ref("base.field_res_partner__name")
|
||||||
|
self.field_street = self.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"))
|
|
@ -0,0 +1,294 @@
|
||||||
|
# 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 fields
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
from .common import ChangesetTestCommon
|
||||||
|
|
||||||
|
|
||||||
|
class TestChangesetFieldType(ChangesetTestCommon, TransactionCase):
|
||||||
|
""" Check that changeset changes are stored expectingly to their types """
|
||||||
|
|
||||||
|
def _setup_rules(self):
|
||||||
|
ChangesetFieldRule = self.env["changeset.field.rule"]
|
||||||
|
ChangesetFieldRule.search([]).unlink()
|
||||||
|
fields = (
|
||||||
|
("char", "ref"),
|
||||||
|
("text", "comment"),
|
||||||
|
("boolean", "is_company"),
|
||||||
|
("date", "date"),
|
||||||
|
("integer", "color"),
|
||||||
|
("float", "credit_limit"),
|
||||||
|
("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 = self.env["ir.model.fields"].search(
|
||||||
|
[("model", "=", "res.partner"), ("name", "=", field)]
|
||||||
|
)
|
||||||
|
self.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(self, attr_name, field_record)
|
||||||
|
ChangesetFieldRule.create(
|
||||||
|
{"field_id": field_record.id, "action": "validate"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self._setup_rules()
|
||||||
|
self.partner = self.env["res.partner"].create(
|
||||||
|
{"name": "Original Name", "street": "Original Street"}
|
||||||
|
)
|
||||||
|
# Add context for this test for compatibility with other modules' tests
|
||||||
|
self.partner = self.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=True).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=True).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], "New comment\non 2 lines")
|
||||||
|
|
||||||
|
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=True).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)
|
|
@ -0,0 +1,331 @@
|
||||||
|
# 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 odoo import fields
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
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'
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _setup_rules(self):
|
||||||
|
ChangesetFieldRule = self.env["changeset.field.rule"]
|
||||||
|
ChangesetFieldRule.search([]).unlink()
|
||||||
|
self.field_name = self.env.ref("base.field_res_partner__name")
|
||||||
|
self.field_street = self.env.ref("base.field_res_partner__street")
|
||||||
|
self.field_street2 = self.env.ref("base.field_res_partner__street2")
|
||||||
|
ChangesetFieldRule.create({"field_id": self.field_name.id, "action": "auto"})
|
||||||
|
ChangesetFieldRule.create(
|
||||||
|
{"field_id": self.field_street.id, "action": "validate"}
|
||||||
|
)
|
||||||
|
ChangesetFieldRule.create(
|
||||||
|
{"field_id": self.field_street2.id, "action": "never"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self._setup_rules()
|
||||||
|
self.partner = self.env["res.partner"].create(
|
||||||
|
{"name": "X", "street": "street X", "street2": "street2 X"}
|
||||||
|
)
|
||||||
|
# Add context for this test for compatibility with other modules' tests
|
||||||
|
self.partner = self.partner.with_context(test_record_changeset=True)
|
||||||
|
|
||||||
|
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.env.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")
|
||||||
|
|
||||||
|
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.env.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=True).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_fields_changeset_changes(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")
|
||||||
|
|
||||||
|
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_fields_changeset_changes(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_fields_changeset_changes(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_fields_changeset_changes(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_fields_changeset_changes(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_fields_changeset_changes(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.env.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)
|
|
@ -0,0 +1,122 @@
|
||||||
|
# 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 .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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _setup_rules(self):
|
||||||
|
ChangesetFieldRule = self.env["changeset.field.rule"]
|
||||||
|
ChangesetFieldRule.search([]).unlink()
|
||||||
|
self.field_name = self.env.ref("base.field_res_partner__name")
|
||||||
|
ChangesetFieldRule.create(
|
||||||
|
{"field_id": self.field_name.id, "action": "validate"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self._setup_rules()
|
||||||
|
self.partner = self.env["res.partner"].create({"name": "X"})
|
||||||
|
# Add context for this test for compatibility with other modules' tests
|
||||||
|
self.partner = self.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=True)) 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=True)) 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=True)) 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=True)) 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=True)) 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=True)) 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=True)) 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=True)) as partner_form:
|
||||||
|
partner_form.name = "B"
|
||||||
|
self.assertEqual(change.old_value_char, "A")
|
||||||
|
self.assertEqual(self.partner.count_pending_changesets, 0)
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?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" />
|
||||||
|
<field name="source_model_id" />
|
||||||
|
<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" />
|
||||||
|
</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" />
|
||||||
|
<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>
|
|
@ -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>
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?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>
|
||||||
|
<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>
|
|
@ -0,0 +1,137 @@
|
||||||
|
<?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>
|
||||||
|
<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>
|
Loading…
Reference in New Issue