mirror of
https://github.com/bringout/oca-technical.git
synced 2026-04-18 03:12:04 +02:00
Initial commit: OCA Technical packages (595 packages)
This commit is contained in:
commit
2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions
46
odoo-bringout-oca-server-tools-base_changeset/README.md
Normal file
46
odoo-bringout-oca-server-tools-base_changeset/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Track record changesets
|
||||
|
||||
Odoo addon: base_changeset
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install odoo-bringout-oca-server-tools-base_changeset
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
This addon depends on:
|
||||
- web
|
||||
|
||||
## Manifest Information
|
||||
|
||||
- **Name**: Track record changesets
|
||||
- **Version**: 16.0.1.0.0
|
||||
- **Category**: Tools
|
||||
- **License**: AGPL-3
|
||||
- **Installable**: True
|
||||
|
||||
## Source
|
||||
|
||||
Based on [OCA/server-tools](https://github.com/OCA/server-tools) branch 16.0, addon `base_changeset`.
|
||||
|
||||
## License
|
||||
|
||||
This package maintains the original AGPL-3 license from the upstream Odoo project.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Overview: doc/OVERVIEW.md
|
||||
- Architecture: doc/ARCHITECTURE.md
|
||||
- Models: doc/MODELS.md
|
||||
- Controllers: doc/CONTROLLERS.md
|
||||
- Wizards: doc/WIZARDS.md
|
||||
- Reports: doc/REPORTS.md
|
||||
- Security: doc/SECURITY.md
|
||||
- Install: doc/INSTALL.md
|
||||
- Usage: doc/USAGE.md
|
||||
- Configuration: doc/CONFIGURATION.md
|
||||
- Dependencies: doc/DEPENDENCIES.md
|
||||
- Troubleshooting: doc/TROUBLESHOOTING.md
|
||||
- FAQ: doc/FAQ.md
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
=======================
|
||||
Track record changesets
|
||||
=======================
|
||||
|
||||
..
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:a0553f512eea126df69773c8a4597af8d63d3427fa46a6bee3090761e8f7b869
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
|
||||
:target: https://odoo-community.org/page/development-status
|
||||
:alt: Alpha
|
||||
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
|
||||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||
:alt: License: AGPL-3
|
||||
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github
|
||||
:target: https://github.com/OCA/server-tools/tree/16.0/base_changeset
|
||||
:alt: OCA/server-tools
|
||||
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
|
||||
:target: https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-base_changeset
|
||||
:alt: Translate me on Weblate
|
||||
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
|
||||
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=16.0
|
||||
:alt: Try me on Runboat
|
||||
|
||||
|badge1| |badge2| |badge3| |badge4| |badge5|
|
||||
|
||||
This module extends the functionality of records. It allows to create
|
||||
changesets that must be validated when a record is modified instead of direct
|
||||
modifications. Rules allow to configure which field must be validated.
|
||||
|
||||
What is a changeset
|
||||
-------------------
|
||||
|
||||
A changeset is a list of changes made on a record.
|
||||
|
||||
Some of the changes may be 'Pending', some 'Accepted' or 'Rejected' according
|
||||
to the changeset rules. The 'Pending' changes require an interaction by the
|
||||
approver user: only when that change is approved, its value is written on
|
||||
the record.
|
||||
|
||||
.. IMPORTANT::
|
||||
This is an alpha version, the data model and design can change at any time without warning.
|
||||
Only for development or testing purpose, do not use in production.
|
||||
`More details on development status <https://odoo-community.org/page/development-status>`_
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Access Rights
|
||||
-------------
|
||||
|
||||
The changesets rules must be edited by users with the group ``Changesets
|
||||
Configuration``. The changesets can be applied or canceled only by users
|
||||
with the group ``Changesets Validations``
|
||||
|
||||
Changesets Rules
|
||||
----------------
|
||||
|
||||
The changesets rules can be configured in ``Configuration >
|
||||
Record Changesets > Fields Rules``.
|
||||
|
||||
* Configuration of rules
|
||||
|
||||
.. image:: https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/rules.png
|
||||
|
||||
For each record field, an action can be defined:
|
||||
|
||||
* Auto: the changes made on this field are always applied
|
||||
* Validate: the changes made on this field must be manually confirmed by
|
||||
a 'Changesets User' user
|
||||
* Never: the changes made on this field are always refused
|
||||
|
||||
In any case, all the changes made by the users are always applied
|
||||
directly on the users, but a 'validated' changeset is created for the
|
||||
history.
|
||||
|
||||
The supported fields are:
|
||||
|
||||
* Char
|
||||
* Text
|
||||
* Date
|
||||
* Datetime
|
||||
* Integer
|
||||
* Float
|
||||
* Monetary
|
||||
* Boolean
|
||||
* Many2one
|
||||
|
||||
Rules can be global (no source model) or configured by source model.
|
||||
Rules by source model have the priority. If a field is not configured
|
||||
for the source model, it will use the global rule (if existing).
|
||||
|
||||
If a field has no rule, it is written to the record without changeset.
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
Changeset rules
|
||||
---------------
|
||||
|
||||
The first step is to configure the changeset rules. Once that done, writes on
|
||||
records will be created as changesets.
|
||||
|
||||
Handling changesets
|
||||
-------------------
|
||||
|
||||
The list of all the changesets is in ``Configuration > Record
|
||||
Changesets > Changesets``.
|
||||
|
||||
By default, only the pending changesets (waiting for validation) are shown.
|
||||
Remove the "Pending" filter to show all the changesets.
|
||||
|
||||
* Changeset waiting for validation
|
||||
|
||||
.. image:: https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/changeset.png
|
||||
|
||||
The changes view shows the name of the record's field, the Origin value
|
||||
and the New value alongside the state of the change. By clicking on the
|
||||
change in some cases a more detailed view is displayed, for instance,
|
||||
links for relations can be clicked on.
|
||||
|
||||
A button on a changeset allows to apply or reject all the changes at
|
||||
once.
|
||||
|
||||
Handling single changes
|
||||
-----------------------
|
||||
|
||||
Accessing the changesets gives the full overview of all the changes made.
|
||||
However, it is more convenient to access the single changes directly from the
|
||||
records. When there is a pending change for a field you get a badge with the
|
||||
number of pending changes next to it like this:
|
||||
|
||||
* Badge with the number of pending changes
|
||||
|
||||
.. image:: https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge.png
|
||||
|
||||
When you click on it:
|
||||
|
||||
* Clicking the badge: red button to reject, green one to apply
|
||||
|
||||
.. image:: https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge_click.png
|
||||
|
||||
Click the red button to reject the change, click the green one to apply it.
|
||||
|
||||
|
||||
Custom source rules in your addon
|
||||
---------------------------------
|
||||
|
||||
Addons wanting to create changeset with their own rules should pass the
|
||||
following keys in the context when they write on the record:
|
||||
|
||||
* ``__changeset_rules_source_model``: name of the model which asks for
|
||||
the change
|
||||
* ``__changeset_rules_source_id``: id of the record which asks for the
|
||||
change
|
||||
|
||||
Also, they should extend the selection in
|
||||
``ChangesetFieldRule._domain_source_models`` to add their model (the
|
||||
same that is passed in ``__changeset_rules_source_model``).
|
||||
|
||||
The source is used for the application of the rules, allowing to have a
|
||||
different rule for a different source. It is also stored on the changeset for
|
||||
information.
|
||||
|
||||
Notes on security
|
||||
-----------------
|
||||
|
||||
Note that by default, changeset users see all changes on all configured
|
||||
rules. This circumvents read restrictions on the original records, so if you
|
||||
have restrictions on models with changeset rules, changeset users will still
|
||||
see all changes of all records, and applying a change on an inaccessible record
|
||||
will fail.
|
||||
|
||||
Known issues / Roadmap
|
||||
======================
|
||||
|
||||
* Only a subset of the type of fields is actually supported
|
||||
* Multicompany not fully supported
|
||||
* The popover widget indicating the number of pending changes is not shown for
|
||||
fields without a label at the moment. The approach was already failing in 15.0
|
||||
(in the case of inline fields such as the partner address fields)
|
||||
and even in 14.0 (in the case of fields for which no value was set yet).
|
||||
Or, for a more flexible approach, implement a kind of view preprocessing that
|
||||
allows a developer to indicate where the widget needs to go (analogous to
|
||||
`<label for="field_name" />`).
|
||||
|
||||
Bug Tracker
|
||||
===========
|
||||
|
||||
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-tools/issues>`_.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us to smash it by providing a detailed and welcomed
|
||||
`feedback <https://github.com/OCA/server-tools/issues/new?body=module:%20base_changeset%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
|
||||
|
||||
Do not contact contributors directly about support or help with technical issues.
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
Authors
|
||||
~~~~~~~
|
||||
|
||||
* Onestein
|
||||
* Camptocamp
|
||||
|
||||
Contributors
|
||||
~~~~~~~~~~~~
|
||||
|
||||
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
|
||||
* Denis Leemann <denis.leemann@camptocamp.com>
|
||||
* Yannick Vaucher <yannick.vaucher@camptocamp.com>
|
||||
* Dennis Sluijk <d.sluijk@onestein.nl>
|
||||
* Andrea Stirpe <a.stirpe@onestein.nl>
|
||||
* Holger Brunn <mail@hunki-enterprises.com>
|
||||
* Mark Schuit <mark@gig.solutions>
|
||||
* Stefan Rijnhart <stefan@opener.amsterdam>
|
||||
|
||||
Maintainers
|
||||
~~~~~~~~~~~
|
||||
|
||||
This module is maintained by the OCA.
|
||||
|
||||
.. image:: https://odoo-community.org/logo.png
|
||||
:alt: Odoo Community Association
|
||||
:target: https://odoo-community.org
|
||||
|
||||
OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||
mission is to support the collaborative development of Odoo features and
|
||||
promote its widespread use.
|
||||
|
||||
.. |maintainer-astirpe| image:: https://github.com/astirpe.png?size=40px
|
||||
:target: https://github.com/astirpe
|
||||
:alt: astirpe
|
||||
|
||||
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|
||||
|
||||
|maintainer-astirpe|
|
||||
|
||||
This module is part of the `OCA/server-tools <https://github.com/OCA/server-tools/tree/16.0/base_changeset>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import models
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# Copyright 2015-2017 Camptocamp SA
|
||||
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "Track record changesets",
|
||||
"version": "16.0.1.0.0",
|
||||
"development_status": "Alpha",
|
||||
"author": "Onestein, Camptocamp, Odoo Community Association (OCA)",
|
||||
"maintainers": ["astirpe"],
|
||||
"license": "AGPL-3",
|
||||
"category": "Tools",
|
||||
"depends": ["web"],
|
||||
"website": "https://github.com/OCA/server-tools",
|
||||
"data": [
|
||||
"security/groups.xml",
|
||||
"security/ir.model.access.csv",
|
||||
"security/rules.xml",
|
||||
"views/record_changeset_views.xml",
|
||||
"views/record_changeset_change_views.xml",
|
||||
"views/changeset_field_rule_views.xml",
|
||||
"views/menu.xml",
|
||||
],
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"base_changeset/static/src/components/form_label.*",
|
||||
"base_changeset/static/src/components/changeset_popover.*",
|
||||
"base_changeset/static/src/components/record.esm.js",
|
||||
],
|
||||
},
|
||||
"demo": ["demo/changeset_field_rule.xml"],
|
||||
"installable": True,
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo noupdate="1">
|
||||
<record model="changeset.field.rule" id="changeset_field_rule_name">
|
||||
<field name="field_id" ref="base.field_res_partner__name" />
|
||||
<field name="action">auto</field>
|
||||
</record>
|
||||
<record model="changeset.field.rule" id="changeset_field_rule_street">
|
||||
<field name="field_id" ref="base.field_res_partner__street" />
|
||||
<field name="action">auto</field>
|
||||
</record>
|
||||
<record model="changeset.field.rule" id="changeset_field_rule_street2">
|
||||
<field name="field_id" ref="base.field_res_partner__street2" />
|
||||
<field name="action">validate</field>
|
||||
</record>
|
||||
<record model="changeset.field.rule" id="changeset_field_rule_zip">
|
||||
<field name="field_id" ref="base.field_res_partner__zip" />
|
||||
<field name="action">validate</field>
|
||||
</record>
|
||||
<record model="changeset.field.rule" id="changeset_field_rule_city">
|
||||
<field name="field_id" ref="base.field_res_partner__city" />
|
||||
<field name="action">validate</field>
|
||||
</record>
|
||||
<record model="changeset.field.rule" id="changeset_field_rule_email">
|
||||
<field name="field_id" ref="base.field_res_partner__email" />
|
||||
<field name="action">never</field>
|
||||
</record>
|
||||
<record model="changeset.field.rule" id="changeset_field_rule_ref">
|
||||
<field name="field_id" ref="base.field_res_partner__ref" />
|
||||
<field name="action">validate</field>
|
||||
</record>
|
||||
<record model="changeset.field.rule" id="changeset_field_rule_country_id">
|
||||
<field name="field_id" ref="base.field_res_partner__country_id" />
|
||||
<field name="action">auto</field>
|
||||
</record>
|
||||
<record model="changeset.field.rule" id="changeset_field_rule_partner_latitude">
|
||||
<field name="field_id" ref="base.field_res_partner__partner_latitude" />
|
||||
<field name="action">auto</field>
|
||||
</record>
|
||||
</odoo>
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
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,214 @@
|
|||
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.tools import config, ormcache
|
||||
|
||||
# put this object into context key '__no_changeset' to disable changeset
|
||||
# functionality
|
||||
disable_changeset = object()
|
||||
|
||||
|
||||
class Base(models.AbstractModel):
|
||||
_inherit = "base"
|
||||
|
||||
changeset_ids = fields.One2many(
|
||||
comodel_name="record.changeset",
|
||||
compute="_compute_changeset_ids",
|
||||
string="Changesets",
|
||||
)
|
||||
changeset_change_ids = fields.One2many(
|
||||
comodel_name="record.changeset.change",
|
||||
compute="_compute_changeset_ids",
|
||||
string="Changeset Changes",
|
||||
)
|
||||
count_changesets = fields.Integer(
|
||||
compute="_compute_count_pending_changesets",
|
||||
help="The overall number of changesets of this record",
|
||||
)
|
||||
count_pending_changesets = fields.Integer(
|
||||
compute="_compute_count_pending_changesets",
|
||||
help="The number of pending changesets of this record",
|
||||
)
|
||||
count_pending_changeset_changes = fields.Integer(
|
||||
compute="_compute_count_pending_changesets",
|
||||
help="The number of pending changes of this record",
|
||||
)
|
||||
user_can_see_changeset = fields.Boolean(compute="_compute_user_can_see_changeset")
|
||||
|
||||
def _compute_changeset_ids(self):
|
||||
model_name = self._name
|
||||
for record in self:
|
||||
changesets = self.env["record.changeset"].search(
|
||||
[("model", "=", model_name), ("res_id", "=", record.id)]
|
||||
)
|
||||
record.changeset_ids = changesets
|
||||
record.changeset_change_ids = changesets.mapped("change_ids")
|
||||
|
||||
def _compute_count_pending_changesets(self):
|
||||
model_name = self._name
|
||||
if model_name in self.models_to_track_changeset():
|
||||
for rec in self:
|
||||
rec.count_changesets = len(rec.changeset_ids)
|
||||
changesets = rec.changeset_ids.filtered(
|
||||
lambda rev: rev.state == "draft"
|
||||
and rev.res_id == rec.id
|
||||
and rev.model == model_name
|
||||
)
|
||||
changes = changesets.mapped("change_ids")
|
||||
changes = changes.filtered(
|
||||
lambda c: c.state in c.get_pending_changes_states()
|
||||
)
|
||||
rec.count_pending_changesets = len(changesets)
|
||||
rec.count_pending_changeset_changes = len(changes)
|
||||
else:
|
||||
for rec in self:
|
||||
rec.count_changesets = 0
|
||||
rec.count_pending_changesets = 0
|
||||
rec.count_pending_changeset_changes = 0
|
||||
|
||||
@api.model
|
||||
@ormcache(skiparg=1)
|
||||
def models_to_track_changeset(self):
|
||||
"""Models to be tracked for changes
|
||||
:args:
|
||||
:returns: list of models
|
||||
"""
|
||||
models = (
|
||||
self.env["changeset.field.rule"].sudo().search([]).mapped("model_id.model")
|
||||
)
|
||||
if config["test_enable"] and self.env.context.get("test_record_changeset"):
|
||||
if "res.partner" not in models:
|
||||
models += ["res.partner"] # Used in tests
|
||||
return models
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
result = super().create(vals_list)
|
||||
if self._changeset_disabled():
|
||||
return result
|
||||
for this, vals in zip(result, vals_list):
|
||||
local_vals = self.env["record.changeset"].add_changeset(
|
||||
this, vals, create=True
|
||||
)
|
||||
local_vals = {
|
||||
key: value for key, value in local_vals.items() if vals[key] != value
|
||||
}
|
||||
if local_vals:
|
||||
this.with_context(
|
||||
__no_changeset=disable_changeset,
|
||||
tracking_disable=True,
|
||||
).write(local_vals)
|
||||
return result
|
||||
|
||||
def write(self, values):
|
||||
if self._changeset_disabled():
|
||||
return super().write(values)
|
||||
|
||||
for record in self:
|
||||
local_values = self.env["record.changeset"].add_changeset(record, values)
|
||||
super(Base, record).write(local_values)
|
||||
return True
|
||||
|
||||
def _changeset_disabled(self):
|
||||
if self.env.context.get("__no_changeset") == disable_changeset:
|
||||
return True
|
||||
# To avoid conflicts with tests of other modules
|
||||
if config["test_enable"] and not self.env.context.get("test_record_changeset"):
|
||||
return True
|
||||
if self._name not in self.models_to_track_changeset():
|
||||
return True
|
||||
return False
|
||||
|
||||
def action_record_changeset_change_view(self):
|
||||
self.ensure_one()
|
||||
res = {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "record.changeset.change",
|
||||
"view_mode": "tree",
|
||||
"views": [
|
||||
[
|
||||
self.env.ref("base_changeset.view_record_changeset_change_tree").id,
|
||||
"list",
|
||||
]
|
||||
],
|
||||
"context": self.env.context,
|
||||
"name": _("Record Changes"),
|
||||
"search_view_id": [
|
||||
self.env.ref("base_changeset.view_record_changeset_change_search").id,
|
||||
"search",
|
||||
],
|
||||
}
|
||||
record_id = self.env.context.get("search_default_record_id")
|
||||
if record_id:
|
||||
res.update(
|
||||
{
|
||||
"domain": [
|
||||
("model", "=", self._name),
|
||||
("changeset_id.res_id", "=", record_id),
|
||||
]
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def get_view(self, view_id=None, view_type="form", **options):
|
||||
"""Insert the pending changes smart button in the form view of tracked models."""
|
||||
res = super().get_view(view_id=view_id, view_type=view_type, **options)
|
||||
if (
|
||||
view_type == "form"
|
||||
and self._name in self.models_to_track_changeset()
|
||||
and self._user_can_see_changeset()
|
||||
):
|
||||
button_label = _("Changes")
|
||||
doc = etree.XML(res["arch"])
|
||||
for node in doc.xpath("//div[@name='button_box']"):
|
||||
field_count_changesets = etree.Element(
|
||||
"field",
|
||||
{
|
||||
"name": "count_changesets",
|
||||
"modifiers": json.dumps({"invisible": True}),
|
||||
},
|
||||
)
|
||||
field_count_pending_changeset_changes = etree.Element(
|
||||
"field",
|
||||
{
|
||||
"name": "count_pending_changeset_changes",
|
||||
"string": button_label,
|
||||
"widget": "statinfo",
|
||||
},
|
||||
)
|
||||
xml_button = etree.Element(
|
||||
"button",
|
||||
{
|
||||
"type": "object",
|
||||
"name": "action_record_changeset_change_view",
|
||||
"icon": "fa-code-fork",
|
||||
"context": "{'search_default_draft': 1, "
|
||||
"'search_default_record_id': active_id}",
|
||||
"modifiers": json.dumps(
|
||||
{"invisible": [("count_changesets", "=", 0)]}
|
||||
),
|
||||
},
|
||||
)
|
||||
xml_button.insert(0, field_count_pending_changeset_changes)
|
||||
xml_button.insert(0, field_count_changesets)
|
||||
node.insert(0, xml_button)
|
||||
res["arch"] = etree.tostring(doc, encoding="unicode")
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _user_can_see_changeset(self):
|
||||
"""Return if the current user has changeset access"""
|
||||
return self.env.is_superuser() or self.user_has_groups(
|
||||
"base_changeset.group_changeset_user"
|
||||
)
|
||||
|
||||
def _compute_user_can_see_changeset(self):
|
||||
user_can_see_changeset = self._user_can_see_changeset()
|
||||
for rec in self:
|
||||
rec.user_can_see_changeset = user_can_see_changeset
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
# Copyright 2015-2017 Camptocamp SA
|
||||
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import api, fields, models, tools
|
||||
from odoo.tools.cache import ormcache
|
||||
|
||||
|
||||
class ChangesetFieldRule(models.Model):
|
||||
_name = "changeset.field.rule"
|
||||
_description = "Changeset Field Rules"
|
||||
_rec_name = "field_id"
|
||||
|
||||
model_id = fields.Many2one(related="field_id.model_id", store=True)
|
||||
field_id = fields.Many2one(
|
||||
comodel_name="ir.model.fields", ondelete="cascade", required=True
|
||||
)
|
||||
action = fields.Selection(
|
||||
selection="_selection_action",
|
||||
required=True,
|
||||
help="Auto: always apply a change.\n"
|
||||
"Validate: manually applied by an administrator.\n"
|
||||
"Never: change never applied.",
|
||||
)
|
||||
source_model_id = fields.Many2one(
|
||||
comodel_name="ir.model",
|
||||
string="Source Model",
|
||||
ondelete="cascade",
|
||||
domain=lambda self: [("id", "in", self._domain_source_models().ids)],
|
||||
help="If a source model is defined, the rule will be applied only "
|
||||
"when the change is made from this origin. "
|
||||
"Rules without source model are global and applies to all "
|
||||
"backends.\n"
|
||||
"Rules with a source model have precedence over global rules, "
|
||||
"but if a field has no rule with a source model, the global rule "
|
||||
"is used.",
|
||||
)
|
||||
company_id = fields.Many2one("res.company", default=lambda self: self.env.company)
|
||||
active = fields.Boolean(default=True)
|
||||
prevent_self_validation = fields.Boolean(default=False)
|
||||
expression = fields.Text(
|
||||
help="Use this rule only on records where this is true. "
|
||||
"Available variables: object, user",
|
||||
)
|
||||
validator_group_ids = fields.Many2many(
|
||||
"res.groups",
|
||||
"changeset_field_rule_validator_group_rel",
|
||||
string="Validator Groups",
|
||||
default=lambda self: self.env.ref(
|
||||
"base_changeset.group_changeset_user",
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
or self.env["res.groups"],
|
||||
)
|
||||
|
||||
def init(self):
|
||||
"""Ensure there is at most one rule with source_model_id NULL."""
|
||||
self.env.cr.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS source_model_null_field_uniq
|
||||
ON %s (field_id)
|
||||
WHERE source_model_id IS NULL
|
||||
"""
|
||||
% self._table
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"model_field_uniq",
|
||||
"unique (source_model_id, field_id)",
|
||||
"A rule already exists for this field.",
|
||||
)
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _domain_source_models(self):
|
||||
"""Returns the models for which we can define rules.
|
||||
|
||||
Example for submodules (replace by the xmlid of the model):
|
||||
|
||||
::
|
||||
models = super()._domain_source_models()
|
||||
return models | self.env.ref('base.model_res_users')
|
||||
|
||||
Rules without model are global and apply for all models.
|
||||
|
||||
"""
|
||||
return self.env.ref("base.model_res_users")
|
||||
|
||||
@api.model
|
||||
def _selection_action(self):
|
||||
return [("auto", "Auto"), ("validate", "Validate"), ("never", "Never")]
|
||||
|
||||
@api.constrains("expression")
|
||||
def _check_expression(self):
|
||||
for this in self:
|
||||
this._evaluate_expression(self.env[this.model_id.model].new({}))
|
||||
|
||||
@ormcache(skiparg=1)
|
||||
@api.model
|
||||
def _get_rules(self, source_model_name, record_model_name):
|
||||
"""Cache rules
|
||||
|
||||
Keep only the id of the rules, because if we keep the recordsets
|
||||
in the ormcache, we won't be able to browse them once their
|
||||
cursor is closed.
|
||||
|
||||
The public method ``get_rules`` return the rules with the recordsets
|
||||
when called.
|
||||
|
||||
"""
|
||||
domain = self._get_rules_search_domain(record_model_name, source_model_name)
|
||||
model_rules = self.sudo().search(
|
||||
domain,
|
||||
# using 'ASC' means that 'NULLS LAST' is the default
|
||||
order="source_model_id ASC",
|
||||
)
|
||||
# model's rules have precedence over global ones so we iterate
|
||||
# over rules which have a source model first, then we complete
|
||||
# them with the global rules
|
||||
result = {}
|
||||
for rule in model_rules:
|
||||
# we already have a model's rule
|
||||
if result.get(rule.field_id.name):
|
||||
continue
|
||||
result[rule.field_id.name] = rule.id
|
||||
return result
|
||||
|
||||
def _get_rules_search_domain(self, record_model_name, source_model_name):
|
||||
return [
|
||||
("model_id.model", "=", record_model_name),
|
||||
"|",
|
||||
("source_model_id.model", "=", source_model_name),
|
||||
("source_model_id", "=", False),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def get_rules(self, source_model_name, record_model_name):
|
||||
"""Return the rules for a model
|
||||
|
||||
When a model is specified, it will return the rules for this
|
||||
model. Fields that have no rule for this model will use the
|
||||
global rules (those without source).
|
||||
|
||||
The source model is the model which ask for a change, it will be
|
||||
for instance ``res.users``, ``lefac.backend`` or
|
||||
``magellan.backend``.
|
||||
|
||||
The second argument (``source_model_name``) is optional but
|
||||
cannot be an optional keyword argument otherwise it would not be
|
||||
in the key for the cache. The callers have to pass ``None`` if
|
||||
they want only global rules.
|
||||
"""
|
||||
rules = {}
|
||||
cached_rules = self._get_rules(source_model_name, record_model_name)
|
||||
for field, rule_id in cached_rules.items():
|
||||
rules[field] = self.browse(rule_id)
|
||||
return rules
|
||||
|
||||
def _evaluate_expression(self, record):
|
||||
"""Evaluate expression if set"""
|
||||
self.ensure_one()
|
||||
return not self.expression or tools.safe_eval.safe_eval(
|
||||
self.expression, {"object": record, "user": self.env.user}
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
record = super().create(vals_list)
|
||||
self.clear_caches()
|
||||
return record
|
||||
|
||||
def write(self, vals):
|
||||
result = super().write(vals)
|
||||
self.clear_caches()
|
||||
return result
|
||||
|
||||
def unlink(self):
|
||||
result = super().unlink()
|
||||
self.clear_caches()
|
||||
return result
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
# Copyright 2015-2017 Camptocamp SA
|
||||
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class RecordChangeset(models.Model):
|
||||
_name = "record.changeset"
|
||||
_description = "Record Changeset"
|
||||
_order = "date desc"
|
||||
_rec_name = "date"
|
||||
|
||||
model = fields.Char(index=True, required=True, readonly=True)
|
||||
res_id = fields.Many2oneReference(
|
||||
string="Record ID",
|
||||
index=True,
|
||||
required=True,
|
||||
readonly=True,
|
||||
model_field="model",
|
||||
)
|
||||
change_ids = fields.One2many(
|
||||
comodel_name="record.changeset.change",
|
||||
inverse_name="changeset_id",
|
||||
string="Changes",
|
||||
readonly=True,
|
||||
)
|
||||
date = fields.Datetime(
|
||||
string="Modified on", default=fields.Datetime.now(), index=True, readonly=True
|
||||
)
|
||||
modified_by_id = fields.Many2one(
|
||||
"res.users", default=lambda self: self.env.user, readonly=True
|
||||
)
|
||||
state = fields.Selection(
|
||||
compute="_compute_state",
|
||||
selection=[("draft", "Pending"), ("done", "Done")],
|
||||
store=True,
|
||||
)
|
||||
note = fields.Text()
|
||||
source = fields.Reference(
|
||||
string="Source of the change", selection="_reference_models", readonly=True
|
||||
)
|
||||
company_id = fields.Many2one("res.company")
|
||||
record_id = fields.Reference(
|
||||
selection="_reference_models", compute="_compute_resource_record", readonly=True
|
||||
)
|
||||
|
||||
@api.depends("model", "res_id")
|
||||
def _compute_resource_record(self):
|
||||
for changeset in self:
|
||||
changeset.record_id = "{},{}".format(changeset.model, changeset.res_id or 0)
|
||||
|
||||
@api.model
|
||||
def _reference_models(self):
|
||||
models = self.env["ir.model"].sudo().search([])
|
||||
return [(model.model, model.name) for model in models]
|
||||
|
||||
@api.depends("change_ids", "change_ids.state")
|
||||
def _compute_state(self):
|
||||
for rec in self:
|
||||
changes = rec.mapped("change_ids")
|
||||
if all(change.state in ("done", "cancel") for change in changes):
|
||||
rec.state = "done"
|
||||
else:
|
||||
rec.state = "draft"
|
||||
|
||||
def name_get(self):
|
||||
result = []
|
||||
for changeset in self:
|
||||
name = "{} ({})".format(changeset.date, changeset.record_id.display_name)
|
||||
result.append((changeset.id, name))
|
||||
return result
|
||||
|
||||
def apply(self):
|
||||
self.with_context(skip_pending_status_check=True).mapped("change_ids").apply()
|
||||
|
||||
def cancel(self):
|
||||
self.with_context(skip_pending_status_check=True).mapped("change_ids").cancel()
|
||||
|
||||
@api.model
|
||||
def add_changeset(self, record, values, create=False):
|
||||
"""Add a changeset on a record
|
||||
|
||||
By default, when a record is modified by a user or by the
|
||||
system, the the changeset will follow the rules configured for
|
||||
the global rules.
|
||||
|
||||
A caller should pass the following keys in the context:
|
||||
|
||||
* ``__changeset_rules_source_model``: name of the model which
|
||||
asks for the change
|
||||
* ``__changeset_rules_source_id``: id of the record which asks
|
||||
for the change
|
||||
|
||||
When the source model and id are not defined, the current user
|
||||
is considered as the origin of the change.
|
||||
|
||||
Should be called before the execution of ``write`` on the record
|
||||
so we can keep track of the existing value and also because the
|
||||
returned values should be used for ``write`` as some of the
|
||||
values may have been removed.
|
||||
|
||||
:param values: the values being written on the record
|
||||
:type values: dict
|
||||
:param create: in create mode, no check is made to see if the field
|
||||
value consitutes a change.
|
||||
:type creatie: boolean
|
||||
|
||||
:returns: dict of values that should be wrote on the record
|
||||
(fields with a 'Validate' or 'Never' rule are excluded)
|
||||
|
||||
"""
|
||||
record.ensure_one()
|
||||
|
||||
source_model = self.env.context.get("__changeset_rules_source_model")
|
||||
source_id = self.env.context.get("__changeset_rules_source_id")
|
||||
if not source_model:
|
||||
# if the changes source is not defined, log the user who
|
||||
# made the change
|
||||
source_model = "res.users"
|
||||
if not source_id:
|
||||
source_id = self.env.uid
|
||||
if source_model and source_id:
|
||||
source = "{},{}".format(source_model, source_id)
|
||||
else:
|
||||
source = False
|
||||
|
||||
change_model = self.env["record.changeset.change"]
|
||||
write_values = values.copy()
|
||||
changes = []
|
||||
rules = self.env["changeset.field.rule"].get_rules(
|
||||
source_model_name=source_model, record_model_name=record._name
|
||||
)
|
||||
for field in values:
|
||||
rule = rules.get(field)
|
||||
if (
|
||||
not rule
|
||||
or not rule._evaluate_expression(record)
|
||||
or (create and not values[field])
|
||||
):
|
||||
continue
|
||||
if field in values:
|
||||
if not create and not change_model._has_field_changed(
|
||||
record, field, values[field]
|
||||
):
|
||||
continue
|
||||
change, pop_value = change_model._prepare_changeset_change(
|
||||
record,
|
||||
rule,
|
||||
field,
|
||||
values[field],
|
||||
create=create,
|
||||
)
|
||||
if pop_value:
|
||||
write_values.pop(field)
|
||||
if create:
|
||||
# overwrite with null value for new records
|
||||
write_values[field] = (
|
||||
# but create some default for required text fields
|
||||
record._fields[field].required
|
||||
and record._fields[field].type in ("char", "text")
|
||||
and "/"
|
||||
or record._fields[field].null(record)
|
||||
)
|
||||
changes.append(change)
|
||||
if changes:
|
||||
changeset_vals = self._prepare_changeset_vals(changes, record, source)
|
||||
self.env["record.changeset"].create(changeset_vals)
|
||||
return write_values
|
||||
|
||||
@api.model
|
||||
def _prepare_changeset_vals(self, changes, record, source):
|
||||
has_company = "company_id" in self.env[record._name]._fields
|
||||
has_company = has_company and record.company_id
|
||||
company = record.company_id if has_company else self.env.company
|
||||
return {
|
||||
# newly created records are passed as newid records with the id in ref
|
||||
"res_id": record.id or record.id.ref,
|
||||
"model": record._name,
|
||||
"company_id": company.id,
|
||||
"change_ids": [(0, 0, vals) for vals in changes],
|
||||
"date": fields.Datetime.now(),
|
||||
"source": source,
|
||||
}
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
# Copyright 2015-2017 Camptocamp SA
|
||||
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from itertools import groupby
|
||||
from operator import attrgetter
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from .base import disable_changeset
|
||||
|
||||
# sentinel object to be sure that no empty value was passed to
|
||||
# RecordChangesetChange._value_for_changeset
|
||||
_NO_VALUE = object()
|
||||
|
||||
|
||||
class RecordChangesetChange(models.Model):
|
||||
"""Store the change of one field for one changeset on one record
|
||||
|
||||
This model is composed of 3 sets of fields:
|
||||
|
||||
* 'origin'
|
||||
* 'old'
|
||||
* 'new'
|
||||
|
||||
The 'new' fields contain the value that needs to be validated.
|
||||
The 'old' field copies the actual value of the record when the
|
||||
change is either applied either canceled. This field is used as a storage
|
||||
place but never shown by itself.
|
||||
The 'origin' fields is a related field towards the actual values of
|
||||
the record until the change is either applied either canceled, past
|
||||
that it shows the 'old' value.
|
||||
The reason behind this is that the values may change on a record between
|
||||
the moment when the changeset is created and when it is applied.
|
||||
|
||||
On the views, we show the origin fields which represent the actual
|
||||
record values or the old values and we show the new fields.
|
||||
|
||||
The 'origin' and 'new_value_display' are displayed on
|
||||
the tree view where we need a unique of field, the other fields are
|
||||
displayed on the form view so we benefit from their widgets.
|
||||
|
||||
"""
|
||||
|
||||
_name = "record.changeset.change"
|
||||
_description = "Record Changeset Change"
|
||||
_rec_name = "field_id"
|
||||
|
||||
changeset_id = fields.Many2one(
|
||||
comodel_name="record.changeset",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
readonly=True,
|
||||
)
|
||||
field_id = fields.Many2one(
|
||||
comodel_name="ir.model.fields",
|
||||
required=True,
|
||||
readonly=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
field_name = fields.Char(related="field_id.name", readonly=True)
|
||||
field_type = fields.Selection(related="field_id.ttype", readonly=True)
|
||||
model = fields.Char(related="field_id.model", readonly=True, store=True)
|
||||
origin_value_display = fields.Char(
|
||||
string="Previous", compute="_compute_value_display"
|
||||
)
|
||||
new_value_display = fields.Char(string="New", compute="_compute_value_display")
|
||||
|
||||
# Fields showing the origin record's value or the 'old' value if
|
||||
# the change is applied or canceled.
|
||||
origin_value_char = fields.Char(compute="_compute_origin_values", readonly=True)
|
||||
origin_value_date = fields.Date(compute="_compute_origin_values", readonly=True)
|
||||
origin_value_datetime = fields.Datetime(
|
||||
compute="_compute_origin_values", readonly=True
|
||||
)
|
||||
origin_value_float = fields.Float(compute="_compute_origin_values", readonly=True)
|
||||
origin_value_monetary = fields.Float(
|
||||
compute="_compute_origin_values", readonly=True
|
||||
)
|
||||
origin_value_integer = fields.Integer(
|
||||
compute="_compute_origin_values", readonly=True
|
||||
)
|
||||
origin_value_text = fields.Text(compute="_compute_origin_values", readonly=True)
|
||||
origin_value_boolean = fields.Boolean(
|
||||
compute="_compute_origin_values", readonly=True
|
||||
)
|
||||
origin_value_reference = fields.Reference(
|
||||
compute="_compute_origin_values", selection="_reference_models", readonly=True
|
||||
)
|
||||
|
||||
# Fields storing the previous record's values (saved when the
|
||||
# changeset is applied)
|
||||
old_value_char = fields.Char(readonly=True)
|
||||
old_value_date = fields.Date(readonly=True)
|
||||
old_value_datetime = fields.Datetime(readonly=True)
|
||||
old_value_float = fields.Float(readonly=True)
|
||||
old_value_monetary = fields.Float(readonly=True)
|
||||
old_value_integer = fields.Integer(readonly=True)
|
||||
old_value_text = fields.Text(readonly=True)
|
||||
old_value_boolean = fields.Boolean(readonly=True)
|
||||
old_value_reference = fields.Reference(selection="_reference_models", readonly=True)
|
||||
|
||||
# Fields storing the value applied on the record
|
||||
new_value_char = fields.Char(readonly=True)
|
||||
new_value_date = fields.Date(readonly=True)
|
||||
new_value_datetime = fields.Datetime(readonly=True)
|
||||
new_value_float = fields.Float(readonly=True)
|
||||
new_value_monetary = fields.Float(readonly=True)
|
||||
new_value_integer = fields.Integer(readonly=True)
|
||||
new_value_text = fields.Text(readonly=True)
|
||||
new_value_boolean = fields.Boolean(readonly=True)
|
||||
new_value_reference = fields.Reference(selection="_reference_models", readonly=True)
|
||||
|
||||
state = fields.Selection(
|
||||
selection=[("draft", "Pending"), ("done", "Approved"), ("cancel", "Rejected")],
|
||||
required=True,
|
||||
default="draft",
|
||||
readonly=True,
|
||||
)
|
||||
record_id = fields.Reference(related="changeset_id.record_id")
|
||||
rule_id = fields.Many2one("changeset.field.rule", readonly=True)
|
||||
user_can_validate_changeset = fields.Boolean(
|
||||
compute="_compute_user_can_validate_changeset"
|
||||
)
|
||||
date = fields.Datetime(related="changeset_id.date")
|
||||
modified_by_id = fields.Many2one(related="changeset_id.modified_by_id")
|
||||
verified_on_date = fields.Datetime(string="Verified on", readonly=True)
|
||||
verified_by_id = fields.Many2one("res.users", readonly=True)
|
||||
|
||||
@api.model
|
||||
def _reference_models(self):
|
||||
"""Get all model names from ir.model.
|
||||
|
||||
Requires sudo, as ir.model is only readable for ERP managers.
|
||||
"""
|
||||
models = self.sudo().env["ir.model"].search([])
|
||||
return [(model.model, model.name) for model in models]
|
||||
|
||||
_suffix_to_types = {
|
||||
"char": ("char", "selection"),
|
||||
"date": ("date",),
|
||||
"datetime": ("datetime",),
|
||||
"float": ("float",),
|
||||
"monetary": ("monetary",),
|
||||
"integer": ("integer",),
|
||||
"text": ("text", "html"),
|
||||
"boolean": ("boolean",),
|
||||
"reference": ("many2one",),
|
||||
}
|
||||
|
||||
_type_to_suffix = {
|
||||
ftype: suffix for suffix, ftypes in _suffix_to_types.items() for ftype in ftypes
|
||||
}
|
||||
|
||||
_origin_value_fields = ["origin_value_%s" % suffix for suffix in _suffix_to_types]
|
||||
_old_value_fields = ["old_value_%s" % suffix for suffix in _suffix_to_types]
|
||||
_new_value_fields = ["new_value_%s" % suffix for suffix in _suffix_to_types]
|
||||
_value_fields = _origin_value_fields + _old_value_fields + _new_value_fields
|
||||
|
||||
@api.depends("changeset_id.res_id", "changeset_id.model")
|
||||
def _compute_origin_values(self):
|
||||
states = self.get_pending_changes_states()
|
||||
field_names = [
|
||||
field_name
|
||||
for field_name in self._fields.keys()
|
||||
if field_name.startswith("origin_value_")
|
||||
and field_name != "origin_value_display"
|
||||
]
|
||||
for rec in self:
|
||||
field_name = rec.get_field_for_type(rec.field_id, "origin")
|
||||
for fname in field_names:
|
||||
if fname == field_name:
|
||||
if rec.state in states:
|
||||
value = rec.record_id[rec.field_id.name]
|
||||
else:
|
||||
old_field = rec.get_field_for_type(rec.field_id, "old")
|
||||
value = rec[old_field]
|
||||
setattr(rec, fname, value)
|
||||
else:
|
||||
setattr(rec, fname, False)
|
||||
|
||||
@api.depends(lambda self: self._value_fields)
|
||||
def _compute_value_display(self):
|
||||
for rec in self:
|
||||
for prefix in ("origin", "new"):
|
||||
value = getattr(rec, "get_%s_value" % prefix)()
|
||||
if rec.field_id.ttype == "many2one" and value:
|
||||
value = value.display_name
|
||||
setattr(rec, "%s_value_display" % prefix, value)
|
||||
|
||||
@api.model
|
||||
def get_field_for_type(self, field, prefix):
|
||||
assert prefix in ("origin", "old", "new")
|
||||
field_type = self._type_to_suffix.get(field.sudo().ttype)
|
||||
if not field_type:
|
||||
raise NotImplementedError("field type %s is not supported" % field_type)
|
||||
return "{}_value_{}".format(prefix, field_type)
|
||||
|
||||
def get_origin_value(self):
|
||||
self.ensure_one()
|
||||
field_name = self.get_field_for_type(self.field_id, "origin")
|
||||
return self[field_name]
|
||||
|
||||
def get_new_value(self):
|
||||
self.ensure_one()
|
||||
field_name = self.get_field_for_type(self.field_id, "new")
|
||||
return self[field_name]
|
||||
|
||||
def set_old_value(self):
|
||||
"""Copy the value of the record to the 'old' field"""
|
||||
for change in self:
|
||||
# copy the existing record's value for the history
|
||||
old_value_for_write = self._value_for_changeset(
|
||||
change.record_id, change.field_id.name
|
||||
)
|
||||
old_field_name = self.get_field_for_type(change.field_id, "old")
|
||||
change.write({old_field_name: old_value_for_write})
|
||||
|
||||
def apply(self):
|
||||
"""Apply the change on the changeset's record
|
||||
|
||||
It is optimized thus that it makes only one write on the record
|
||||
per changeset if many changes are applied at once.
|
||||
"""
|
||||
for change in self:
|
||||
if not change.user_can_validate_changeset:
|
||||
raise UserError(_("You don't have the rights to apply the changes."))
|
||||
changes_ok = self.browse()
|
||||
key = attrgetter("changeset_id")
|
||||
for changeset, changes in groupby(
|
||||
self.with_context(__no_changeset=disable_changeset).sorted(key=key), key=key
|
||||
):
|
||||
values = {}
|
||||
for change in changes:
|
||||
if change.state in ("cancel", "done"):
|
||||
continue
|
||||
|
||||
field = change.field_id
|
||||
new_value = change.get_new_value()
|
||||
value_for_write = change._convert_value_for_write(new_value)
|
||||
values[field.name] = value_for_write
|
||||
|
||||
change.set_old_value()
|
||||
|
||||
changes_ok |= change
|
||||
|
||||
if not values:
|
||||
continue
|
||||
|
||||
self._check_previous_changesets(changeset)
|
||||
|
||||
changeset.record_id.with_context(__no_changeset=disable_changeset).write(
|
||||
values
|
||||
)
|
||||
|
||||
changes_ok._finalize_change_approval()
|
||||
|
||||
def _check_previous_changesets(self, changeset):
|
||||
if self.env.context.get("require_previous_changesets_done"):
|
||||
states = self.get_pending_changes_states()
|
||||
previous_changesets = self.env["record.changeset"].search(
|
||||
[
|
||||
("date", "<", changeset.date),
|
||||
("state", "in", states),
|
||||
("model", "=", changeset.model),
|
||||
("res_id", "=", changeset.res_id),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
if previous_changesets:
|
||||
raise UserError(
|
||||
_(
|
||||
"This change cannot be applied because a previous "
|
||||
"changeset for the same record is pending.\n"
|
||||
"Apply all the anterior changesets before applying "
|
||||
"this one."
|
||||
)
|
||||
)
|
||||
|
||||
def cancel(self):
|
||||
"""Reject the change"""
|
||||
for change in self:
|
||||
if not change.user_can_validate_changeset:
|
||||
raise UserError(_("You don't have the rights to reject the changes."))
|
||||
if any(change.state == "done" for change in self):
|
||||
raise UserError(_("This change has already be applied."))
|
||||
self.set_old_value()
|
||||
self._finalize_change_rejection()
|
||||
|
||||
def _finalize_change_approval(self):
|
||||
self.write(
|
||||
{
|
||||
"state": "done",
|
||||
"verified_by_id": self.env.user.id,
|
||||
"verified_on_date": fields.Datetime.now(),
|
||||
}
|
||||
)
|
||||
|
||||
def _finalize_change_rejection(self):
|
||||
self.write(
|
||||
{
|
||||
"state": "cancel",
|
||||
"verified_by_id": self.env.user.id,
|
||||
"verified_on_date": fields.Datetime.now(),
|
||||
}
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _has_field_changed(self, record, field, value):
|
||||
field_def = record._fields[field]
|
||||
current_value = field_def.convert_to_write(record[field], record)
|
||||
if not (current_value or value):
|
||||
return False
|
||||
return current_value != value
|
||||
|
||||
def _convert_value_for_write(self, value):
|
||||
if not value:
|
||||
return value
|
||||
model = self.env[self.field_id.model_id.model]
|
||||
model_field_def = model._fields[self.field_id.name]
|
||||
return model_field_def.convert_to_write(value, self.record_id)
|
||||
|
||||
@api.model
|
||||
def _value_for_changeset(self, record, field_name, value=_NO_VALUE):
|
||||
"""Return a value from the record ready to write in a changeset field
|
||||
|
||||
:param record: modified record
|
||||
:param field_name: name of the modified field
|
||||
:param value: if no value is given, it is read from the record
|
||||
"""
|
||||
field_def = record._fields[field_name]
|
||||
if value is _NO_VALUE:
|
||||
# when the value is read from the record, we need to prepare
|
||||
# it for the write (e.g. extract .id from a many2one record)
|
||||
value = field_def.convert_to_write(record[field_name], record)
|
||||
if field_def.type == "many2one":
|
||||
# store as 'reference'
|
||||
comodel = field_def.comodel_name
|
||||
return "{},{}".format(comodel, value) if value else False
|
||||
else:
|
||||
return value
|
||||
|
||||
@api.model
|
||||
def _prepare_changeset_change(self, record, rule, field_name, value, create=False):
|
||||
"""Prepare data for a changeset change
|
||||
|
||||
It returns a dict of the values to write on the changeset change
|
||||
and a boolean that indicates if the value should be popped out
|
||||
of the values to write on the model.
|
||||
|
||||
:returns: dict of values, boolean
|
||||
"""
|
||||
field = rule.sudo().field_id
|
||||
new_field_name = self.get_field_for_type(field, "new")
|
||||
new_value = self._value_for_changeset(record, field_name, value=value)
|
||||
change = {
|
||||
new_field_name: new_value,
|
||||
"field_id": field.id,
|
||||
"rule_id": rule.id,
|
||||
}
|
||||
if rule.action == "auto":
|
||||
change["state"] = "done"
|
||||
pop_value = False
|
||||
elif rule.action == "validate":
|
||||
change["state"] = "draft"
|
||||
pop_value = True # change to apply manually
|
||||
elif rule.action == "never":
|
||||
change["state"] = "cancel"
|
||||
pop_value = True # change never applied
|
||||
|
||||
if create or change["state"] in ("cancel", "done"):
|
||||
# Normally the 'old' value is set when we use the 'apply'
|
||||
# button, but since we short circuit the 'apply', we
|
||||
# directly set the 'old' value here
|
||||
old_field_name = self.get_field_for_type(field, "old")
|
||||
# get values ready to write as expected by the changeset
|
||||
# (for instance, a many2one is written in a reference
|
||||
# field)
|
||||
origin_value = self._value_for_changeset(
|
||||
record, field_name, value=False if create else _NO_VALUE
|
||||
)
|
||||
change[old_field_name] = origin_value
|
||||
|
||||
return change, pop_value
|
||||
|
||||
@api.model
|
||||
def get_changeset_changes_by_field(self, model, res_id):
|
||||
"""Return changes grouped by field.
|
||||
|
||||
:returns: dictionary with field names as keys and lists of dictionaries
|
||||
describing changes as keys.
|
||||
:rtype: dict
|
||||
"""
|
||||
fields = [
|
||||
"new_value_display",
|
||||
"origin_value_display",
|
||||
"field_name",
|
||||
"user_can_validate_changeset",
|
||||
]
|
||||
states = self.get_pending_changes_states()
|
||||
domain = [
|
||||
("changeset_id.model", "=", model),
|
||||
("changeset_id.res_id", "=", res_id),
|
||||
("state", "in", states),
|
||||
]
|
||||
return {
|
||||
field_name: list(changes)
|
||||
for (field_name, changes) in groupby(
|
||||
self.search_read(domain, fields), lambda vals: vals["field_name"]
|
||||
)
|
||||
}
|
||||
|
||||
@api.depends_context("user")
|
||||
def _compute_user_can_validate_changeset(self):
|
||||
is_superuser = self.env.is_superuser()
|
||||
has_group = self.user_has_groups("base_changeset.group_changeset_user")
|
||||
user_groups = self.env.user.groups_id
|
||||
for rec in self:
|
||||
can_validate = rec._is_change_pending() and (
|
||||
is_superuser
|
||||
or rec.rule_id.validator_group_ids & user_groups
|
||||
or has_group
|
||||
)
|
||||
if rec.rule_id.prevent_self_validation:
|
||||
can_validate = can_validate and rec.modified_by_id != self.env.user
|
||||
rec.user_can_validate_changeset = can_validate
|
||||
|
||||
@api.model
|
||||
def get_pending_changes_states(self):
|
||||
return ["draft"]
|
||||
|
||||
def _is_change_pending(self):
|
||||
self.ensure_one()
|
||||
skip_status_check = self.env.context.get("skip_pending_status_check")
|
||||
return skip_status_check or self.state in self.get_pending_changes_states()
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
Access Rights
|
||||
-------------
|
||||
|
||||
The changesets rules must be edited by users with the group ``Changesets
|
||||
Configuration``. The changesets can be applied or canceled only by users
|
||||
with the group ``Changesets Validations``
|
||||
|
||||
Changesets Rules
|
||||
----------------
|
||||
|
||||
The changesets rules can be configured in ``Configuration >
|
||||
Record Changesets > Fields Rules``.
|
||||
|
||||
* Configuration of rules
|
||||
|
||||
.. image:: ../static/src/img/rules.png
|
||||
|
||||
For each record field, an action can be defined:
|
||||
|
||||
* Auto: the changes made on this field are always applied
|
||||
* Validate: the changes made on this field must be manually confirmed by
|
||||
a 'Changesets User' user
|
||||
* Never: the changes made on this field are always refused
|
||||
|
||||
In any case, all the changes made by the users are always applied
|
||||
directly on the users, but a 'validated' changeset is created for the
|
||||
history.
|
||||
|
||||
The supported fields are:
|
||||
|
||||
* Char
|
||||
* Text
|
||||
* Date
|
||||
* Datetime
|
||||
* Integer
|
||||
* Float
|
||||
* Monetary
|
||||
* Boolean
|
||||
* Many2one
|
||||
|
||||
Rules can be global (no source model) or configured by source model.
|
||||
Rules by source model have the priority. If a field is not configured
|
||||
for the source model, it will use the global rule (if existing).
|
||||
|
||||
If a field has no rule, it is written to the record without changeset.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
|
||||
* Denis Leemann <denis.leemann@camptocamp.com>
|
||||
* Yannick Vaucher <yannick.vaucher@camptocamp.com>
|
||||
* Dennis Sluijk <d.sluijk@onestein.nl>
|
||||
* Andrea Stirpe <a.stirpe@onestein.nl>
|
||||
* Holger Brunn <mail@hunki-enterprises.com>
|
||||
* Mark Schuit <mark@gig.solutions>
|
||||
* Stefan Rijnhart <stefan@opener.amsterdam>
|
||||
|
|
@ -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,9 @@
|
|||
* Only a subset of the type of fields is actually supported
|
||||
* Multicompany not fully supported
|
||||
* The popover widget indicating the number of pending changes is not shown for
|
||||
fields without a label at the moment. The approach was already failing in 15.0
|
||||
(in the case of inline fields such as the partner address fields)
|
||||
and even in 14.0 (in the case of fields for which no value was set yet).
|
||||
Or, for a more flexible approach, implement a kind of view preprocessing that
|
||||
allows a developer to indicate where the widget needs to go (analogous to
|
||||
`<label for="field_name" />`).
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
Changeset rules
|
||||
---------------
|
||||
|
||||
The first step is to configure the changeset rules. Once that done, writes on
|
||||
records will be created as changesets.
|
||||
|
||||
Handling changesets
|
||||
-------------------
|
||||
|
||||
The list of all the changesets is in ``Configuration > Record
|
||||
Changesets > Changesets``.
|
||||
|
||||
By default, only the pending changesets (waiting for validation) are shown.
|
||||
Remove the "Pending" filter to show all the changesets.
|
||||
|
||||
* Changeset waiting for validation
|
||||
|
||||
.. image:: ../static/src/img/changeset.png
|
||||
|
||||
The changes view shows the name of the record's field, the Origin value
|
||||
and the New value alongside the state of the change. By clicking on the
|
||||
change in some cases a more detailed view is displayed, for instance,
|
||||
links for relations can be clicked on.
|
||||
|
||||
A button on a changeset allows to apply or reject all the changes at
|
||||
once.
|
||||
|
||||
Handling single changes
|
||||
-----------------------
|
||||
|
||||
Accessing the changesets gives the full overview of all the changes made.
|
||||
However, it is more convenient to access the single changes directly from the
|
||||
records. When there is a pending change for a field you get a badge with the
|
||||
number of pending changes next to it like this:
|
||||
|
||||
* Badge with the number of pending changes
|
||||
|
||||
.. image:: ../static/src/img/badge.png
|
||||
|
||||
When you click on it:
|
||||
|
||||
* Clicking the badge: red button to reject, green one to apply
|
||||
|
||||
.. image:: ../static/src/img/badge_click.png
|
||||
|
||||
Click the red button to reject the change, click the green one to apply it.
|
||||
|
||||
|
||||
Custom source rules in your addon
|
||||
---------------------------------
|
||||
|
||||
Addons wanting to create changeset with their own rules should pass the
|
||||
following keys in the context when they write on the record:
|
||||
|
||||
* ``__changeset_rules_source_model``: name of the model which asks for
|
||||
the change
|
||||
* ``__changeset_rules_source_id``: id of the record which asks for the
|
||||
change
|
||||
|
||||
Also, they should extend the selection in
|
||||
``ChangesetFieldRule._domain_source_models`` to add their model (the
|
||||
same that is passed in ``__changeset_rules_source_model``).
|
||||
|
||||
The source is used for the application of the rules, allowing to have a
|
||||
different rule for a different source. It is also stored on the changeset for
|
||||
information.
|
||||
|
||||
Notes on security
|
||||
-----------------
|
||||
|
||||
Note that by default, changeset users see all changes on all configured
|
||||
rules. This circumvents read restrictions on the original records, so if you
|
||||
have restrictions on models with changeset rules, changeset users will still
|
||||
see all changes of all records, and applying a change on an inaccessible record
|
||||
will fail.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="group_changeset_manager" model="res.groups">
|
||||
<field name="name">Changeset Configuration</field>
|
||||
<field
|
||||
name="comment"
|
||||
>The user will have an access to the configuration of the changeset rules.</field>
|
||||
</record>
|
||||
<record id="group_changeset_user" model="res.groups">
|
||||
<field name="name">Changeset Validations</field>
|
||||
<field
|
||||
name="comment"
|
||||
>The user will be able to apply or reject changes.</field>
|
||||
</record>
|
||||
</data>
|
||||
<data noupdate="1">
|
||||
<record id="group_changeset_manager" model="res.groups">
|
||||
<field
|
||||
name="users"
|
||||
eval="[Command.link(ref('base.user_root')), Command.link(ref('base.user_admin'))]"
|
||||
/>
|
||||
<field
|
||||
name="implied_ids"
|
||||
eval="[Command.link(ref('group_changeset_user'))]"
|
||||
/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_record_changeset,access_record_changeset,model_record_changeset,base.group_user,1,1,1,1
|
||||
access_record_changeset_change,access_record_changeset_change,model_record_changeset_change,base.group_user,1,1,1,1
|
||||
access_changeset_field_rule,access_changeset_field_rule,model_changeset_field_rule,base.group_user,1,1,1,1
|
||||
access_view_record_changeset_user,changeset for changeset users,model_record_changeset,group_changeset_user,1,1,1,0
|
||||
access_view_record_changeset_change_user,changeset change for changeset users,model_record_changeset_change,group_changeset_user,1,1,1,0
|
||||
access_view_record_changeset_manager,changeset for changeset managers,model_record_changeset,group_changeset_manager,1,1,1,1
|
||||
access_view_record_changeset_change_manager,changeset change for changeset managers,model_record_changeset_change,group_changeset_manager,1,1,1,1
|
||||
access_ir_model_fields_changeset,ir_model_fields changeset,base.model_ir_model_fields,group_changeset_user,1,0,0,0
|
||||
access_ir_model_changeset,ir_model_changeset,base.model_ir_model,group_changeset_user,1,0,0,0
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="changeset_field_rule_rule" model="ir.rule">
|
||||
<field name="name">Changeset Field Rules</field>
|
||||
<field name="model_id" ref="model_changeset_field_rule" />
|
||||
<field name="global" eval="True" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
<record id="record_changeset_rule" model="ir.rule">
|
||||
<field name="name">Record Changeset</field>
|
||||
<field name="model_id" ref="model_record_changeset" />
|
||||
<field name="global" eval="True" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
<record id="rule_record_changeset_change_base_user" model="ir.rule">
|
||||
<field name="name">Restrict changeset change access for users</field>
|
||||
<field name="model_id" ref="model_record_changeset_change" />
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
<record id="rule_record_changeset_change_user" model="ir.rule">
|
||||
<field name="name">Allow changeset change access for changeset users</field>
|
||||
<field name="model_id" ref="model_record_changeset_change" />
|
||||
<field name="groups" eval="[(4, ref('group_changeset_user'))]" />
|
||||
<field
|
||||
name="domain_force"
|
||||
>[('rule_id.validator_group_ids', 'in', user.groups_id.ids)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
|
|
@ -0,0 +1,569 @@
|
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
|
||||
<title>Track record changesets</title>
|
||||
<style type="text/css">
|
||||
|
||||
/*
|
||||
:Author: David Goodger (goodger@python.org)
|
||||
:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $
|
||||
:Copyright: This stylesheet has been placed in the public domain.
|
||||
|
||||
Default cascading style sheet for the HTML output of Docutils.
|
||||
|
||||
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
|
||||
customize this style sheet.
|
||||
*/
|
||||
|
||||
/* used to remove borders from tables and images */
|
||||
.borderless, table.borderless td, table.borderless th {
|
||||
border: 0 }
|
||||
|
||||
table.borderless td, table.borderless th {
|
||||
/* Override padding for "table.docutils td" with "! important".
|
||||
The right padding separates the table cells. */
|
||||
padding: 0 0.5em 0 0 ! important }
|
||||
|
||||
.first {
|
||||
/* Override more specific margin styles with "! important". */
|
||||
margin-top: 0 ! important }
|
||||
|
||||
.last, .with-subtitle {
|
||||
margin-bottom: 0 ! important }
|
||||
|
||||
.hidden {
|
||||
display: none }
|
||||
|
||||
.subscript {
|
||||
vertical-align: sub;
|
||||
font-size: smaller }
|
||||
|
||||
.superscript {
|
||||
vertical-align: super;
|
||||
font-size: smaller }
|
||||
|
||||
a.toc-backref {
|
||||
text-decoration: none ;
|
||||
color: black }
|
||||
|
||||
blockquote.epigraph {
|
||||
margin: 2em 5em ; }
|
||||
|
||||
dl.docutils dd {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Uncomment (and remove this text!) to get bold-faced definition list terms
|
||||
dl.docutils dt {
|
||||
font-weight: bold }
|
||||
*/
|
||||
|
||||
div.abstract {
|
||||
margin: 2em 5em }
|
||||
|
||||
div.abstract p.topic-title {
|
||||
font-weight: bold ;
|
||||
text-align: center }
|
||||
|
||||
div.admonition, div.attention, div.caution, div.danger, div.error,
|
||||
div.hint, div.important, div.note, div.tip, div.warning {
|
||||
margin: 2em ;
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.admonition p.admonition-title, div.hint p.admonition-title,
|
||||
div.important p.admonition-title, div.note p.admonition-title,
|
||||
div.tip p.admonition-title {
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
div.attention p.admonition-title, div.caution p.admonition-title,
|
||||
div.danger p.admonition-title, div.error p.admonition-title,
|
||||
div.warning p.admonition-title, .code .error {
|
||||
color: red ;
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
/* Uncomment (and remove this text!) to get reduced vertical space in
|
||||
compound paragraphs.
|
||||
div.compound .compound-first, div.compound .compound-middle {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
div.compound .compound-last, div.compound .compound-middle {
|
||||
margin-top: 0.5em }
|
||||
*/
|
||||
|
||||
div.dedication {
|
||||
margin: 2em 5em ;
|
||||
text-align: center ;
|
||||
font-style: italic }
|
||||
|
||||
div.dedication p.topic-title {
|
||||
font-weight: bold ;
|
||||
font-style: normal }
|
||||
|
||||
div.figure {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
div.footer, div.header {
|
||||
clear: both;
|
||||
font-size: smaller }
|
||||
|
||||
div.line-block {
|
||||
display: block ;
|
||||
margin-top: 1em ;
|
||||
margin-bottom: 1em }
|
||||
|
||||
div.line-block div.line-block {
|
||||
margin-top: 0 ;
|
||||
margin-bottom: 0 ;
|
||||
margin-left: 1.5em }
|
||||
|
||||
div.sidebar {
|
||||
margin: 0 0 0.5em 1em ;
|
||||
border: medium outset ;
|
||||
padding: 1em ;
|
||||
background-color: #ffffee ;
|
||||
width: 40% ;
|
||||
float: right ;
|
||||
clear: right }
|
||||
|
||||
div.sidebar p.rubric {
|
||||
font-family: sans-serif ;
|
||||
font-size: medium }
|
||||
|
||||
div.system-messages {
|
||||
margin: 5em }
|
||||
|
||||
div.system-messages h1 {
|
||||
color: red }
|
||||
|
||||
div.system-message {
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.system-message p.system-message-title {
|
||||
color: red ;
|
||||
font-weight: bold }
|
||||
|
||||
div.topic {
|
||||
margin: 2em }
|
||||
|
||||
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
|
||||
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
|
||||
margin-top: 0.4em }
|
||||
|
||||
h1.title {
|
||||
text-align: center }
|
||||
|
||||
h2.subtitle {
|
||||
text-align: center }
|
||||
|
||||
hr.docutils {
|
||||
width: 75% }
|
||||
|
||||
img.align-left, .figure.align-left, object.align-left, table.align-left {
|
||||
clear: left ;
|
||||
float: left ;
|
||||
margin-right: 1em }
|
||||
|
||||
img.align-right, .figure.align-right, object.align-right, table.align-right {
|
||||
clear: right ;
|
||||
float: right ;
|
||||
margin-left: 1em }
|
||||
|
||||
img.align-center, .figure.align-center, object.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
table.align-center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left }
|
||||
|
||||
.align-center {
|
||||
clear: both ;
|
||||
text-align: center }
|
||||
|
||||
.align-right {
|
||||
text-align: right }
|
||||
|
||||
/* reset inner alignment in figures */
|
||||
div.align-right {
|
||||
text-align: inherit }
|
||||
|
||||
/* div.align-center * { */
|
||||
/* text-align: left } */
|
||||
|
||||
.align-top {
|
||||
vertical-align: top }
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle }
|
||||
|
||||
.align-bottom {
|
||||
vertical-align: bottom }
|
||||
|
||||
ol.simple, ul.simple {
|
||||
margin-bottom: 1em }
|
||||
|
||||
ol.arabic {
|
||||
list-style: decimal }
|
||||
|
||||
ol.loweralpha {
|
||||
list-style: lower-alpha }
|
||||
|
||||
ol.upperalpha {
|
||||
list-style: upper-alpha }
|
||||
|
||||
ol.lowerroman {
|
||||
list-style: lower-roman }
|
||||
|
||||
ol.upperroman {
|
||||
list-style: upper-roman }
|
||||
|
||||
p.attribution {
|
||||
text-align: right ;
|
||||
margin-left: 50% }
|
||||
|
||||
p.caption {
|
||||
font-style: italic }
|
||||
|
||||
p.credits {
|
||||
font-style: italic ;
|
||||
font-size: smaller }
|
||||
|
||||
p.label {
|
||||
white-space: nowrap }
|
||||
|
||||
p.rubric {
|
||||
font-weight: bold ;
|
||||
font-size: larger ;
|
||||
color: maroon ;
|
||||
text-align: center }
|
||||
|
||||
p.sidebar-title {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold ;
|
||||
font-size: larger }
|
||||
|
||||
p.sidebar-subtitle {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
p.topic-title {
|
||||
font-weight: bold }
|
||||
|
||||
pre.address {
|
||||
margin-bottom: 0 ;
|
||||
margin-top: 0 ;
|
||||
font: inherit }
|
||||
|
||||
pre.literal-block, pre.doctest-block, pre.math, pre.code {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
pre.code .ln { color: grey; } /* line numbers */
|
||||
pre.code, code { background-color: #eeeeee }
|
||||
pre.code .comment, code .comment { color: #5C6576 }
|
||||
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
|
||||
pre.code .literal.string, code .literal.string { color: #0C5404 }
|
||||
pre.code .name.builtin, code .name.builtin { color: #352B84 }
|
||||
pre.code .deleted, code .deleted { background-color: #DEB0A1}
|
||||
pre.code .inserted, code .inserted { background-color: #A3D289}
|
||||
|
||||
span.classifier {
|
||||
font-family: sans-serif ;
|
||||
font-style: oblique }
|
||||
|
||||
span.classifier-delimiter {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
span.interpreted {
|
||||
font-family: sans-serif }
|
||||
|
||||
span.option {
|
||||
white-space: nowrap }
|
||||
|
||||
span.pre {
|
||||
white-space: pre }
|
||||
|
||||
span.problematic {
|
||||
color: red }
|
||||
|
||||
span.section-subtitle {
|
||||
/* font-size relative to parent (h1..h6 element) */
|
||||
font-size: 80% }
|
||||
|
||||
table.citation {
|
||||
border-left: solid 1px gray;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docinfo {
|
||||
margin: 2em 4em }
|
||||
|
||||
table.docutils {
|
||||
margin-top: 0.5em ;
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
table.footnote {
|
||||
border-left: solid 1px black;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docutils td, table.docutils th,
|
||||
table.docinfo td, table.docinfo th {
|
||||
padding-left: 0.5em ;
|
||||
padding-right: 0.5em ;
|
||||
vertical-align: top }
|
||||
|
||||
table.docutils th.field-name, table.docinfo th.docinfo-name {
|
||||
font-weight: bold ;
|
||||
text-align: left ;
|
||||
white-space: nowrap ;
|
||||
padding-left: 0 }
|
||||
|
||||
/* "booktabs" style (no vertical lines) */
|
||||
table.docutils.booktabs {
|
||||
border: 0px;
|
||||
border-top: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.docutils.booktabs * {
|
||||
border: 0px;
|
||||
}
|
||||
table.docutils.booktabs th {
|
||||
border-bottom: thin solid;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
|
||||
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
|
||||
font-size: 100% }
|
||||
|
||||
ul.auto-toc {
|
||||
list-style-type: none }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document" id="track-record-changesets">
|
||||
<h1 class="title">Track record changesets</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:a0553f512eea126df69773c8a4597af8d63d3427fa46a6bee3090761e8f7b869
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Alpha" src="https://img.shields.io/badge/maturity-Alpha-red.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/server-tools/tree/16.0/base_changeset"><img alt="OCA/server-tools" src="https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-base_changeset"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
|
||||
<p>This module extends the functionality of records. It allows to create
|
||||
changesets that must be validated when a record is modified instead of direct
|
||||
modifications. Rules allow to configure which field must be validated.</p>
|
||||
<div class="section" id="what-is-a-changeset">
|
||||
<h1>What is a changeset</h1>
|
||||
<p>A changeset is a list of changes made on a record.</p>
|
||||
<p>Some of the changes may be ‘Pending’, some ‘Accepted’ or ‘Rejected’ according
|
||||
to the changeset rules. The ‘Pending’ changes require an interaction by the
|
||||
approver user: only when that change is approved, its value is written on
|
||||
the record.</p>
|
||||
<div class="admonition important">
|
||||
<p class="first admonition-title">Important</p>
|
||||
<p class="last">This is an alpha version, the data model and design can change at any time without warning.
|
||||
Only for development or testing purpose, do not use in production.
|
||||
<a class="reference external" href="https://odoo-community.org/page/development-status">More details on development status</a></p>
|
||||
</div>
|
||||
<p><strong>Table of contents</strong></p>
|
||||
<div class="contents local topic" id="contents">
|
||||
<ul class="simple">
|
||||
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="configuration">
|
||||
<h2><a class="toc-backref" href="#toc-entry-1">Configuration</a></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="access-rights">
|
||||
<h1>Access Rights</h1>
|
||||
<p>The changesets rules must be edited by users with the group <tt class="docutils literal">Changesets
|
||||
Configuration</tt>. The changesets can be applied or canceled only by users
|
||||
with the group <tt class="docutils literal">Changesets Validations</tt></p>
|
||||
</div>
|
||||
<div class="section" id="changesets-rules">
|
||||
<h1>Changesets Rules</h1>
|
||||
<p>The changesets rules can be configured in <tt class="docutils literal">Configuration >
|
||||
Record Changesets > Fields Rules</tt>.</p>
|
||||
<ul>
|
||||
<li><p class="first">Configuration of rules</p>
|
||||
<img alt="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/rules.png" src="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/rules.png" />
|
||||
</li>
|
||||
</ul>
|
||||
<p>For each record field, an action can be defined:</p>
|
||||
<ul class="simple">
|
||||
<li>Auto: the changes made on this field are always applied</li>
|
||||
<li>Validate: the changes made on this field must be manually confirmed by
|
||||
a ‘Changesets User’ user</li>
|
||||
<li>Never: the changes made on this field are always refused</li>
|
||||
</ul>
|
||||
<p>In any case, all the changes made by the users are always applied
|
||||
directly on the users, but a ‘validated’ changeset is created for the
|
||||
history.</p>
|
||||
<p>The supported fields are:</p>
|
||||
<ul class="simple">
|
||||
<li>Char</li>
|
||||
<li>Text</li>
|
||||
<li>Date</li>
|
||||
<li>Datetime</li>
|
||||
<li>Integer</li>
|
||||
<li>Float</li>
|
||||
<li>Monetary</li>
|
||||
<li>Boolean</li>
|
||||
<li>Many2one</li>
|
||||
</ul>
|
||||
<p>Rules can be global (no source model) or configured by source model.
|
||||
Rules by source model have the priority. If a field is not configured
|
||||
for the source model, it will use the global rule (if existing).</p>
|
||||
<p>If a field has no rule, it is written to the record without changeset.</p>
|
||||
<div class="section" id="usage">
|
||||
<h2>Usage</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="changeset-rules">
|
||||
<h1>Changeset rules</h1>
|
||||
<p>The first step is to configure the changeset rules. Once that done, writes on
|
||||
records will be created as changesets.</p>
|
||||
</div>
|
||||
<div class="section" id="handling-changesets">
|
||||
<h1>Handling changesets</h1>
|
||||
<p>The list of all the changesets is in <tt class="docutils literal">Configuration > 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/16.0/base_changeset/static/src/img/changeset.png" src="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/changeset.png" />
|
||||
</li>
|
||||
</ul>
|
||||
<p>The changes view shows the name of the 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/16.0/base_changeset/static/src/img/badge.png" src="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge.png" />
|
||||
</li>
|
||||
</ul>
|
||||
<p>When you click on it:</p>
|
||||
<ul>
|
||||
<li><p class="first">Clicking the badge: red button to reject, green one to apply</p>
|
||||
<img alt="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge_click.png" src="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/badge_click.png" />
|
||||
</li>
|
||||
</ul>
|
||||
<p>Click the red button to reject the change, click the green one to apply it.</p>
|
||||
</div>
|
||||
<div class="section" id="custom-source-rules-in-your-addon">
|
||||
<h1>Custom source rules in your addon</h1>
|
||||
<p>Addons wanting to create changeset with their own rules should pass the
|
||||
following keys in the context when they write on the record:</p>
|
||||
<ul class="simple">
|
||||
<li><tt class="docutils literal">__changeset_rules_source_model</tt>: name of the model which asks for
|
||||
the change</li>
|
||||
<li><tt class="docutils literal">__changeset_rules_source_id</tt>: id of the record which asks for the
|
||||
change</li>
|
||||
</ul>
|
||||
<p>Also, they should extend the selection in
|
||||
<tt class="docutils literal">ChangesetFieldRule._domain_source_models</tt> to add their model (the
|
||||
same that is passed in <tt class="docutils literal">__changeset_rules_source_model</tt>).</p>
|
||||
<p>The source is used for the application of the rules, allowing to have a
|
||||
different rule for a different source. It is also stored on the changeset for
|
||||
information.</p>
|
||||
</div>
|
||||
<div class="section" id="notes-on-security">
|
||||
<h1>Notes on security</h1>
|
||||
<p>Note that by default, changeset users see all changes on all configured
|
||||
rules. This circumvents read restrictions on the original records, so if you
|
||||
have restrictions on models with changeset rules, changeset users will still
|
||||
see all changes of all records, and applying a change on an inaccessible record
|
||||
will fail.</p>
|
||||
<div class="section" id="known-issues-roadmap">
|
||||
<h2>Known issues / Roadmap</h2>
|
||||
<ul class="simple">
|
||||
<li>Only a subset of the type of fields is actually supported</li>
|
||||
<li>Multicompany not fully supported</li>
|
||||
<li>The popover widget indicating the number of pending changes is not shown for
|
||||
fields without a label at the moment. The approach was already failing in 15.0
|
||||
(in the case of inline fields such as the partner address fields)
|
||||
and even in 14.0 (in the case of fields for which no value was set yet).
|
||||
Or, for a more flexible approach, implement a kind of view preprocessing that
|
||||
allows a developer to indicate where the widget needs to go (analogous to
|
||||
<cite><label for=”field_name” /></cite>).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h2>Bug Tracker</h2>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/server-tools/issues">GitHub Issues</a>.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us to smash it by providing a detailed and welcomed
|
||||
<a class="reference external" href="https://github.com/OCA/server-tools/issues/new?body=module:%20base_changeset%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
|
||||
<p>Do not contact contributors directly about support or help with technical issues.</p>
|
||||
</div>
|
||||
<div class="section" id="credits">
|
||||
<h2>Credits</h2>
|
||||
<div class="section" id="authors">
|
||||
<h3>Authors</h3>
|
||||
<ul class="simple">
|
||||
<li>Onestein</li>
|
||||
<li>Camptocamp</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="contributors">
|
||||
<h3>Contributors</h3>
|
||||
<ul class="simple">
|
||||
<li>Guewen Baconnier <<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>
|
||||
<li>Holger Brunn <<a class="reference external" href="mailto:mail@hunki-enterprises.com">mail@hunki-enterprises.com</a>></li>
|
||||
<li>Mark Schuit <<a class="reference external" href="mailto:mark@gig.solutions">mark@gig.solutions</a>></li>
|
||||
<li>Stefan Rijnhart <<a class="reference external" href="mailto:stefan@opener.amsterdam">stefan@opener.amsterdam</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 image-reference" href="https://github.com/astirpe"><img alt="astirpe" src="https://github.com/astirpe.png?size=40px" /></a></p>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/server-tools/tree/16.0/base_changeset">OCA/server-tools</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import {Component} from "@odoo/owl";
|
||||
import {FormLabel} from "@web/views/form/form_label";
|
||||
import Popover from "web.Popover";
|
||||
|
||||
export class BaseChangesetPopover extends Popover {
|
||||
/*
|
||||
Call the ORM to accept the change and refresh the form view
|
||||
to update the field value.
|
||||
*/
|
||||
async applyChange(change_id) {
|
||||
await this.props.record.model.orm.call(
|
||||
"record.changeset.change",
|
||||
"apply",
|
||||
[[change_id]],
|
||||
{
|
||||
context: {set_change_by_ui: true},
|
||||
}
|
||||
);
|
||||
this._close();
|
||||
// Save the record first to prevent losing unsaved data on load.
|
||||
await this.props.record.save();
|
||||
await this.props.record.load();
|
||||
await this.props.record.model.notify();
|
||||
}
|
||||
/*
|
||||
Call the ORM to reject the change and only update the record's pending changes.
|
||||
*/
|
||||
async rejectChange(change_id) {
|
||||
await this.props.record.model.orm.call(
|
||||
"record.changeset.change",
|
||||
"cancel",
|
||||
[[change_id]],
|
||||
{
|
||||
context: {set_change_by_ui: true},
|
||||
}
|
||||
);
|
||||
this._close();
|
||||
this.props.record.changesetChanges =
|
||||
await this.props.record.fetchChangesetChanges();
|
||||
this.props.record.model.notify();
|
||||
}
|
||||
}
|
||||
BaseChangesetPopover.template = "base_changeset.ChangesetPopover";
|
||||
BaseChangesetPopover.props = ["fieldName", "popoverClass", "record", "title"];
|
||||
|
||||
export class BaseChangesetPopoverWrapper extends Component {}
|
||||
BaseChangesetPopoverWrapper.components = {BaseChangesetPopover};
|
||||
BaseChangesetPopoverWrapper.template = "base_changeset.ChangesetPopoverWrapper";
|
||||
|
||||
FormLabel.components = FormLabel.components || {};
|
||||
Object.assign(FormLabel.components, {BaseChangesetPopoverWrapper});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.o_changeset_popover {
|
||||
background-color: $o-view-background-color;
|
||||
}
|
||||
span.o_changeset_popover_wrapper > div {
|
||||
display: inline;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="base_changeset.ChangesetPopoverWrapper" owl="1">
|
||||
<!--
|
||||
The popover button has to be set to inline display using a wrapper around
|
||||
the inherited Popover template. Otherwise, if instead we modify the top
|
||||
level div element of the Popover template, the component loses its `el`.
|
||||
-->
|
||||
<span class="o_changeset_popover_wrapper">
|
||||
<BaseChangesetPopover
|
||||
record="props.record"
|
||||
fieldName="props.fieldName"
|
||||
title="'Pending Changes'"
|
||||
popoverClass="'o_changeset_popover'"
|
||||
t-if="props.record.changesetChanges ? props.record.changesetChanges[props.fieldName] : 0"
|
||||
/>
|
||||
</span>
|
||||
</t>
|
||||
<t
|
||||
t-name="base_changeset.ChangesetPopover"
|
||||
owl="1"
|
||||
t-inherit="web.Popover"
|
||||
t-inherit-mode="primary"
|
||||
>
|
||||
<t t-portal="'body'" position="before">
|
||||
<a
|
||||
class="o_ChangesetPopoverView badge rounded-pill text-bg-warning mx-3 align-self-center"
|
||||
t-esc="props.record.changesetChanges[props.fieldName].length"
|
||||
role="button"
|
||||
/>
|
||||
</t>
|
||||
<t t-slot="opened" position="replace">
|
||||
<table class="pb-4">
|
||||
<tr
|
||||
t-foreach="props.record.changesetChanges[props.fieldName]"
|
||||
t-as="change"
|
||||
t-key="change.id"
|
||||
>
|
||||
<td>
|
||||
<t t-esc="change.origin_value_display" />
|
||||
</td>
|
||||
<td class="pl-2 pr-2">
|
||||
<i class="fa fa-arrow-right" />
|
||||
</td>
|
||||
<td>
|
||||
<t t-esc="change.new_value_display" />
|
||||
</td>
|
||||
<td class="pl-4" t-if="change.user_can_validate_changeset">
|
||||
<div class="btn-group">
|
||||
<button
|
||||
class="btn btn-danger base_changeset_reject btn-sm"
|
||||
t-attf-data-id="#{change.id}"
|
||||
t-on-click.synthetic="() => this.rejectChange(change.id)"
|
||||
>
|
||||
<i class="fa fa-times" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success base_changeset_apply btn-sm"
|
||||
t-attf-data-id="#{change.id}"
|
||||
t-on-click.synthetic="() => this.applyChange(change.id)"
|
||||
>
|
||||
<i class="fa fa-check" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="web.FormLabel" t-inherit-mode="extension">
|
||||
<xpath expr="//label" position="inside">
|
||||
<BaseChangesetPopoverWrapper record="props.record" fieldName="props.id" />
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/* @odoo-module */
|
||||
|
||||
import {Record} from "@web/views/basic_relational_model";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
|
||||
patch(Record.prototype, "base_changeset.Record", {
|
||||
/* Call the ORM to get this record's changeset changes */
|
||||
async fetchChangesetChanges() {
|
||||
return this.model.orm.call(
|
||||
"record.changeset.change",
|
||||
"get_changeset_changes_by_field",
|
||||
[this.resModel, this.resId]
|
||||
);
|
||||
},
|
||||
/* After loading the form's record data, fetch the changeset changes */
|
||||
async load() {
|
||||
await this._super(...arguments);
|
||||
if (this.__viewType === "form" && this.resId) {
|
||||
this.changesetChanges = await this.fetchChangesetChanges();
|
||||
}
|
||||
},
|
||||
/* Call the ORM to get this record's changeset changes after the form is modified */
|
||||
async save() {
|
||||
const isSaved = await this._super(...arguments);
|
||||
if (this.__viewType === "form" && this.resId) {
|
||||
this.changesetChanges = await this.fetchChangesetChanges();
|
||||
this.model.notify();
|
||||
}
|
||||
return isSaved;
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
|
|
@ -0,0 +1,5 @@
|
|||
from . import test_changeset_flow
|
||||
from . import test_changeset_field_type
|
||||
from . import test_changeset_origin
|
||||
from . import test_changeset_field_rule
|
||||
from . import test_changeset_security
|
||||
|
|
@ -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,73 @@
|
|||
# Copyright 2015-2017 Camptocamp SA
|
||||
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo.tests import common
|
||||
|
||||
|
||||
class TestChangesetFieldRule(common.TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company_model_id = cls.env.ref("base.model_res_company").id
|
||||
cls.field_name = cls.env.ref("base.field_res_partner__name")
|
||||
cls.field_street = cls.env.ref("base.field_res_partner__street")
|
||||
|
||||
def test_get_rules(self):
|
||||
ChangesetFieldRule = self.env["changeset.field.rule"]
|
||||
ChangesetFieldRule.search([]).unlink()
|
||||
rule1 = ChangesetFieldRule.create(
|
||||
{"field_id": self.field_name.id, "action": "validate"}
|
||||
)
|
||||
rule2 = ChangesetFieldRule.create(
|
||||
{"field_id": self.field_street.id, "action": "never"}
|
||||
)
|
||||
get_rules = ChangesetFieldRule.get_rules(None, "res.partner")
|
||||
self.assertEqual(get_rules, {"name": rule1, "street": rule2})
|
||||
|
||||
def test_get_rules_source(self):
|
||||
ChangesetFieldRule = self.env["changeset.field.rule"]
|
||||
ChangesetFieldRule.search([]).unlink()
|
||||
rule1 = ChangesetFieldRule.create(
|
||||
{"field_id": self.field_name.id, "action": "validate"}
|
||||
)
|
||||
rule2 = ChangesetFieldRule.create(
|
||||
{"field_id": self.field_street.id, "action": "never"}
|
||||
)
|
||||
rule3 = ChangesetFieldRule.create(
|
||||
{
|
||||
"source_model_id": self.company_model_id,
|
||||
"field_id": self.field_street.id,
|
||||
"action": "never",
|
||||
}
|
||||
)
|
||||
model = ChangesetFieldRule
|
||||
rules = model.get_rules(None, "res.partner")
|
||||
self.assertEqual(rules, {"name": rule1, "street": rule2})
|
||||
rules = model.get_rules("res.company", "res.partner")
|
||||
self.assertEqual(rules, {"name": rule1, "street": rule3})
|
||||
|
||||
def test_get_rules_cache(self):
|
||||
ChangesetFieldRule = self.env["changeset.field.rule"]
|
||||
ChangesetFieldRule.search([]).unlink()
|
||||
rule = ChangesetFieldRule.create(
|
||||
{"field_id": self.field_name.id, "action": "validate"}
|
||||
)
|
||||
self.assertEqual(
|
||||
ChangesetFieldRule.get_rules(None, "res.partner")["name"].action, "validate"
|
||||
)
|
||||
# Write on cursor to bypass the cache invalidation for the
|
||||
# matter of the test
|
||||
self.env.cr.execute(
|
||||
"UPDATE changeset_field_rule " "SET action = 'never' " "WHERE id = %s",
|
||||
(rule.id,),
|
||||
)
|
||||
self.assertEqual(
|
||||
ChangesetFieldRule.get_rules(None, "res.partner")["name"].action, "validate"
|
||||
)
|
||||
rule.action = "auto"
|
||||
self.assertEqual(
|
||||
ChangesetFieldRule.get_rules(None, "res.partner")["name"].action, "auto"
|
||||
)
|
||||
rule.unlink()
|
||||
self.assertFalse(ChangesetFieldRule.get_rules(None, "res.partner"))
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
# Copyright 2015-2017 Camptocamp SA
|
||||
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from ..models.base import disable_changeset
|
||||
from .common import ChangesetTestCommon
|
||||
|
||||
|
||||
class TestChangesetFieldType(ChangesetTestCommon, TransactionCase):
|
||||
"""Check that changeset changes are stored expectingly to their types"""
|
||||
|
||||
@classmethod
|
||||
def _setup_rules(cls):
|
||||
ChangesetFieldRule = cls.env["changeset.field.rule"]
|
||||
ChangesetFieldRule.search([]).unlink()
|
||||
fields = (
|
||||
("char", "ref"),
|
||||
("text", "comment"),
|
||||
("boolean", "is_company"),
|
||||
("date", "date"),
|
||||
("integer", "color"),
|
||||
("float", "partner_latitude"),
|
||||
("selection", "type"),
|
||||
("many2one", "country_id"),
|
||||
("many2many", "category_id"),
|
||||
("one2many", "user_ids"),
|
||||
("binary", "image_1920"),
|
||||
)
|
||||
for field_type, field in fields:
|
||||
attr_name = "field_%s" % field_type
|
||||
field_record = cls.env["ir.model.fields"].search(
|
||||
[("model", "=", "res.partner"), ("name", "=", field)]
|
||||
)
|
||||
cls.assertTrue(field_record, "Field %s not available" % field)
|
||||
# set attribute such as 'self.field_char' is a
|
||||
# ir.model.fields record of the field res_partner.ref
|
||||
setattr(cls, attr_name, field_record)
|
||||
ChangesetFieldRule.create(
|
||||
{"field_id": field_record.id, "action": "validate"}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._setup_rules()
|
||||
cls.partner = cls.env["res.partner"].create(
|
||||
{"name": "Original Name", "street": "Original Street"}
|
||||
)
|
||||
# Add context for this test for compatibility with other modules' tests
|
||||
cls.partner = cls.partner.with_context(test_record_changeset=True)
|
||||
|
||||
def test_new_changeset_char(self):
|
||||
"""Add a new changeset on a Char field"""
|
||||
self.partner.write({self.field_char.name: "New value"})
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.env.user,
|
||||
[
|
||||
(
|
||||
self.field_char,
|
||||
self.partner[self.field_char.name],
|
||||
"New value",
|
||||
"draft",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_new_changeset_text(self):
|
||||
"""Add a new changeset on a Text field"""
|
||||
self.partner.write({self.field_text.name: "New comment\non 2 lines"})
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.env.user,
|
||||
[
|
||||
(
|
||||
self.field_text,
|
||||
self.partner[self.field_text.name],
|
||||
"New comment\non 2 lines",
|
||||
"draft",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_new_changeset_boolean(self):
|
||||
"""Add a new changeset on a Boolean field"""
|
||||
# ensure the changeset has to change the value
|
||||
self.partner.with_context(__no_changeset=disable_changeset).write(
|
||||
{self.field_boolean.name: False}
|
||||
)
|
||||
|
||||
self.partner.write({self.field_boolean.name: True})
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.env.user,
|
||||
[
|
||||
(
|
||||
self.field_boolean,
|
||||
self.partner[self.field_boolean.name],
|
||||
True,
|
||||
"draft",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_new_changeset_date(self):
|
||||
"""Add a new changeset on a Date field"""
|
||||
self.partner.write({self.field_date.name: "2015-09-15"})
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.env.user,
|
||||
[
|
||||
(
|
||||
self.field_date,
|
||||
self.partner[self.field_date.name],
|
||||
fields.Date.from_string("2015-09-15"),
|
||||
"draft",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_new_changeset_integer(self):
|
||||
"""Add a new changeset on a Integer field"""
|
||||
self.partner.write({self.field_integer.name: 42})
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.env.user,
|
||||
[(self.field_integer, self.partner[self.field_integer.name], 42, "draft")],
|
||||
)
|
||||
|
||||
def test_new_changeset_float(self):
|
||||
"""Add a new changeset on a Float field"""
|
||||
self.partner.write({self.field_float.name: 3.1415})
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.env.user,
|
||||
[(self.field_float, self.partner[self.field_float.name], 3.1415, "draft")],
|
||||
)
|
||||
|
||||
def test_new_changeset_selection(self):
|
||||
"""Add a new changeset on a Selection field"""
|
||||
self.partner.write({self.field_selection.name: "delivery"})
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.env.user,
|
||||
[
|
||||
(
|
||||
self.field_selection,
|
||||
self.partner[self.field_selection.name],
|
||||
"delivery",
|
||||
"draft",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_new_changeset_many2one(self):
|
||||
"""Add a new changeset on a Many2one field"""
|
||||
self.partner.with_context(__no_changeset=disable_changeset).write(
|
||||
{self.field_many2one.name: self.env.ref("base.fr").id}
|
||||
)
|
||||
self.partner.write({self.field_many2one.name: self.env.ref("base.ch").id})
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.env.user,
|
||||
[
|
||||
(
|
||||
self.field_many2one,
|
||||
self.partner[self.field_many2one.name],
|
||||
self.env.ref("base.ch"),
|
||||
"draft",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_new_changeset_many2many(self):
|
||||
"""Add a new changeset on a Many2many field is not supported"""
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.partner.write(
|
||||
{self.field_many2many.name: [self.env.ref("base.ch").id]}
|
||||
)
|
||||
|
||||
def test_new_changeset_one2many(self):
|
||||
"""Add a new changeset on a One2many field is not supported"""
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.partner.write(
|
||||
{self.field_one2many.name: [self.env.ref("base.user_root").id]}
|
||||
)
|
||||
|
||||
def test_new_changeset_binary(self):
|
||||
"""Add a new changeset on a Binary field is not supported"""
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.partner.write({self.field_binary.name: "xyz"})
|
||||
|
||||
def test_apply_char(self):
|
||||
"""Apply a change on a Char field"""
|
||||
changes = [(self.field_char, "New Ref", "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
changeset.change_ids.apply()
|
||||
self.assertEqual(self.partner[self.field_char.name], "New Ref")
|
||||
|
||||
def test_apply_text(self):
|
||||
"""Apply a change on a Text field"""
|
||||
changes = [(self.field_text, "New comment\non 2 lines", "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
changeset.change_ids.apply()
|
||||
self.assertEqual(
|
||||
self.partner[self.field_text.name], Markup("<p>New comment\non 2 lines</p>")
|
||||
)
|
||||
|
||||
def test_apply_boolean(self):
|
||||
"""Apply a change on a Boolean field"""
|
||||
# ensure the changeset has to change the value
|
||||
self.partner.write({self.field_boolean.name: False})
|
||||
|
||||
changes = [(self.field_boolean, True, "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
changeset.change_ids.apply()
|
||||
self.assertEqual(self.partner[self.field_boolean.name], True)
|
||||
|
||||
# Cannot do this while it is on the same transaction. The cache may not
|
||||
# be updated
|
||||
# changes = [(self.field_boolean, False, 'draft')]
|
||||
# changeset = self._create_changeset(self.partner, changes)
|
||||
# changeset.change_ids.apply()
|
||||
# self.assertEqual(self.partner[self.field_boolean.name], False)
|
||||
|
||||
def test_apply_date(self):
|
||||
"""Apply a change on a Date field"""
|
||||
changes = [(self.field_date, "2015-09-15", "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
changeset.change_ids.apply()
|
||||
self.assertAlmostEqual(
|
||||
self.partner[self.field_date.name], fields.Date.from_string("2015-09-15")
|
||||
)
|
||||
|
||||
def test_apply_integer(self):
|
||||
"""Apply a change on a Integer field"""
|
||||
changes = [(self.field_integer, 42, "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
changeset.change_ids.apply()
|
||||
self.assertAlmostEqual(self.partner[self.field_integer.name], 42)
|
||||
|
||||
def test_apply_float(self):
|
||||
"""Apply a change on a Float field"""
|
||||
changes = [(self.field_float, 52.47, "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
changeset.change_ids.apply()
|
||||
self.assertAlmostEqual(self.partner[self.field_float.name], 52.47)
|
||||
|
||||
def test_apply_selection(self):
|
||||
"""Apply a change on a Selection field"""
|
||||
changes = [(self.field_selection, "delivery", "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
changeset.change_ids.apply()
|
||||
self.assertAlmostEqual(self.partner[self.field_selection.name], "delivery")
|
||||
|
||||
def test_apply_many2one(self):
|
||||
"""Apply a change on a Many2one field"""
|
||||
self.partner.with_context(__no_changeset=disable_changeset).write(
|
||||
{self.field_many2one.name: self.env.ref("base.fr").id}
|
||||
)
|
||||
changes = [
|
||||
(
|
||||
self.field_many2one,
|
||||
"res.country,%d" % self.env.ref("base.ch").id,
|
||||
"draft",
|
||||
)
|
||||
]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
changeset.change_ids.apply()
|
||||
self.assertEqual(
|
||||
self.partner[self.field_many2one.name], self.env.ref("base.ch")
|
||||
)
|
||||
|
||||
def test_apply_many2many(self):
|
||||
"""Apply a change on a Many2many field is not supported"""
|
||||
changes = [(self.field_many2many, self.env.ref("base.ch").id, "draft")]
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self._create_changeset(self.partner, changes)
|
||||
|
||||
def test_apply_one2many(self):
|
||||
"""Apply a change on a One2many field is not supported"""
|
||||
changes = [
|
||||
(
|
||||
self.field_one2many,
|
||||
[self.env.ref("base.user_root").id, self.env.ref("base.user_demo").id],
|
||||
"draft",
|
||||
)
|
||||
]
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self._create_changeset(self.partner, changes)
|
||||
|
||||
def test_apply_binary(self):
|
||||
"""Apply a change on a Binary field is not supported"""
|
||||
changes = [(self.field_one2many, "", "draft")]
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self._create_changeset(self.partner, changes)
|
||||
|
|
@ -0,0 +1,485 @@
|
|||
# Copyright 2015-2017 Camptocamp SA
|
||||
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from odoo import fields
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from ..models.base import disable_changeset
|
||||
from .common import ChangesetTestCommon
|
||||
|
||||
|
||||
class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
|
||||
"""Check how changeset are generated and applied based on the rules.
|
||||
|
||||
We do not really care about the types of the fields in this test
|
||||
suite, so we only use 'char' fields. We have to ensure that the
|
||||
general changeset flows work as expected, that is:
|
||||
|
||||
* create a changeset when a manual/system write is made on partner
|
||||
* create a changeset according to the changeset rules when a source model
|
||||
is specified
|
||||
* apply a changeset change writes the value on the partner
|
||||
* apply a whole changeset writes all the changes' values on the partner
|
||||
* changes in state 'cancel' or 'done' do not write on the partner
|
||||
* when all the changes are either 'cancel' or 'done', the changeset
|
||||
becomes 'done'
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _setup_rules(cls):
|
||||
ChangesetFieldRule = cls.env["changeset.field.rule"]
|
||||
ChangesetFieldRule.search([]).unlink()
|
||||
cls.field_name = cls.env.ref("base.field_res_partner__name")
|
||||
cls.field_street = cls.env.ref("base.field_res_partner__street")
|
||||
cls.field_street2 = cls.env.ref("base.field_res_partner__street2")
|
||||
ChangesetFieldRule.create({"field_id": cls.field_name.id, "action": "auto"})
|
||||
ChangesetFieldRule.create(
|
||||
{"field_id": cls.field_street.id, "action": "validate"}
|
||||
)
|
||||
ChangesetFieldRule.create({"field_id": cls.field_street2.id, "action": "never"})
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._setup_rules()
|
||||
cls.demo_user = cls.env.ref("base.user_demo")
|
||||
cls.partner = (
|
||||
cls.env["res.partner"]
|
||||
.with_user(cls.demo_user)
|
||||
.create({"name": "X", "street": "street X", "street2": "street2 X"})
|
||||
)
|
||||
# Add context for this test for compatibility with other modules' tests
|
||||
cls.partner = cls.partner.with_context(test_record_changeset=True)
|
||||
|
||||
def test_get_view(self):
|
||||
"""For privileged users, the smart button is present on the form"""
|
||||
view = self.env.ref("base.view_partner_form")
|
||||
|
||||
def get_nodes(user):
|
||||
arch = etree.XML(
|
||||
self.env["res.partner"]
|
||||
.with_user(user)
|
||||
.get_view(view_id=view.id)["arch"]
|
||||
)
|
||||
return len(
|
||||
arch.xpath(
|
||||
"//div[@name='button_box']"
|
||||
"/button[@name='action_record_changeset_change_view']"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(get_nodes(self.env.ref("base.user_admin")))
|
||||
self.assertFalse(get_nodes(self.env.ref("base.user_demo")))
|
||||
|
||||
def test_new_changeset(self):
|
||||
"""Add a new changeset on a partner
|
||||
|
||||
A new changeset is created when we write on a partner
|
||||
"""
|
||||
self.partner.write({"name": "Y", "street": "street Y", "street2": "street2 Y"})
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
self.assertEqual(self.partner.count_pending_changeset_changes, 1)
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.demo_user,
|
||||
[
|
||||
(self.field_name, "X", "Y", "done"),
|
||||
(self.field_street, "street X", "street Y", "draft"),
|
||||
(self.field_street2, "street2 X", "street2 Y", "cancel"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.partner.name, "Y")
|
||||
self.assertEqual(self.partner.street, "street X")
|
||||
self.assertEqual(self.partner.street2, "street2 X")
|
||||
# Pending Changes widget can be rendered for the unprivileged user
|
||||
self.env.invalidate_all()
|
||||
self.env["record.changeset.change"].with_user(
|
||||
self.demo_user
|
||||
).get_changeset_changes_by_field(self.partner._name, self.partner.id)
|
||||
|
||||
def test_create_new_changeset(self):
|
||||
"""Create a new partner with a changeset"""
|
||||
new = (
|
||||
self.env["res.partner"]
|
||||
.with_context(test_record_changeset=True)
|
||||
.create(
|
||||
{
|
||||
"name": "partner",
|
||||
"street": "street",
|
||||
"street2": "street2",
|
||||
}
|
||||
)
|
||||
)
|
||||
new._compute_changeset_ids()
|
||||
new._compute_count_pending_changesets()
|
||||
self.assertEqual(new.count_pending_changesets, 1)
|
||||
self.assert_changeset(
|
||||
new,
|
||||
self.env.user,
|
||||
[
|
||||
(self.field_name, False, "partner", "done"),
|
||||
(self.field_street, False, "street", "draft"),
|
||||
(self.field_street2, False, "street2", "cancel"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(new.name, "partner")
|
||||
self.assertFalse(new.street)
|
||||
self.assertFalse(new.street2)
|
||||
|
||||
def test_create_new_changeset_empty_value(self):
|
||||
"""No change is created for empty values on create"""
|
||||
new = (
|
||||
self.env["res.partner"]
|
||||
.with_context(test_record_changeset=True)
|
||||
.create(
|
||||
{
|
||||
"name": "partner",
|
||||
"street": "street",
|
||||
"street2": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
new._compute_changeset_ids()
|
||||
new._compute_count_pending_changesets()
|
||||
self.assertEqual(new.count_pending_changesets, 1)
|
||||
self.assert_changeset(
|
||||
new,
|
||||
self.env.user,
|
||||
[
|
||||
(self.field_name, False, "partner", "done"),
|
||||
(self.field_street, False, "street", "draft"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(new.name, "partner")
|
||||
self.assertFalse(new.street)
|
||||
self.assertFalse(new.street2)
|
||||
|
||||
def test_new_changeset_empty_value(self):
|
||||
"""Create a changeset change that empty a value"""
|
||||
self.partner.write({"street": False})
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
self.assert_changeset(
|
||||
self.partner,
|
||||
self.demo_user,
|
||||
[(self.field_street, "street X", False, "draft")],
|
||||
)
|
||||
|
||||
def test_no_changeset_empty_value_both_sides(self):
|
||||
"""No changeset created when both sides have an empty value"""
|
||||
# we have to ensure that even if we write '' to a False field, we won't
|
||||
# write a changeset
|
||||
self.partner.with_context(__no_changeset=disable_changeset).write(
|
||||
{"street": False}
|
||||
)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.partner.write({"street": ""})
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertFalse(self.partner.changeset_ids)
|
||||
|
||||
def test_apply_change(self):
|
||||
"""Apply a changeset change on a partner"""
|
||||
changes = [(self.field_name, "Y", "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
for change in changeset.change_ids:
|
||||
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
|
||||
changeset.change_ids.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(self.partner.name, "Y")
|
||||
self.assertEqual(changeset.change_ids.state, "done")
|
||||
# All computed fields are assigned
|
||||
changeset.change_ids.read()
|
||||
|
||||
def test_apply_change_with_prevent_self_validation(self):
|
||||
"""Don't apply a changeset change and prevent self validation"""
|
||||
self.partner.write({"street": "street Z"})
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
self.assertEqual(self.partner.count_pending_changeset_changes, 1)
|
||||
self.partner.changeset_ids.change_ids.rule_id.prevent_self_validation = True
|
||||
with self.assertRaises(
|
||||
UserError, msg="You don't have the rights to reject the changes."
|
||||
):
|
||||
self.partner.changeset_ids.change_ids.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
self.assertEqual(self.partner.count_pending_changeset_changes, 1)
|
||||
self.assertEqual(self.partner.street, "street X")
|
||||
self.assertEqual(self.partner.changeset_ids.change_ids.state, "draft")
|
||||
|
||||
# Copy the user to have another user with similar rights, so that
|
||||
# self validation prevention doesn't kick in.
|
||||
other_demo_user = self.demo_user.copy()
|
||||
other_demo_user.groups_id += self.env.ref("base_changeset.group_changeset_user")
|
||||
self.partner.changeset_ids.change_ids.with_user(other_demo_user).apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(self.partner.count_pending_changeset_changes, 0)
|
||||
self.assertEqual(self.partner.street, "street Z")
|
||||
self.assertEqual(self.partner.changeset_ids.change_ids.state, "done")
|
||||
|
||||
def test_apply_done_change(self):
|
||||
"""Done changes do not apply (already applied)"""
|
||||
changes = [(self.field_name, "Y", "done")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
with self.assertRaises(UserError):
|
||||
changeset.change_ids.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(self.partner.name, "X")
|
||||
changeset.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(self.partner.name, "X")
|
||||
|
||||
def test_apply_cancel_change(self):
|
||||
"""Cancel changes do not apply"""
|
||||
changes = [(self.field_name, "Y", "cancel")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
with self.assertRaises(UserError):
|
||||
changeset.change_ids.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(self.partner.name, "X")
|
||||
changeset.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(self.partner.name, "X")
|
||||
|
||||
def test_apply_empty_value(self):
|
||||
"""Apply a change that empty a value"""
|
||||
changes = [(self.field_street, False, "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
for change in changeset.change_ids:
|
||||
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
|
||||
changeset.change_ids.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertFalse(self.partner.street)
|
||||
|
||||
def test_apply_change_loop(self):
|
||||
"""Test multiple changes"""
|
||||
changes = [
|
||||
(self.field_name, "Y", "draft"),
|
||||
(self.field_street, "street Y", "draft"),
|
||||
(self.field_street2, "street2 Y", "draft"),
|
||||
]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
for change in changeset.change_ids:
|
||||
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
|
||||
changeset.change_ids.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(self.partner.name, "Y")
|
||||
self.assertEqual(self.partner.street, "street Y")
|
||||
self.assertEqual(self.partner.street2, "street2 Y")
|
||||
|
||||
def test_apply(self):
|
||||
"""Apply a full changeset on a partner"""
|
||||
changes = [
|
||||
(self.field_name, "Y", "draft"),
|
||||
(self.field_street, "street Y", "draft"),
|
||||
(self.field_street2, "street2 Y", "draft"),
|
||||
]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
self.assertEqual(self.partner.count_pending_changeset_changes, 3)
|
||||
for change in changeset.change_ids:
|
||||
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
|
||||
changeset.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(self.partner.count_pending_changeset_changes, 0)
|
||||
self.assertEqual(self.partner.name, "Y")
|
||||
self.assertEqual(self.partner.street, "street Y")
|
||||
self.assertEqual(self.partner.street2, "street2 Y")
|
||||
|
||||
def test_changeset_state_on_done(self):
|
||||
"""Check that changeset state becomes done when changes are done"""
|
||||
changes = [(self.field_name, "Y", "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
self.assertEqual(changeset.state, "draft")
|
||||
changeset.change_ids.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(changeset.state, "done")
|
||||
|
||||
def test_changeset_state_on_cancel(self):
|
||||
"""Check that rev. state becomes done when changes are canceled"""
|
||||
changes = [(self.field_name, "Y", "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
self.assertEqual(changeset.state, "draft")
|
||||
changeset.change_ids.cancel()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(changeset.state, "done")
|
||||
|
||||
def test_changeset_state(self):
|
||||
"""Check that changeset state becomes done with multiple changes"""
|
||||
changes = [
|
||||
(self.field_name, "Y", "draft"),
|
||||
(self.field_street, "street Y", "draft"),
|
||||
(self.field_street2, "street2 Y", "draft"),
|
||||
]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
self.assertEqual(self.partner.count_pending_changeset_changes, 3)
|
||||
self.assertEqual(changeset.state, "draft")
|
||||
changeset.apply()
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
self.assertEqual(self.partner.count_pending_changeset_changes, 0)
|
||||
self.assertEqual(changeset.state, "done")
|
||||
|
||||
def test_apply_changeset_with_other_pending(self):
|
||||
"""Error when applying when previous pending changesets exist"""
|
||||
changes = [(self.field_name, "Y", "draft")]
|
||||
old_changeset = self._create_changeset(self.partner, changes)
|
||||
# if the date is the same, both changeset can be applied
|
||||
to_string = fields.Datetime.to_string
|
||||
old_changeset.date = to_string(datetime.now() - timedelta(days=1))
|
||||
changes = [(self.field_name, "Z", "draft")]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
with self.assertRaises(UserError):
|
||||
changeset.change_ids.with_context(
|
||||
require_previous_changesets_done=True
|
||||
).apply()
|
||||
changeset.change_ids.apply()
|
||||
|
||||
def test_apply_different_changesets(self):
|
||||
"""Apply different changesets at once"""
|
||||
partner2 = self.env["res.partner"].create({"name": "P2"})
|
||||
changes = [
|
||||
(self.field_name, "Y", "draft"),
|
||||
(self.field_street, "street Y", "draft"),
|
||||
(self.field_street2, "street2 Y", "draft"),
|
||||
]
|
||||
changeset = self._create_changeset(self.partner, changes)
|
||||
self.partner._compute_changeset_ids()
|
||||
self.partner._compute_count_pending_changesets()
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
self.assertEqual(self.partner.count_pending_changeset_changes, 3)
|
||||
for change in changeset.change_ids:
|
||||
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
|
||||
changeset2 = self._create_changeset(partner2, changes)
|
||||
partner2._compute_changeset_ids()
|
||||
partner2._compute_count_pending_changesets()
|
||||
self.assertEqual(changeset.state, "draft")
|
||||
self.assertEqual(changeset2.state, "draft")
|
||||
self.assertEqual(partner2.count_pending_changesets, 1)
|
||||
self.assertEqual(partner2.count_pending_changeset_changes, 3)
|
||||
for change in changeset2.change_ids:
|
||||
change.get_changeset_changes_by_field(changeset2.model, changeset2.res_id)
|
||||
(changeset + changeset2).apply()
|
||||
self.assertEqual(self.partner.name, "Y")
|
||||
self.assertEqual(self.partner.street, "street Y")
|
||||
self.assertEqual(self.partner.street2, "street2 Y")
|
||||
self.assertEqual(partner2.name, "Y")
|
||||
self.assertEqual(partner2.street, "street Y")
|
||||
self.assertEqual(partner2.street2, "street2 Y")
|
||||
self.assertEqual(changeset.state, "done")
|
||||
self.assertEqual(changeset2.state, "done")
|
||||
|
||||
def test_new_changeset_source(self):
|
||||
"""Source is the user who made the change"""
|
||||
self.partner.write({"street": False})
|
||||
self.partner._compute_changeset_ids()
|
||||
changeset = self.partner.changeset_ids
|
||||
self.assertEqual(changeset.source, self.demo_user)
|
||||
|
||||
def test_new_changeset_source_other_model(self):
|
||||
"""Define source from another model"""
|
||||
company = self.env.ref("base.main_company")
|
||||
keys = {
|
||||
"force_changeset_for_partners": True,
|
||||
"__changeset_rules_source_model": "res.company",
|
||||
"__changeset_rules_source_id": company.id,
|
||||
}
|
||||
self.partner.with_context(**keys).write({"street": False})
|
||||
self.partner._compute_changeset_ids()
|
||||
changeset = self.partner.changeset_ids
|
||||
self.assertEqual(changeset.source, company)
|
||||
|
||||
def test_name_get(self):
|
||||
"""Test the name_get of a changeset for a model without name field"""
|
||||
self.env["changeset.field.rule"].create(
|
||||
{
|
||||
"field_id": self.env.ref("base.field_res_partner_bank__active").id,
|
||||
"action": "validate",
|
||||
}
|
||||
)
|
||||
bank = self.env.ref("base.bank_partner_demo").with_context(
|
||||
test_record_changeset=True
|
||||
)
|
||||
bank.active = False
|
||||
self.assertTrue(bank.changeset_ids)
|
||||
self.assertIn(bank.acc_number, bank.changeset_ids.name_get()[0][1])
|
||||
|
||||
def test_new_changeset_expression(self):
|
||||
"""Test that rules can be conditional"""
|
||||
self.env["changeset.field.rule"].search(
|
||||
[
|
||||
("field_id", "=", self.field_street.id),
|
||||
]
|
||||
).expression = "object.street != 'street X'"
|
||||
self.partner.street = "street Y"
|
||||
self.partner.invalidate_recordset()
|
||||
self.assertEqual(self.partner.street, "street Y")
|
||||
self.assertFalse(self.partner.changeset_ids)
|
||||
self.partner.street = "street Z"
|
||||
self.partner.invalidate_recordset()
|
||||
self.assertTrue(self.partner.changeset_ids)
|
||||
self.assertEqual(self.partner.street, "street Y")
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
# Copyright 2015-2017 Camptocamp SA
|
||||
# Copyright 2020 Onestein (<https://www.onestein.eu>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo.tests.common import Form, TransactionCase
|
||||
|
||||
from ..models.base import disable_changeset
|
||||
from .common import ChangesetTestCommon
|
||||
|
||||
|
||||
class TestChangesetOrigin(ChangesetTestCommon, TransactionCase):
|
||||
"""Check that origin - old fields are stored as expected.
|
||||
|
||||
'origin' fields dynamically read fields from the partner when the state
|
||||
of the change is 'draft'. Once a change becomes 'done' or 'cancel', the
|
||||
'old' field copies the value from the partner and then the 'origin' field
|
||||
displays the 'old' value.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _setup_rules(cls):
|
||||
ChangesetFieldRule = cls.env["changeset.field.rule"]
|
||||
ChangesetFieldRule.search([]).unlink()
|
||||
cls.field_name = cls.env.ref("base.field_res_partner__name")
|
||||
ChangesetFieldRule.create({"field_id": cls.field_name.id, "action": "validate"})
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._setup_rules()
|
||||
cls.partner = cls.env["res.partner"].create({"name": "X"})
|
||||
# Add context for this test for compatibility with other modules' tests
|
||||
cls.partner = cls.partner.with_context(test_record_changeset=True)
|
||||
|
||||
def test_origin_value_of_change_with_apply(self):
|
||||
"""Origin field is read from the parter or 'old' - with apply
|
||||
|
||||
According to the state of the change.
|
||||
"""
|
||||
with Form(self.partner) as partner_form:
|
||||
partner_form.name = "Y"
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
changeset = self.partner.changeset_ids
|
||||
change = changeset.change_ids
|
||||
self.assertEqual(self.partner.name, "X")
|
||||
self.assertEqual(change.origin_value_char, "X")
|
||||
self.assertEqual(change.origin_value_display, "X")
|
||||
with Form(
|
||||
self.partner.with_context(__no_changeset=disable_changeset)
|
||||
) as partner_form:
|
||||
partner_form.name = "A"
|
||||
self.assertEqual(change.origin_value_char, "A")
|
||||
self.assertEqual(change.origin_value_display, "A")
|
||||
change.apply()
|
||||
self.assertEqual(change.origin_value_char, "A")
|
||||
self.assertEqual(change.origin_value_display, "A")
|
||||
with Form(
|
||||
self.partner.with_context(__no_changeset=disable_changeset)
|
||||
) as partner_form:
|
||||
partner_form.name = "B"
|
||||
self.assertEqual(change.origin_value_char, "A")
|
||||
self.assertEqual(change.origin_value_display, "A")
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
|
||||
def test_origin_value_of_change_with_cancel(self):
|
||||
"""Origin field is read from the parter or 'old' - with cancel
|
||||
|
||||
According to the state of the change.
|
||||
"""
|
||||
with Form(self.partner) as partner_form:
|
||||
partner_form.name = "Y"
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
changeset = self.partner.changeset_ids
|
||||
change = changeset.change_ids
|
||||
self.assertEqual(self.partner.name, "X")
|
||||
self.assertEqual(change.origin_value_char, "X")
|
||||
self.assertEqual(change.origin_value_display, "X")
|
||||
with Form(
|
||||
self.partner.with_context(__no_changeset=disable_changeset)
|
||||
) as partner_form:
|
||||
partner_form.name = "A"
|
||||
self.assertEqual(change.origin_value_char, "A")
|
||||
self.assertEqual(change.origin_value_display, "A")
|
||||
change.cancel()
|
||||
self.assertEqual(change.origin_value_char, "A")
|
||||
self.assertEqual(change.origin_value_display, "A")
|
||||
with Form(
|
||||
self.partner.with_context(__no_changeset=disable_changeset)
|
||||
) as partner_form:
|
||||
partner_form.name = "B"
|
||||
self.assertEqual(change.origin_value_char, "A")
|
||||
self.assertEqual(change.origin_value_display, "A")
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
|
||||
def test_old_field_of_change_with_apply(self):
|
||||
"""Old field is stored when the change is applied"""
|
||||
with Form(self.partner) as partner_form:
|
||||
partner_form.name = "Y"
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
changeset = self.partner.changeset_ids
|
||||
change = changeset.change_ids
|
||||
self.assertEqual(self.partner.name, "X")
|
||||
self.assertFalse(change.old_value_char)
|
||||
with Form(
|
||||
self.partner.with_context(__no_changeset=disable_changeset)
|
||||
) as partner_form:
|
||||
partner_form.name = "A"
|
||||
self.assertFalse(change.old_value_char)
|
||||
change.apply()
|
||||
self.assertEqual(change.old_value_char, "A")
|
||||
with Form(
|
||||
self.partner.with_context(__no_changeset=disable_changeset)
|
||||
) as partner_form:
|
||||
partner_form.name = "B"
|
||||
self.assertEqual(change.old_value_char, "A")
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
|
||||
def test_old_field_of_change_with_cancel(self):
|
||||
"""Old field is stored when the change is canceled"""
|
||||
with Form(self.partner) as partner_form:
|
||||
partner_form.name = "Y"
|
||||
self.assertEqual(self.partner.count_pending_changesets, 1)
|
||||
changeset = self.partner.changeset_ids
|
||||
change = changeset.change_ids
|
||||
self.assertEqual(self.partner.name, "X")
|
||||
self.assertFalse(change.old_value_char)
|
||||
with Form(
|
||||
self.partner.with_context(__no_changeset=disable_changeset)
|
||||
) as partner_form:
|
||||
partner_form.name = "A"
|
||||
self.assertFalse(change.old_value_char)
|
||||
change.cancel()
|
||||
self.assertEqual(change.old_value_char, "A")
|
||||
with Form(
|
||||
self.partner.with_context(__no_changeset=disable_changeset)
|
||||
) as partner_form:
|
||||
partner_form.name = "B"
|
||||
self.assertEqual(change.old_value_char, "A")
|
||||
self.assertEqual(self.partner.count_pending_changesets, 0)
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Copyright 2021 Hunki Enterprises BV (<https://hunki-enterprises.com>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from .common import ChangesetTestCommon
|
||||
|
||||
|
||||
class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
|
||||
"""Check that changesets don't leak information"""
|
||||
|
||||
@classmethod
|
||||
def setUp(cls):
|
||||
super().setUpClass()
|
||||
cls.env["changeset.field.rule"].search([]).unlink()
|
||||
cls.rule = cls.env["changeset.field.rule"].create(
|
||||
{
|
||||
"model_id": cls.env.ref("base.model_ir_config_parameter").id,
|
||||
"field_id": cls.env.ref("base.field_ir_config_parameter__key").id,
|
||||
"action": "auto",
|
||||
}
|
||||
)
|
||||
|
||||
def test_change_unprivileged_user(self):
|
||||
"""
|
||||
Check that unprivileged users can't see changesets they didn't create
|
||||
"""
|
||||
user = self.env.ref("base.user_demo")
|
||||
self.env["ir.config_parameter"].with_context(
|
||||
test_record_changeset=True,
|
||||
).set_param("hello", "world")
|
||||
changeset = self.env["record.changeset.change"].search(
|
||||
[
|
||||
("rule_id", "=", self.rule.id),
|
||||
]
|
||||
)
|
||||
self.assertTrue(changeset)
|
||||
self.assertFalse(changeset.with_user(user).search([("id", "=", changeset.id)]))
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="view_changeset_field_rule_tree" model="ir.ui.view">
|
||||
<field name="model">changeset.field.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="model_id" />
|
||||
<field name="field_id" options="{'no_create': True}" />
|
||||
<field name="source_model_id" />
|
||||
<field name="expression" />
|
||||
<field name="validator_group_ids" />
|
||||
<field
|
||||
name="company_id"
|
||||
groups="base.group_multi_company"
|
||||
options="{'no_create': True}"
|
||||
/>
|
||||
<field name="action" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_changeset_field_rule_form" model="ir.ui.view">
|
||||
<field name="model">changeset.field.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="model_id" options="{'no_open': True}" />
|
||||
<field
|
||||
name="field_id"
|
||||
options="{'no_create_edit': True, 'no_open': True}"
|
||||
domain="[('ttype', 'in', ('char', 'selection', 'date', 'datetime', 'float', 'monetary', 'integer', 'text', 'boolean', 'many2one')),
|
||||
('readonly', '=', False)]"
|
||||
/>
|
||||
</group>
|
||||
<group>
|
||||
<field
|
||||
name="company_id"
|
||||
groups="base.group_multi_company"
|
||||
options="{'no_create': True}"
|
||||
/>
|
||||
<field name="action" />
|
||||
<field name="source_model_id" widget="selection" />
|
||||
<field name="prevent_self_validation" />
|
||||
<field name="expression" placeholder="True" />
|
||||
<field name="validator_group_ids" widget="many2many_tags" />
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_changeset_field_rule_search" model="ir.ui.view">
|
||||
<field name="model">changeset.field.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="field_id" />
|
||||
<field name="source_model_id" />
|
||||
<field name="action" />
|
||||
<field name="validator_group_ids" />
|
||||
<group string="Group By" name="groupby">
|
||||
<filter
|
||||
name="model_groupby"
|
||||
string="Model"
|
||||
context="{'group_by': 'model_id'}"
|
||||
/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_changeset_field_rule_view" model="ir.actions.act_window">
|
||||
<field name="name">Changeset Fields Rules</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">changeset.field.rule</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context" eval="{'search_default_model_groupby': 1}" />
|
||||
<field name="search_view_id" ref="view_changeset_field_rule_search" />
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -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,112 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="view_record_changeset_change_tree" model="ir.ui.view">
|
||||
<field name="model">record.changeset.change</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree delete="false" create="false">
|
||||
<field name="record_id" />
|
||||
<field name="changeset_id" optional="hidden" />
|
||||
<field name="field_id" />
|
||||
<field name="field_type" />
|
||||
<field name="origin_value_display" />
|
||||
<field name="new_value_display" />
|
||||
<field name="date" />
|
||||
<field name="modified_by_id" />
|
||||
<field name="verified_on_date" />
|
||||
<field name="verified_by_id" />
|
||||
<field name="state" />
|
||||
<field name="user_can_validate_changeset" invisible="1" />
|
||||
<button
|
||||
name="apply"
|
||||
string="Apply"
|
||||
type="object"
|
||||
icon="fa-plus-circle"
|
||||
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
|
||||
/>
|
||||
<button
|
||||
name="cancel"
|
||||
string="Reject"
|
||||
type="object"
|
||||
icon="fa-times"
|
||||
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
|
||||
/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_record_changeset_change_form" model="ir.ui.view">
|
||||
<field name="model">record.changeset.change</field>
|
||||
<field name="arch" type="xml">
|
||||
<form delete="false" create="false">
|
||||
<header>
|
||||
<button
|
||||
name="apply"
|
||||
string="Apply"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
|
||||
/>
|
||||
<button
|
||||
name="cancel"
|
||||
string="Reject"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
|
||||
/>
|
||||
<field
|
||||
name="state"
|
||||
widget="statusbar"
|
||||
statusbar_visible="draft,done"
|
||||
/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group name="main">
|
||||
<group>
|
||||
<field name="record_id" />
|
||||
<field name="field_id" options="{'no_open': True}" />
|
||||
<field name="field_type" />
|
||||
<field name="date" />
|
||||
<field name="modified_by_id" />
|
||||
<field name="verified_on_date" />
|
||||
<field name="verified_by_id" />
|
||||
<field name="user_can_validate_changeset" invisible="1" />
|
||||
</group>
|
||||
<group>
|
||||
<field name="origin_value_display" />
|
||||
<field name="new_value_display" />
|
||||
<field name="changeset_id" />
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_record_changeset_change_search" model="ir.ui.view">
|
||||
<field name="model">record.changeset.change</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="field_name" />
|
||||
<filter
|
||||
string="Pending"
|
||||
name="pending"
|
||||
domain="[('state','=','draft')]"
|
||||
/>
|
||||
<filter string="Done" name="done" domain="[('state','=','done')]" />
|
||||
<group expand="0" string="Group By">
|
||||
<filter
|
||||
string="State"
|
||||
name="groupby_state"
|
||||
context="{'group_by': 'state'}"
|
||||
/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_record_changeset_change_view" model="ir.actions.act_window">
|
||||
<field name="name">Changes</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">record.changeset.change</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'search_default_pending': 1}</field>
|
||||
<field name="search_view_id" ref="view_record_changeset_change_search" />
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="view_record_changeset_tree" model="ir.ui.view">
|
||||
<field name="model">record.changeset</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree delete="false" create="false">
|
||||
<field name="record_id" />
|
||||
<field name="model" />
|
||||
<field name="res_id" widget="integer" />
|
||||
<field name="date" />
|
||||
<field name="modified_by_id" />
|
||||
<field name="state" />
|
||||
<button
|
||||
name="apply"
|
||||
string="Apply"
|
||||
type="object"
|
||||
icon="fa-plus-circle"
|
||||
states="draft"
|
||||
/>
|
||||
<button
|
||||
name="cancel"
|
||||
string="Reject"
|
||||
type="object"
|
||||
icon="fa-times"
|
||||
states="draft"
|
||||
/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_record_changeset_form" model="ir.ui.view">
|
||||
<field name="model">record.changeset</field>
|
||||
<field name="arch" type="xml">
|
||||
<form delete="false" create="false">
|
||||
<header>
|
||||
<button
|
||||
name="apply"
|
||||
string="Apply pending changes"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
states="draft"
|
||||
/>
|
||||
<button
|
||||
name="cancel"
|
||||
string="Reject pending changes"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
states="draft"
|
||||
/>
|
||||
<field
|
||||
name="state"
|
||||
widget="statusbar"
|
||||
statusbar_visible="draft,done"
|
||||
/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="record_id" />
|
||||
<field name="model" />
|
||||
<field name="res_id" widget="integer" />
|
||||
</group>
|
||||
<group>
|
||||
<field
|
||||
name="company_id"
|
||||
groups="base.group_multi_company"
|
||||
options="{'no_create': True}"
|
||||
/>
|
||||
<field name="source" />
|
||||
<field name="date" />
|
||||
<field name="modified_by_id" />
|
||||
</group>
|
||||
</group>
|
||||
<group string="Changes">
|
||||
<field name="change_ids" nolabel="1">
|
||||
<tree>
|
||||
<field name="field_id" context="{'no_open': True}" />
|
||||
<field name="field_type" invisible="1" />
|
||||
<field name="origin_value_display" string="Previous" />
|
||||
<field name="new_value_display" />
|
||||
<field name="state" />
|
||||
<field
|
||||
name="user_can_validate_changeset"
|
||||
invisible="1"
|
||||
/>
|
||||
<button
|
||||
name="apply"
|
||||
string="Apply"
|
||||
type="object"
|
||||
icon="fa-plus-circle"
|
||||
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
|
||||
/>
|
||||
<button
|
||||
name="cancel"
|
||||
string="Reject"
|
||||
type="object"
|
||||
icon="fa-times"
|
||||
attrs="{'invisible': [('user_can_validate_changeset', '=', False)]}"
|
||||
/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
<group>
|
||||
<field name="note" />
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_record_changeset_search" model="ir.ui.view">
|
||||
<field name="model">record.changeset</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="model" />
|
||||
<filter
|
||||
string="Pending"
|
||||
name="pending"
|
||||
domain="[('state','=','draft')]"
|
||||
/>
|
||||
<filter string="Done" name="done" domain="[('state','=','done')]" />
|
||||
<group expand="0" string="Group By">
|
||||
<filter
|
||||
string="State"
|
||||
name="groupby_state"
|
||||
context="{'group_by': 'state'}"
|
||||
/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_record_changeset_view" model="ir.actions.act_window">
|
||||
<field name="name">Record Changeset</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">record.changeset</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'search_default_pending': 1}</field>
|
||||
<field name="search_view_id" ref="view_record_changeset_search" />
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U[Users] -->|HTTP| V[Views and QWeb Templates]
|
||||
V --> C[Controllers]
|
||||
V --> W[Wizards – Transient Models]
|
||||
C --> M[Models and ORM]
|
||||
W --> M
|
||||
M --> R[Reports]
|
||||
DX[Data XML] --> M
|
||||
S[Security – ACLs and Groups] -. enforces .-> M
|
||||
|
||||
subgraph Base_changeset Module - base_changeset
|
||||
direction LR
|
||||
M:::layer
|
||||
W:::layer
|
||||
C:::layer
|
||||
V:::layer
|
||||
R:::layer
|
||||
S:::layer
|
||||
DX:::layer
|
||||
end
|
||||
|
||||
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
|
||||
```
|
||||
|
||||
Notes
|
||||
- Views include tree/form/kanban templates and report templates.
|
||||
- Controllers provide website/portal routes when present.
|
||||
- Wizards are UI flows implemented with `models.TransientModel`.
|
||||
- Data XML loads data/demo records; Security defines groups and access.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Configuration
|
||||
|
||||
Refer to Odoo settings for base_changeset. Configure related models, access rights, and options as needed.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Controllers
|
||||
|
||||
This module does not define custom HTTP controllers.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Dependencies
|
||||
|
||||
This addon depends on:
|
||||
|
||||
- [web](../../odoo-bringout-oca-ocb-web)
|
||||
4
odoo-bringout-oca-server-tools-base_changeset/doc/FAQ.md
Normal file
4
odoo-bringout-oca-server-tools-base_changeset/doc/FAQ.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# FAQ
|
||||
|
||||
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
|
||||
- Q: How to enable? A: Start server with --addon base_changeset or install in UI.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# Install
|
||||
|
||||
```bash
|
||||
pip install odoo-bringout-oca-server-tools-base_changeset"
|
||||
# or
|
||||
uv pip install odoo-bringout-oca-server-tools-base_changeset"
|
||||
```
|
||||
15
odoo-bringout-oca-server-tools-base_changeset/doc/MODELS.md
Normal file
15
odoo-bringout-oca-server-tools-base_changeset/doc/MODELS.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Models
|
||||
|
||||
Detected core models and extensions in base_changeset.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class changeset_field_rule
|
||||
class record_changeset
|
||||
class record_changeset_change
|
||||
class base
|
||||
```
|
||||
|
||||
Notes
|
||||
- Classes show model technical names; fields omitted for brevity.
|
||||
- Items listed under _inherit are extensions of existing models.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# Overview
|
||||
|
||||
Packaged Odoo addon: base_changeset. Provides features documented in upstream Odoo 16 under this addon.
|
||||
|
||||
- Source: OCA/OCB 16.0, addon base_changeset
|
||||
- License: LGPL-3
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Reports
|
||||
|
||||
This module does not define custom reports.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Security
|
||||
|
||||
Access control and security definitions in base_changeset.
|
||||
|
||||
## Access Control Lists (ACLs)
|
||||
|
||||
Model access permissions defined in:
|
||||
- **[ir.model.access.csv](../base_changeset/security/ir.model.access.csv)**
|
||||
- 9 model access rules
|
||||
|
||||
## Record Rules
|
||||
|
||||
Row-level security rules defined in:
|
||||
- **[rules.xml](../base_changeset/security/rules.xml)**
|
||||
|
||||
## Security Groups & Configuration
|
||||
|
||||
Security groups and permissions defined in:
|
||||
- **[groups.xml](../base_changeset/security/groups.xml)**
|
||||
- 3 security groups defined
|
||||
- **[rules.xml](../base_changeset/security/rules.xml)**
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Security Layers"
|
||||
A[Users] --> B[Groups]
|
||||
B --> C[Access Control Lists]
|
||||
C --> D[Models]
|
||||
B --> E[Record Rules]
|
||||
E --> F[Individual Records]
|
||||
end
|
||||
```
|
||||
|
||||
Security files overview:
|
||||
- **[groups.xml](../base_changeset/security/groups.xml)**
|
||||
- Security groups, categories, and XML-based rules
|
||||
- **[ir.model.access.csv](../base_changeset/security/ir.model.access.csv)**
|
||||
- Model access permissions (CRUD rights)
|
||||
- **[rules.xml](../base_changeset/security/rules.xml)**
|
||||
- Security groups, categories, and XML-based rules
|
||||
|
||||
Notes
|
||||
- Access Control Lists define which groups can access which models
|
||||
- Record Rules provide row-level security (filter records by user/group)
|
||||
- Security groups organize users and define permission sets
|
||||
- All security is enforced at the ORM level by Odoo
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Troubleshooting
|
||||
|
||||
- Ensure Python and Odoo environment matches repo guidance.
|
||||
- Check database connectivity and logs if startup fails.
|
||||
- Validate that dependent addons listed in DEPENDENCIES.md are installed.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# Usage
|
||||
|
||||
Start Odoo including this addon (from repo root):
|
||||
|
||||
```bash
|
||||
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon base_changeset
|
||||
```
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Wizards
|
||||
|
||||
This module does not include UI wizards.
|
||||
42
odoo-bringout-oca-server-tools-base_changeset/pyproject.toml
Normal file
42
odoo-bringout-oca-server-tools-base_changeset/pyproject.toml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
[project]
|
||||
name = "odoo-bringout-oca-server-tools-base_changeset"
|
||||
version = "16.0.0"
|
||||
description = "Track record changesets - Odoo addon"
|
||||
authors = [
|
||||
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
|
||||
]
|
||||
dependencies = [
|
||||
"odoo-bringout-oca-ocb-web>=16.0.0",
|
||||
"requests>=2.25.1"
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">= 3.11"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Office/Business",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
homepage = "https://github.com/bringout/0"
|
||||
repository = "https://github.com/bringout/0"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.metadata]
|
||||
allow-direct-references = true
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["base_changeset"]
|
||||
|
||||
[tool.rye]
|
||||
managed = true
|
||||
dev-dependencies = [
|
||||
"pytest>=8.4.1",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue