[IMP] base: allow server actions to update records across relations

Up until now, the 'Update record' server actions could only update the
record on which the action was triggered.

This is a rather serious limitations, especially in modules that
leverage server actions, like base automation - indeed, if you want a
server action run on e.g. Leads that will write changes on the leads'
customer, you *had* to use Python code.

This commit makes it possible to traverse relations when selecting the
field to update, and indeed to write across relations.

Task-3450200

Part-of: odoo/odoo#138804
This commit is contained in:
Damien Bouvy
2023-10-26 13:41:07 +00:00
parent 8bdac7e26c
commit 253c214b5b
5 changed files with 162 additions and 34 deletions
@@ -22,7 +22,7 @@ class TestAutomation(TransactionCase):
"name": "Set Active To False",
"base_automation_id": automation.id,
"state": "object_write",
"update_field_id": self.env.ref("base.field_res_partner__active").id,
"update_path": "active",
"value": False,
"model_id": model.id,
})
@@ -51,9 +51,10 @@ class TestAutomation(TransactionCase):
"base_automation_id": automation.id,
"model_id": model.id,
"state": "object_write",
"update_field_id": self.env.ref("base.field_ir_filters__active").id,
"update_path": "active",
"value": False
})
action.flush_recordset()
automation.write({"action_server_ids": [Command.link(action.id)]})
# action cached was cached with admin, force CacheMiss
automation.env.clear()
@@ -695,6 +695,10 @@
}
}
.o_field_DynamicModelFieldSelectorChar.oe_inline {
min-width: 7rem;
}
// One2Many, Many2Many outside of group
.o_field_widget {
&.o_field_one2many, &.o_field_many2many {
+109 -24
View File
@@ -11,8 +11,9 @@ from odoo.http import request
import base64
from collections import defaultdict
import functools
from functools import partial, reduce
import logging
from operator import getitem
from pytz import timezone
@@ -439,6 +440,18 @@ class IrActionsServer(models.Model):
# - Command: x2many commands namespace
# To return an action, assign: action = {...}\n\n\n\n"""
@api.model
def _default_update_path(self):
if not self.env.context.get('default_model_id'):
return ''
ir_model = self.env['ir.model'].browse(self.env.context['default_model_id'])
model = self.env[ir_model.model]
sensible_default_fields = ['partner_id', 'user_id', 'user_ids', 'stage_id', 'state', 'active']
for field_name in sensible_default_fields:
if field_name in model._fields:
return field_name
return ''
name = fields.Char(compute='_compute_name', store=True, readonly=False, required=True)
type = fields.Char(default='ir.actions.server')
usage = fields.Selection([
@@ -479,7 +492,7 @@ class IrActionsServer(models.Model):
# Create
crud_model_id = fields.Many2one(
'ir.model', string='Record to Create',
compute='_compute_crud_model_id', readonly=False, store=True,
compute='_compute_crud_relations', readonly=False, store=True,
help="Specify which kind of record should be created. Set this field only to specify a different model than the base model.")
crud_model_name = fields.Char(related='crud_model_id.model', string='Target Model Name', readonly=True)
link_field_id = fields.Many2one(
@@ -489,8 +502,9 @@ class IrActionsServer(models.Model):
groups_id = fields.Many2many('res.groups', 'ir_act_server_group_rel',
'act_id', 'gid', string='Allowed Groups', help='Groups that can execute the server action. Leave empty to allow everybody.')
update_field_id = fields.Many2one('ir.model.fields', string='Field to Update', ondelete='cascade')
update_related_model_id = fields.Many2one('ir.model', compute='_compute_update_related_model_id')
update_field_id = fields.Many2one('ir.model.fields', string='Field to Update', ondelete='cascade', compute='_compute_crud_relations', store=True, readonly=False)
update_path = fields.Char(string='Field to Update Path', help="Path to the field to update, e.g. 'partner_id.name'", default=_default_update_path)
update_related_model_id = fields.Many2one('ir.model', compute='_compute_crud_relations', store=True)
value = fields.Text(help="For Python expressions, this field may hold a Python expression "
"that can use the same values as for the code field on the server action,"
@@ -517,10 +531,8 @@ class IrActionsServer(models.Model):
def _compute_name(self):
for action in self.filtered('state'):
if action.state == 'object_write':
action.name = _(
"Update %(field_name)s",
field_name=action.update_field_id.field_description
)
action.name = _("Update %s", action._stringify_path())
elif action.state == 'object_create':
action.name = _(
"Create %(model_name)s with name %(value)s",
@@ -537,11 +549,91 @@ class IrActionsServer(models.Model):
)
self.available_model_ids = allowed_models.ids
@api.onchange('model_id')
def _compute_crud_model_id(self):
invalid = self.filtered(lambda act: act.crud_model_id != act.model_id)
if invalid:
invalid.crud_model_id = False
@api.depends('model_id', 'update_path', 'state')
def _compute_crud_relations(self):
""" Compute the crud_model_id and update_field_id fields.
The crud_model_id is the model on which the action will create or update
records. In the case of record creation, it is the same as the main model
of the action. For record update, it will be the model linked to the last
field in the update_path.
This is only used for object_create and object_write actions.
The update_field_id is the field at the end of the update_path that will
be updated by the action - only used for object_write actions.
"""
for action in self:
if action.model_id and action.state in ('object_write', 'object_create'):
if action.state == 'object_create':
action.crud_model_id = action.model_id
action.update_field_id = False
action.update_path = False
elif action.state == 'object_write':
if action.update_path:
# we need to traverse relations to find the target model and field
model, field, _ = action._traverse_path()
action.crud_model_id = model
action.update_field_id = field
need_update_model = action.evaluation_type == 'value' and action.update_field_id and action.update_field_id.relation
action.update_related_model_id = action.env["ir.model"]._get_id(field.relation) if need_update_model else False
else:
action.crud_model_id = action.model_id
action.update_field_id = False
else:
action.crud_model_id = False
action.update_field_id = False
action.update_path = False
def _traverse_path(self, record=None):
""" Traverse the update_path to find the target model and field, and optionally
the target record of an action of type 'object_write'.
:param record: optional record to use as starting point for the path traversal
:return: a tuple (model, field, records) where model is the target model and field is the
target field; if no record was provided, records is None, otherwise it is the
recordset at the end of the path starting from the provided record
"""
self.ensure_one()
path = self.update_path.split('.')
Model = self.env[self.model_id.model]
# sanity check: we're starting from a record that belongs to the model
if record and record._name != Model._name:
raise ValidationError(_("I have no idea how you *did that*, but you're trying to use a gibberish configuration: the model of the record on which the action is triggered is not the same as the model of the action."))
for field_name in path:
is_last_field = field_name == path[-1]
field = Model._fields[field_name]
if field.relational and not is_last_field:
Model = self.env[field.comodel_name]
elif not field.relational:
# sanity check: this should be the last field in the path
if not is_last_field:
raise ValidationError(_("The path to the field to update contains a non-relational field (%s) that is not the last field in the path. You can't traverse non-relational fields (even in the quantum realm). Make sure only the last field in the path is non-relational.", field_name))
if isinstance(field, fields.Json):
raise ValidationError(_("I'm sorry to say that JSON fields (such as %s) are currently not supported.", field_name))
elif field.readonly:
raise ValidationError(_("The field to update (%s) is read-only. You can't update a read-only field - even when asking nicely.", field_name))
target_records = None
if record is not None:
target_records = reduce(getitem, path[:-1], record)
model_id = self.env['ir.model']._get(Model._name)
field_id = self.env['ir.model.fields']._get(Model._name, field_name)
return model_id, field_id, target_records
def _stringify_path(self):
""" Returns a string representation of the update_path, with the field names
separated by the `>` symbol."""
self.ensure_one()
path = self.update_path
if not path:
return ''
model = self.env[self.model_id.model]
pretty_path = []
for field_name in path.split('.'):
field = model._fields[field_name]
field_id = self.env['ir.model.fields']._get(model._name, field_name)
if field.relational:
model = self.env[field.comodel_name]
pretty_path.append(field_id.field_description)
return ' > '.join(pretty_path)
@api.depends('model_id')
def _compute_link_field_id(self):
@@ -576,7 +668,7 @@ class IrActionsServer(models.Model):
fn = getattr(t, f'_run_action_{self.state}', None)\
or getattr(t, f'run_action_{self.state}', None)
if fn and fn.__name__.startswith('run_action_'):
fn = functools.partial(fn, self)
fn = partial(fn, self)
return fn, multi
def _register_hook(self):
@@ -623,7 +715,9 @@ class IrActionsServer(models.Model):
for field, new_value in res.items():
record_cached[field] = new_value
else:
self.env[self.model_id.model].browse(self._context.get('active_id')).write(res)
starting_record = self.env[self.model_id.model].browse(self._context.get('active_id'))
_, _, target_records = self._traverse_path(record=starting_record)
target_records.write(res)
def _run_action_object_create(self, eval_context=None):
"""Create specified model object with specified name contained in value.
@@ -756,15 +850,6 @@ class IrActionsServer(models.Model):
)
return res or False
@api.depends('update_field_id')
def _compute_update_related_model_id(self):
for action in self:
if action.evaluation_type == 'value' and action.update_field_id and action.update_field_id.relation:
relation = action.update_field_id.relation
action.update_related_model_id = action.env["ir.model"]._get_id(relation)
else:
action.update_related_model_id = False
@api.depends('evaluation_type', 'update_field_id')
def _compute_value_field_to_show(self): # check if value_field_to_show can be removed and use ttype in xml view instead
for action in self:
+44 -4
View File
@@ -189,7 +189,7 @@ ZeroDivisionError: division by zero""" % self.test_server_action.id
# Do: update partner name
self.action.write({
'state': 'object_write',
'update_field_id': self.res_partner_name_field.id,
'update_path': 'name',
'value': 'TestNew',
})
run_res = self.action.with_context(self.context).run()
@@ -209,7 +209,7 @@ ZeroDivisionError: division by zero""" % self.test_server_action.id
'model_id': self.res_country_model.id,
'model_name': 'res.country',
'state': 'object_write',
'update_field_id': self.res_country_name_position_field.id,
'update_path': 'name_position',
'selection_value': selection_value.id,
})
action._set_selection_value() # manual onchange
@@ -223,6 +223,47 @@ ZeroDivisionError: division by zero""" % self.test_server_action.id
# Test: country updated
self.assertEqual(self.test_country.name_position, 'after')
def test_37_field_path_traversal(self):
""" Test the update_path field traversal - allowing records to be updated along relational links """
# update the country's name via the partner
self.action.write({
'state': 'object_write',
'update_path': 'country_id.name',
'value': 'TestUpdatedCountry',
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: partner updated
self.assertEqual(self.test_partner.country_id.name, 'TestUpdatedCountry', 'ir_actions_server: country name should have been updated through relation')
# input an invalid path
with self.assertRaises(ValidationError):
self.action.write({
'state': 'object_write',
'update_path': 'country_id.name.foo',
'value': 'DoesNotMatter',
})
self.action.flush_recordset(['update_path', 'update_field_id'])
# update a readonly field
self.action.write({
'state': 'object_write',
'update_path': 'country_id.id',
'value': 0,
})
self.action.flush_recordset(['update_path', 'update_field_id'])
def test_38_field_traversal_name_compute(self):
""" Test the automated computed name of an 'update record' action """
# update the country's name via the partner
action_with_name = self.action.with_context(automatic_action_name=True)
action_with_name.write({
'state': 'object_write',
'update_path': 'country_id.name',
'value': 'TestUpdatedCountry',
})
self.assertEqual(action_with_name.name, 'Update Country > Country Name', 'ir_actions_server: name should have been updated automatically')
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.models')
def test_40_multi(self):
# Data: 2 server actions that will be nested
@@ -245,9 +286,8 @@ ZeroDivisionError: division by zero""" % self.test_server_action.id
'name': 'Subaction2',
'sequence': 3,
'model_id': self.res_partner_model.id,
'crud_model_id': self.res_partner_model.id,
'state': 'object_write',
'update_field_id': self.res_partner_city_field.id,
'update_path': 'city',
'value': 'RaoulettePoiluchette',
})
action4 = self.action.create({
+2 -4
View File
@@ -342,10 +342,8 @@
<separator string="Action Details" groups="base.group_no_one" />
<div class="d-flex flex-row flex-wrap gap-2" invisible="state != 'object_write'">
<field name="evaluation_type" class="oe_inline"/>
<field name="update_field_id"
placeholder="Select a field..."
options="{'no_create': True, 'no_open': True}" context="{'hide_model': True}"
domain="[('store', '=', True), '|', ('model_id', '=', crud_model_id), ('model_id', '=', model_id)]"/>
<field name="update_path" widget="DynamicModelFieldSelectorChar" class="oe_inline" options="{'model': 'model_name'}"/>
<field name="update_field_id" invisible="1"/>
<field name="value_field_to_show" invisible="1"/>
<field name="update_related_model_id" invisible="1"/>
<span invisible="evaluation_type != 'value'">to</span>