[13.0][ADD] base_changeset
parent
6492cc32f9
commit
844cc413d5
|
@ -0,0 +1,228 @@
|
|||
=======================
|
||||
Track record changesets
|
||||
=======================
|
||||
|
||||
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
|
||||
:target: https://odoo-community.org/page/development-status
|
||||
:alt: Alpha
|
||||
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
|
||||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||
:alt: License: AGPL-3
|
||||
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github
|
||||
:target: https://github.com/OCA/server-tools/tree/13.0/base_changeset
|
||||
:alt: OCA/server-tools
|
||||
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
|
||||
:target: https://translation.odoo-community.org/projects/server-tools-13-0/server-tools-13-0-base_changeset
|
||||
:alt: Translate me on Weblate
|
||||
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
|
||||
:target: https://runbot.odoo-community.org/runbot/149/13.0
|
||||
:alt: Try me on Runbot
|
||||
|
||||
|badge1| |badge2| |badge3| |badge4| |badge5|
|
||||
|
||||
This module extends the functionality of records. It allows to create
|
||||
changesets that must be validated when a record is modified instead of direct
|
||||
modifications. Rules allow to configure which field must be validated.
|
||||
|
||||
What is a changeset
|
||||
-------------------
|
||||
|
||||
A changeset is a list of changes made on a record.
|
||||
|
||||
Some of the changes may be 'Pending', some 'Accepted' or 'Rejected' according
|
||||
to the changeset rules. The 'Pending' changes require an interaction by the
|
||||
approver user: only when that change is approved, its value is written on
|
||||
the record.
|
||||
|
||||
.. IMPORTANT::
|
||||
This is an alpha version, the data model and design can change at any time without warning.
|
||||
Only for development or testing purpose, do not use in production.
|
||||
`More details on development status <https://odoo-community.org/page/development-status>`_
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Access Rights
|
||||
-------------
|
||||
|
||||
The changesets rules must be edited by users with the group ``Changesets
|
||||
Configuration``. The changesets can be applied or canceled only by users
|
||||
with the group ``Changesets Validations``
|
||||
|
||||
Changesets Rules
|
||||
----------------
|
||||
|
||||
The changesets rules can be configured in ``Configuration >
|
||||
Record Changesets > Fields Rules``.
|
||||
|
||||
* Configuration of rules
|
||||
|
||||
.. image:: https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/rules.png
|
||||
|
||||
For each record field, an action can be defined:
|
||||
|
||||
* Auto: the changes made on this field are always applied
|
||||
* Validate: the changes made on this field must be manually confirmed by
|
||||
a 'Changesets User' user
|
||||
* Never: the changes made on this field are always refused
|
||||
|
||||
In any case, all the changes made by the users are always applied
|
||||
directly on the users, but a 'validated' changeset is created for the
|
||||
history.
|
||||
|
||||
The supported fields are:
|
||||
|
||||
* Char
|
||||
* Text
|
||||
* Date
|
||||
* Datetime
|
||||
* Integer
|
||||
* Float
|
||||
* Monetary
|
||||
* Boolean
|
||||
* Many2one
|
||||
|
||||
Rules can be global (no source model) or configured by source model.
|
||||
Rules by source model have the priority. If a field is not configured
|
||||
for the source model, it will use the global rule (if existing).
|
||||
|
||||
If a field has no rule, it is written to the record without changeset.
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
Changeset rules
|
||||
---------------
|
||||
|
||||
The first step is to configure the changeset rules. Once that done, writes on
|
||||
records will be created as changesets.
|
||||
|
||||
Handling changesets
|
||||
-------------------
|
||||
|
||||
The list of all the changesets is in ``Configuration > Record
|
||||
Changesets > Changesets``.
|
||||
|
||||
By default, only the pending changesets (waiting for validation) are shown.
|
||||
Remove the "Pending" filter to show all the changesets.
|
||||
|
||||
* Changeset waiting for validation
|
||||
|
||||
.. image:: https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/changeset.png
|
||||
|
||||
The changes view shows the name of the record's field, the Origin value
|
||||
and the New value alongside the state of the change. By clicking on the
|
||||
change in some cases a more detailed view is displayed, for instance,
|
||||
links for relations can be clicked on.
|
||||
|
||||
A button on a changeset allows to apply or reject all the changes at
|
||||
once.
|
||||
|
||||
Handling single changes
|
||||
-----------------------
|
||||
|
||||
Accessing the changesets gives the full overview of all the changes made.
|
||||
However, it is more convenient to access the single changes directly from the
|
||||
records. When there is a pending change for a field you get a badge with the
|
||||
number of pending changes next to it like this:
|
||||
|
||||
* Badge with the number of pending changes
|
||||
|
||||
.. image:: https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/badge.png
|
||||
|
||||
When you click on it:
|
||||
|
||||
* Clicking the badge: red button to reject, green one to apply
|
||||
|
||||
.. image:: https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/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.
|
||||
|
||||
Known issues / Roadmap
|
||||
======================
|
||||
|
||||
* Only a subset of the type of fields is actually supported
|
||||
* Multicompany not fully supported
|
||||
|
||||
Bug Tracker
|
||||
===========
|
||||
|
||||
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-tools/issues>`_.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us smashing it by providing a detailed and welcomed
|
||||
`feedback <https://github.com/OCA/server-tools/issues/new?body=module:%20base_changeset%0Aversion:%2013.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
|
||||
|
||||
Do not contact contributors directly about support or help with technical issues.
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
Authors
|
||||
~~~~~~~
|
||||
|
||||
* Onestein
|
||||
* Camptocamp
|
||||
|
||||
Contributors
|
||||
~~~~~~~~~~~~
|
||||
|
||||
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
|
||||
* Denis Leemann <denis.leemann@camptocamp.com>
|
||||
* Yannick Vaucher <yannick.vaucher@camptocamp.com>
|
||||
* Dennis Sluijk <d.sluijk@onestein.nl>
|
||||
* Andrea Stirpe <a.stirpe@onestein.nl>
|
||||
|
||||
Maintainers
|
||||
~~~~~~~~~~~
|
||||
|
||||
This module is maintained by the OCA.
|
||||
|
||||
.. image:: https://odoo-community.org/logo.png
|
||||
:alt: Odoo Community Association
|
||||
:target: https://odoo-community.org
|
||||
|
||||
OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||
mission is to support the collaborative development of Odoo features and
|
||||
promote its widespread use.
|
||||
|
||||
.. |maintainer-astirpe| image:: https://github.com/astirpe.png?size=40px
|
||||
:target: https://github.com/astirpe
|
||||
:alt: astirpe
|
||||
|
||||
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|
||||
|
||||
|maintainer-astirpe|
|
||||
|
||||
This module is part of the `OCA/server-tools <https://github.com/OCA/server-tools/tree/13.0/base_changeset>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
|
|
@ -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>
|
File diff suppressed because it is too large
Load Diff
|
@ -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 |
|
@ -0,0 +1,550 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
|
||||
<title>Track record changesets</title>
|
||||
<style type="text/css">
|
||||
|
||||
/*
|
||||
:Author: David Goodger (goodger@python.org)
|
||||
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
|
||||
:Copyright: This stylesheet has been placed in the public domain.
|
||||
|
||||
Default cascading style sheet for the HTML output of Docutils.
|
||||
|
||||
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
|
||||
customize this style sheet.
|
||||
*/
|
||||
|
||||
/* used to remove borders from tables and images */
|
||||
.borderless, table.borderless td, table.borderless th {
|
||||
border: 0 }
|
||||
|
||||
table.borderless td, table.borderless th {
|
||||
/* Override padding for "table.docutils td" with "! important".
|
||||
The right padding separates the table cells. */
|
||||
padding: 0 0.5em 0 0 ! important }
|
||||
|
||||
.first {
|
||||
/* Override more specific margin styles with "! important". */
|
||||
margin-top: 0 ! important }
|
||||
|
||||
.last, .with-subtitle {
|
||||
margin-bottom: 0 ! important }
|
||||
|
||||
.hidden {
|
||||
display: none }
|
||||
|
||||
.subscript {
|
||||
vertical-align: sub;
|
||||
font-size: smaller }
|
||||
|
||||
.superscript {
|
||||
vertical-align: super;
|
||||
font-size: smaller }
|
||||
|
||||
a.toc-backref {
|
||||
text-decoration: none ;
|
||||
color: black }
|
||||
|
||||
blockquote.epigraph {
|
||||
margin: 2em 5em ; }
|
||||
|
||||
dl.docutils dd {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Uncomment (and remove this text!) to get bold-faced definition list terms
|
||||
dl.docutils dt {
|
||||
font-weight: bold }
|
||||
*/
|
||||
|
||||
div.abstract {
|
||||
margin: 2em 5em }
|
||||
|
||||
div.abstract p.topic-title {
|
||||
font-weight: bold ;
|
||||
text-align: center }
|
||||
|
||||
div.admonition, div.attention, div.caution, div.danger, div.error,
|
||||
div.hint, div.important, div.note, div.tip, div.warning {
|
||||
margin: 2em ;
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.admonition p.admonition-title, div.hint p.admonition-title,
|
||||
div.important p.admonition-title, div.note p.admonition-title,
|
||||
div.tip p.admonition-title {
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
div.attention p.admonition-title, div.caution p.admonition-title,
|
||||
div.danger p.admonition-title, div.error p.admonition-title,
|
||||
div.warning p.admonition-title, .code .error {
|
||||
color: red ;
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
/* Uncomment (and remove this text!) to get reduced vertical space in
|
||||
compound paragraphs.
|
||||
div.compound .compound-first, div.compound .compound-middle {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
div.compound .compound-last, div.compound .compound-middle {
|
||||
margin-top: 0.5em }
|
||||
*/
|
||||
|
||||
div.dedication {
|
||||
margin: 2em 5em ;
|
||||
text-align: center ;
|
||||
font-style: italic }
|
||||
|
||||
div.dedication p.topic-title {
|
||||
font-weight: bold ;
|
||||
font-style: normal }
|
||||
|
||||
div.figure {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
div.footer, div.header {
|
||||
clear: both;
|
||||
font-size: smaller }
|
||||
|
||||
div.line-block {
|
||||
display: block ;
|
||||
margin-top: 1em ;
|
||||
margin-bottom: 1em }
|
||||
|
||||
div.line-block div.line-block {
|
||||
margin-top: 0 ;
|
||||
margin-bottom: 0 ;
|
||||
margin-left: 1.5em }
|
||||
|
||||
div.sidebar {
|
||||
margin: 0 0 0.5em 1em ;
|
||||
border: medium outset ;
|
||||
padding: 1em ;
|
||||
background-color: #ffffee ;
|
||||
width: 40% ;
|
||||
float: right ;
|
||||
clear: right }
|
||||
|
||||
div.sidebar p.rubric {
|
||||
font-family: sans-serif ;
|
||||
font-size: medium }
|
||||
|
||||
div.system-messages {
|
||||
margin: 5em }
|
||||
|
||||
div.system-messages h1 {
|
||||
color: red }
|
||||
|
||||
div.system-message {
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.system-message p.system-message-title {
|
||||
color: red ;
|
||||
font-weight: bold }
|
||||
|
||||
div.topic {
|
||||
margin: 2em }
|
||||
|
||||
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
|
||||
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
|
||||
margin-top: 0.4em }
|
||||
|
||||
h1.title {
|
||||
text-align: center }
|
||||
|
||||
h2.subtitle {
|
||||
text-align: center }
|
||||
|
||||
hr.docutils {
|
||||
width: 75% }
|
||||
|
||||
img.align-left, .figure.align-left, object.align-left, table.align-left {
|
||||
clear: left ;
|
||||
float: left ;
|
||||
margin-right: 1em }
|
||||
|
||||
img.align-right, .figure.align-right, object.align-right, table.align-right {
|
||||
clear: right ;
|
||||
float: right ;
|
||||
margin-left: 1em }
|
||||
|
||||
img.align-center, .figure.align-center, object.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
table.align-center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left }
|
||||
|
||||
.align-center {
|
||||
clear: both ;
|
||||
text-align: center }
|
||||
|
||||
.align-right {
|
||||
text-align: right }
|
||||
|
||||
/* reset inner alignment in figures */
|
||||
div.align-right {
|
||||
text-align: inherit }
|
||||
|
||||
/* div.align-center * { */
|
||||
/* text-align: left } */
|
||||
|
||||
.align-top {
|
||||
vertical-align: top }
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle }
|
||||
|
||||
.align-bottom {
|
||||
vertical-align: bottom }
|
||||
|
||||
ol.simple, ul.simple {
|
||||
margin-bottom: 1em }
|
||||
|
||||
ol.arabic {
|
||||
list-style: decimal }
|
||||
|
||||
ol.loweralpha {
|
||||
list-style: lower-alpha }
|
||||
|
||||
ol.upperalpha {
|
||||
list-style: upper-alpha }
|
||||
|
||||
ol.lowerroman {
|
||||
list-style: lower-roman }
|
||||
|
||||
ol.upperroman {
|
||||
list-style: upper-roman }
|
||||
|
||||
p.attribution {
|
||||
text-align: right ;
|
||||
margin-left: 50% }
|
||||
|
||||
p.caption {
|
||||
font-style: italic }
|
||||
|
||||
p.credits {
|
||||
font-style: italic ;
|
||||
font-size: smaller }
|
||||
|
||||
p.label {
|
||||
white-space: nowrap }
|
||||
|
||||
p.rubric {
|
||||
font-weight: bold ;
|
||||
font-size: larger ;
|
||||
color: maroon ;
|
||||
text-align: center }
|
||||
|
||||
p.sidebar-title {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold ;
|
||||
font-size: larger }
|
||||
|
||||
p.sidebar-subtitle {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
p.topic-title {
|
||||
font-weight: bold }
|
||||
|
||||
pre.address {
|
||||
margin-bottom: 0 ;
|
||||
margin-top: 0 ;
|
||||
font: inherit }
|
||||
|
||||
pre.literal-block, pre.doctest-block, pre.math, pre.code {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
pre.code .ln { color: grey; } /* line numbers */
|
||||
pre.code, code { background-color: #eeeeee }
|
||||
pre.code .comment, code .comment { color: #5C6576 }
|
||||
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
|
||||
pre.code .literal.string, code .literal.string { color: #0C5404 }
|
||||
pre.code .name.builtin, code .name.builtin { color: #352B84 }
|
||||
pre.code .deleted, code .deleted { background-color: #DEB0A1}
|
||||
pre.code .inserted, code .inserted { background-color: #A3D289}
|
||||
|
||||
span.classifier {
|
||||
font-family: sans-serif ;
|
||||
font-style: oblique }
|
||||
|
||||
span.classifier-delimiter {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
span.interpreted {
|
||||
font-family: sans-serif }
|
||||
|
||||
span.option {
|
||||
white-space: nowrap }
|
||||
|
||||
span.pre {
|
||||
white-space: pre }
|
||||
|
||||
span.problematic {
|
||||
color: red }
|
||||
|
||||
span.section-subtitle {
|
||||
/* font-size relative to parent (h1..h6 element) */
|
||||
font-size: 80% }
|
||||
|
||||
table.citation {
|
||||
border-left: solid 1px gray;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docinfo {
|
||||
margin: 2em 4em }
|
||||
|
||||
table.docutils {
|
||||
margin-top: 0.5em ;
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
table.footnote {
|
||||
border-left: solid 1px black;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docutils td, table.docutils th,
|
||||
table.docinfo td, table.docinfo th {
|
||||
padding-left: 0.5em ;
|
||||
padding-right: 0.5em ;
|
||||
vertical-align: top }
|
||||
|
||||
table.docutils th.field-name, table.docinfo th.docinfo-name {
|
||||
font-weight: bold ;
|
||||
text-align: left ;
|
||||
white-space: nowrap ;
|
||||
padding-left: 0 }
|
||||
|
||||
/* "booktabs" style (no vertical lines) */
|
||||
table.docutils.booktabs {
|
||||
border: 0px;
|
||||
border-top: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.docutils.booktabs * {
|
||||
border: 0px;
|
||||
}
|
||||
table.docutils.booktabs th {
|
||||
border-bottom: thin solid;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
|
||||
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
|
||||
font-size: 100% }
|
||||
|
||||
ul.auto-toc {
|
||||
list-style-type: none }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document" id="track-record-changesets">
|
||||
<h1 class="title">Track record changesets</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Alpha" src="https://img.shields.io/badge/maturity-Alpha-red.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/server-tools/tree/13.0/base_changeset"><img alt="OCA/server-tools" src="https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/server-tools-13-0/server-tools-13-0-base_changeset"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/149/13.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
|
||||
<p>This module extends the functionality of records. It allows to create
|
||||
changesets that must be validated when a record is modified instead of direct
|
||||
modifications. Rules allow to configure which field must be validated.</p>
|
||||
<div class="section" id="what-is-a-changeset">
|
||||
<h1>What is a changeset</h1>
|
||||
<p>A changeset is a list of changes made on a record.</p>
|
||||
<p>Some of the changes may be ‘Pending’, some ‘Accepted’ or ‘Rejected’ according
|
||||
to the changeset rules. The ‘Pending’ changes require an interaction by the
|
||||
approver user: only when that change is approved, its value is written on
|
||||
the record.</p>
|
||||
<div class="admonition important">
|
||||
<p class="first admonition-title">Important</p>
|
||||
<p class="last">This is an alpha version, the data model and design can change at any time without warning.
|
||||
Only for development or testing purpose, do not use in production.
|
||||
<a class="reference external" href="https://odoo-community.org/page/development-status">More details on development status</a></p>
|
||||
</div>
|
||||
<p><strong>Table of contents</strong></p>
|
||||
<div class="contents local topic" id="contents">
|
||||
<ul class="simple">
|
||||
<li><a class="reference internal" href="#configuration" id="id1">Configuration</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="configuration">
|
||||
<h2><a class="toc-backref" href="#id1">Configuration</a></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="access-rights">
|
||||
<h1>Access Rights</h1>
|
||||
<p>The changesets rules must be edited by users with the group <tt class="docutils literal">Changesets
|
||||
Configuration</tt>. The changesets can be applied or canceled only by users
|
||||
with the group <tt class="docutils literal">Changesets Validations</tt></p>
|
||||
</div>
|
||||
<div class="section" id="changesets-rules">
|
||||
<h1>Changesets Rules</h1>
|
||||
<p>The changesets rules can be configured in <tt class="docutils literal">Configuration >
|
||||
Record Changesets > Fields Rules</tt>.</p>
|
||||
<ul>
|
||||
<li><p class="first">Configuration of rules</p>
|
||||
<img alt="https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/rules.png" src="https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/rules.png" />
|
||||
</li>
|
||||
</ul>
|
||||
<p>For each record field, an action can be defined:</p>
|
||||
<ul class="simple">
|
||||
<li>Auto: the changes made on this field are always applied</li>
|
||||
<li>Validate: the changes made on this field must be manually confirmed by
|
||||
a ‘Changesets User’ user</li>
|
||||
<li>Never: the changes made on this field are always refused</li>
|
||||
</ul>
|
||||
<p>In any case, all the changes made by the users are always applied
|
||||
directly on the users, but a ‘validated’ changeset is created for the
|
||||
history.</p>
|
||||
<p>The supported fields are:</p>
|
||||
<ul class="simple">
|
||||
<li>Char</li>
|
||||
<li>Text</li>
|
||||
<li>Date</li>
|
||||
<li>Datetime</li>
|
||||
<li>Integer</li>
|
||||
<li>Float</li>
|
||||
<li>Monetary</li>
|
||||
<li>Boolean</li>
|
||||
<li>Many2one</li>
|
||||
</ul>
|
||||
<p>Rules can be global (no source model) or configured by source model.
|
||||
Rules by source model have the priority. If a field is not configured
|
||||
for the source model, it will use the global rule (if existing).</p>
|
||||
<p>If a field has no rule, it is written to the record without changeset.</p>
|
||||
<div class="section" id="usage">
|
||||
<h2>Usage</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="changeset-rules">
|
||||
<h1>Changeset rules</h1>
|
||||
<p>The first step is to configure the changeset rules. Once that done, writes on
|
||||
records will be created as changesets.</p>
|
||||
</div>
|
||||
<div class="section" id="handling-changesets">
|
||||
<h1>Handling changesets</h1>
|
||||
<p>The list of all the changesets is in <tt class="docutils literal">Configuration > Record
|
||||
Changesets > Changesets</tt>.</p>
|
||||
<p>By default, only the pending changesets (waiting for validation) are shown.
|
||||
Remove the “Pending” filter to show all the changesets.</p>
|
||||
<ul>
|
||||
<li><p class="first">Changeset waiting for validation</p>
|
||||
<img alt="https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/changeset.png" src="https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/changeset.png" />
|
||||
</li>
|
||||
</ul>
|
||||
<p>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.</p>
|
||||
<p>A button on a changeset allows to apply or reject all the changes at
|
||||
once.</p>
|
||||
</div>
|
||||
<div class="section" id="handling-single-changes">
|
||||
<h1>Handling single changes</h1>
|
||||
<p>Accessing the changesets gives the full overview of all the changes made.
|
||||
However, it is more convenient to access the single changes directly from the
|
||||
records. When there is a pending change for a field you get a badge with the
|
||||
number of pending changes next to it like this:</p>
|
||||
<ul>
|
||||
<li><p class="first">Badge with the number of pending changes</p>
|
||||
<img alt="https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/badge.png" src="https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/badge.png" />
|
||||
</li>
|
||||
</ul>
|
||||
<p>When you click on it:</p>
|
||||
<ul>
|
||||
<li><p class="first">Clicking the badge: red button to reject, green one to apply</p>
|
||||
<img alt="https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/badge_click.png" src="https://raw.githubusercontent.com/OCA/server-tools/13.0/base_changeset/base_changeset/static/src/img/badge_click.png" />
|
||||
</li>
|
||||
</ul>
|
||||
<p>Click the red button to reject the change, click the green one to apply it.</p>
|
||||
</div>
|
||||
<div class="section" id="custom-source-rules-in-your-addon">
|
||||
<h1>Custom source rules in your addon</h1>
|
||||
<p>Addons wanting to create changeset with their own rules should pass the
|
||||
following keys in the context when they write on the record:</p>
|
||||
<ul class="simple">
|
||||
<li><tt class="docutils literal">__changeset_rules_source_model</tt>: name of the model which asks for
|
||||
the change</li>
|
||||
<li><tt class="docutils literal">__changeset_rules_source_id</tt>: id of the record which asks for the
|
||||
change</li>
|
||||
</ul>
|
||||
<p>Also, they should extend the selection in
|
||||
<tt class="docutils literal">ChangesetFieldRule._domain_source_models</tt> to add their model (the
|
||||
same that is passed in <tt class="docutils literal">__changeset_rules_source_model</tt>).</p>
|
||||
<p>The source is used for the application of the rules, allowing to have a
|
||||
different rule for a different source. It is also stored on the changeset for
|
||||
information.</p>
|
||||
<div class="section" id="known-issues-roadmap">
|
||||
<h2>Known issues / Roadmap</h2>
|
||||
<ul class="simple">
|
||||
<li>Only a subset of the type of fields is actually supported</li>
|
||||
<li>Multicompany not fully supported</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h2>Bug Tracker</h2>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/server-tools/issues">GitHub Issues</a>.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us smashing it by providing a detailed and welcomed
|
||||
<a class="reference external" href="https://github.com/OCA/server-tools/issues/new?body=module:%20base_changeset%0Aversion:%2013.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
|
||||
<p>Do not contact contributors directly about support or help with technical issues.</p>
|
||||
</div>
|
||||
<div class="section" id="credits">
|
||||
<h2>Credits</h2>
|
||||
<div class="section" id="authors">
|
||||
<h3>Authors</h3>
|
||||
<ul class="simple">
|
||||
<li>Onestein</li>
|
||||
<li>Camptocamp</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="contributors">
|
||||
<h3>Contributors</h3>
|
||||
<ul class="simple">
|
||||
<li>Guewen Baconnier <<a class="reference external" href="mailto:guewen.baconnier@camptocamp.com">guewen.baconnier@camptocamp.com</a>></li>
|
||||
<li>Denis Leemann <<a class="reference external" href="mailto:denis.leemann@camptocamp.com">denis.leemann@camptocamp.com</a>></li>
|
||||
<li>Yannick Vaucher <<a class="reference external" href="mailto:yannick.vaucher@camptocamp.com">yannick.vaucher@camptocamp.com</a>></li>
|
||||
<li>Dennis Sluijk <<a class="reference external" href="mailto:d.sluijk@onestein.nl">d.sluijk@onestein.nl</a>></li>
|
||||
<li>Andrea Stirpe <<a class="reference external" href="mailto:a.stirpe@onestein.nl">a.stirpe@onestein.nl</a>></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h3>Maintainers</h3>
|
||||
<p>This module is maintained by the OCA.</p>
|
||||
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
|
||||
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||
mission is to support the collaborative development of Odoo features and
|
||||
promote its widespread use.</p>
|
||||
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p>
|
||||
<p><a class="reference external" href="https://github.com/astirpe"><img alt="astirpe" src="https://github.com/astirpe.png?size=40px" /></a></p>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/server-tools/tree/13.0/base_changeset">OCA/server-tools</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
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