[MIG] base_changeset: Migration to 16.0

Co-authored-by stefan@opener.amsterdam
Co-authored-by shams.mukhibillaev@emesa.nl
Co-authored-by remy@emesa.nl
pull/2663/head
Mark Schuit 2023-07-24 09:40:55 +02:00 committed by Stefan Rijnhart
parent cb6b3983a7
commit 0513667f18
24 changed files with 389 additions and 351 deletions

View File

@ -2,10 +2,13 @@
Track record changesets
=======================
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:97cd931d612b60483f97e88ae7a01f5dbb3d5bfba9ed8caed7a148c490c14369
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
:target: https://odoo-community.org/page/development-status
@ -14,16 +17,16 @@ Track record changesets
: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/15.0/base_changeset
: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-15-0/server-tools-15-0-base_changeset
: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/runbot-Try%20me-875A7B.png
:target: https://runbot.odoo-community.org/runbot/149/15.0
:alt: Try me on Runbot
.. |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|
|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
@ -67,7 +70,7 @@ Record Changesets > Fields Rules``.
* Configuration of rules
.. image:: https://raw.githubusercontent.com/OCA/server-tools/15.0/base_changeset/static/src/img/rules.png
.. 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:
@ -118,7 +121,7 @@ Remove the "Pending" filter to show all the changesets.
* Changeset waiting for validation
.. image:: https://raw.githubusercontent.com/OCA/server-tools/15.0/base_changeset/static/src/img/changeset.png
.. 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
@ -138,13 +141,13 @@ number of pending changes next to it like this:
* Badge with the number of pending changes
.. image:: https://raw.githubusercontent.com/OCA/server-tools/15.0/base_changeset/static/src/img/badge.png
.. 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/15.0/base_changeset/static/src/img/badge_click.png
.. 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.
@ -182,14 +185,21 @@ 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 smashing it by providing a detailed and welcomed
`feedback <https://github.com/OCA/server-tools/issues/new?body=module:%20base_changeset%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
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.
@ -211,6 +221,8 @@ Contributors
* 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
~~~~~~~~~~~
@ -233,6 +245,6 @@ 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/15.0/base_changeset>`_ project on GitHub.
This module is part of the `OCA/server-tools <https://github.com/OCA/server-tools/tree/16.0/base_changeset>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@ -23,10 +23,10 @@
],
"assets": {
"web.assets_backend": [
"base_changeset/static/src/js/backend.js",
"base_changeset/static/src/scss/backend.scss",
"base_changeset/static/src/components/form_label.*",
"base_changeset/static/src/components/changeset_popover.*",
"base_changeset/static/src/components/record.esm.js",
],
"web.assets_qweb": ["base_changeset/static/src/xml/backend.xml"],
},
"demo": ["demo/changeset_field_rule.xml"],
"installable": True,

View File

@ -32,8 +32,8 @@
<field name="field_id" ref="base.field_res_partner__country_id" />
<field name="action">auto</field>
</record>
<record model="changeset.field.rule" id="changeset_field_rule_credit_limit">
<field name="field_id" ref="base.field_res_partner__credit_limit" />
<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>

View File

@ -68,7 +68,9 @@ class Base(models.AbstractModel):
:args:
:returns: list of models
"""
models = self.env["changeset.field.rule"].search([]).mapped("model_id.model")
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
@ -144,16 +146,15 @@ class Base(models.AbstractModel):
return res
@api.model
def _fields_view_get(
self, view_id=None, view_type="form", toolbar=False, submenu=False
):
res = super()._fields_view_get(
view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu
)
to_track_changeset = self._name in self.models_to_track_changeset()
can_see = len(self) == 1 and self.user_can_see_changeset
button_label = _("Changes")
if to_track_changeset and can_see and view_type == "form":
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']"):
xml_field = etree.Element(
@ -179,10 +180,14 @@ class Base(models.AbstractModel):
res["arch"] = etree.tostring(doc, encoding="unicode")
return res
def _compute_user_can_see_changeset(self):
is_superuser = self.env.is_superuser()
has_changeset_group = self.user_has_groups(
@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 = is_superuser or has_changeset_group
rec.user_can_see_changeset = user_can_see_changeset

View File

@ -110,7 +110,7 @@ class ChangesetFieldRule(models.Model):
"""
domain = self._get_rules_search_domain(record_model_name, source_model_name)
model_rules = self.search(
model_rules = self.sudo().search(
domain,
# using 'ASC' means that 'NULLS LAST' is the default
order="source_model_id ASC",
@ -164,9 +164,9 @@ class ChangesetFieldRule(models.Model):
self.expression, {"object": record, "user": self.env.user}
)
@api.model
def create(self, vals):
record = super().create(vals)
@api.model_create_multi
def create(self, vals_list):
record = super().create(vals_list)
self.clear_caches()
return record

View File

@ -130,7 +130,11 @@ class RecordChangesetChange(models.Model):
@api.model
def _reference_models(self):
models = self.env["ir.model"].search([])
"""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 = {
@ -188,7 +192,7 @@ class RecordChangesetChange(models.Model):
@api.model
def get_field_for_type(self, field, prefix):
assert prefix in ("origin", "old", "new")
field_type = self._type_to_suffix.get(field.ttype)
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)
@ -347,11 +351,12 @@ class RecordChangesetChange(models.Model):
:returns: dict of values, boolean
"""
new_field_name = self.get_field_for_type(rule.field_id, "new")
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": rule.field_id.id,
"field_id": field.id,
"rule_id": rule.id,
}
if rule.action == "auto":
@ -368,7 +373,7 @@ class RecordChangesetChange(models.Model):
# Normally the 'old' value is set when we use the 'apply'
# button, but since we short circuit the 'apply', we
# directly set the 'old' value here
old_field_name = self.get_field_for_type(rule.field_id, "old")
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)
@ -380,7 +385,13 @@ class RecordChangesetChange(models.Model):
return change, pop_value
@api.model
def get_fields_changeset_changes(self, model, res_id):
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",
@ -393,8 +404,14 @@ class RecordChangesetChange(models.Model):
("changeset_id.res_id", "=", res_id),
("state", "in", states),
]
return self.search_read(domain, fields)
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")

View File

@ -4,3 +4,5 @@
* Dennis Sluijk <d.sluijk@onestein.nl>
* Andrea Stirpe <a.stirpe@onestein.nl>
* Holger Brunn <mail@hunki-enterprises.com>
* Mark Schuit <mark@gig.solutions>
* Stefan Rijnhart <stefan@opener.amsterdam>

View File

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

View File

@ -16,8 +16,14 @@
</data>
<data noupdate="1">
<record id="group_changeset_manager" model="res.groups">
<field name="users" eval="[(4, ref('base.user_root'))]" />
<field name="implied_ids" eval="[(4, ref('group_changeset_user'))]" />
<field
name="users"
eval="[Command.link(ref('base.user_root')), Command.link(ref('base.user_admin'))]"
/>
<field
name="implied_ids"
eval="[Command.link(ref('group_changeset_user'))]"
/>
</record>
</data>
</odoo>

View File

@ -1,20 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
<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 7952 2016-07-26 18:15:59Z milde $
: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 http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
@ -366,8 +365,10 @@ ul.auto-toc {
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:97cd931d612b60483f97e88ae7a01f5dbb3d5bfba9ed8caed7a148c490c14369
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Alpha" src="https://img.shields.io/badge/maturity-Alpha-red.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/server-tools/tree/15.0/base_changeset"><img alt="OCA/server-tools" src="https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/server-tools-15-0/server-tools-15-0-base_changeset"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/149/15.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Alpha" src="https://img.shields.io/badge/maturity-Alpha-red.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/server-tools/tree/16.0/base_changeset"><img alt="OCA/server-tools" src="https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-base_changeset"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/server-tools&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module extends the functionality of records. It allows to create
changesets that must be validated when a record is modified instead of direct
modifications. Rules allow to configure which field must be validated.</p>
@ -387,11 +388,11 @@ Only for development or testing purpose, do not use in production.
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="id1">Configuration</a></li>
<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="#id1">Configuration</a></h2>
<h2><a class="toc-backref" href="#toc-entry-1">Configuration</a></h2>
</div>
</div>
<div class="section" id="access-rights">
@ -406,7 +407,7 @@ with the group <tt class="docutils literal">Changesets Validations</tt></p>
Record Changesets &gt; Fields Rules</tt>.</p>
<ul>
<li><p class="first">Configuration of rules</p>
<img alt="https://raw.githubusercontent.com/OCA/server-tools/15.0/base_changeset/static/src/img/rules.png" src="https://raw.githubusercontent.com/OCA/server-tools/15.0/base_changeset/static/src/img/rules.png" />
<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>
@ -452,7 +453,7 @@ Changesets &gt; Changesets</tt>.</p>
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/15.0/base_changeset/static/src/img/changeset.png" src="https://raw.githubusercontent.com/OCA/server-tools/15.0/base_changeset/static/src/img/changeset.png" />
<img alt="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/changeset.png" src="https://raw.githubusercontent.com/OCA/server-tools/16.0/base_changeset/static/src/img/changeset.png" />
</li>
</ul>
<p>The changes view shows the name of the records field, the Origin value
@ -470,13 +471,13 @@ 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/15.0/base_changeset/static/src/img/badge.png" src="https://raw.githubusercontent.com/OCA/server-tools/15.0/base_changeset/static/src/img/badge.png" />
<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/15.0/base_changeset/static/src/img/badge_click.png" src="https://raw.githubusercontent.com/OCA/server-tools/15.0/base_changeset/static/src/img/badge_click.png" />
<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>
@ -510,14 +511,21 @@ will fail.</p>
<ul class="simple">
<li>Only a subset of the type of fields is actually supported</li>
<li>Multicompany not fully supported</li>
<li>The popover widget indicating the number of pending changes is not shown for
fields without a label at the moment. The approach was already failing in 15.0
(in the case of inline fields such as the partner address fields)
and even in 14.0 (in the case of fields for which no value was set yet).
Or, for a more flexible approach, implement a kind of view preprocessing that
allows a developer to indicate where the widget needs to go (analogous to
<cite>&lt;label for=”field_name” /&gt;</cite>).</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h2>Bug Tracker</h2>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/server-tools/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/server-tools/issues/new?body=module:%20base_changeset%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
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">
@ -538,6 +546,8 @@ If you spotted it first, help us smashing it by providing a detailed and welcome
<li>Dennis Sluijk &lt;<a class="reference external" href="mailto:d.sluijk&#64;onestein.nl">d.sluijk&#64;onestein.nl</a>&gt;</li>
<li>Andrea Stirpe &lt;<a class="reference external" href="mailto:a.stirpe&#64;onestein.nl">a.stirpe&#64;onestein.nl</a>&gt;</li>
<li>Holger Brunn &lt;<a class="reference external" href="mailto:mail&#64;hunki-enterprises.com">mail&#64;hunki-enterprises.com</a>&gt;</li>
<li>Mark Schuit &lt;<a class="reference external" href="mailto:mark&#64;gig.solutions">mark&#64;gig.solutions</a>&gt;</li>
<li>Stefan Rijnhart &lt;<a class="reference external" href="mailto:stefan&#64;opener.amsterdam">stefan&#64;opener.amsterdam</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
@ -548,8 +558,8 @@ If you spotted it first, help us smashing it by providing a detailed and welcome
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p>
<p><a class="reference external" href="https://github.com/astirpe"><img alt="astirpe" src="https://github.com/astirpe.png?size=40px" /></a></p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/server-tools/tree/15.0/base_changeset">OCA/server-tools</a> project on GitHub.</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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,160 +0,0 @@
odoo.define("base_changeset", function (require) {
"use strict";
var FormRenderer = require("web.FormRenderer");
var FormController = require("web.FormController");
var BasicModel = require("web.BasicModel");
var core = require("web.core");
var qweb = core.qweb;
FormController.include({
start: function () {
return this._super
.apply(this, arguments)
.then(this._updateChangeset.bind(this));
},
update: function () {
var self = this;
var res = this._super.apply(this, arguments);
res.then(function () {
self._updateChangeset();
});
return res;
},
_updateChangeset: function () {
var self = this;
var state = this.model.get(this.handle);
this.model
.getChangeset(state.model, state.data.id)
.then(function (changeset) {
self.renderer.renderChangesetPopovers(changeset);
});
},
applyChange: function (id) {
this.model.applyChange(id).then(this.reload.bind(this));
},
rejectChange: function (id) {
this.model.rejectChange(id).then(this.reload.bind(this));
},
});
FormRenderer.include({
renderChangesetPopovers: function (changeset) {
var self = this;
_.each(changeset, function (changes, fieldName) {
var labelId = self._getIDForLabel(fieldName);
var $label = self.$el.find(_.str.sprintf('label[for="%s"]', labelId));
if (!$label.length) {
var widgets = _.filter(
self.allFieldWidgets[self.state.id],
function (widget) {
return widget.name === fieldName;
}
);
if (widgets.length === 1) {
var widget = widgets[0];
$label = widget.$el;
} else {
return;
}
}
self._renderChangesetPopover($label, changes);
});
},
_renderChangesetPopover: function ($el, changes) {
var self = this;
if (this.mode !== "readonly") {
return;
}
var $button = $(
qweb.render("ChangesetButton", {
count: changes.length,
})
);
$el.append($button);
var options = {
content: function () {
var $content = $(
qweb.render("ChangesetPopover", {
changes: changes,
})
);
$content.find(".base_changeset_apply").on("click", function () {
self._applyClicked($(this));
});
$content.find(".base_changeset_reject").on("click", function () {
self._rejectClicked($(this));
});
return $content;
},
html: true,
placement: "bottom",
title: "Pending Changes",
trigger: "focus",
delay: {show: 0, hide: 100},
template: qweb.render("ChangesetTemplate"),
};
$button.popover(options);
},
_applyClicked: function ($el) {
var id = parseInt($el.data("id"), 10);
this.getParent().applyChange(id);
},
_rejectClicked: function ($el) {
var id = parseInt($el.data("id"), 10);
this.getParent().rejectChange(id);
},
});
BasicModel.include({
applyChange: function (id) {
return this._rpc({
model: "record.changeset.change",
method: "apply",
args: [[id]],
context: _.extend({}, this.context, {set_change_by_ui: true}),
});
},
rejectChange: function (id) {
return this._rpc({
model: "record.changeset.change",
method: "cancel",
args: [[id]],
context: _.extend({}, this.context, {set_change_by_ui: true}),
});
},
getChangeset: function (modelName, resId) {
var self = this;
return new Promise(function (resolve) {
return self
._rpc({
model: "record.changeset.change",
method: "get_fields_changeset_changes",
args: [modelName, resId],
})
.then(function (changeset) {
var res = {};
_.each(changeset, function (changesetChange) {
if (!_.contains(_.keys(res), changesetChange.field_name)) {
res[changesetChange.field_name] = [];
}
res[changesetChange.field_name].push(changesetChange);
});
resolve(res);
});
});
},
});
});

View File

@ -1,12 +0,0 @@
.base_changeset_reject,
.base_changeset_apply {
width: 25px;
}
.base_changeset_button {
cursor: pointer;
}
.base_changeset_popover {
max-width: 100%;
}

View File

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t t-name="ChangesetPopover">
<table class="pb-4">
<tr t-foreach="changes" t-as="change">
<td>
<t t-esc="change.origin_value_display" />
</td>
<td class="pl-2 pr-2">
<i class="fa fa-arrow-right" />
</td>
<td>
<t t-esc="change.new_value_display" />
</td>
<td class="pl-4" t-if="change.user_can_validate_changeset">
<div class="btn-group">
<button
class="btn btn-danger base_changeset_reject btn-sm"
t-attf-data-id="#{change.id}"
>
<i class="fa fa-times" />
</button>
<button
class="btn btn-success base_changeset_apply btn-sm"
t-attf-data-id="#{change.id}"
>
<i class="fa fa-check" />
</button>
</div>
</td>
</tr>
</table>
</t>
<t t-name="ChangesetButton">
<a
role="button"
tabindex="0"
aria-label="Pending Changes"
title="Pending Changes"
data-toggle="tooltip"
class="badge badge-warning badge-pill base_changeset_button ml-2"
>
<t t-esc="count" />
</a>
</t>
<t t-name="ChangesetTemplate">
<div class="popover base_changeset_popover" role="tooltip">
<div class="arrow" />
<h3 class="popover-header" />
<div class="popover-body" />
</div>
</t>
</templates>

View File

@ -6,11 +6,12 @@ from odoo.tests import common
class TestChangesetFieldRule(common.TransactionCase):
def setUp(self):
super().setUp()
self.company_model_id = self.env.ref("base.model_res_company").id
self.field_name = self.env.ref("base.field_res_partner__name")
self.field_street = self.env.ref("base.field_res_partner__street")
@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"]

View File

@ -14,8 +14,9 @@ from .common import ChangesetTestCommon
class TestChangesetFieldType(ChangesetTestCommon, TransactionCase):
"""Check that changeset changes are stored expectingly to their types"""
def _setup_rules(self):
ChangesetFieldRule = self.env["changeset.field.rule"]
@classmethod
def _setup_rules(cls):
ChangesetFieldRule = cls.env["changeset.field.rule"]
ChangesetFieldRule.search([]).unlink()
fields = (
("char", "ref"),
@ -23,7 +24,7 @@ class TestChangesetFieldType(ChangesetTestCommon, TransactionCase):
("boolean", "is_company"),
("date", "date"),
("integer", "color"),
("float", "credit_limit"),
("float", "partner_latitude"),
("selection", "type"),
("many2one", "country_id"),
("many2many", "category_id"),
@ -32,25 +33,26 @@ class TestChangesetFieldType(ChangesetTestCommon, TransactionCase):
)
for field_type, field in fields:
attr_name = "field_%s" % field_type
field_record = self.env["ir.model.fields"].search(
field_record = cls.env["ir.model.fields"].search(
[("model", "=", "res.partner"), ("name", "=", field)]
)
self.assertTrue(field_record, "Field %s not available" % 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(self, attr_name, field_record)
setattr(cls, attr_name, field_record)
ChangesetFieldRule.create(
{"field_id": field_record.id, "action": "validate"}
)
def setUp(self):
super().setUp()
self._setup_rules()
self.partner = self.env["res.partner"].create(
@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
self.partner = self.partner.with_context(test_record_changeset=True)
cls.partner = cls.partner.with_context(test_record_changeset=True)
def test_new_changeset_char(self):
"""Add a new changeset on a Char field"""

View File

@ -4,6 +4,8 @@
from datetime import datetime, timedelta
from lxml import etree
from odoo import fields
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase
@ -29,28 +31,51 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
becomes 'done'
"""
def _setup_rules(self):
ChangesetFieldRule = self.env["changeset.field.rule"]
@classmethod
def _setup_rules(cls):
ChangesetFieldRule = cls.env["changeset.field.rule"]
ChangesetFieldRule.search([]).unlink()
self.field_name = self.env.ref("base.field_res_partner__name")
self.field_street = self.env.ref("base.field_res_partner__street")
self.field_street2 = self.env.ref("base.field_res_partner__street2")
ChangesetFieldRule.create({"field_id": self.field_name.id, "action": "auto"})
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": self.field_street.id, "action": "validate"}
)
ChangesetFieldRule.create(
{"field_id": self.field_street2.id, "action": "never"}
{"field_id": cls.field_street.id, "action": "validate"}
)
ChangesetFieldRule.create({"field_id": cls.field_street2.id, "action": "never"})
def setUp(self):
super().setUp()
self._setup_rules()
self.partner = self.env["res.partner"].create(
{"name": "X", "street": "street X", "street2": "street2 X"}
@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
self.partner = self.partner.with_context(test_record_changeset=True)
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
@ -64,7 +89,7 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.assertEqual(self.partner.count_pending_changeset_changes, 1)
self.assert_changeset(
self.partner,
self.env.user,
self.demo_user,
[
(self.field_name, "X", "Y", "done"),
(self.field_street, "street X", "street Y", "draft"),
@ -74,6 +99,11 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
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"""
@ -140,7 +170,7 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assert_changeset(
self.partner,
self.env.user,
self.demo_user,
[(self.field_street, "street X", False, "draft")],
)
@ -168,7 +198,7 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
for change in changeset.change_ids:
change.get_fields_changeset_changes(changeset.model, changeset.res_id)
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()
@ -197,9 +227,11 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.assertEqual(self.partner.street, "street X")
self.assertEqual(self.partner.changeset_ids.change_ids.state, "draft")
user = self.env.ref("base.user_demo")
user.groups_id += self.env.ref("base_changeset.group_changeset_user")
self.partner.changeset_ids.change_ids.with_user(user).apply()
# 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)
@ -253,7 +285,7 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
for change in changeset.change_ids:
change.get_fields_changeset_changes(changeset.model, changeset.res_id)
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()
@ -272,7 +304,7 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.partner._compute_count_pending_changesets()
self.assertEqual(self.partner.count_pending_changesets, 1)
for change in changeset.change_ids:
change.get_fields_changeset_changes(changeset.model, changeset.res_id)
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()
@ -294,7 +326,7 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(self.partner.count_pending_changeset_changes, 3)
for change in changeset.change_ids:
change.get_fields_changeset_changes(changeset.model, changeset.res_id)
change.get_changeset_changes_by_field(changeset.model, changeset.res_id)
changeset.apply()
self.partner._compute_changeset_ids()
self.partner._compute_count_pending_changesets()
@ -381,7 +413,7 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.assertEqual(self.partner.count_pending_changesets, 1)
self.assertEqual(self.partner.count_pending_changeset_changes, 3)
for change in changeset.change_ids:
change.get_fields_changeset_changes(changeset.model, changeset.res_id)
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()
@ -390,7 +422,7 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.assertEqual(partner2.count_pending_changesets, 1)
self.assertEqual(partner2.count_pending_changeset_changes, 3)
for change in changeset2.change_ids:
change.get_fields_changeset_changes(changeset2.model, changeset2.res_id)
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")
@ -406,7 +438,7 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
self.partner.write({"street": False})
self.partner._compute_changeset_ids()
changeset = self.partner.changeset_ids
self.assertEqual(changeset.source, self.env.user)
self.assertEqual(changeset.source, self.demo_user)
def test_new_changeset_source_other_model(self):
"""Define source from another model"""
@ -444,10 +476,10 @@ class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
]
).expression = "object.street != 'street X'"
self.partner.street = "street Y"
self.partner.refresh()
self.partner.invalidate_recordset()
self.assertEqual(self.partner.street, "street Y")
self.assertFalse(self.partner.changeset_ids)
self.partner.street = "street Z"
self.partner.refresh()
self.partner.invalidate_recordset()
self.assertTrue(self.partner.changeset_ids)
self.assertEqual(self.partner.street, "street Y")

View File

@ -17,20 +17,20 @@ class TestChangesetOrigin(ChangesetTestCommon, TransactionCase):
displays the 'old' value.
"""
def _setup_rules(self):
ChangesetFieldRule = self.env["changeset.field.rule"]
@classmethod
def _setup_rules(cls):
ChangesetFieldRule = cls.env["changeset.field.rule"]
ChangesetFieldRule.search([]).unlink()
self.field_name = self.env.ref("base.field_res_partner__name")
ChangesetFieldRule.create(
{"field_id": self.field_name.id, "action": "validate"}
)
cls.field_name = cls.env.ref("base.field_res_partner__name")
ChangesetFieldRule.create({"field_id": cls.field_name.id, "action": "validate"})
def setUp(self):
super().setUp()
self._setup_rules()
self.partner = self.env["res.partner"].create({"name": "X"})
@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
self.partner = self.partner.with_context(test_record_changeset=True)
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

View File

@ -9,13 +9,14 @@ from .common import ChangesetTestCommon
class TestChangesetFlow(ChangesetTestCommon, TransactionCase):
"""Check that changesets don't leak information"""
def setUp(self):
super().setUp()
self.env["changeset.field.rule"].search([]).unlink()
self.rule = self.env["changeset.field.rule"].create(
@classmethod
def setUp(cls):
super().setUpClass()
cls.env["changeset.field.rule"].search([]).unlink()
cls.rule = cls.env["changeset.field.rule"].create(
{
"model_id": self.env.ref("base.model_ir_config_parameter").id,
"field_id": self.env.ref("base.field_ir_config_parameter__key").id,
"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",
}
)

View File

@ -5,7 +5,7 @@
<field name="arch" type="xml">
<tree>
<field name="model_id" />
<field name="field_id" />
<field name="field_id" options="{'no_create': True}" />
<field name="source_model_id" />
<field name="expression" />
<field name="validator_group_ids" />