[IMP] base_automation,*: simpler edition workflow
*: base, crm, digest, mail, mass_mailing, sms, test_base_automation,
website_forum, website_sale
This commit makes "Automated Actions" more discoverable and usable by:
- Adding a menu in the kanban header config dropdown to add/edit them.
- Creating a new custom kanban view for a clear understanding of each
automated action record and its associated actions.
- Introducing new "smart" triggers that appear in the form view based on the
chosen model:
- Updated Values category:
- "Stage is set to" when a `stage_id` field exists in the model,
allowing users to select a specific stage value.
- "State is set to" when a `state` field exists in the model,
allowing users to select a specific state value.
- "Priority is set to" (`priority`) where users can select a specific priority.
- "User is set" (`user_id`, `user_ids` fields)
- "Tag is added" (`tag_ids` field) where users can select a specific tag.
- "On Archive"
- "On Unarchive"
- Timing Conditions:
- "After creation"
- "After last update"
- Deprecating previously known triggers "On Creation" (`on_create`) and "On
Update" (`on_write`) to simplify the user experience. "On Creation & Update"
(`on_create_or_write`) is retained and renamed to "On save".
- Changing the `ir.actions.server` Many2one relationship to a One2many
relationship. Automated actions can now directly contain multiple actions,
eliminating the need for an "Execute several actions" action in automation
rules.
- Introducing a widget for the new `ir.actions.server` One2many field for a
clearer understanding of multiple actions.
This commit also enhances the usability of "Server Actions" (`ir.actions`) by:
- Removing the `ir.server.object.lines` model and the associated `fields_lines`
One2Many field. The attributes of the removed model are now merged into
`ir.actions`. An action can now write to only one field, and the create action
is now a name_create action.
- Adapting the form view when creating an "Update the record" action. The value
field shown adapts itself based on the field to update; this field can be a
`reference` field for a `one2many` `update_field_id`, a `one2many` field for a
selection `update_field_id`, or a `text` field otherwise.
- Refactoring the form view to display only relevant details and other
miscellaneous improvements.
Taskid: 3085360
Part-of: odoo/odoo#114352
Co-authored-by: Florent Dardenne <dafl@odoo.com>
Co-authored-by: Julien Carion <juca@odoo.com>
Co-authored-by: Lucas Perais <lpe@odoo.com>
This commit is contained in:
committed by
Damien Bouvy
co-authored by
Florent Dardenne
Julien Carion
Lucas Perais
parent
e1b0763347
commit
0a744accc2
@@ -2,14 +2,14 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Automated Action Rules',
|
||||
'name': 'Automation Rules',
|
||||
'version': '1.0',
|
||||
'category': 'Sales/Sales',
|
||||
'description': """
|
||||
This module allows to implement action rules for any object.
|
||||
============================================================
|
||||
This module allows to implement automation rules for any object.
|
||||
================================================================
|
||||
|
||||
Use automated actions to automatically trigger actions for various screens.
|
||||
Use automation rules to automatically trigger actions for various screens.
|
||||
|
||||
**Example:** A lead created by a specific user may be automatically set to a specific
|
||||
Sales Team, or an opportunity which still has status pending after 14 days might
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="ir_cron_data_base_automation_check" model="ir.cron">
|
||||
<field name="name">Base Action Rule: check and execute</field>
|
||||
<field name="name">Automation Rules: check and execute</field>
|
||||
<field name="model_id" ref="model_base_automation"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._check(True)</field>
|
||||
|
||||
@@ -8,7 +8,7 @@ from collections import defaultdict
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import _, api, exceptions, fields, models
|
||||
from odoo import _, api, Command, exceptions, fields, models
|
||||
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
|
||||
from odoo.tools import safe_eval
|
||||
|
||||
@@ -30,25 +30,97 @@ DATE_RANGE_FACTOR = {
|
||||
False: 0,
|
||||
}
|
||||
|
||||
CREATE_TRIGGERS = [
|
||||
'on_create',
|
||||
|
||||
'on_create_or_write',
|
||||
'on_priority_set',
|
||||
'on_stage_set',
|
||||
'on_state_set',
|
||||
'on_tag_set',
|
||||
'on_user_set',
|
||||
]
|
||||
|
||||
WRITE_TRIGGERS = [
|
||||
'on_write',
|
||||
'on_archive',
|
||||
'on_unarchive',
|
||||
|
||||
'on_create_or_write',
|
||||
'on_priority_set',
|
||||
'on_stage_set',
|
||||
'on_state_set',
|
||||
'on_tag_set',
|
||||
'on_user_set',
|
||||
]
|
||||
|
||||
CREATE_WRITE_SET = set(CREATE_TRIGGERS + WRITE_TRIGGERS)
|
||||
|
||||
TIME_TRIGGERS = [
|
||||
'on_time',
|
||||
'on_time_created',
|
||||
'on_time_updated',
|
||||
]
|
||||
|
||||
|
||||
class BaseAutomation(models.Model):
|
||||
_name = 'base.automation'
|
||||
_description = 'Automated Action'
|
||||
_order = 'sequence'
|
||||
_description = 'Automation Rule'
|
||||
|
||||
action_server_id = fields.Many2one(
|
||||
'ir.actions.server', 'Server Actions',
|
||||
domain="[('model_id', '=', model_id)]",
|
||||
delegate=True, required=True, ondelete='restrict')
|
||||
name = fields.Char(string="Automation Rule Name", required=True, translate=True)
|
||||
model_id = fields.Many2one(
|
||||
"ir.model", string="Model", required=True, ondelete="cascade", help="Model on which the automation rule runs."
|
||||
)
|
||||
model_name = fields.Char(related="model_id.model", string="Model Name", readonly=True)
|
||||
action_server_ids = fields.One2many("ir.actions.server", "base_automation_id",
|
||||
context={'default_usage': 'base_automation'},
|
||||
string="Actions",
|
||||
compute="_compute_action_server_ids",
|
||||
store=True,
|
||||
readonly=False,
|
||||
)
|
||||
active = fields.Boolean(default=True, help="When unchecked, the rule is hidden and will not be executed.")
|
||||
trigger = fields.Selection([
|
||||
('on_create', 'On Creation'),
|
||||
('on_write', 'On Update'),
|
||||
('on_create_or_write', 'On Creation & Update'),
|
||||
('on_unlink', 'On Deletion'),
|
||||
('on_change', 'Based on Form Modification'),
|
||||
('on_time', 'Based on Timed Condition')
|
||||
], string='Trigger', required=True)
|
||||
trigger = fields.Selection(
|
||||
[
|
||||
('on_stage_set', "Stage is set to"),
|
||||
('on_user_set', "User is set"),
|
||||
('on_tag_set', "Tag is added"),
|
||||
('on_state_set', "State is set to"),
|
||||
('on_priority_set', "Priority is set to"),
|
||||
('on_archive', "On archived"),
|
||||
('on_unarchive', "On unarchived"),
|
||||
('on_create_or_write', "On save"),
|
||||
('on_create', "On creation"), # deprecated, use 'on_create_or_write' instead
|
||||
('on_write', "On update"), # deprecated, use 'on_create_or_write' instead
|
||||
|
||||
('on_unlink', "On deletion"),
|
||||
('on_change', "On live update"),
|
||||
|
||||
('on_time', "Based on date field"),
|
||||
('on_time_created', "After creation"),
|
||||
('on_time_updated', "After last update"),
|
||||
], string='Trigger',
|
||||
compute='_compute_trigger_and_trigger_field_ids', readonly=False, store=True, required=True)
|
||||
trg_selection_field_id = fields.Many2one(
|
||||
'ir.model.fields.selection',
|
||||
string='Trigger Field',
|
||||
domain="[('field_id', 'in', trigger_field_ids)]",
|
||||
compute='_compute_trg_selection_field_id',
|
||||
readonly=False, store=True,
|
||||
help="Some triggers need a reference to a selection field. This field is used to store it.")
|
||||
trg_field_ref_model_name = fields.Char(
|
||||
string='Trigger Field Model',
|
||||
compute='_compute_trg_field_ref__model_and_display_names')
|
||||
trg_field_ref = fields.Many2oneReference(
|
||||
model_field='trg_field_ref_model_name',
|
||||
compute='_compute_trg_field_ref',
|
||||
string='Trigger Reference',
|
||||
readonly=False,
|
||||
store=True,
|
||||
help="Some triggers need a reference to another field. This field is used to store it.")
|
||||
trg_field_ref_display_name = fields.Char(
|
||||
string='Trigger Reference Display Name',
|
||||
compute='_compute_trg_field_ref__model_and_display_names')
|
||||
trg_date_id = fields.Many2one(
|
||||
'ir.model.fields', string='Trigger Date',
|
||||
compute='_compute_trg_date_id',
|
||||
@@ -72,13 +144,19 @@ class BaseAutomation(models.Model):
|
||||
"resource.calendar", string='Use Calendar',
|
||||
compute='_compute_trg_date_calendar_id',
|
||||
readonly=False, store=True,
|
||||
help="When calculating a day-based timed condition, it is possible to use a calendar to compute the date based on working days.")
|
||||
help="When calculating a day-based timed condition, it is possible"
|
||||
"to use a calendar to compute the date based on working days.")
|
||||
filter_pre_domain = fields.Char(
|
||||
string='Before Update Domain',
|
||||
compute='_compute_filter_pre_domain',
|
||||
readonly=False, store=True,
|
||||
help="If present, this condition must be satisfied before the update of the record.")
|
||||
filter_domain = fields.Char(string='Apply on', help="If present, this condition must be satisfied before executing the action rule.")
|
||||
filter_domain = fields.Char(
|
||||
string='Apply on',
|
||||
help="If present, this condition must be satisfied before executing the automation rule.",
|
||||
compute='_compute_filter_domain',
|
||||
readonly=False, store=True
|
||||
)
|
||||
last_run = fields.Datetime(readonly=True, copy=False)
|
||||
on_change_field_ids = fields.Many2many(
|
||||
"ir.model.fields",
|
||||
@@ -90,91 +168,181 @@ class BaseAutomation(models.Model):
|
||||
)
|
||||
trigger_field_ids = fields.Many2many(
|
||||
'ir.model.fields', string='Trigger Fields',
|
||||
compute='_compute_trigger_field_ids', readonly=False, store=True,
|
||||
help="The action will be triggered if and only if one of these fields is updated. If empty, all fields are watched.")
|
||||
compute='_compute_trigger_and_trigger_field_ids', readonly=False, store=True,
|
||||
help="The automation rule will be triggered if and only if one of these fields is updated."
|
||||
"If empty, all fields are watched.")
|
||||
least_delay_msg = fields.Char(compute='_compute_least_delay_msg')
|
||||
|
||||
# which fields have an impact on the registry and the cron
|
||||
CRITICAL_FIELDS = ['model_id', 'active', 'trigger', 'on_change_field_ids']
|
||||
RANGE_FIELDS = ['trg_date_range', 'trg_date_range_type']
|
||||
|
||||
@api.constrains('trigger', 'state')
|
||||
@api.constrains('trigger', 'action_server_ids')
|
||||
def _check_trigger_state(self):
|
||||
if any(action.trigger == 'on_change' and action.state != 'code' for action in self):
|
||||
raise exceptions.ValidationError(
|
||||
_('Form Modification based actions can only be used with code action type.')
|
||||
)
|
||||
if any(action.trigger == 'on_unlink' and action.state in ['mail_post', 'followers', 'next_activity'] for action in self):
|
||||
raise exceptions.ValidationError(
|
||||
_('Email, followers or activities action types cannot be used when deleting records.')
|
||||
for record in self:
|
||||
no_code_actions = record.action_server_ids.filtered(lambda a: a.state != 'code')
|
||||
if record.trigger == 'on_change' and no_code_actions:
|
||||
raise exceptions.ValidationError(
|
||||
_('"On live update" automation rules can only be used with "Execute Python Code" action type.')
|
||||
)
|
||||
mail_actions = record.action_server_ids.filtered(
|
||||
lambda a: a.state in ['mail_post', 'followers', 'next_activity']
|
||||
)
|
||||
if record.trigger == 'on_unlink' and mail_actions:
|
||||
raise exceptions.ValidationError(
|
||||
_('Email, followers or activities action types cannot be used when deleting records, '
|
||||
'as there is no more records on which to apppply these changes!')
|
||||
)
|
||||
|
||||
@api.depends('model_id', 'trigger')
|
||||
@api.depends('model_id')
|
||||
def _compute_action_server_ids(self):
|
||||
self.action_server_ids = [Command.clear()]
|
||||
|
||||
@api.depends('trigger', 'trigger_field_ids')
|
||||
def _compute_trg_date_id(self):
|
||||
invalid = self.filtered(
|
||||
lambda act: act.trigger != 'on_time' or \
|
||||
(act.model_id and act.trg_date_id.model_id != act.model_id)
|
||||
)
|
||||
if invalid:
|
||||
invalid.trg_date_id = False
|
||||
to_reset = self.filtered(lambda a: a.trigger not in TIME_TRIGGERS or len(a.trigger_field_ids) != 1)
|
||||
to_reset.trg_date_id = False
|
||||
for record in (self - to_reset):
|
||||
record.trg_date_id = record.trigger_field_ids
|
||||
|
||||
|
||||
@api.depends('trigger')
|
||||
def _compute_trg_date_range_data(self):
|
||||
not_timed = self.filtered(lambda act: act.trigger != 'on_time')
|
||||
if not_timed:
|
||||
not_timed.trg_date_range = False
|
||||
not_timed.trg_date_range_type = False
|
||||
remaining = (self - not_timed).filtered(lambda act: not act.trg_date_range_type)
|
||||
if remaining:
|
||||
remaining.trg_date_range_type = 'hour'
|
||||
to_reset = self.filtered(lambda a: a.trigger not in TIME_TRIGGERS)
|
||||
to_reset.trg_date_range = False
|
||||
to_reset.trg_date_range_type = False
|
||||
(self - to_reset).filtered(lambda a: not a.trg_date_range_type).trg_date_range_type = 'hour'
|
||||
|
||||
@api.depends('trigger', 'trg_date_id', 'trg_date_range_type')
|
||||
def _compute_trg_date_calendar_id(self):
|
||||
invalid = self.filtered(
|
||||
lambda act: act.trigger != 'on_time' or \
|
||||
not act.trg_date_id or \
|
||||
act.trg_date_range_type != 'day'
|
||||
to_reset = self.filtered(
|
||||
lambda a: a.trigger not in TIME_TRIGGERS or not a.trg_date_id or a.trg_date_range_type != 'day'
|
||||
)
|
||||
if invalid:
|
||||
invalid.trg_date_calendar_id = False
|
||||
to_reset.trg_date_calendar_id = False
|
||||
|
||||
@api.depends('trigger')
|
||||
@api.depends('trigger', 'trigger_field_ids')
|
||||
def _compute_trg_selection_field_id(self):
|
||||
to_reset = self.filtered(lambda a: a.trigger not in ['on_priority_set', 'on_state_set'] or len(a.trigger_field_ids) != 1)
|
||||
to_reset.trg_selection_field_id = False
|
||||
for automation in (self - to_reset):
|
||||
domain = [('field_id', 'in', automation.trigger_field_ids.ids)]
|
||||
automation.trg_selection_field_id = self.env['ir.model.fields.selection'].search(domain, limit=1)
|
||||
|
||||
@api.depends('trigger', 'trigger_field_ids')
|
||||
def _compute_trg_field_ref(self):
|
||||
to_reset = self.filtered(lambda a: a.trigger not in ['on_stage_set', 'on_tag_set'] or len(a.trigger_field_ids) != 1)
|
||||
to_reset.trg_field_ref = False
|
||||
for automation in (self - to_reset):
|
||||
relation = automation.trigger_field_ids.relation
|
||||
automation.trg_field_ref_model_name = relation
|
||||
automation.trg_field_ref = self.env[relation].search([], limit=1)
|
||||
|
||||
@api.depends('trg_field_ref', 'trigger_field_ids')
|
||||
def _compute_trg_field_ref__model_and_display_names(self):
|
||||
to_compute = self.filtered('trg_field_ref')
|
||||
to_reset = (self - to_compute)
|
||||
to_reset.trg_field_ref_model_name = False
|
||||
to_reset.trg_field_ref_display_name = False
|
||||
for automation in to_compute:
|
||||
relation = automation.trigger_field_ids.relation
|
||||
resid = automation.trg_field_ref
|
||||
automation.trg_field_ref_model_name = relation
|
||||
automation.trg_field_ref_display_name = self.env[relation].browse(resid).display_name
|
||||
|
||||
@api.depends('trigger', 'trigger_field_ids', 'trg_field_ref')
|
||||
def _compute_filter_pre_domain(self):
|
||||
to_reset = self.filtered(lambda act: act.trigger not in ('on_write', 'on_create_or_write'))
|
||||
if to_reset:
|
||||
to_reset.filter_pre_domain = False
|
||||
to_reset = self.filtered(lambda a: a.trigger != 'on_tag_set' or len(a.trigger_field_ids) != 1)
|
||||
to_reset.filter_pre_domain = False
|
||||
for automation in (self - to_reset):
|
||||
field = automation.trigger_field_ids.name
|
||||
value = automation.trg_field_ref
|
||||
automation.filter_pre_domain = f"[('{field}', 'not in', [{value}])]" if value else False
|
||||
|
||||
@api.depends('trigger', 'trigger_field_ids', 'trg_selection_field_id', 'trg_field_ref')
|
||||
def _compute_filter_domain(self):
|
||||
for record in self:
|
||||
trigger_fields_count = len(record.trigger_field_ids)
|
||||
if trigger_fields_count == 0:
|
||||
record.filter_domain = False
|
||||
|
||||
elif trigger_fields_count == 1:
|
||||
field = record.trigger_field_ids.name
|
||||
trigger = record.trigger
|
||||
if trigger in ['on_state_set', 'on_priority_set']:
|
||||
value = record.trg_selection_field_id.value
|
||||
record.filter_domain = f"[('{field}', '=', '{value}')]" if value else False
|
||||
elif trigger == 'on_stage_set':
|
||||
value = record.trg_field_ref
|
||||
record.filter_domain = f"[('{field}', '=', {value})]" if value else False
|
||||
elif trigger == 'on_tag_set':
|
||||
value = record.trg_field_ref
|
||||
record.filter_domain = f"[('{field}', 'in', [{value}])]" if value else False
|
||||
elif trigger == 'on_user_set':
|
||||
record.filter_domain = f"[('{field}', '!=', False)]"
|
||||
elif trigger in ['on_archive', 'on_unarchive']:
|
||||
record.filter_domain = f"[('{field}', '=', {trigger == 'on_unarchive'})]"
|
||||
else:
|
||||
record.filter_domain = False
|
||||
|
||||
@api.depends('model_id', 'trigger')
|
||||
def _compute_on_change_field_ids(self):
|
||||
to_reset = self.filtered(lambda act: act.trigger != 'on_change')
|
||||
if to_reset:
|
||||
to_reset.on_change_field_ids = False
|
||||
for action in (self - to_reset).filtered('on_change_field_ids'):
|
||||
action.on_change_field_ids = action.on_change_field_ids.filtered(lambda field: field.model_id == action.model_id)
|
||||
to_reset = self.filtered(lambda a: a.trigger != 'on_change')
|
||||
to_reset.on_change_field_ids = False
|
||||
for record in (self - to_reset).filtered('on_change_field_ids'):
|
||||
record.on_change_field_ids = record.on_change_field_ids.filtered(lambda field: field.model_id == record.model_id)
|
||||
|
||||
@api.depends('model_id', 'trigger')
|
||||
def _compute_trigger_field_ids(self):
|
||||
to_reset = self.filtered(lambda act: act.trigger not in ('on_write', 'on_create_or_write'))
|
||||
if to_reset:
|
||||
to_reset.trigger_field_ids = False
|
||||
for action in (self - to_reset).filtered('trigger_field_ids'):
|
||||
action.trigger_field_ids = action.trigger_field_ids.filtered(lambda field: field.model_id == action.model_id)
|
||||
def _compute_trigger_and_trigger_field_ids(self):
|
||||
for automation in self:
|
||||
domain = [('model_id', '=', automation.model_id.id)]
|
||||
if automation.trigger == 'on_stage_set':
|
||||
domain += [('ttype', '=', 'many2one'), ('name', 'in', ['stage_id', 'x_studio_stage_id'])]
|
||||
elif automation.trigger == 'on_tag_set':
|
||||
domain += [('ttype', '=', 'many2many'), ('name', 'in', ['tag_ids', 'x_studio_tag_ids'])]
|
||||
elif automation.trigger == 'on_priority_set':
|
||||
domain += [('ttype', '=', 'selection'), ('name', 'in', ['priority', 'x_studio_priority'])]
|
||||
elif automation.trigger == 'on_state_set':
|
||||
domain += [('ttype', '=', 'selection'), ('name', 'in', ['state', 'x_studio_state'])]
|
||||
elif automation.trigger == 'on_user_set':
|
||||
domain += [
|
||||
('relation', '=', 'res.users'),
|
||||
('ttype', 'in', ['many2one', 'many2many']),
|
||||
('name', 'in', ['user_id', 'user_ids', 'x_studio_user_id', 'x_studio_user_ids']),
|
||||
]
|
||||
elif automation.trigger in ['on_archive', 'on_unarchive']:
|
||||
domain += [('ttype', '=', 'boolean'), ('name', 'in', ['active', 'x_active'])]
|
||||
elif automation.trigger == 'on_time_created':
|
||||
domain += [('ttype', '=', 'datetime'), ('name', '=', 'create_date')]
|
||||
elif automation.trigger == 'on_time_updated':
|
||||
domain += [('ttype', '=', 'datetime'), ('name', '=', 'write_date')]
|
||||
else:
|
||||
automation.trigger_field_ids = False
|
||||
continue
|
||||
|
||||
@api.onchange('trigger', 'state')
|
||||
def _onchange_state(self):
|
||||
if self.trigger == 'on_change' and self.state != 'code':
|
||||
ff = self.fields_get(['trigger', 'state'])
|
||||
automation.trigger_field_ids = self.env['ir.model.fields'].search(domain, limit=1)
|
||||
automation.trigger = False if not automation.trigger_field_ids else automation.trigger
|
||||
|
||||
@api.onchange('trigger', 'action_server_ids')
|
||||
def _onchange_trigger_or_actions(self):
|
||||
no_code_actions = self.action_server_ids.filtered(lambda a: a.state != 'code')
|
||||
if self.trigger == 'on_change' and len(no_code_actions) > 0:
|
||||
trigger_field = self._fields['trigger']
|
||||
action_states = dict(self.action_server_ids._fields['state']._description_selection(self.env))
|
||||
return {'warning': {
|
||||
'title': _("Warning"),
|
||||
'message': _("The \"%(trigger_value)s\" %(trigger_label)s can only be used with the \"%(state_value)s\" action type") % {
|
||||
'trigger_value': dict(ff['trigger']['selection'])['on_change'],
|
||||
'trigger_label': ff['trigger']['string'],
|
||||
'state_value': dict(ff['state']['selection'])['code'],
|
||||
'message': _(
|
||||
"The \"%(trigger_value)s\" %(trigger_label)s can only be "
|
||||
"used with the \"%(state_value)s\" action type"
|
||||
) % {
|
||||
'trigger_value': dict(trigger_field._description_selection(self.env))['on_change'],
|
||||
'trigger_label': trigger_field._description_string(self.env),
|
||||
'state_value': action_states['code'],
|
||||
}
|
||||
}}
|
||||
|
||||
MAIL_STATES = ('email', 'followers', 'next_activity')
|
||||
if self.trigger == 'on_unlink' and self.state in MAIL_STATES:
|
||||
MAIL_STATES = ('mail_post', 'followers', 'next_activity')
|
||||
mail_actions = self.action_server_ids.filtered(lambda a: a.state in MAIL_STATES)
|
||||
if self.trigger == 'on_unlink' and len(mail_actions) > 0:
|
||||
return {'warning': {
|
||||
'title': _("Warning"),
|
||||
'message': _(
|
||||
@@ -185,8 +353,6 @@ class BaseAutomation(models.Model):
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
vals['usage'] = 'base_automation'
|
||||
base_automations = super(BaseAutomation, self).create(vals_list)
|
||||
self._update_cron()
|
||||
self._update_registry()
|
||||
@@ -208,22 +374,22 @@ class BaseAutomation(models.Model):
|
||||
return res
|
||||
|
||||
def _update_cron(self):
|
||||
""" Activate the cron job depending on whether there exists action rules
|
||||
based on time conditions. Also update its frequency according to
|
||||
the smallest action delay, or restore the default 4 hours if there
|
||||
is no time based action.
|
||||
""" Activate the cron job depending on whether there exists automation rules
|
||||
based on time conditions. Also update its frequency according to
|
||||
the smallest automation delay, or restore the default 4 hours if there
|
||||
is no time based automation.
|
||||
"""
|
||||
cron = self.env.ref('base_automation.ir_cron_data_base_automation_check', raise_if_not_found=False)
|
||||
if cron:
|
||||
actions = self.with_context(active_test=True).search([('trigger', '=', 'on_time')])
|
||||
automations = self.with_context(active_test=True).search([('trigger', 'in', TIME_TRIGGERS)])
|
||||
cron.try_write({
|
||||
'active': bool(actions),
|
||||
'active': bool(automations),
|
||||
'interval_type': 'minutes',
|
||||
'interval_number': self._get_cron_interval(actions),
|
||||
'interval_number': self._get_cron_interval(automations),
|
||||
})
|
||||
|
||||
def _update_registry(self):
|
||||
""" Update the registry after a modification on action rules. """
|
||||
""" Update the registry after a modification on automation rules. """
|
||||
if self.env.registry.ready and not self.env.context.get('import_file'):
|
||||
# re-install the model patches, and notify other workers
|
||||
self._unregister_hook()
|
||||
@@ -231,14 +397,16 @@ class BaseAutomation(models.Model):
|
||||
self.env.registry.registry_invalidated = True
|
||||
|
||||
def _get_actions(self, records, triggers):
|
||||
""" Return the actions of the given triggers for records' model. The
|
||||
returned actions' context contain an object to manage processing.
|
||||
""" Return the automations of the given triggers for records' model. The
|
||||
returned automations' context contain an object to manage processing.
|
||||
"""
|
||||
# Note: we keep the old action naming for the method and context variable
|
||||
# to avoid breaking existing code/downstream modules
|
||||
if '__action_done' not in self._context:
|
||||
self = self.with_context(__action_done={})
|
||||
domain = [('model_name', '=', records._name), ('trigger', 'in', triggers)]
|
||||
actions = self.with_context(active_test=True).sudo().search(domain)
|
||||
return actions.with_env(self.env)
|
||||
automations = self.with_context(active_test=True).sudo().search(domain)
|
||||
return automations.with_env(self.env)
|
||||
|
||||
def _get_eval_context(self):
|
||||
""" Prepare the context used when evaluating python code
|
||||
@@ -252,24 +420,24 @@ class BaseAutomation(models.Model):
|
||||
'user': self.env.user,
|
||||
}
|
||||
|
||||
def _get_cron_interval(self, actions=None):
|
||||
def _get_cron_interval(self, automations=None):
|
||||
""" Return the expected time interval used by the cron, in minutes. """
|
||||
def get_delay(rec):
|
||||
return rec.trg_date_range * DATE_RANGE_FACTOR[rec.trg_date_range_type]
|
||||
|
||||
if actions is None:
|
||||
actions = self.with_context(active_test=True).search([('trigger', '=', 'on_time')])
|
||||
if automations is None:
|
||||
automations = self.with_context(active_test=True).search([('trigger', 'in', TIME_TRIGGERS)])
|
||||
|
||||
# Minimum 1 minute, maximum 4 hours, 10% tolerance
|
||||
delay = min(actions.mapped(get_delay), default=0)
|
||||
delay = min(automations.mapped(get_delay), default=0)
|
||||
return min(max(1, delay // 10), 4 * 60) if delay else 4 * 60
|
||||
|
||||
def _compute_least_delay_msg(self):
|
||||
msg = _("Note that this action can be triggered up to %d minutes after its schedule.")
|
||||
msg = _("Note that this automation rule can be triggered up to %d minutes after its schedule.")
|
||||
self.least_delay_msg = msg % self._get_cron_interval()
|
||||
|
||||
def _filter_pre(self, records):
|
||||
""" Filter the records that satisfy the precondition of action ``self``. """
|
||||
""" Filter the records that satisfy the precondition of automation ``self``. """
|
||||
self_sudo = self.sudo()
|
||||
if self_sudo.filter_pre_domain and records:
|
||||
domain = safe_eval.safe_eval(self_sudo.filter_pre_domain, self._get_eval_context())
|
||||
@@ -281,7 +449,7 @@ class BaseAutomation(models.Model):
|
||||
return self._filter_post_export_domain(records)[0]
|
||||
|
||||
def _filter_post_export_domain(self, records):
|
||||
""" Filter the records that satisfy the postcondition of action ``self``. """
|
||||
""" Filter the records that satisfy the postcondition of automation ``self``. """
|
||||
self_sudo = self.sudo()
|
||||
if self_sudo.filter_domain and records:
|
||||
domain = safe_eval.safe_eval(self_sudo.filter_domain, self._get_eval_context())
|
||||
@@ -290,7 +458,7 @@ class BaseAutomation(models.Model):
|
||||
return records, None
|
||||
|
||||
@api.model
|
||||
def _add_postmortem_action(self, e):
|
||||
def _add_postmortem(self, e):
|
||||
if self.user_has_groups('base.group_user'):
|
||||
e.context = {}
|
||||
e.context['exception_class'] = 'base_automation'
|
||||
@@ -300,44 +468,44 @@ class BaseAutomation(models.Model):
|
||||
}
|
||||
|
||||
def _process(self, records, domain_post=None):
|
||||
""" Process action ``self`` on the ``records`` that have not been done yet. """
|
||||
""" Process automation ``self`` on the ``records`` that have not been done yet. """
|
||||
# filter out the records on which self has already been done
|
||||
action_done = self._context['__action_done']
|
||||
records_done = action_done.get(self, records.browse())
|
||||
automation_done = self._context['__action_done']
|
||||
records_done = automation_done.get(self, records.browse())
|
||||
records -= records_done
|
||||
if not records:
|
||||
return
|
||||
|
||||
# mark the remaining records as done (to avoid recursive processing)
|
||||
action_done = dict(action_done)
|
||||
action_done[self] = records_done + records
|
||||
self = self.with_context(__action_done=action_done)
|
||||
records = records.with_context(__action_done=action_done)
|
||||
automation_done = dict(automation_done)
|
||||
automation_done[self] = records_done + records
|
||||
self = self.with_context(__action_done=automation_done)
|
||||
records = records.with_context(__action_done=automation_done)
|
||||
|
||||
# modify records
|
||||
values = {}
|
||||
if 'date_action_last' in records._fields:
|
||||
values['date_action_last'] = fields.Datetime.now()
|
||||
if values:
|
||||
records.write(values)
|
||||
if 'date_automation_last' in records._fields:
|
||||
records.date_automation_last = fields.Datetime.now()
|
||||
|
||||
# prepare the contexts for server actions
|
||||
contexts = []
|
||||
for record in records:
|
||||
# we process the automation if any watched field has been modified
|
||||
if self._check_trigger_fields(record):
|
||||
contexts.append({
|
||||
'active_model': record._name,
|
||||
'active_ids': record.ids,
|
||||
'active_id': record.id,
|
||||
'domain_post': domain_post,
|
||||
})
|
||||
|
||||
# execute server actions
|
||||
action_server = self.action_server_id
|
||||
if action_server:
|
||||
for record in records:
|
||||
# we process the action if any watched field has been modified
|
||||
if self._check_trigger_fields(record):
|
||||
ctx = {
|
||||
'active_model': record._name,
|
||||
'active_ids': record.ids,
|
||||
'active_id': record.id,
|
||||
'domain_post': domain_post,
|
||||
}
|
||||
try:
|
||||
action_server.sudo().with_context(**ctx).run()
|
||||
except Exception as e:
|
||||
self._add_postmortem_action(e)
|
||||
raise e
|
||||
for action in self.sudo().action_server_ids:
|
||||
for ctx in contexts:
|
||||
try:
|
||||
action.with_context(**ctx).run()
|
||||
except Exception as e:
|
||||
self._add_postmortem(e)
|
||||
raise
|
||||
|
||||
def _check_trigger_fields(self, record):
|
||||
""" Return whether any of the trigger fields has been modified on ``record``. """
|
||||
@@ -376,65 +544,65 @@ class BaseAutomation(models.Model):
|
||||
#
|
||||
|
||||
def make_create():
|
||||
""" Instanciate a create method that processes action rules. """
|
||||
""" Instanciate a create method that processes automation rules. """
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list, **kw):
|
||||
# retrieve the action rules to possibly execute
|
||||
actions = self.env['base.automation']._get_actions(self, ['on_create', 'on_create_or_write'])
|
||||
if not actions:
|
||||
# retrieve the automation rules to possibly execute
|
||||
automations = self.env['base.automation']._get_actions(self, CREATE_TRIGGERS)
|
||||
if not automations:
|
||||
return create.origin(self, vals_list, **kw)
|
||||
# call original method
|
||||
records = create.origin(self.with_env(actions.env), vals_list, **kw)
|
||||
records = create.origin(self.with_env(automations.env), vals_list, **kw)
|
||||
# check postconditions, and execute actions on the records that satisfy them
|
||||
for action in actions.with_context(old_values=None):
|
||||
action._process(action._filter_post(records))
|
||||
for automation in automations.with_context(old_values=None):
|
||||
automation._process(automation._filter_post(records))
|
||||
return records.with_env(self.env)
|
||||
|
||||
return create
|
||||
|
||||
def make_write():
|
||||
""" Instanciate a write method that processes action rules. """
|
||||
""" Instanciate a write method that processes automation rules. """
|
||||
def write(self, vals, **kw):
|
||||
# retrieve the action rules to possibly execute
|
||||
actions = self.env['base.automation']._get_actions(self, ['on_write', 'on_create_or_write'])
|
||||
if not (actions and self):
|
||||
# retrieve the automation rules to possibly execute
|
||||
automations = self.env['base.automation']._get_actions(self, WRITE_TRIGGERS)
|
||||
if not (automations and self):
|
||||
return write.origin(self, vals, **kw)
|
||||
records = self.with_env(actions.env).filtered('id')
|
||||
records = self.with_env(automations.env).filtered('id')
|
||||
# check preconditions on records
|
||||
pre = {action: action._filter_pre(records) for action in actions}
|
||||
pre = {a: a._filter_pre(records) for a in automations}
|
||||
# read old values before the update
|
||||
old_values = {
|
||||
old_vals.pop('id'): old_vals
|
||||
for old_vals in (records.read(list(vals)) if vals else [])
|
||||
}
|
||||
# call original method
|
||||
write.origin(self.with_env(actions.env), vals, **kw)
|
||||
write.origin(self.with_env(automations.env), vals, **kw)
|
||||
# check postconditions, and execute actions on the records that satisfy them
|
||||
for action in actions.with_context(old_values=old_values):
|
||||
records, domain_post = action._filter_post_export_domain(pre[action])
|
||||
action._process(records, domain_post=domain_post)
|
||||
for automation in automations.with_context(old_values=old_values):
|
||||
records, domain_post = automation._filter_post_export_domain(pre[automation])
|
||||
automation._process(records, domain_post=domain_post)
|
||||
return True
|
||||
|
||||
return write
|
||||
|
||||
def make_compute_field_value():
|
||||
""" Instanciate a compute_field_value method that processes action rules. """
|
||||
""" Instanciate a compute_field_value method that processes automation rules. """
|
||||
#
|
||||
# Note: This is to catch updates made by field recomputations.
|
||||
#
|
||||
def _compute_field_value(self, field):
|
||||
# determine fields that may trigger an action
|
||||
# determine fields that may trigger an automation
|
||||
stored_fields = [f for f in self.pool.field_computed[field] if f.store]
|
||||
if not any(stored_fields):
|
||||
return _compute_field_value.origin(self, field)
|
||||
# retrieve the action rules to possibly execute
|
||||
actions = self.env['base.automation']._get_actions(self, ['on_write', 'on_create_or_write'])
|
||||
records = self.filtered('id').with_env(actions.env)
|
||||
if not (actions and records):
|
||||
automations = self.env['base.automation']._get_actions(self, WRITE_TRIGGERS)
|
||||
records = self.filtered('id').with_env(automations.env)
|
||||
if not (automations and records):
|
||||
_compute_field_value.origin(self, field)
|
||||
return True
|
||||
# check preconditions on records
|
||||
pre = {action: action._filter_pre(records) for action in actions}
|
||||
pre = {a: a._filter_pre(records) for a in automations}
|
||||
# read old values before the update
|
||||
old_values = {
|
||||
old_vals.pop('id'): old_vals
|
||||
@@ -442,58 +610,60 @@ class BaseAutomation(models.Model):
|
||||
}
|
||||
# call original method
|
||||
_compute_field_value.origin(self, field)
|
||||
# check postconditions, and execute actions on the records that satisfy them
|
||||
for action in actions.with_context(old_values=old_values):
|
||||
records, domain_post = action._filter_post_export_domain(pre[action])
|
||||
action._process(records, domain_post=domain_post)
|
||||
# check postconditions, and execute automations on the records that satisfy them
|
||||
for automation in automations.with_context(old_values=old_values):
|
||||
records, domain_post = automation._filter_post_export_domain(pre[automation])
|
||||
automation._process(records, domain_post=domain_post)
|
||||
return True
|
||||
|
||||
return _compute_field_value
|
||||
|
||||
def make_unlink():
|
||||
""" Instanciate an unlink method that processes action rules. """
|
||||
""" Instanciate an unlink method that processes automation rules. """
|
||||
def unlink(self, **kwargs):
|
||||
# retrieve the action rules to possibly execute
|
||||
actions = self.env['base.automation']._get_actions(self, ['on_unlink'])
|
||||
records = self.with_env(actions.env)
|
||||
automations = self.env['base.automation']._get_actions(self, ['on_unlink'])
|
||||
records = self.with_env(automations.env)
|
||||
# check conditions, and execute actions on the records that satisfy them
|
||||
for action in actions:
|
||||
action._process(action._filter_post(records))
|
||||
for automation in automations:
|
||||
automation._process(automation._filter_post(records))
|
||||
# call original method
|
||||
return unlink.origin(self, **kwargs)
|
||||
|
||||
return unlink
|
||||
|
||||
def make_onchange(action_rule_id):
|
||||
""" Instanciate an onchange method for the given action rule. """
|
||||
def make_onchange(automation_rule_id):
|
||||
""" Instanciate an onchange method for the given automation rule. """
|
||||
def base_automation_onchange(self):
|
||||
action_rule = self.env['base.automation'].browse(action_rule_id)
|
||||
automation_rule = self.env['base.automation'].browse(automation_rule_id)
|
||||
result = {}
|
||||
server_action = action_rule.sudo().action_server_id.with_context(
|
||||
actions = automation_rule.sudo().action_server_ids.with_context(
|
||||
active_model=self._name,
|
||||
active_id=self._origin.id,
|
||||
active_ids=self._origin.ids,
|
||||
onchange_self=self,
|
||||
)
|
||||
try:
|
||||
res = server_action.run()
|
||||
except Exception as e:
|
||||
action_rule._add_postmortem_action(e)
|
||||
raise e
|
||||
for action in actions:
|
||||
try:
|
||||
res = action.run()
|
||||
except Exception as e:
|
||||
automation_rule._add_postmortem(e)
|
||||
raise
|
||||
|
||||
if res:
|
||||
if 'value' in res:
|
||||
res['value'].pop('id', None)
|
||||
self.update({key: val for key, val in res['value'].items() if key in self._fields})
|
||||
if 'domain' in res:
|
||||
result.setdefault('domain', {}).update(res['domain'])
|
||||
if 'warning' in res:
|
||||
result['warning'] = res['warning']
|
||||
if res:
|
||||
if 'value' in res:
|
||||
res['value'].pop('id', None)
|
||||
self.update({key: val for key, val in res['value'].items() if key in self._fields})
|
||||
if 'domain' in res:
|
||||
result.setdefault('domain', {}).update(res['domain'])
|
||||
if 'warning' in res:
|
||||
result['warning'] += res["warning"]
|
||||
return result
|
||||
|
||||
return base_automation_onchange
|
||||
|
||||
patched_models = defaultdict(set)
|
||||
|
||||
def patch(model, name, method):
|
||||
""" Patch method `name` on `model`, unless it has been patched already. """
|
||||
if model not in patched_models[name]:
|
||||
@@ -503,35 +673,33 @@ class BaseAutomation(models.Model):
|
||||
setattr(ModelClass, name, method)
|
||||
|
||||
# retrieve all actions, and patch their corresponding model
|
||||
for action_rule in self.with_context({}).search([]):
|
||||
Model = self.env.get(action_rule.model_name)
|
||||
for automation_rule in self.with_context({}).search([]):
|
||||
Model = self.env.get(automation_rule.model_name)
|
||||
|
||||
# Do not crash if the model of the base_action_rule was uninstalled
|
||||
if Model is None:
|
||||
_logger.warning("Action rule with ID %d depends on model %s" %
|
||||
(action_rule.id,
|
||||
action_rule.model_name))
|
||||
_logger.warning(
|
||||
"Automation rule with name '%s' (ID %d) depends on model %s (ID: %d)",
|
||||
automation_rule.id,
|
||||
automation_rule.name,
|
||||
automation_rule.model_name,
|
||||
automation_rule.model_id.id)
|
||||
continue
|
||||
|
||||
if action_rule.trigger == 'on_create':
|
||||
patch(Model, 'create', make_create())
|
||||
if automation_rule.trigger in CREATE_WRITE_SET:
|
||||
if automation_rule.trigger in CREATE_TRIGGERS:
|
||||
patch(Model, 'create', make_create())
|
||||
if automation_rule.trigger in WRITE_TRIGGERS:
|
||||
patch(Model, 'write', make_write())
|
||||
patch(Model, '_compute_field_value', make_compute_field_value())
|
||||
|
||||
elif action_rule.trigger == 'on_create_or_write':
|
||||
patch(Model, 'create', make_create())
|
||||
patch(Model, 'write', make_write())
|
||||
patch(Model, '_compute_field_value', make_compute_field_value())
|
||||
|
||||
elif action_rule.trigger == 'on_write':
|
||||
patch(Model, 'write', make_write())
|
||||
patch(Model, '_compute_field_value', make_compute_field_value())
|
||||
|
||||
elif action_rule.trigger == 'on_unlink':
|
||||
elif automation_rule.trigger == 'on_unlink':
|
||||
patch(Model, 'unlink', make_unlink())
|
||||
|
||||
elif action_rule.trigger == 'on_change':
|
||||
# register an onchange method for the action_rule
|
||||
method = make_onchange(action_rule.id)
|
||||
for field in action_rule.on_change_field_ids:
|
||||
elif automation_rule.trigger == 'on_change':
|
||||
# register an onchange method for the automation_rule
|
||||
method = make_onchange(automation_rule.id)
|
||||
for field in automation_rule.on_change_field_ids:
|
||||
Model._onchange_methods[field.name].append(method)
|
||||
|
||||
def _unregister_hook(self):
|
||||
@@ -545,15 +713,15 @@ class BaseAutomation(models.Model):
|
||||
pass
|
||||
|
||||
@api.model
|
||||
def _check_delay(self, action, record, record_dt):
|
||||
if action.trg_date_calendar_id and action.trg_date_range_type == 'day':
|
||||
return action.trg_date_calendar_id.plan_days(
|
||||
action.trg_date_range,
|
||||
def _check_delay(self, automation, record, record_dt):
|
||||
if automation.trg_date_calendar_id and automation.trg_date_range_type == 'day':
|
||||
return automation.trg_date_calendar_id.plan_days(
|
||||
automation.trg_date_range,
|
||||
fields.Datetime.from_string(record_dt),
|
||||
compute_leaves=True,
|
||||
)
|
||||
else:
|
||||
delay = DATE_RANGE_FUNCTION[action.trg_date_range_type](action.trg_date_range)
|
||||
delay = DATE_RANGE_FUNCTION[automation.trg_date_range_type](automation.trg_date_range)
|
||||
return fields.Datetime.from_string(record_dt) + delay
|
||||
|
||||
@api.model
|
||||
@@ -562,24 +730,25 @@ class BaseAutomation(models.Model):
|
||||
if '__action_done' not in self._context:
|
||||
self = self.with_context(__action_done={})
|
||||
|
||||
# retrieve all the action rules to run based on a timed condition
|
||||
# retrieve all the automation rules to run based on a timed condition
|
||||
eval_context = self._get_eval_context()
|
||||
for action in self.with_context(active_test=True).search([('trigger', '=', 'on_time')]):
|
||||
_logger.info("Starting time-based automated action `%s`.", action.name)
|
||||
last_run = fields.Datetime.from_string(action.last_run) or datetime.datetime.utcfromtimestamp(0)
|
||||
for automation in self.with_context(active_test=True).search([('trigger', 'in', TIME_TRIGGERS)]):
|
||||
_logger.info("Starting time-based automation rule `%s`.", automation.name)
|
||||
last_run = fields.Datetime.from_string(automation.last_run) or datetime.datetime.utcfromtimestamp(0)
|
||||
|
||||
# retrieve all the records that satisfy the action's condition
|
||||
# retrieve all the records that satisfy the automation's condition
|
||||
domain = []
|
||||
context = dict(self._context)
|
||||
if action.filter_domain:
|
||||
domain = safe_eval.safe_eval(action.filter_domain, eval_context)
|
||||
records = self.env[action.model_name].with_context(context).search(domain)
|
||||
if automation.filter_domain:
|
||||
domain = safe_eval.safe_eval(automation.filter_domain, eval_context)
|
||||
records = self.env[automation.model_name].with_context(context).search(domain)
|
||||
|
||||
# determine when action should occur for the records
|
||||
if action.trg_date_id.name == 'date_action_last' and 'create_date' in records._fields:
|
||||
get_record_dt = lambda record: record[action.trg_date_id.name] or record.create_date
|
||||
else:
|
||||
get_record_dt = lambda record: record[action.trg_date_id.name]
|
||||
def get_record_dt(record):
|
||||
# determine when automation should occur for the records
|
||||
if automation.trg_date_id.name == "date_automation_last" and "create_date" in records._fields:
|
||||
return record[automation.trg_date_id.name] or record.create_date
|
||||
else:
|
||||
return record[automation.trg_date_id.name]
|
||||
|
||||
# process action on the records that should be executed
|
||||
now = datetime.datetime.now()
|
||||
@@ -587,15 +756,15 @@ class BaseAutomation(models.Model):
|
||||
record_dt = get_record_dt(record)
|
||||
if not record_dt:
|
||||
continue
|
||||
action_dt = self._check_delay(action, record, record_dt)
|
||||
action_dt = self._check_delay(automation, record, record_dt)
|
||||
if last_run <= action_dt < now:
|
||||
try:
|
||||
action._process(record)
|
||||
automation._process(record)
|
||||
except Exception:
|
||||
_logger.error(traceback.format_exc())
|
||||
|
||||
action.write({'last_run': now.strftime(DEFAULT_SERVER_DATETIME_FORMAT)})
|
||||
_logger.info("Time-based automated action `%s` done.", action.name)
|
||||
automation.write({'last_run': now.strftime(DEFAULT_SERVER_DATETIME_FORMAT)})
|
||||
_logger.info("Time-based automation rule `%s` done.", automation.name)
|
||||
|
||||
if automatic:
|
||||
# auto-commit for batch processing
|
||||
|
||||
@@ -8,5 +8,6 @@ class ServerAction(models.Model):
|
||||
_inherit = "ir.actions.server"
|
||||
|
||||
usage = fields.Selection(selection_add=[
|
||||
('base_automation', 'Automated Action')
|
||||
('base_automation', 'Automation Rule')
|
||||
], ondelete={'base_automation': 'cascade'})
|
||||
base_automation_id = fields.Many2one('base.automation', string='Automation Rule', ondelete='cascade')
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.o_base_automation_actions_field,
|
||||
.o_base_automation_kanban_view {
|
||||
.o_kanban_ungrouped {
|
||||
padding: 0;
|
||||
|
||||
.o_kanban_record {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
||||
&.o_kanban_ghost {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
@include media-breakpoint-up(md) {
|
||||
.o_automation_base_info {
|
||||
width: 25%;
|
||||
min-width: 200px;
|
||||
min-height: 90px
|
||||
};
|
||||
.o_automation_actions {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.o_base_automation_actions_field .o_kanban_ghost {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_base_automation_kanban_view {
|
||||
.o_kanban_grouped .row {
|
||||
flex-direction: column !important;
|
||||
gap: 0.5rem !important;
|
||||
|
||||
> * {
|
||||
width: 100% !important;
|
||||
|
||||
> * {
|
||||
margin: 0 0.5rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_kanban_ungrouped .o_kanban_record .oe_kanban_global_click {
|
||||
border-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.o_widget_web_ribbon {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useExternalListener, useEffect, useRef } from "@odoo/owl";
|
||||
import { _lt, _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useThrottleForAnimation } from "@web/core/utils/timing";
|
||||
|
||||
class ActionsOne2ManyField extends Component {
|
||||
static props = ["*"];
|
||||
static template = "base_automation.ActionsOne2ManyField";
|
||||
static actionStates = {
|
||||
code: _lt("Execute Python Code"),
|
||||
object_create: _lt("Create a new Record"),
|
||||
object_write: _lt("Update the Record"),
|
||||
multi: _lt("Execute several actions"),
|
||||
mail_post: _lt("Send email"),
|
||||
followers: _lt("Add followers"),
|
||||
remove_followers: _lt("Remove followers"),
|
||||
next_activity: _lt("Create next activity"),
|
||||
sms: _lt("Send SMS Text Message"),
|
||||
};
|
||||
setup() {
|
||||
this.root = useRef("root");
|
||||
|
||||
let adaptCounter = 0;
|
||||
useEffect(
|
||||
() => {
|
||||
this.adapt();
|
||||
},
|
||||
() => [adaptCounter]
|
||||
);
|
||||
const throttledRenderAndAdapt = useThrottleForAnimation(() => {
|
||||
adaptCounter++;
|
||||
this.render();
|
||||
});
|
||||
useExternalListener(window, "resize", throttledRenderAndAdapt);
|
||||
this.currentActions = this.props.record.data[this.props.name].records;
|
||||
this.hiddenActionsCount = 0;
|
||||
}
|
||||
async adapt() {
|
||||
// --- Initialize ---
|
||||
// use getBoundingClientRect to get unrounded width
|
||||
// of the elements in order to avoid rounding issues
|
||||
const rootWidth = this.root.el.getBoundingClientRect().width;
|
||||
|
||||
// remove all d-none classes (needed to get the real width of the elements)
|
||||
const actionsEls = Array.from(this.root.el.children).filter((el) => el.dataset.actionId);
|
||||
actionsEls.forEach((el) => el.classList.remove("d-none"));
|
||||
const actionsTotalWidth = actionsEls.reduce(
|
||||
(sum, el) => sum + el.getBoundingClientRect().width,
|
||||
0
|
||||
);
|
||||
|
||||
// --- Check first overflowing action ---
|
||||
let overflowingActionId;
|
||||
if (actionsTotalWidth > rootWidth) {
|
||||
let width = 56; // for the ellipsis
|
||||
for (const el of actionsEls) {
|
||||
const elWidth = el.getBoundingClientRect().width;
|
||||
if (width + elWidth > rootWidth) {
|
||||
// All the remaining elements are overflowing
|
||||
overflowingActionId = el.dataset.actionId;
|
||||
const firstOverflowingEl = actionsEls.find(
|
||||
(el) => el.dataset.actionId === overflowingActionId
|
||||
);
|
||||
const firstOverflowingIndex = actionsEls.indexOf(firstOverflowingEl);
|
||||
const overflowingEls = actionsEls.slice(firstOverflowingIndex);
|
||||
// hide overflowing elements
|
||||
overflowingEls.forEach((el) => el.classList.add("d-none"));
|
||||
break;
|
||||
}
|
||||
width += elWidth;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Final rendering ---
|
||||
const initialHiddenActionsCount = this.hiddenActionsCount;
|
||||
this.hiddenActionsCount = overflowingActionId
|
||||
? this.currentActions.length -
|
||||
this.currentActions.findIndex((action) => action.id === overflowingActionId)
|
||||
: 0;
|
||||
if (initialHiddenActionsCount !== this.hiddenActionsCount) {
|
||||
// Render only if hidden actions count has changed.
|
||||
return this.render();
|
||||
}
|
||||
}
|
||||
getActionType(action) {
|
||||
return this.constructor.actionStates[action.data.state] || action.data.state;
|
||||
}
|
||||
get moreText() {
|
||||
const isPlural = this.hiddenActionsCount > 1;
|
||||
return isPlural ? _t("%s actions", this.hiddenActionsCount) : _t("1 action");
|
||||
}
|
||||
}
|
||||
|
||||
const actionsOne2ManyField = {
|
||||
component: ActionsOne2ManyField,
|
||||
relatedFields: [
|
||||
{ name: "name", type: "char" },
|
||||
{
|
||||
name: "state",
|
||||
type: "selection",
|
||||
selection: [
|
||||
["code", _lt("Execute Python Code")],
|
||||
["object_create", _lt("Create a new Record")],
|
||||
["object_write", _lt("Update the Record")],
|
||||
["multi", _lt("Execute several actions")],
|
||||
["mail_post", _lt("Send email")],
|
||||
["followers", _lt("Add followers")],
|
||||
["remove_followers", _lt("Remove followers")],
|
||||
["next_activity", _lt("Create next activity")],
|
||||
["sms", _lt("Send SMS Text Message")],
|
||||
],
|
||||
},
|
||||
// Execute Python Code
|
||||
{ name: "code", type: "text" },
|
||||
// Create
|
||||
{ name: "crud_model_id", type: "many2one" },
|
||||
{ name: "crud_model_name", type: "char" },
|
||||
// Add Followers
|
||||
{ name: "partner_ids", type: "many2many" },
|
||||
// Message Post / Email
|
||||
{ name: "template_id", type: "many2one" },
|
||||
{ name: "mail_post_autofollow", type: "boolean" },
|
||||
{
|
||||
name: "mail_post_method",
|
||||
type: "selection",
|
||||
selection: [
|
||||
["email", _lt("Email")],
|
||||
["comment", _lt("Post as Message")],
|
||||
["note", _lt("Post as Note")],
|
||||
],
|
||||
},
|
||||
// Schedule Next Activity
|
||||
{ name: "activity_type_id", type: "many2one" },
|
||||
{ name: "activity_summary", type: "char" },
|
||||
{ name: "activity_note", type: "html" },
|
||||
{ name: "activity_date_deadline_range", type: "integer" },
|
||||
{
|
||||
name: "activity_date_deadline_range_type",
|
||||
type: "selection",
|
||||
selection: [
|
||||
["days", _lt("Days")],
|
||||
["weeks", _lt("Weeks")],
|
||||
["months", _lt("Months")],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "activity_user_type",
|
||||
type: "selection",
|
||||
selection: [
|
||||
["specific", _lt("Specific User")],
|
||||
["generic", _lt("Generic User")],
|
||||
],
|
||||
},
|
||||
{ name: "activity_user_id", type: "many2one" },
|
||||
{ name: "activity_user_field_name", type: "char" },
|
||||
],
|
||||
};
|
||||
|
||||
registry.category("fields").add("base_automation_actions_one2many", actionsOne2ManyField);
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
<t t-name="base_automation.ActionsOne2ManyField" owl="1">
|
||||
<div class="d-flex align-items-center" t-ref="root">
|
||||
<t t-if="currentActions.length === 0">
|
||||
<span class="text-muted">no action defined...</span>
|
||||
</t>
|
||||
<t t-foreach="currentActions" t-as="action" t-key="action.id">
|
||||
<div style="min-width: fit-content;" t-att-data-action-id="action.id">
|
||||
<div class="fs-5 d-flex align-items-center">
|
||||
<i
|
||||
data-name="server_action_icon"
|
||||
t-att-title="getActionType(action)"
|
||||
class="fa"
|
||||
t-att-class="{
|
||||
'code': 'fa-file-code-o',
|
||||
'object_create': 'fa-edit',
|
||||
'object_write': 'fa-refresh',
|
||||
'multi': 'fa-list-ul',
|
||||
'mail_post': 'fa-envelope',
|
||||
'followers': 'fa-user-o',
|
||||
'remove_followers': 'fa-user-times',
|
||||
'next_activity': 'fa-clock-o',
|
||||
'sms': 'fa-comments-o',
|
||||
}[action.data.state]"
|
||||
/>
|
||||
<div class="ps-2" t-esc="action.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="!action_last" class="px-3 align-self-center" t-att-data-action-id="action.id">
|
||||
<i class="fa fa-lg fa-plus text-primary"></i>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="hiddenActionsCount">
|
||||
<div class="fs-3 align-self-center text-muted">
|
||||
<t t-out="moreText"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -8,8 +8,8 @@ export class BaseAutomationErrorDialog extends RPCErrorDialog {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
const { id, name } = this.props.data.context.base_automation;
|
||||
this.actionId = id;
|
||||
this.actionName = name;
|
||||
this.automationId = id;
|
||||
this.automationName = name;
|
||||
this.isUserAdmin = useService("user").isAdmin;
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
@@ -20,32 +20,30 @@ export class BaseAutomationErrorDialog extends RPCErrorDialog {
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This method is called when the user clicks on the 'Disable action' button
|
||||
* displayed when a crash occurs in the evaluation of an automated action.
|
||||
* Then, we write `active` to `False` on the automated action to disable it.
|
||||
* This method is called when the user clicks on the 'Disable Automation Rule' button
|
||||
* displayed when a crash occurs in the evaluation of an automation rule.
|
||||
* Then, we write `active` to `False` on the automation rule to disable it.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async disableAction(ev) {
|
||||
await this.orm.write("base.automation", [this.actionId], {
|
||||
active: false,
|
||||
});
|
||||
async disableAutomation(ev) {
|
||||
await this.orm.write("base.automation", [this.automationId], { active: false });
|
||||
this.props.close();
|
||||
}
|
||||
/**
|
||||
* This method is called when the user clicks on the 'Edit action' button
|
||||
* displayed when a crash occurs in the evaluation of an automated action.
|
||||
* Then, we redirect the user to the automated action form.
|
||||
* displayed when a crash occurs in the evaluation of an automation rule.
|
||||
* Then, we redirect the user to the automation rule form.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
editAction(ev) {
|
||||
editAutomation(ev) {
|
||||
this.actionService.doAction({
|
||||
name: "Automated Actions",
|
||||
name: "Automation Rules",
|
||||
res_model: "base.automation",
|
||||
res_id: this.actionId,
|
||||
res_id: this.automationId,
|
||||
views: [[false, "form"]],
|
||||
type: "ir.actions.act_window",
|
||||
view_mode: "form",
|
||||
|
||||
@@ -4,29 +4,29 @@
|
||||
<t t-name="base_automation.ErrorDialog" t-inherit="web.ErrorDialog" t-inherit-mode="primary">
|
||||
<xpath expr="//div[@role='alert']" position="inside">
|
||||
<p>
|
||||
The error occurred during the execution of the automated action
|
||||
The error occurred during the execution of the automation rule
|
||||
"<t t-esc="actionName"/>"
|
||||
(ID: <t t-esc="actionId"/>).
|
||||
<br/>
|
||||
</p>
|
||||
<p t-if="isUserAdmin">
|
||||
You can disable this automated action or edit it to solve the issue.<br/>
|
||||
Disabling this automated action will enable you to continue your workflow
|
||||
You can disable this automation rule or edit it to solve the issue.<br/>
|
||||
Disabling this automation rule will enable you to continue your workflow
|
||||
but any data created after this could potentially be corrupted,
|
||||
as you are effectively disabling a customization that may set
|
||||
important and/or required fields.
|
||||
</p>
|
||||
<p t-else="">
|
||||
You can ask an administrator to disable or correct this automated action.
|
||||
You can ask an administrator to disable or correct this automation rule.
|
||||
</p>
|
||||
</xpath>
|
||||
<xpath expr="//div[@role='alert']//button" position="after">
|
||||
<t t-if="isUserAdmin">
|
||||
<button class="btn btn-secondary mt4 o_disable_action_button" t-on-click.prevent="disableAction">
|
||||
<i class="fa fa-ban mr8"/>Disable Action
|
||||
<button class="btn btn-secondary mt4 o_disable_action_button me-3" t-on-click.prevent="disableAction">
|
||||
<i class="fa fa-ban mr8"/>Disable Automation Rule
|
||||
</button>
|
||||
<button class="btn btn-secondary mt4 o_edit_action_button" t-on-click.prevent="editAction">
|
||||
<i class="fa fa-edit mr8"/>Edit action
|
||||
<i class="fa fa-edit mr8"/>Edit Automation Rule
|
||||
</button>
|
||||
</t>
|
||||
</xpath>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Many2OneField, many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
|
||||
export class TriggerReferenceField extends Component {
|
||||
static components = { Many2OneField };
|
||||
static props = { ...Many2OneField.props };
|
||||
static template = xml`<Many2OneField t-props="m2oProps" />`;
|
||||
|
||||
get m2oProps() {
|
||||
return {
|
||||
...this.props,
|
||||
relation: this.m2oRelation,
|
||||
value: this.m2oValue,
|
||||
update: this.updateM2O.bind(this),
|
||||
canCreate: false,
|
||||
canCreateEdit: false,
|
||||
canOpen: false,
|
||||
canQuickCreate: false,
|
||||
};
|
||||
}
|
||||
|
||||
get m2oRelation() {
|
||||
return this.props.record.data.trg_field_ref_model_name;
|
||||
}
|
||||
|
||||
get m2oValue() {
|
||||
if (!this.value) {
|
||||
return null;
|
||||
}
|
||||
const displayName = this.props.record.data.trg_field_ref_display_name;
|
||||
return [this.value, displayName];
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
|
||||
updateM2O(data) {
|
||||
const value = data[this.props.name];
|
||||
const resId = value && value[0];
|
||||
this.props.record.update({ [this.props.name]: resId });
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerReferenceField = {
|
||||
supportedTypes: ["char"],
|
||||
component: TriggerReferenceField,
|
||||
extractProps: many2OneField.extractProps,
|
||||
};
|
||||
registry.category("fields").add("base_automation_trigger_reference", triggerReferenceField);
|
||||
@@ -0,0 +1,109 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { useState } from "@odoo/owl";
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useRecordObserver } from "@web/model/relational_model/utils";
|
||||
import { selectionField, SelectionField } from "@web/views/fields/selection/selection_field";
|
||||
import { TRIGGER_FILTERS } from "./utils";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const OPT_GROUPS = [
|
||||
{
|
||||
group: { sequence: 10, key: "values", name: _lt("Values Updated") },
|
||||
triggers: [
|
||||
"on_create_or_write",
|
||||
"on_stage_set",
|
||||
"on_user_set",
|
||||
"on_tag_set",
|
||||
"on_state_set",
|
||||
"on_priority_set",
|
||||
"on_archive",
|
||||
"on_unarchive",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: { sequence: 30, key: "timing", name: _lt("Timing Conditions") },
|
||||
triggers: ["on_time", "on_time_created", "on_time_updated"],
|
||||
},
|
||||
{
|
||||
group: { sequence: 40, key: "custom", name: _lt("Custom") },
|
||||
triggers: ["on_unlink", "on_change"],
|
||||
},
|
||||
{
|
||||
group: { sequence: 50, key: "deprecated", name: _lt("Deprecated (do not use)") },
|
||||
triggers: ["on_create", "on_write"],
|
||||
},
|
||||
];
|
||||
|
||||
function computeDerivedOptions(options, fields, recordValue) {
|
||||
// filter options to display, derived from the current value and the model fields
|
||||
const derivedOptions = [];
|
||||
for (const [value, label] of options) {
|
||||
const { group, triggers } = OPT_GROUPS.find((g) => g.triggers.includes(value));
|
||||
if (group.key === "deprecated" && !triggers.includes(recordValue)) {
|
||||
// skip deprecated triggers if the current value is not deprecated
|
||||
continue;
|
||||
}
|
||||
const filterFn = TRIGGER_FILTERS[value];
|
||||
const triggerFields = fields.filter(filterFn);
|
||||
if (triggerFields.length === 0) {
|
||||
// skip triggers that don't have any corresponding field
|
||||
continue;
|
||||
}
|
||||
const option = { group, value, label };
|
||||
derivedOptions.push(option);
|
||||
}
|
||||
return derivedOptions;
|
||||
}
|
||||
|
||||
export class TriggerSelectionField extends SelectionField {
|
||||
static template = "base_automation.TriggerSelectionField";
|
||||
setup() {
|
||||
super.setup();
|
||||
this.groupedOptions = useState([]);
|
||||
|
||||
const orm = useService("orm");
|
||||
let lastRelatedModelId;
|
||||
let relatedModelFields;
|
||||
useRecordObserver(async (record) => {
|
||||
const { data, fields } = record;
|
||||
const modelId = data.model_id?.[0];
|
||||
if (lastRelatedModelId !== modelId) {
|
||||
lastRelatedModelId = modelId;
|
||||
relatedModelFields = await orm.searchRead(
|
||||
"ir.model.fields",
|
||||
[["model_id", "=", modelId]],
|
||||
["field_description", "name", "ttype", "relation"]
|
||||
);
|
||||
}
|
||||
|
||||
// first, compute the derived options
|
||||
const derivedOptions = computeDerivedOptions(
|
||||
fields[this.props.name].selection,
|
||||
relatedModelFields,
|
||||
data[this.props.name]
|
||||
);
|
||||
|
||||
// then group and sort them
|
||||
this.groupedOptions.length = 0;
|
||||
for (const option of derivedOptions) {
|
||||
const group = this.groupedOptions.find((g) => g.key === option.group.key) ?? {
|
||||
...option.group,
|
||||
options: [],
|
||||
};
|
||||
group.options.push(option);
|
||||
if (!this.groupedOptions.includes(group)) {
|
||||
this.groupedOptions.push(group);
|
||||
}
|
||||
}
|
||||
this.groupedOptions.sort((a, b) => a.sequence - b.sequence);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerSelectionField = {
|
||||
...selectionField,
|
||||
component: TriggerSelectionField,
|
||||
};
|
||||
registry.category("fields").add("base_automation_trigger_selection", triggerSelectionField);
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
<t t-name="base_automation.TriggerSelectionField" t-inherit="web.SelectionField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-foreach='options']" position="replace">
|
||||
<t t-foreach="groupedOptions" t-as="group" t-key="group.key">
|
||||
<optgroup t-att-label="group.name">
|
||||
<t t-foreach="group.options" t-as="option" t-key="option.value">
|
||||
<option
|
||||
t-att-selected="option.value === value"
|
||||
t-att-value="stringify(option.value)"
|
||||
t-esc="option.label"/>
|
||||
</t>
|
||||
</optgroup>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,100 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { KanbanHeader } from "@web/views/kanban/kanban_header";
|
||||
import { TRIGGER_FILTERS } from "./utils";
|
||||
|
||||
const SUPPORTED_TRIGGERS = [
|
||||
"on_stage_set",
|
||||
"on_tag_set",
|
||||
"on_state_set",
|
||||
"on_priority_set",
|
||||
"on_user_set",
|
||||
"on_archive",
|
||||
];
|
||||
|
||||
function enrichContext(context, group) {
|
||||
const { displayName, groupByField, value } = group;
|
||||
const { name, relation, type: ttype } = groupByField;
|
||||
for (const trigger of SUPPORTED_TRIGGERS) {
|
||||
if (!TRIGGER_FILTERS[trigger]({ name, relation, ttype })) {
|
||||
continue;
|
||||
}
|
||||
switch (trigger) {
|
||||
case "on_stage_set":
|
||||
return {
|
||||
...context,
|
||||
default_trigger: trigger,
|
||||
default_name: _t('Stage is set to "%s"', displayName),
|
||||
default_trg_field_ref: value,
|
||||
};
|
||||
case "on_tag_set":
|
||||
return {
|
||||
...context,
|
||||
default_trigger: trigger,
|
||||
default_name: _t('"%s" tag is added', displayName),
|
||||
default_trg_field_ref: value,
|
||||
};
|
||||
default:
|
||||
return { ...context, default_trigger: trigger };
|
||||
}
|
||||
}
|
||||
|
||||
// Default trigger
|
||||
return { ...context, default_trigger: "on_create_or_write" };
|
||||
}
|
||||
|
||||
patch(KanbanHeader.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.action = useService("action");
|
||||
this.user = useService("user");
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get permissions() {
|
||||
const permissions = super.permissions;
|
||||
Object.defineProperty(permissions, "canEditAutomations", {
|
||||
get: () => this.user.isAdmin,
|
||||
configurable: true,
|
||||
});
|
||||
return permissions;
|
||||
},
|
||||
|
||||
async openAutomations() {
|
||||
const domain = [["model", "=", this.props.list.resModel]];
|
||||
const modelId = await this.orm.search("ir.model", domain, { limit: 1 });
|
||||
const context = {
|
||||
active_test: false,
|
||||
default_model_id: modelId[0],
|
||||
search_default_model_id: modelId[0],
|
||||
};
|
||||
this.action.doAction({
|
||||
name: _t("Automation Rules"),
|
||||
res_model: "base.automation",
|
||||
views: [
|
||||
[false, "kanban"],
|
||||
[false, "form"],
|
||||
],
|
||||
type: "ir.actions.act_window",
|
||||
target: "current",
|
||||
context: enrichContext(context, this.group),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
registry.category("kanban_header_config_items").add(
|
||||
"open_automations",
|
||||
{
|
||||
label: _t("Automations"),
|
||||
method: "openAutomations",
|
||||
isVisible: ({ permissions }) => permissions.canEditAutomations,
|
||||
class: "o_column_automations",
|
||||
},
|
||||
{ sequence: 25, force: true }
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @odoo-module */
|
||||
|
||||
export const TRIGGER_FILTERS = {
|
||||
on_create_or_write: (f) => true,
|
||||
on_create: (f) => true,
|
||||
on_write: (f) => true,
|
||||
on_change: (f) => true,
|
||||
on_unlink: (f) => true,
|
||||
on_time: (f) => true,
|
||||
on_time_created: (f) => f.ttype === "datetime" && f.name === "create_date",
|
||||
on_time_updated: (f) => f.ttype === "datetime" && f.name === "write_date",
|
||||
on_stage_set: (f) =>
|
||||
f.ttype === "many2one" && ["stage_id", "x_studio_stage_id"].includes(f.name),
|
||||
on_user_set: (f) =>
|
||||
f.relation === "res.users" &&
|
||||
["many2one", "many2many"].includes(f.ttype) &&
|
||||
["user_id", "user_ids", "x_studio_user_id", "x_studio_user_ids"].includes(f.name),
|
||||
on_tag_set: (f) => f.ttype === "many2many" && ["tag_ids", "x_studio_tag_ids"].includes(f.name),
|
||||
on_state_set: (f) => f.ttype === "selection" && ["state", "x_studio_state"].includes(f.name),
|
||||
on_priority_set: (f) =>
|
||||
f.ttype === "selection" && ["priority", "x_studio_priority"].includes(f.name),
|
||||
on_archive: (f) => f.ttype === "boolean" && ["active", "x_active"].includes(f.name),
|
||||
on_unarchive: (f) => f.ttype === "boolean" && ["active", "x_active"].includes(f.name),
|
||||
};
|
||||
@@ -53,7 +53,7 @@ QUnit.module("base_automation", {}, function () {
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test("Error due to an automated action", async function (assert) {
|
||||
QUnit.test("Error due to an automation rule", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const errorContext = {
|
||||
@@ -101,7 +101,7 @@ QUnit.module("base_automation", {}, function () {
|
||||
assert.containsOnce(target, ".modal .o_edit_action_button");
|
||||
});
|
||||
|
||||
QUnit.test("Error not due to an automated action", async function (assert) {
|
||||
QUnit.test("Error not due to an automation rule", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const error = makeServerError({
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { click, getFixture } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
function getColumn(groupIndex = 0, ignoreFolded = false) {
|
||||
let selector = ".o_kanban_group";
|
||||
if (ignoreFolded) {
|
||||
selector += ":not(.o_column_folded)";
|
||||
}
|
||||
return target.querySelectorAll(selector)[groupIndex];
|
||||
}
|
||||
|
||||
async function toggleColumnActions(columnIndex) {
|
||||
const group = getColumn(columnIndex);
|
||||
await click(group, ".o_kanban_config .dropdown-toggle");
|
||||
const buttons = group.querySelectorAll(".o_kanban_config .dropdown-menu .dropdown-item");
|
||||
return (buttonText) => {
|
||||
const re = new RegExp(`\\b${buttonText}\\b`, "i");
|
||||
const button = [...buttons].find((b) => re.test(b.innerText));
|
||||
return click(button);
|
||||
};
|
||||
}
|
||||
|
||||
let target;
|
||||
let serverData;
|
||||
|
||||
QUnit.module("BaseAutomation", {}, function () {
|
||||
QUnit.module("BaseAutomationKanbanHeader", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char" },
|
||||
bar: { string: "Bar", type: "boolean" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
bar: true,
|
||||
foo: "yop",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
bar: true,
|
||||
foo: "blip",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
bar: true,
|
||||
foo: "gnap",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
bar: false,
|
||||
foo: "blip",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.test("basic grouped rendering with automations", async (assert) => {
|
||||
const actionService = {
|
||||
start() {
|
||||
return {
|
||||
doAction: (action, options) => {
|
||||
assert.step(action);
|
||||
assert.deepEqual(options, {
|
||||
additionalContext: {
|
||||
active_test: false,
|
||||
search_default_model_id: 42,
|
||||
default_model_id: 42,
|
||||
default_trigger: "on_create_or_write",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
registry.category("services").add("action", actionService, { force: true });
|
||||
await makeView({
|
||||
type: "kanban",
|
||||
resModel: "partner",
|
||||
serverData: serverData,
|
||||
arch: `
|
||||
<kanban class="o_kanban_test">
|
||||
<field name="bar" />
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div>
|
||||
<field name="foo" />
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
groupBy: ["bar"],
|
||||
mockRPC: (route, { args, kwargs }) => {
|
||||
if (route === "/web/dataset/call_kw/ir.model/search") {
|
||||
assert.deepEqual(args, [[["model", "=", "partner"]]]);
|
||||
assert.strictEqual(kwargs.limit, 1);
|
||||
return [42]; // model id
|
||||
}
|
||||
},
|
||||
});
|
||||
assert.hasClass(target.querySelector(".o_kanban_view"), "o_kanban_test");
|
||||
assert.hasClass(target.querySelector(".o_kanban_renderer"), "o_kanban_grouped");
|
||||
assert.containsN(target, ".o_kanban_group", 2);
|
||||
assert.containsOnce(target, ".o_kanban_group:first-child .o_kanban_record");
|
||||
assert.containsN(target, ".o_kanban_group:nth-child(2) .o_kanban_record", 3);
|
||||
|
||||
await toggleColumnActions(0);
|
||||
|
||||
// check available actions in kanban header's config dropdown
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_kanban_header:first-child .o_kanban_config .o_kanban_toggle_fold"
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_kanban_header:first-child .o_kanban_config .o_column_automations"
|
||||
);
|
||||
assert.containsNone(
|
||||
target,
|
||||
".o_kanban_header:first-child .o_kanban_config .o_column_edit"
|
||||
);
|
||||
assert.containsNone(
|
||||
target,
|
||||
".o_kanban_header:first-child .o_kanban_config .o_column_delete"
|
||||
);
|
||||
assert.containsNone(
|
||||
target,
|
||||
".o_kanban_header:first-child .o_kanban_config .o_column_archive_records"
|
||||
);
|
||||
assert.containsNone(
|
||||
target,
|
||||
".o_kanban_header:first-child .o_kanban_config .o_column_unarchive_records"
|
||||
);
|
||||
assert.verifySteps([]);
|
||||
await click(
|
||||
target,
|
||||
".o_kanban_header:first-child .o_kanban_config .o_column_automations"
|
||||
);
|
||||
assert.verifySteps(["base_automation.base_automation_act"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo.tests import TransactionCase
|
||||
from odoo import Command
|
||||
|
||||
import odoo.tests
|
||||
|
||||
@@ -8,20 +9,24 @@ import odoo.tests
|
||||
@odoo.tests.tagged('post_install', '-at_install')
|
||||
class TestAutomation(TransactionCase):
|
||||
|
||||
def test_01_on_create(self):
|
||||
def test_01_on_create_or_write(self):
|
||||
""" Simple on_create with admin user """
|
||||
self.env["base.automation"].create({
|
||||
model = self.env.ref("base.model_res_partner")
|
||||
automation = self.env["base.automation"].create({
|
||||
"name": "Force Archived Contacts",
|
||||
"trigger": "on_create_or_write",
|
||||
"model_id": self.env.ref("base.model_res_partner").id,
|
||||
"type": "ir.actions.server",
|
||||
"model_id": model.id,
|
||||
"trigger_field_ids": [(6, 0, [self.env.ref("base.field_res_partner__name").id])],
|
||||
"fields_lines": [(0, 0, {
|
||||
"col1": self.env.ref("base.field_res_partner__active").id,
|
||||
"evaluation_type": "equation",
|
||||
"value": "False",
|
||||
})],
|
||||
})
|
||||
action = self.env["ir.actions.server"].create({
|
||||
"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,
|
||||
"value": False,
|
||||
"model_id": model.id,
|
||||
})
|
||||
automation.write({"action_server_ids": [Command.link(action.id)]})
|
||||
|
||||
# verify the partner can be created and the action still runs
|
||||
bilbo = self.env["res.partner"].create({"name": "Bilbo Baggins"})
|
||||
@@ -32,22 +37,26 @@ class TestAutomation(TransactionCase):
|
||||
bilbo.name = "Bilbo"
|
||||
self.assertFalse(bilbo.active)
|
||||
|
||||
def test_02_on_create_restricted(self):
|
||||
def test_02_on_create_or_write_restricted(self):
|
||||
""" on_create action with low portal user """
|
||||
action = self.env["base.automation"].create({
|
||||
model = self.env.ref("base.model_ir_filters")
|
||||
automation = self.env["base.automation"].create({
|
||||
"name": "Force Archived Filters",
|
||||
"trigger": "on_create_or_write",
|
||||
"model_id": self.env.ref("base.model_ir_filters").id,
|
||||
"type": "ir.actions.server",
|
||||
"model_id": model.id,
|
||||
"trigger_field_ids": [(6, 0, [self.env.ref("base.field_ir_filters__name").id])],
|
||||
"fields_lines": [(0, 0, {
|
||||
"col1": self.env.ref("base.field_ir_filters__active").id,
|
||||
"evaluation_type": "equation",
|
||||
"value": "False",
|
||||
})],
|
||||
})
|
||||
action = self.env["ir.actions.server"].create({
|
||||
"name": "Set Active To False",
|
||||
"base_automation_id": automation.id,
|
||||
"model_id": model.id,
|
||||
"state": "object_write",
|
||||
"update_field_id": self.env.ref("base.field_ir_filters__active").id,
|
||||
"value": False
|
||||
})
|
||||
automation.write({"action_server_ids": [Command.link(action.id)]})
|
||||
# action cached was cached with admin, force CacheMiss
|
||||
action.env.clear()
|
||||
automation.env.clear()
|
||||
|
||||
self_portal = self.env["ir.filters"].with_user(self.env.ref("base.user_demo").id)
|
||||
# verify the portal user can create ir.filters but can not read base.automation
|
||||
@@ -69,17 +78,23 @@ class TestAutomation(TransactionCase):
|
||||
|
||||
def test_03_on_change_restricted(self):
|
||||
""" on_create action with low portal user """
|
||||
action = self.env["base.automation"].create({
|
||||
model = self.env.ref("base.model_ir_filters")
|
||||
automation = self.env["base.automation"].create({
|
||||
"name": "Force Archived Filters",
|
||||
"trigger": "on_change",
|
||||
"model_id": self.env.ref("base.model_ir_filters").id,
|
||||
"type": "ir.actions.server",
|
||||
"model_id": model.id,
|
||||
"on_change_field_ids": [(6, 0, [self.env.ref("base.field_ir_filters__name").id])],
|
||||
})
|
||||
action = self.env["ir.actions.server"].create({
|
||||
"name": "Set Active To False",
|
||||
"base_automation_id": automation.id,
|
||||
"model_id": model.id,
|
||||
"state": "code",
|
||||
"code": """action = {'value': {'active': False}}""",
|
||||
})
|
||||
automation.write({"action_server_ids": [Command.link(action.id)]})
|
||||
# action cached was cached with admin, force CacheMiss
|
||||
action.env.clear()
|
||||
automation.env.clear()
|
||||
|
||||
self_portal = self.env["ir.filters"].with_user(self.env.ref("base.user_demo").id)
|
||||
|
||||
|
||||
@@ -25,15 +25,21 @@ class TestMailFullComposer(HttpCase):
|
||||
'password': 'testuser',
|
||||
})
|
||||
|
||||
automated_action = self.env['base.automation'].create({
|
||||
automation = self.env['base.automation'].create({
|
||||
'name': 'Test',
|
||||
'active': True,
|
||||
'trigger': 'on_change',
|
||||
'on_change_field_ids': (4, self.ref('mail.field_mail_compose_message__template_id'),),
|
||||
'model_id': self.env.ref('mail.model_mail_compose_message').id,
|
||||
})
|
||||
server_action = self.env['ir.actions.server'].create({
|
||||
'name': 'Test',
|
||||
'base_automation_id': automation.id,
|
||||
'state': 'code',
|
||||
'model_id': self.env.ref('mail.model_mail_compose_message').id,
|
||||
})
|
||||
automation.write({'action_server_ids': [(4, server_action.id)]})
|
||||
|
||||
self.start_tour("/web#id=%d&model=res.partner" % test_user.partner_id, 'mail/static/tests/tours/mail_full_composer_test_tour.js', login='testuser')
|
||||
|
||||
automated_action.unlink()
|
||||
automation.unlink()
|
||||
|
||||
@@ -5,59 +5,151 @@
|
||||
<record id="view_base_automation_form" model="ir.ui.view">
|
||||
<field name="name">Automations</field>
|
||||
<field name="model">base.automation</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Automation Rule">
|
||||
<sheet>
|
||||
<field name="active" invisible="1" />
|
||||
<field name="model_name" invisible="1" />
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="e.g. Support flow"/></h1>
|
||||
</div>
|
||||
<group groups="!base.group_no_one" invisible="context.get('default_model_id')">
|
||||
<group>
|
||||
<field name="model_id" options="{'no_create': True}" />
|
||||
</group>
|
||||
</group>
|
||||
<group groups="base.group_no_one">
|
||||
<group>
|
||||
<field name="model_id" options="{'no_create': True}" />
|
||||
</group>
|
||||
</group>
|
||||
<group invisible="not model_id">
|
||||
<group>
|
||||
<label for="trigger"/>
|
||||
<div>
|
||||
<div class="d-flex flex-row">
|
||||
<field name="trigger" widget="base_automation_trigger_selection" class="oe_inline me-3"/>
|
||||
<field name="trg_selection_field_id" placeholder="Select a value..." class="oe_inline"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
invisible="trigger not in ['on_state_set', 'on_priority_set']"
|
||||
required="trigger in ['on_state_set', 'on_priority_set']"
|
||||
/>
|
||||
<field name="trg_field_ref" placeholder="Select a value..." class="oe_inline"
|
||||
widget="base_automation_trigger_reference"
|
||||
invisible="trigger not in ['on_stage_set', 'on_tag_set']"
|
||||
required="trigger in ['on_stage_set', 'on_tag_set']"
|
||||
/>
|
||||
<field name="trg_field_ref_model_name" invisible="1" />
|
||||
<field name="trg_field_ref_display_name" invisible="1" />
|
||||
<field name="trigger_field_ids" invisible="1" />
|
||||
<field name="trigger_field_ids" placeholder="Select fields..." class="oe_inline" groups="base.group_no_one"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
domain="[('model_id', '=', model_id)]"
|
||||
invisible="trigger != 'on_create_or_write'" widget="many2many_tags" />
|
||||
<field name="on_change_field_ids" class="oe_inline" string="Trigger Fields" placeholder="Trigger fields..."
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
domain="[('model_id', '=', model_id)]"
|
||||
invisible="trigger != 'on_change'" required="trigger == 'on_change'"
|
||||
widget="many2many_tags"/>
|
||||
<field name="trg_date_id" class="oe_inline" string="Date Field"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
invisible="trigger != 'on_time'"
|
||||
required="trigger in ['on_time', 'on_time_created', 'on_time_updated']"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-muted" invisible="trigger != 'on_change'"><i class="fa fa-lightbulb-o"/> Automation rules that run on live updates will be executed every time the trigger fields change, <em>whether you save or not</em>.</div>
|
||||
</div>
|
||||
<label for="trg_date_range" class="oe_inline" string="Delay"
|
||||
invisible="trigger not in ['on_time', 'on_time_created', 'on_time_updated']"/>
|
||||
<div class="o_row oe_inline" invisible="trigger not in ['on_time', 'on_time_created', 'on_time_updated']" >
|
||||
<field name="trg_date_range" required="trigger in ['on_time', 'on_time_created', 'on_time_updated']" />
|
||||
<field name="trg_date_range_type" required="trigger in ['on_time', 'on_time_created', 'on_time_updated']" />
|
||||
</div>
|
||||
<field name="trg_date_calendar_id" class="oe_inline"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
invisible="not trg_date_id or trg_date_range_type != 'day'"/>
|
||||
<label for="least_delay_msg" invisible="trigger not in ['on_time', 'on_time_created', 'on_time_updated'] or not least_delay_msg" string="" />
|
||||
<div class="alert alert-info" role="alert" invisible="trigger not in ['on_time', 'on_time_created', 'on_time_updated'] or not least_delay_msg">
|
||||
<field name="least_delay_msg"/>
|
||||
</div>
|
||||
<field name="filter_pre_domain" class="oe_inline" widget="domain" groups="base.group_no_one"
|
||||
options="{'model': 'model_name', 'in_dialog': True}"
|
||||
/>
|
||||
<field name="filter_domain" class="oe_inline" widget="domain"
|
||||
options="{'model': 'model_name', 'in_dialog': True}"
|
||||
invisible="trigger not in ['on_create_or_write', 'on_change', 'on_unlink']"
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook invisible="not model_id">
|
||||
<page string="Actions To Do" name="actions">
|
||||
<field
|
||||
name="action_server_ids"
|
||||
widget="one2many"
|
||||
class="o_base_automation_actions_field"
|
||||
context="{'default_model_id': model_id, 'default_state': False, 'automatic_action_name': True, 'form_view_ref': 'base_automation.view_server_action_form'}"
|
||||
>
|
||||
<kanban>
|
||||
<control>
|
||||
<create string="Add a new action" />
|
||||
</control>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click row g-0 align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<field name="sequence" widget="handle" class="px-3" />
|
||||
<field name="state" invisible="1" />
|
||||
<!-- Icon section -->
|
||||
<i
|
||||
data-name="server_action_icon"
|
||||
t-att-title="record.state.value"
|
||||
class="fa"
|
||||
t-att-class="{
|
||||
'code': 'fa-file-code-o',
|
||||
'object_create': 'fa-edit',
|
||||
'object_write': 'fa-refresh',
|
||||
'multi': 'fa-list-ul',
|
||||
'mail_post': 'fa-envelope',
|
||||
'followers': 'fa-user-o',
|
||||
'remove_followers': 'fa-user-times',
|
||||
'next_activity': 'fa-clock-o',
|
||||
'sms': 'fa-comments-o',
|
||||
}[record.state.raw_value]"
|
||||
/>
|
||||
<field name="name" class="ps-2 text-truncate w-0 flex-grow-1" />
|
||||
<button type="delete" name="delete" class="btn fa fa-trash fa-xl px-3" title="Delete Action" />
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Automation: Server Action Form View -->
|
||||
<record id="view_server_action_form" model="ir.ui.view">
|
||||
<field name="name">base.automation.server.action.form</field>
|
||||
<field name="model">ir.actions.server</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="inherit_id" ref="base.view_server_action_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='create_action']" position="replace">
|
||||
<xpath expr='//field[@name="name"]' position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='unlink_action']" position="replace">
|
||||
<xpath expr='//field[@name="state"]' position="attributes">
|
||||
<attribute name="string">Type</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='run']" position="replace">
|
||||
<xpath expr="//header" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='partner_ids']" position='attributes'>
|
||||
<attribute name="options">{'no_create': True}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='template_id']" position='attributes'>
|
||||
<attribute name="context">{'default_model_id': model_id}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='security']" position='attributes'>
|
||||
<attribute name='invisible'>1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='action_content']" position="inside">
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="trigger"/>
|
||||
<field name="trigger_field_ids"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
domain="[('model_id', '=', model_id)]"
|
||||
invisible="trigger not in ['on_write', 'on_create_or_write']" widget="many2many_tags"/>
|
||||
<field name="on_change_field_ids" string="Trigger Fields"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
domain="[('model_id', '=', model_id)]"
|
||||
invisible="trigger != 'on_change'"
|
||||
required="trigger == 'on_change'"
|
||||
widget='many2many_tags'/>
|
||||
|
||||
<field name="filter_pre_domain" widget="domain" options="{'model': 'model_name', 'in_dialog': True}"
|
||||
invisible="trigger not in ['on_write', 'on_create_or_write']"/>
|
||||
<field name="filter_domain" widget="domain" options="{'model': 'model_name', 'in_dialog': True}"/>
|
||||
<field name="trg_date_id"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
invisible="trigger != 'on_time'"
|
||||
required="trigger == 'on_time'"/>
|
||||
<label for="trg_date_range"
|
||||
invisible="trigger != 'on_time'"/>
|
||||
<div class="o_row" invisible="trigger != 'on_time'">
|
||||
<field name="trg_date_range" required="trigger == 'on_time'"/>
|
||||
<field name="trg_date_range_type" required="trigger == 'on_time'"/>
|
||||
</div>
|
||||
<field name="trg_date_calendar_id"
|
||||
options="{'no_open': True, 'no_create': True}"
|
||||
invisible="not trg_date_id or trg_date_range_type != 'day'"/>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='action_wrapper']" position="after">
|
||||
<div class="alert alert-info" role="alert" invisible="trigger != 'on_time'">
|
||||
<field name="least_delay_msg"/>
|
||||
</div>
|
||||
<xpath expr="//field[@name='model_id']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
@@ -67,8 +159,7 @@
|
||||
<field name="name">base.automation.tree</field>
|
||||
<field name="model">base.automation</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Automation">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<tree string="Automation Rules">
|
||||
<field name="name"/>
|
||||
<field name="trigger"/>
|
||||
<field name="model_id"/>
|
||||
@@ -76,6 +167,67 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- automation Kanban View -->
|
||||
<record id="view_base_automation_kanban" model="ir.ui.view">
|
||||
<field name="name">base.automation.kanban</field>
|
||||
<field name="model">base.automation</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban
|
||||
string="Automation Rules"
|
||||
class="o_base_automation_kanban_view"
|
||||
records_draggable="false"
|
||||
groups_draggable="false"
|
||||
quick_create="false"
|
||||
group_create="false"
|
||||
group_edit="false"
|
||||
group_delete="false"
|
||||
>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click">
|
||||
<field name="active" invisible="1" />
|
||||
<field name="model_name" invisible="1" />
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active" />
|
||||
<div class="d-flex flex-column flex-md-row gap-3 flex-grow-1">
|
||||
<div class="d-flex align-items-center o_automation_base_info">
|
||||
<div class="d-flex flex-column">
|
||||
<field name="name" class="fs-2 fw-bold"/>
|
||||
<field name="model_id"/>
|
||||
<div class="d-flex align-items-center gap-1" invisible="trigger in ['on_time', 'on_time_created', 'on_time_updated']">
|
||||
<field name="trigger" />
|
||||
<field name="on_change_field_ids" invisible="trigger != 'on_change'" class="my-1" />
|
||||
<field name="trg_selection_field_id" invisible="trigger not in ['on_state_set', 'on_priority_set']" class="o_tag o_tag_color_0 rounded-pill p-1 px-2" />
|
||||
<field name="trg_field_ref_display_name" invisible="trigger not in ['on_stage_set', 'on_tag_set']" class="o_tag o_tag_color_0 rounded-pill p-1 px-2" />
|
||||
<field name="trigger_field_ids" invisible="trigger not in ['on_create_or_write']" class="my-1" />
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1" invisible="trigger != 'on_time'">
|
||||
<field name="trg_date_range"/>
|
||||
<field name="trg_date_range_type" class="text-lowercase" />
|
||||
<span class="flex-shrink-0">
|
||||
based on <field name="trg_date_id" invisible="trigger != 'on_time'" class="o_tag o_tag_color_0 rounded-pill p-1 px-2" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1" invisible="trigger not in ['on_time_created', 'on_time_updated']">
|
||||
<field name="trg_date_range"/>
|
||||
<field name="trg_date_range_type" class="text-lowercase" />
|
||||
<field name="trigger" class="text-lowercase" />
|
||||
<field name="trg_date_id" invisible="trigger != 'on_time'" class="o_tag o_tag_color_0 rounded-pill p-1 px-2" />
|
||||
</div>
|
||||
<field name="trg_date_calendar_id" invisible="not trg_date_id or trg_date_range_type != 'day'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none flex-grow-1 align-items-center gap-3 o_automation_actions" data-name="more-info">
|
||||
<i class="fa fa-2x fa-arrow-right text-primary" title="Actions" />
|
||||
<field name="action_server_ids" widget="base_automation_actions_one2many" class="align-self-center w-100 me-md-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_base_automation_search" model="ir.ui.view">
|
||||
<field name="name">base.automation.search</field>
|
||||
<field name="model">base.automation</field>
|
||||
@@ -91,19 +243,20 @@
|
||||
|
||||
<!-- automation Action -->
|
||||
<record id="base_automation_act" model="ir.actions.act_window">
|
||||
<field name="name">Automated Actions</field>
|
||||
<field name="name">Automation Rules</field>
|
||||
<field name="res_model">base.automation</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="view_base_automation_tree"/>
|
||||
<field name="view_mode">kanban,tree,form</field>
|
||||
<field name="view_id" ref="view_base_automation_kanban"/>
|
||||
<field name="context">{'active_test': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Setup a new automated automation
|
||||
Setup a new automation rule
|
||||
</p><p>
|
||||
Use automated actions to automatically trigger actions for
|
||||
various screens. Example: a lead created by a specific user may
|
||||
be automatically set to a specific Sales Team, or an
|
||||
opportunity which still has status pending after 14 days might
|
||||
trigger an automatic reminder email.
|
||||
Send an email when an object changes state, archive records
|
||||
after a month of inactivity or remind yourself to follow-up on
|
||||
tasks when a specific tag is added.
|
||||
</p><p>With Automation Rules, you can automate
|
||||
<em>any</em> workflow.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -58,7 +58,7 @@ Lead Automation and Marketing Campaigns
|
||||
Drive performance by automating tasks with Odoo <a href="https://www.odoo.com/app/crm">CRM</a>.
|
||||
|
||||
Use our marketing campaigns to automate lead acquisition, follow ups and
|
||||
promotions. Define automated actions (e.g. ask a salesperson to call, send an
|
||||
promotions. Define automation rules (e.g. ask a salesperson to call, send an
|
||||
email, ...) based on triggers (no activity since 20 days, answered a
|
||||
promotional email, etc.)
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
<div>
|
||||
Created on: <span t-field="lead.create_date"/>
|
||||
</div>
|
||||
<div t-if="lead.date_action_last">
|
||||
Last Action: <span t-field="lead.date_action_last"/>
|
||||
<div t-if="lead.date_automation_last">
|
||||
Last Automation: <span t-field="lead.date_automation_last"/>
|
||||
</div>
|
||||
<div t-if="lead.date_deadline">
|
||||
Expected Closing: <span t-field="lead.date_deadline"/>
|
||||
|
||||
@@ -49,7 +49,7 @@ CRM_LEAD_FIELDS_TO_MERGE = [
|
||||
'recurring_revenue',
|
||||
# dates
|
||||
'create_date',
|
||||
'date_action_last',
|
||||
'date_automation_last',
|
||||
'date_deadline',
|
||||
# partner / contact
|
||||
'partner_id',
|
||||
@@ -162,7 +162,7 @@ class Lead(models.Model):
|
||||
company_currency = fields.Many2one("res.currency", string='Currency', compute="_compute_company_currency", compute_sudo=True)
|
||||
# Dates
|
||||
date_closed = fields.Datetime('Closed Date', readonly=True, copy=False)
|
||||
date_action_last = fields.Datetime('Last Action', readonly=True)
|
||||
date_automation_last = fields.Datetime('Last Action', readonly=True)
|
||||
date_open = fields.Datetime(
|
||||
'Assignment Date', compute='_compute_date_open', readonly=True, store=True)
|
||||
day_open = fields.Float('Days to Assign', compute='_compute_day_open', store=True)
|
||||
|
||||
@@ -421,9 +421,9 @@ class Team(models.Model):
|
||||
which is a good trade-off between transaction time and speed
|
||||
:config int crm.assignment.delay: optional config parameter giving a
|
||||
delay before taking a lead into assignment process (BUNDLE_HOURS_DELAY)
|
||||
given in hours. Purpose if to allow other crons or automated actions
|
||||
given in hours. Purpose if to allow other crons or automation rules
|
||||
to make their job. This option is mainly historic as its purpose was
|
||||
to let automated actions prepare leads and score before PLS was added
|
||||
to let automation rules prepare leads and score before PLS was added
|
||||
into CRM. This is now not required anymore but still supported;
|
||||
|
||||
:param float work_days: see ``CrmTeam.action_assign_leads()``;
|
||||
|
||||
@@ -51,7 +51,7 @@ class TestCrmCommon(TestSalesCommon, MailCase):
|
||||
'team_id', 'state_id', 'stage_id', 'medium_id', 'source_id', 'user_id',
|
||||
'title', 'city', 'contact_name', 'mobile', 'partner_name',
|
||||
'phone', 'probability', 'expected_revenue', 'street', 'street2', 'zip',
|
||||
'create_date', 'date_action_last', 'email_from', 'email_cc', 'website'
|
||||
'create_date', 'date_automation_last', 'email_from', 'email_cc', 'website'
|
||||
]
|
||||
merge_fields = ['description', 'type', 'priority']
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ class Digest(models.Model):
|
||||
|
||||
def action_send_manual(self):
|
||||
""" Manually send digests emails to all registered users. In that case
|
||||
do not update periodicity as this is not an automated action that could
|
||||
do not update periodicity as this is not an automation rule that could
|
||||
be considered as unwanted spam. """
|
||||
return self._action_send(update_periodicity=False)
|
||||
|
||||
|
||||
@@ -16,17 +16,17 @@ class ServerActions(models.Model):
|
||||
state = fields.Selection(
|
||||
selection_add=[('mail_post', 'Send Email'),
|
||||
('followers', 'Add Followers'),
|
||||
('remove_followers', 'Remove Followers'),
|
||||
('next_activity', 'Create Next Activity'),
|
||||
],
|
||||
ondelete={'mail_post': 'cascade',
|
||||
'followers': 'cascade',
|
||||
'remove_followers': 'cascade',
|
||||
'next_activity': 'cascade',
|
||||
}
|
||||
)
|
||||
# Followers
|
||||
partner_ids = fields.Many2many(
|
||||
'res.partner', string='Add Followers',
|
||||
compute='_compute_partner_ids', readonly=False, store=True)
|
||||
partner_ids = fields.Many2many('res.partner', compute='_compute_partner_ids', readonly=False, store=True)
|
||||
# Message Post / Email
|
||||
template_id = fields.Many2one(
|
||||
'mail.template', 'Email Template',
|
||||
@@ -39,19 +39,19 @@ class ServerActions(models.Model):
|
||||
'Subscribe Recipients', compute='_compute_mail_post_autofollow',
|
||||
readonly=False, store=True)
|
||||
mail_post_method = fields.Selection(
|
||||
selection=[('email', 'Email'), ('comment', 'Post as Message'), ('note', 'Post as Note')],
|
||||
string='Send as',
|
||||
selection=[('email', 'Email'), ('comment', 'Message'), ('note', 'Note')],
|
||||
string='Send Email As',
|
||||
compute='_compute_mail_post_method',
|
||||
readonly=False, store=True,
|
||||
help='Choose method for email sending:\nEMail: send directly emails\nPost as Message: post on document and notify followers\nPost as Note: log a note on document')
|
||||
readonly=False, store=True)
|
||||
|
||||
# Next Activity
|
||||
activity_type_id = fields.Many2one(
|
||||
'mail.activity.type', string='Activity',
|
||||
'mail.activity.type', string='Activity Type',
|
||||
domain="['|', ('res_model', '=', False), ('res_model', '=', model_name)]",
|
||||
compute='_compute_activity_type_id', readonly=False, store=True,
|
||||
ondelete='restrict')
|
||||
activity_summary = fields.Char(
|
||||
'Summary',
|
||||
'Title',
|
||||
compute='_compute_activity_info', readonly=False, store=True)
|
||||
activity_note = fields.Html(
|
||||
'Note',
|
||||
@@ -67,16 +67,34 @@ class ServerActions(models.Model):
|
||||
compute='_compute_activity_info', readonly=False, store=True)
|
||||
activity_user_type = fields.Selection(
|
||||
[('specific', 'Specific User'),
|
||||
('generic', 'Generic User From Record')],
|
||||
('generic', 'Dynamic User (based on record)')],
|
||||
string='User Type',
|
||||
compute='_compute_activity_info', readonly=False, store=True,
|
||||
help="Use 'Specific User' to always assign the same user on the next activity. Use 'Generic User From Record' to specify the field name of the user to choose on the record.")
|
||||
help="Use 'Specific User' to always assign the same user on the next activity. Use 'Dynamic User' to specify the field name of the user to choose on the record.")
|
||||
activity_user_id = fields.Many2one(
|
||||
'res.users', string='Responsible',
|
||||
compute='_compute_activity_info', readonly=False, store=True)
|
||||
activity_user_field_name = fields.Char(
|
||||
'User field name',
|
||||
'User Field',
|
||||
compute='_compute_activity_info', readonly=False, store=True)
|
||||
|
||||
|
||||
@api.depends('state', 'template_id', 'partner_ids', 'activity_summary')
|
||||
def _compute_name(self):
|
||||
for action in self:
|
||||
if not action.state or not self.env.context.get('automatic_action_name'):
|
||||
continue
|
||||
if action.state == 'mail_post':
|
||||
action.name = 'Send email: %s' % action.template_id.name
|
||||
elif action.state == 'followers':
|
||||
action.name = 'Add followers: %s' % ', '.join(action.partner_ids.mapped('name'))
|
||||
elif action.state == 'remove_followers':
|
||||
action.name = 'Remove followers: %s' % ', '.join(action.partner_ids.mapped('name'))
|
||||
elif action.state == 'next_activity':
|
||||
action.name = 'Next activity: %s' % action.activity_summary
|
||||
else:
|
||||
super(ServerActions, action)._compute_name()
|
||||
|
||||
@api.depends('model_id', 'state')
|
||||
def _compute_template_id(self):
|
||||
to_reset = self.filtered(
|
||||
@@ -102,7 +120,7 @@ class ServerActions(models.Model):
|
||||
to_reset.mail_post_method = False
|
||||
other = self - to_reset
|
||||
if other:
|
||||
other.mail_post_method = 'email'
|
||||
other.mail_post_method = 'comment'
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_partner_ids(self):
|
||||
@@ -161,6 +179,13 @@ class ServerActions(models.Model):
|
||||
records.message_subscribe(partner_ids=self.partner_ids.ids)
|
||||
return False
|
||||
|
||||
def _run_action_remove_followers_multi(self, eval_context=None):
|
||||
Model = self.env[self.model_name]
|
||||
if self.partner_ids and hasattr(Model, 'message_unsubscribe'):
|
||||
records = Model.browse(self._context.get('active_ids', self._context.get('active_id')))
|
||||
records.message_unsubscribe(partner_ids=self.partner_ids.ids)
|
||||
return False
|
||||
|
||||
def _is_recompute(self):
|
||||
"""When an activity is set on update of a record,
|
||||
update might be triggered many times by recomputes.
|
||||
|
||||
@@ -7,39 +7,54 @@
|
||||
<field name="model">ir.actions.server</field>
|
||||
<field name="inherit_id" ref="base.view_server_action_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='page_object']" position="after">
|
||||
<page string="Activity" name="next_activity" autofocus="autofocus" invisible="state != 'next_activity'">
|
||||
<group>
|
||||
<group>
|
||||
<field name="activity_type_id" options="{'no_create': True, 'no_open': True}"
|
||||
required="state == 'next_activity'"/>
|
||||
<field name="activity_summary" placeholder="e.g. Discuss proposal"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="activity_date_deadline_range"/>
|
||||
<div class="o_row">
|
||||
<field name="activity_date_deadline_range"/>
|
||||
<field name="activity_date_deadline_range_type" required="state == 'next_activity' and activity_date_deadline_range > 0"/>
|
||||
</div>
|
||||
<field name="activity_user_type" required="state == 'next_activity'"/>
|
||||
<field name="activity_user_field_name" invisible="activity_user_type == 'specific'" required="state == 'next_activity' and activity_user_type == 'generic'"/>
|
||||
<field name="activity_user_id" invisible="activity_user_type == 'generic'" required="state == 'next_activity' and activity_user_type == 'specific'"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="activity_note" class="oe-bordered-editor" placeholder="Log a note..."/>
|
||||
</page>
|
||||
<xpath expr="//group[@name='action_content']" position="inside">
|
||||
<field name="activity_type_id" options="{'no_create': True, 'no_open': True}"
|
||||
required="state == 'next_activity'" invisible="state != 'next_activity'"/>
|
||||
<field name="activity_summary" placeholder="e.g. Discuss proposal" invisible="state != 'next_activity'"/>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='action_content']" position="after">
|
||||
<group invisible="state != 'next_activity'">
|
||||
<label for="activity_date_deadline_range"/>
|
||||
<div class="o_row">
|
||||
<field name="activity_date_deadline_range" class="oe_inline"/>
|
||||
<field name="activity_date_deadline_range_type" class="oe_inline" required="state == 'next_activity' and activity_date_deadline_range > 0"/>
|
||||
</div>
|
||||
<field name="activity_user_type" required="state == 'next_activity'"/>
|
||||
<field name="activity_user_field_name" placeholder="e.g. user_id" invisible="activity_user_type == 'specific'" required="state == 'next_activity' and activity_user_type == 'generic'"/>
|
||||
<field name="activity_user_id" placeholder="Select a user..." invisible="activity_user_type == 'generic'" required="state == 'next_activity' and activity_user_type == 'specific'"/>
|
||||
</group>
|
||||
<field name="activity_note" class="oe-bordered-editor" placeholder="Add a description to your activity..." invisible="state != 'next_activity'"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='link_field_id']" position="after">
|
||||
<field name="partner_ids" widget="many2many_tags"
|
||||
invisible="state != 'followers'"/>
|
||||
<field name="partner_ids" string="Followers to add" widget="many2many_tags"
|
||||
invisible="state != 'followers'"
|
||||
required="state == 'followers'"/>
|
||||
<field name="partner_ids" string="Followers to remove" widget="many2many_tags"
|
||||
invisible="state != 'remove_followers'"
|
||||
required="state == 'remove_followers'"/>
|
||||
<field name="template_id"
|
||||
invisible="state != 'mail_post'"
|
||||
required="state == 'mail_post'"
|
||||
context="{'default_model': model_name,
|
||||
'default_use_default_to': True}"/>
|
||||
<field name="mail_post_method"
|
||||
invisible="state != 'mail_post'"
|
||||
required="state == 'mail_post'"/>
|
||||
<label for="mail_post_method" invisible="state != 'mail_post'"/>
|
||||
<div class="d-flex flex-column" invisible="state != 'mail_post'">
|
||||
<field name="mail_post_method" required="state == 'mail_post'"/>
|
||||
<div class="text-muted">
|
||||
<span invisible="mail_post_method != 'email'">
|
||||
The message will be sent as an email to the recipients of the
|
||||
template and will not appear in the messaging history.
|
||||
</span>
|
||||
<span invisible="mail_post_method != 'note'">
|
||||
The message will be posted as an internal note visible to internal
|
||||
users in the messaging history.
|
||||
</span>
|
||||
<span invisible="mail_post_method != 'comment'">
|
||||
The message will be posted as a message on the record,
|
||||
notifying all followers. It will appear in the messaging history.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<field name="mail_post_autofollow" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
@@ -79,7 +79,7 @@ gives you a direct overview of your campaign performance.
|
||||
Fully Integrated With Others Apps
|
||||
---------------------------------
|
||||
|
||||
Define automated actions (e.g. ask a salesperson to call, send an email, ...)
|
||||
Define automation rules (e.g. ask a salesperson to call, send an email, ...)
|
||||
based on triggers (no activity since 20 days, answered a promotional email,
|
||||
etc.)
|
||||
|
||||
|
||||
@@ -21,11 +21,20 @@ class ServerActions(models.Model):
|
||||
domain="[('model_id', '=', model_id)]",
|
||||
)
|
||||
sms_method = fields.Selection(
|
||||
selection=[('sms', 'SMS'), ('comment', 'Post as Message'), ('note', 'Post as Note')],
|
||||
string='Send as (SMS)',
|
||||
selection=[('sms', 'SMS (without note)'), ('comment', 'SMS (with note)'), ('note', 'Note only')],
|
||||
string='Send SMS As',
|
||||
compute='_compute_sms_method',
|
||||
readonly=False, store=True,
|
||||
help='Choose method for SMS sending:\nSMS: mass SMS\nPost as Message: log on document\nPost as Note: mass SMS with archives')
|
||||
readonly=False, store=True)
|
||||
|
||||
@api.depends('state', 'sms_template_id')
|
||||
def _compute_name(self):
|
||||
for action in self:
|
||||
if not action.state or not self.env.context.get('automatic_action_name'):
|
||||
continue
|
||||
if action.state == 'sms':
|
||||
action.name = 'Send SMS: %s' % action.sms_template_id.name
|
||||
else:
|
||||
super(ServerActions, action)._compute_name()
|
||||
|
||||
@api.depends('model_id', 'state')
|
||||
def _compute_sms_template_id(self):
|
||||
|
||||
@@ -11,10 +11,25 @@
|
||||
context="{'default_model': model_name}"
|
||||
invisible="state != 'sms'"
|
||||
required="state == 'sms'"/>
|
||||
<field name="sms_method"
|
||||
string="Send as"
|
||||
invisible="state != 'sms'"
|
||||
required="state == 'sms'"/>
|
||||
<label for="sms_method" invisible="state != 'sms'"/>
|
||||
<div class="d-flex flex-column" invisible="state != 'sms'">
|
||||
<field name="sms_method" required="state == 'sms'"/>
|
||||
<div class="text-muted">
|
||||
<span invisible="sms_method != 'sms'">
|
||||
The message will be sent as an SMS to the recipients of the template
|
||||
and will not appear in the messaging history.
|
||||
</span>
|
||||
<span invisible="sms_method != 'note'">
|
||||
The SMS will not be sent, it will only be posted as an
|
||||
internal note in the messaging history.
|
||||
</span>
|
||||
<span invisible="sms_method != 'comment'">
|
||||
The SMS will be sent as an SMS to the recipients of the
|
||||
template and it will also be posted as an internal note
|
||||
in the messaging history.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -14,6 +14,11 @@ tests independently to functional aspects of other models.""",
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_tests': [
|
||||
'test_base_automation/static/tests/**/*',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class LeadTest(models.Model):
|
||||
string="Status", readonly=True, default='draft')
|
||||
active = fields.Boolean(default=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
date_action_last = fields.Datetime(string='Last Action', readonly=True)
|
||||
date_automation_last = fields.Datetime(string='Last Automation', readonly=True)
|
||||
employee = fields.Boolean(compute='_compute_employee_deadline', store=True)
|
||||
line_ids = fields.One2many('base.automation.line.test', 'lead_id')
|
||||
|
||||
@@ -72,6 +72,10 @@ class Project(models.Model):
|
||||
|
||||
name = fields.Char()
|
||||
task_ids = fields.One2many('test_base_automation.task', 'project_id')
|
||||
stage_id = fields.Many2one('test_base_automation.stage')
|
||||
tag_ids = fields.Many2many('test_base_automation.tag')
|
||||
priority = fields.Selection([('0', 'Low'), ('1', 'Normal'), ('2', 'High')], default='1')
|
||||
user_ids = fields.Many2many('res.users')
|
||||
|
||||
|
||||
class Task(models.Model):
|
||||
@@ -89,3 +93,13 @@ class Task(models.Model):
|
||||
for task in self:
|
||||
if not task.project_id:
|
||||
task.project_id = task.parent_id.project_id
|
||||
|
||||
|
||||
class Stage(models.Model):
|
||||
_name = _description = 'test_base_automation.stage'
|
||||
name = fields.Char()
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
_name = _description = 'test_base_automation.tag'
|
||||
name = fields.Char()
|
||||
|
||||
@@ -5,3 +5,5 @@ access_base_automation_link_test,access_base_automation_link_test,model_base_aut
|
||||
access_base_automation_linked_test,access_base_automation_linked_test,model_base_automation_linked_test,base.group_user,1,1,1,1
|
||||
access_test_base_automation_project,access_test_base_automation_project,model_test_base_automation_project,base.group_user,1,1,1,1
|
||||
access_test_base_automation_task,access_test_base_automation_task,model_test_base_automation_task,base.group_user,1,1,1,1
|
||||
access_test_base_automation_stage,access_test_base_automation_stage,model_test_base_automation_stage,base.group_user,1,1,1,1
|
||||
access_test_base_automation_tag,access_test_base_automation_tag,model_test_base_automation_tag,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,572 @@
|
||||
/** @odoo-module */
|
||||
import { ORM } from "@web/core/orm_service";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_service/tour_utils";
|
||||
|
||||
function assertEqual(actual, expected) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Assert failed: expected: ${expected} ; got: ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function nextTick() {
|
||||
await new Promise(setTimeout);
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
|
||||
function observeOrmCalls() {
|
||||
const calls = [];
|
||||
|
||||
const unpatch = patch(ORM.prototype, {
|
||||
call() {
|
||||
const prom = super.call(...arguments);
|
||||
calls.push([prom, arguments]);
|
||||
return prom;
|
||||
},
|
||||
});
|
||||
|
||||
async function wait(unobserve = true) {
|
||||
await Promise.all(calls.map((i) => i[0]));
|
||||
if (unobserve) {
|
||||
unpatch();
|
||||
}
|
||||
}
|
||||
return wait;
|
||||
}
|
||||
|
||||
registry.category("web_tour.tours").add("test_base_automation", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
content: "Create new rule",
|
||||
trigger: ".o_control_panel button.o-kanban-button-new",
|
||||
},
|
||||
{
|
||||
content: "Enter rule name",
|
||||
trigger: ".o_form_renderer .oe_title .o_input",
|
||||
run: "text Test rule",
|
||||
},
|
||||
{
|
||||
content: "Select model",
|
||||
trigger: '.o_form_renderer .o_group div[name="model_id"] input',
|
||||
run: "text res.partner",
|
||||
},
|
||||
{
|
||||
content: "Select model contact",
|
||||
extra_trigger:
|
||||
'.o_form_renderer .o_group div[name="model_id"] .dropdown-menu:contains(Contact)',
|
||||
trigger:
|
||||
'.o_form_renderer .o_group div[name="model_id"] .dropdown-menu li a:contains(Contact):not(:has(.fa-spin))',
|
||||
},
|
||||
{
|
||||
content: "Open select",
|
||||
trigger: ".o_form_renderer #trigger_0",
|
||||
},
|
||||
{
|
||||
content: "Select On save",
|
||||
trigger: ".o_form_renderer #trigger_0",
|
||||
run: `text "on_create_or_write"`,
|
||||
},
|
||||
{
|
||||
content: "Add new action",
|
||||
trigger: '.o_form_renderer div[name="action_server_ids"] button',
|
||||
},
|
||||
{
|
||||
content: "Set new action to update the record",
|
||||
trigger: " .modal-content .o_form_renderer [name='state'] select",
|
||||
run: 'text "object_write"',
|
||||
},
|
||||
{
|
||||
content: "Open update select",
|
||||
trigger:
|
||||
'.modal-content .o_form_renderer .o_field_widget[name="update_field_id"] input',
|
||||
run: "text Job Position",
|
||||
},
|
||||
{
|
||||
content: "Open update select",
|
||||
trigger:
|
||||
'.modal-content .o_form_renderer div[name="update_field_id"] .dropdown-menu li a:contains(Job Position):not(:has(.fa-spin))',
|
||||
},
|
||||
{
|
||||
content: "Open update select",
|
||||
trigger: '.modal-content .o_form_renderer div[name="value"] textarea',
|
||||
run: "text Test",
|
||||
},
|
||||
{
|
||||
content: "Open update select",
|
||||
trigger: ".modal-content .o_form_button_save",
|
||||
},
|
||||
...stepUtils.saveForm(),
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_base_automation_on_tag_added", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
trigger: ".o_control_panel button.o-kanban-button-new",
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_renderer .oe_title .o_input",
|
||||
run: "text Test rule",
|
||||
},
|
||||
{
|
||||
trigger: '.o_form_renderer .o_group div[name="model_id"] input',
|
||||
run: "text test_base_automation.project",
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
'.o_form_renderer .o_group div[name="model_id"] .dropdown-menu li a:contains(test_base_automation.project):not(:has(.fa-spin))',
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_renderer #trigger_0",
|
||||
run() {
|
||||
const options = Object.fromEntries(
|
||||
Array.from(this.$anchor[0].querySelectorAll("option")).map((el) => [
|
||||
JSON.parse(el.value),
|
||||
el.textContent,
|
||||
])
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
JSON.stringify(options),
|
||||
JSON.stringify({
|
||||
false: "",
|
||||
on_stage_set: "Stage is set to",
|
||||
on_user_set: "User is set",
|
||||
on_tag_set: "Tag is added",
|
||||
on_priority_set: "Priority is set to",
|
||||
on_create_or_write: "On save",
|
||||
on_time: "Based on date field",
|
||||
on_time_created: "After creation",
|
||||
on_time_updated: "After last update",
|
||||
on_unlink: "On deletion",
|
||||
on_change: "On live update",
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_renderer #trigger_0",
|
||||
run: 'text "on_tag_set"',
|
||||
},
|
||||
{
|
||||
trigger: '.o_form_renderer div[name="action_server_ids"] button',
|
||||
},
|
||||
{
|
||||
trigger: " .modal-content .o_form_renderer [name='state'] select",
|
||||
run: 'text "object_write"',
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
'.modal-content .o_form_renderer .o_field_widget[name="update_field_id"] input',
|
||||
run: "text Name",
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
'.modal-content .o_form_renderer div[name="update_field_id"] .dropdown-menu li a:contains(Name):not(:has(.fa-spin))',
|
||||
},
|
||||
{
|
||||
trigger: '.modal-content .o_form_renderer div[name="value"] textarea',
|
||||
run: "text Test",
|
||||
},
|
||||
{
|
||||
trigger: ".modal-content .o_form_button_save",
|
||||
},
|
||||
{
|
||||
trigger: '.o_form_renderer div[name="action_server_ids"] button',
|
||||
},
|
||||
{
|
||||
trigger: " .modal-content .o_form_renderer [name='state'] select",
|
||||
run: 'text "object_write"',
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
'.modal-content .o_form_renderer .o_field_widget[name="update_field_id"] input',
|
||||
run: "text Priority",
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
'.modal-content .o_form_renderer div[name="update_field_id"] .dropdown-menu li a:contains(Priority):not(:has(.fa-spin))',
|
||||
},
|
||||
{
|
||||
trigger: '.modal-content .o_form_renderer div[name="selection_value"] input',
|
||||
run: "text High",
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
'.modal-content .o_form_renderer div[name="selection_value"] .dropdown-menu li a:contains(High):not(:has(.fa-spin))',
|
||||
},
|
||||
{
|
||||
trigger: ".modal-content .o_form_button_save",
|
||||
},
|
||||
...stepUtils.saveForm(),
|
||||
{
|
||||
trigger: ".breadcrumb .o_back_button a",
|
||||
},
|
||||
{
|
||||
trigger: ".o_base_automation_kanban_view .o_kanban_record",
|
||||
run() {
|
||||
const card = this.$anchor[0];
|
||||
assertEqual(
|
||||
card.querySelector(".o_automation_base_info").textContent,
|
||||
"Test ruletest_base_automation.projectTag is addedtest"
|
||||
);
|
||||
assertEqual(
|
||||
card.querySelector(".o_automation_actions").textContent,
|
||||
"Update Display NameUpdate Priority"
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_open_automation_from_grouped_kanban", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".o_kanban_view .o-dropdown.o_kanban_config button",
|
||||
},
|
||||
{
|
||||
trigger: ".o_kanban_view .o-dropdown.o_kanban_config .o_column_automations",
|
||||
},
|
||||
{
|
||||
trigger: ".o_base_automation_kanban_view .o_control_panel button.o-kanban-button-new",
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_view",
|
||||
run() {
|
||||
const form = this.$anchor[0];
|
||||
assertEqual(
|
||||
form.querySelector(".o_field_widget[name='trigger'] select").value,
|
||||
'"on_tag_set"'
|
||||
);
|
||||
assertEqual(
|
||||
form.querySelector(".o_field_widget[name='trg_field_ref'] input").value,
|
||||
"test tag"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_view .o_field_widget[name='name'] input",
|
||||
run: "text From Tour",
|
||||
},
|
||||
...stepUtils.saveForm(),
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_kanban_automation_view_stage_trigger", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".o_base_automation_kanban_view",
|
||||
async run() {
|
||||
assertEqual(
|
||||
document.querySelector(".o_kanban_record .fs-2").innerText,
|
||||
"Test Stage"
|
||||
);
|
||||
assertEqual(
|
||||
document.querySelector(".o_kanban_record .o_tag").innerText,
|
||||
"Stage value"
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_kanban_automation_view_time_trigger", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".o_base_automation_kanban_view",
|
||||
async run() {
|
||||
assertEqual(
|
||||
document.querySelector(
|
||||
".o_automation_base_info > div > div > span:nth-child(1)"
|
||||
).innerText,
|
||||
"1"
|
||||
);
|
||||
assertEqual(
|
||||
document.querySelector(".o_automation_base_info .text-lowercase").innerText,
|
||||
"hours"
|
||||
);
|
||||
assertEqual(
|
||||
document.querySelector(".o_kanban_record .o_tag").innerText,
|
||||
"Date (res.partner)"
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_kanban_automation_view_time_updated_trigger", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".o_base_automation_kanban_view",
|
||||
async run() {
|
||||
const lowercaseTexts = document.querySelectorAll(
|
||||
".o_automation_base_info .text-lowercase"
|
||||
);
|
||||
const number = document.querySelector(
|
||||
".o_automation_base_info > div > div > span:nth-child(1)"
|
||||
).innerText;
|
||||
assertEqual(number, "1");
|
||||
assertEqual(lowercaseTexts.length, 2);
|
||||
assertEqual(lowercaseTexts[0].innerText, "hours");
|
||||
assertEqual(lowercaseTexts[1].innerText, "after last update");
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_kanban_automation_view_create_action", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".o_base_automation_kanban_view",
|
||||
async run() {
|
||||
assertEqual(
|
||||
document.querySelector("div[name='action_server_ids']").innerText,
|
||||
"Create User with name NameX"
|
||||
);
|
||||
assertEqual(document.querySelectorAll(".fa.fa-edit").length, 1);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_resize_kanban", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".o_base_automation_kanban_view",
|
||||
async run() {
|
||||
assertEqual(
|
||||
this.$anchor[0].querySelector(".o_automation_actions").innerText,
|
||||
"Set Active To False\nSet Active To False\nSet Active To False"
|
||||
);
|
||||
document.body.style.setProperty("width", "500px");
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assertEqual(
|
||||
this.$anchor[0].querySelector(".o_automation_actions").innerText,
|
||||
"Set Active To False\n2 actions"
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_form_view_resequence_actions", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger:
|
||||
".o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_renderer",
|
||||
async run() {
|
||||
assertEqual(
|
||||
this.$anchor[0].innerText,
|
||||
"Set Active To False 0\nSet Active To False 1\nSet Active To False 2"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
".o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_record:nth-child(3)",
|
||||
run: "drag_and_drop_native (.o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_record:nth-child(1))",
|
||||
},
|
||||
...stepUtils.saveForm(),
|
||||
{
|
||||
trigger:
|
||||
".o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_renderer",
|
||||
async run() {
|
||||
assertEqual(
|
||||
this.$anchor[0].innerText,
|
||||
"Set Active To False 2\nSet Active To False 0\nSet Active To False 1"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
".o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_view .o_cp_buttons button",
|
||||
},
|
||||
{
|
||||
trigger: ".modal-content .o_form_renderer",
|
||||
run() {
|
||||
const allFields = this.$anchor[0].querySelectorAll(".o_field_widget[name]");
|
||||
assertEqual(
|
||||
Array.from(allFields)
|
||||
.map((el) => el.getAttribute("name"))
|
||||
.includes("model_id"),
|
||||
false
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".modal-content .o_form_renderer [name='state'] select",
|
||||
run: 'text "object_write"',
|
||||
},
|
||||
{
|
||||
trigger: ".modal-content .o_form_renderer [name='state'] select",
|
||||
run: 'text "followers"',
|
||||
},
|
||||
{
|
||||
extra_trigger:
|
||||
".modal-content .o_form_renderer [name='state'] select:contains(Add Followers)",
|
||||
trigger: ".modal-content .o_form_button_cancel",
|
||||
},
|
||||
{
|
||||
extra_trigger: "body:not(:has(.modal-content))",
|
||||
trigger: ".o_form_button_cancel",
|
||||
isCheck: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_form_view_debug", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger:
|
||||
".o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_view .o_cp_buttons button",
|
||||
},
|
||||
{
|
||||
trigger: ".modal-content .o_form_renderer",
|
||||
run() {
|
||||
const allFields = this.$anchor[0].querySelectorAll(".o_field_widget[name]");
|
||||
assertEqual(
|
||||
Array.from(allFields)
|
||||
.map((el) => el.getAttribute("name"))
|
||||
.includes("model_id"),
|
||||
true
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let waitOrmCalls;
|
||||
registry.category("web_tour.tours").add("test_form_view_model_id", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".o_field_widget[name='model_id'] input",
|
||||
run: "text base.automation.line.test",
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
".o_field_widget[name='model_id'] .dropdown-menu li a:contains(Automated Rule Line Test)",
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name='trigger']",
|
||||
run() {
|
||||
const triggerGroups = Array.from(this.$anchor[0].querySelectorAll("optgroup"));
|
||||
assertEqual(
|
||||
triggerGroups.map((el) => el.getAttribute("label")).join(" // "),
|
||||
"Values Updated // Timing Conditions // Custom"
|
||||
);
|
||||
assertEqual(
|
||||
triggerGroups.map((el) => el.innerText).join(" // "),
|
||||
"User is setOn save // Based on date fieldAfter creationAfter last update // On deletionOn live update"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name='model_id'] input",
|
||||
run: "text test_base_automation.project",
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
".o_field_widget[name='model_id'] .dropdown-menu li a:contains(test_base_automation.project)",
|
||||
run(helpers) {
|
||||
waitOrmCalls = observeOrmCalls();
|
||||
helpers.click(this.$anchor);
|
||||
return nextTick();
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: "body",
|
||||
async run() {
|
||||
await waitOrmCalls();
|
||||
await nextTick();
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name='trigger']",
|
||||
run() {
|
||||
const triggerGroups = Array.from(this.$anchor[0].querySelectorAll("optgroup"));
|
||||
assertEqual(
|
||||
triggerGroups.map((el) => el.getAttribute("label")).join(" // "),
|
||||
"Values Updated // Timing Conditions // Custom"
|
||||
);
|
||||
assertEqual(
|
||||
triggerGroups.map((el) => el.innerText).join(" // "),
|
||||
"Stage is set toUser is setTag is addedPriority is set toOn save // Based on date fieldAfter creationAfter last update // On deletionOn live update"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_button_cancel",
|
||||
},
|
||||
{
|
||||
trigger: ".o_base_automation_kanban_view",
|
||||
isCheck: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add("test_form_view_custom_reference_field", {
|
||||
test: true,
|
||||
steps: () => [
|
||||
{
|
||||
trigger: ".o_field_widget[name='model_id'] input",
|
||||
run: "text test_base_automation.project",
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
".o_field_widget[name='model_id'] .dropdown-menu li a:contains(test_base_automation.project)",
|
||||
},
|
||||
{
|
||||
extra_trigger: "body:not(:has(.o_field_widget[name='trg_field_ref']))",
|
||||
trigger: ".o_field_widget[name='trigger'] select",
|
||||
run: 'text "on_stage_set"',
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name='trg_field_ref'] input",
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
".o_field_widget[name='trg_field_ref'] .o-autocomplete--dropdown-menu:not(:has(a .fa-spin)",
|
||||
run() {
|
||||
assertEqual(this.$anchor[0].innerText, "test stage\nSearch More...");
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name='trigger'] select",
|
||||
run: 'text "on_tag_set"',
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name='trg_field_ref'] input",
|
||||
},
|
||||
{
|
||||
trigger:
|
||||
".o_field_widget[name='trg_field_ref'] .o-autocomplete--dropdown-menu:not(:has(a .fa-spin)",
|
||||
run() {
|
||||
assertEqual(this.$anchor[0].innerText, "test tag\nSearch More...");
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_button_cancel",
|
||||
},
|
||||
{
|
||||
trigger: ".o_base_automation_kanban_view",
|
||||
isCheck: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -2,3 +2,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_flow
|
||||
from . import test_tour
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from urllib.parse import urlencode
|
||||
import ast
|
||||
|
||||
from odoo import Command
|
||||
|
||||
from odoo.tests import HttpCase, tagged
|
||||
|
||||
|
||||
def _urlencode_kwargs(**kwargs):
|
||||
return urlencode(kwargs)
|
||||
|
||||
|
||||
@tagged("post_install_l10n", "post_install", "-at_install")
|
||||
class BaseAutomationTestUi(HttpCase):
|
||||
def _neutralize_preexisting_automations(self, neutralize_action=True):
|
||||
self.env["base.automation"].with_context(active_test=False).search([]).write({"active": False})
|
||||
if neutralize_action:
|
||||
context = ast.literal_eval(self.env.ref("base_automation.base_automation_act").context)
|
||||
del context["active_test"]
|
||||
self.env.ref("base_automation.base_automation_act").context = str(context)
|
||||
|
||||
def test_01_base_automation_tour(self):
|
||||
self._neutralize_preexisting_automations()
|
||||
self.start_tour(f"/web?debug=tests#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id)}", "test_base_automation", login="admin")
|
||||
base_automation = self.env["base.automation"].search([])
|
||||
self.assertEqual(base_automation.model_id.model, "res.partner")
|
||||
self.assertEqual(base_automation.trigger, "on_create_or_write")
|
||||
self.assertEqual(base_automation.action_server_ids.state, "object_write") # only one action
|
||||
self.assertEqual(base_automation.action_server_ids.model_name, "res.partner")
|
||||
self.assertEqual(base_automation.action_server_ids.update_field_id.name, "function")
|
||||
self.assertEqual(base_automation.action_server_ids.value, "Test")
|
||||
|
||||
def test_base_automation_on_tag_added(self):
|
||||
self._neutralize_preexisting_automations()
|
||||
self.env["test_base_automation.tag"].create({"name": "test"})
|
||||
self.start_tour(f"/web?debug=tests#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id)}", "test_base_automation_on_tag_added", login="admin")
|
||||
|
||||
def test_open_automation_from_grouped_kanban(self):
|
||||
self._neutralize_preexisting_automations()
|
||||
|
||||
test_view = self.env["ir.ui.view"].create(
|
||||
{
|
||||
"name": "test_view",
|
||||
"model": "test_base_automation.project",
|
||||
"type": "kanban",
|
||||
"arch": """
|
||||
<kanban default_group_by="tag_ids">
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click">
|
||||
<div class="o_kanban_card_content">
|
||||
<field name="name" />
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
""",
|
||||
}
|
||||
)
|
||||
test_action = self.env["ir.actions.act_window"].create(
|
||||
{
|
||||
"name": "test action",
|
||||
"res_model": "test_base_automation.project",
|
||||
"view_ids": [Command.create({"view_id": test_view.id, "view_mode": "kanban"})],
|
||||
}
|
||||
)
|
||||
tag = self.env["test_base_automation.tag"].create({"name": "test tag"})
|
||||
self.env["test_base_automation.project"].create({"name": "test", "tag_ids": [Command.link(tag.id)]})
|
||||
|
||||
_hash = _urlencode_kwargs(action=test_action.id)
|
||||
self.start_tour(f"/web?debug=0#{_hash}", "test_open_automation_from_grouped_kanban", login="admin")
|
||||
base_auto = self.env["base.automation"].search([])
|
||||
self.assertEqual(base_auto.name, "From Tour")
|
||||
self.assertEqual(base_auto.model_name, "test_base_automation.project")
|
||||
self.assertEqual(base_auto.trigger_field_ids.name, "tag_ids")
|
||||
self.assertEqual(base_auto.trigger, "on_tag_set")
|
||||
self.assertEqual(base_auto.trg_field_ref_model_name, "test_base_automation.tag")
|
||||
self.assertEqual(base_auto.trg_field_ref, tag.id)
|
||||
|
||||
|
||||
def test_kanban_automation_view_stage_trigger(self):
|
||||
self._neutralize_preexisting_automations()
|
||||
|
||||
project_model = self.env.ref('test_base_automation.model_test_base_automation_project')
|
||||
stage_field = self.env['ir.model.fields'].search([
|
||||
('model_id', '=', project_model.id),
|
||||
('name', '=', 'stage_id'),
|
||||
])
|
||||
self.env['test_base_automation.stage'].create({'name': 'Stage value'})
|
||||
|
||||
automation = self.env["base.automation"].create({
|
||||
"name": "Test Stage",
|
||||
"trigger": "on_stage_set",
|
||||
"model_id": project_model.id,
|
||||
"trigger_field_ids": [stage_field.id],
|
||||
})
|
||||
|
||||
action = {
|
||||
"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,
|
||||
"value": False,
|
||||
"model_id": project_model.id
|
||||
}
|
||||
automation.write({"action_server_ids": [Command.create(action)]})
|
||||
|
||||
self.start_tour(
|
||||
f"/web#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id)}",
|
||||
"test_kanban_automation_view_stage_trigger", login="admin"
|
||||
)
|
||||
|
||||
def test_kanban_automation_view_time_trigger(self):
|
||||
self._neutralize_preexisting_automations()
|
||||
model = self.env.ref("base.model_res_partner")
|
||||
|
||||
date_field = self.env['ir.model.fields'].search([
|
||||
('model_id', '=', model.id),
|
||||
('name', '=', 'date'),
|
||||
])
|
||||
|
||||
self.env["base.automation"].create({
|
||||
"name": "Test Date",
|
||||
"trigger": "on_time",
|
||||
"model_id": model.id,
|
||||
"trg_date_range": 1,
|
||||
"trg_date_range_type": "hour",
|
||||
"trg_date_id": date_field.id,
|
||||
})
|
||||
|
||||
self.start_tour(
|
||||
f"/web#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id)}",
|
||||
"test_kanban_automation_view_time_trigger", login="admin"
|
||||
)
|
||||
|
||||
def test_kanban_automation_view_time_updated_trigger(self):
|
||||
self._neutralize_preexisting_automations()
|
||||
model = self.env.ref("base.model_res_partner")
|
||||
|
||||
self.env["base.automation"].create({
|
||||
"name": "Test Date",
|
||||
"trigger": "on_time_updated",
|
||||
"model_id": model.id,
|
||||
"trg_date_range": 1,
|
||||
"trg_date_range_type": "hour",
|
||||
})
|
||||
|
||||
self.start_tour(
|
||||
f"/web#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id)}",
|
||||
"test_kanban_automation_view_time_updated_trigger", login="admin"
|
||||
)
|
||||
|
||||
def test_kanban_automation_view_create_action(self):
|
||||
self._neutralize_preexisting_automations()
|
||||
model = self.env.ref("base.model_res_partner")
|
||||
|
||||
automation = self.env["base.automation"].create({
|
||||
"name": "Test",
|
||||
"trigger": "on_create_or_write",
|
||||
"model_id": model.id,
|
||||
})
|
||||
|
||||
action = {
|
||||
"name": "Create User with name NameX",
|
||||
"base_automation_id": automation.id,
|
||||
"state": "object_create",
|
||||
"value": "NameX",
|
||||
"model_id": model.id
|
||||
}
|
||||
|
||||
automation.write({"action_server_ids": [Command.create(action)]})
|
||||
|
||||
self.start_tour(
|
||||
f"/web#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id)}",
|
||||
"test_kanban_automation_view_create_action", login="admin"
|
||||
)
|
||||
|
||||
def test_resize_kanban(self):
|
||||
self._neutralize_preexisting_automations()
|
||||
model = self.env.ref("base.model_res_partner")
|
||||
|
||||
automation = self.env["base.automation"].create(
|
||||
{
|
||||
"name": "Test",
|
||||
"trigger": "on_create_or_write",
|
||||
"model_id": model.id,
|
||||
}
|
||||
)
|
||||
|
||||
action = {
|
||||
"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,
|
||||
"value": False,
|
||||
"model_id": model.id,
|
||||
}
|
||||
automation.write({"action_server_ids": [Command.create(action) for i in range(3)]})
|
||||
|
||||
self.start_tour(
|
||||
f"/web#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id)}",
|
||||
"test_resize_kanban",
|
||||
login="admin",
|
||||
)
|
||||
|
||||
def test_form_view(self):
|
||||
model = self.env.ref("base.model_res_partner")
|
||||
automation = self.env["base.automation"].create(
|
||||
{
|
||||
"name": "Test",
|
||||
"trigger": "on_create_or_write",
|
||||
"model_id": model.id,
|
||||
}
|
||||
)
|
||||
action = {
|
||||
"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,
|
||||
"value": False,
|
||||
"model_id": model.id,
|
||||
}
|
||||
automation.write(
|
||||
{"action_server_ids": [Command.create(dict(action, name=action["name"] + f" {i}")) for i in range(3)]}
|
||||
)
|
||||
self.assertEqual(
|
||||
automation.action_server_ids.mapped("name"),
|
||||
["Set Active To False 0", "Set Active To False 1", "Set Active To False 2"],
|
||||
)
|
||||
|
||||
onchange_link_passes = 0
|
||||
origin_link_onchange = type(self.env["ir.actions.server"]).onchange
|
||||
|
||||
def _onchange_base_auto_link(self_model, *args):
|
||||
nonlocal onchange_link_passes
|
||||
onchange_link_passes += 1
|
||||
res = origin_link_onchange(self_model, *args)
|
||||
if onchange_link_passes == 1:
|
||||
default_keys = {k: v for k, v in self_model._context.items() if k.startswith("default_")}
|
||||
self.assertEqual(
|
||||
default_keys,
|
||||
{"default_model_id": model.id, "default_state": False, "default_usage": "base_automation"},
|
||||
)
|
||||
if onchange_link_passes == 2:
|
||||
self.assertEqual(res["value"]["name"], "Update False")
|
||||
if onchange_link_passes == 3:
|
||||
self.assertEqual(res["value"]["name"], "Add followers: ")
|
||||
|
||||
return res
|
||||
|
||||
self.patch(type(self.env["ir.actions.server"]), "onchange", _onchange_base_auto_link)
|
||||
|
||||
self.start_tour(
|
||||
(
|
||||
f"/web?debug=0#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id, id=automation.id, view_type='form')}"
|
||||
),
|
||||
"test_form_view_resequence_actions",
|
||||
login="admin",
|
||||
)
|
||||
self.assertEqual(onchange_link_passes, 3)
|
||||
self.assertEqual(
|
||||
automation.action_server_ids.mapped("name"),
|
||||
["Set Active To False 2", "Set Active To False 0", "Set Active To False 1"],
|
||||
)
|
||||
|
||||
def test_form_view_debug(self):
|
||||
model = self.env.ref("base.model_res_partner")
|
||||
automation = self.env["base.automation"].create(
|
||||
{
|
||||
"name": "Test",
|
||||
"trigger": "on_create_or_write",
|
||||
"model_id": model.id,
|
||||
}
|
||||
)
|
||||
self.start_tour(
|
||||
(
|
||||
f"/web?debug=1#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id, id=automation.id, view_type='form')}"
|
||||
),
|
||||
"test_form_view_debug",
|
||||
login="admin",
|
||||
)
|
||||
|
||||
def test_form_view_model_id(self):
|
||||
self.start_tour(
|
||||
(
|
||||
f"/web?debug=0#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id, view_type='form')}"
|
||||
),
|
||||
"test_form_view_model_id",
|
||||
login="admin",
|
||||
)
|
||||
|
||||
def test_form_view_custom_reference_field(self):
|
||||
self.env["test_base_automation.stage"].create({"name": "test stage"})
|
||||
self.env["test_base_automation.tag"].create({"name": "test tag"})
|
||||
self.start_tour(
|
||||
(
|
||||
f"/web?debug=0#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id, view_type='form')}"
|
||||
),
|
||||
"test_form_view_custom_reference_field",
|
||||
login="admin",
|
||||
)
|
||||
@@ -30,9 +30,9 @@ In simple words does Odoo became CMS+ERP platform?</p>]]></field>
|
||||
<field name="name">Re: How to configure alerts for employee contract expiration</field>
|
||||
<field name="content"><![CDATA[<p>Just for posterity so other can see. Here are the steps to set automatic alerts on any contract.. i.e. HR Employee, or Fleet for example. I will use fleet as an example.</p>
|
||||
<ul>
|
||||
<li>Step 1. As a user who has access rights to Technical Features, go to Settings --> Automated Actions. Create A new Automated Action. For the Related Document Model choose.. Contract information on a vehicle (you can also type in the actual model name.. fleet.vehicle.log.contract ) . Set the trigger date to ... Contract Expiration Date. The Next Field (Delay After Trigger Date) is a bit ridiculous. Who wants to be reminded of a contract expiration AFTER the fact? The field should say Days Before Date to Fire Action and the number should be converted to a negative. IMHO. Any way... to get a workable solution you must enter in the number in the negative. So for instance like me if you want to be warned 35 days BEFORE the expiration... put in Delay After Trigger Date.. the number -35 But the sake of testing, right now just put in -1 for 1 day before. Save the Action.
|
||||
<li>Step 1. As a user who has access rights to Technical Features, go to Settings --> Automation Rules. Create A new Automation Rule. For the Related Document Model choose.. Contract information on a vehicle (you can also type in the actual model name.. fleet.vehicle.log.contract ) . Set the trigger date to ... Contract Expiration Date. The Next Field (Delay After Trigger Date) is a bit ridiculous. Who wants to be reminded of a contract expiration AFTER the fact? The field should say Days Before Date to Fire Rule and the number should be converted to a negative. IMHO. Any way... to get a workable solution you must enter in the number in the negative. So for instance like me if you want to be warned 35 days BEFORE the expiration... put in Delay After Trigger Date.. the number -35 But the sake of testing, right now just put in -1 for 1 day before. Save the Rule.
|
||||
<li>Step 2. Go to Server Actions and create new Action. Call it Fleet Contract Expiration Warning. The Object will be the same as above .. Contract information on a vehicle. The Action Type is Email. For email address I just put my email. Under subject put in... [[object.name]]. This will tell you the name of the car. Message you can put any text message you like. Now save the Server Action.</li>
|
||||
<li>Step 3. Now go back to the Automated Action you created and go to the Action tab next to the conditions tab. Click Add and add the server action you created . In this case Fleet Contract Expiration Warning. Then Save.</li>
|
||||
<li>Step 3. Now go back to the Automation Rule you created and go to the Rule tab next to the conditions tab. Click Add and add the server action you created . In this case Fleet Contract Expiration Warning. Then Save.</li>
|
||||
<li>Step 4. To test, set a contract to expire tomorrow under one of your fleets vehicles. Then Save it.</li>
|
||||
<li>Step 5. Go to Scheduled Actions.. Set interval number to 1. Interval Unit to Minutes. Then Set the Next Execution date to 2 minutes from now. If your SMTP is configured correctly you will start to get a mail every minute with the reminder.</li></ul>]]></field>
|
||||
<field name="parent_id" ref="question_0" />
|
||||
|
||||
@@ -410,7 +410,7 @@ class SaleOrder(models.Model):
|
||||
def _cart_recovery_email_send(self):
|
||||
"""Send the cart recovery email on the current recordset,
|
||||
making sure that the portal token exists to avoid broken links, and marking the email as sent.
|
||||
Similar method to action_recovery_email_send, made to be called in automated actions.
|
||||
Similar method to action_recovery_email_send, made to be called in automation rules.
|
||||
Contrary to the former, it will use the website-specific template for each order."""
|
||||
sent_orders = self.env['sale.order']
|
||||
for order in self:
|
||||
|
||||
@@ -439,6 +439,26 @@ class IrActionsServer(models.Model):
|
||||
# - Command: x2many commands namespace
|
||||
# To return an action, assign: action = {...}\n\n\n\n"""
|
||||
|
||||
@api.model
|
||||
def _default_update_field_id(self):
|
||||
if self.model_id:
|
||||
model_id = self.model_id.id
|
||||
elif 'default_model_id' in self.env.context:
|
||||
model_id = self.env.context['default_model_id']
|
||||
else:
|
||||
return False
|
||||
|
||||
ir_model = self.env["ir.model"].browse(model_id)
|
||||
if ir_model:
|
||||
fields = self.env[ir_model.model]._fields
|
||||
if 'state' in fields:
|
||||
return ir_model.field_id.filtered(lambda ir_field: ir_field.name == 'state')
|
||||
elif 'stage_id' in fields:
|
||||
return ir_model.field_id.filtered(lambda ir_field: ir_field.name == 'stage_id')
|
||||
elif 'priority' in fields:
|
||||
return ir_model.field_id.filtered(lambda ir_field: ir_field.name == 'priority')
|
||||
|
||||
name = fields.Char(compute='_compute_name', store=True, readonly=False, required=True)
|
||||
type = fields.Char(default='ir.actions.server')
|
||||
usage = fields.Selection([
|
||||
('ir_actions_server', 'Server Action'),
|
||||
@@ -448,8 +468,8 @@ class IrActionsServer(models.Model):
|
||||
('code', 'Execute Python Code'),
|
||||
('object_create', 'Create a new Record'),
|
||||
('object_write', 'Update the Record'),
|
||||
('multi', 'Execute several actions')], string='Action To Do',
|
||||
default='object_write', required=True, copy=True,
|
||||
('multi', 'Execute several actions')], string='Type',
|
||||
default='code', required=True, copy=True,
|
||||
help="Type of server action. The following values are available:\n"
|
||||
"- 'Execute Python Code': a block of python code that will be executed\n"
|
||||
"- 'Create a new Record': create a new record with new values\n"
|
||||
@@ -476,17 +496,53 @@ class IrActionsServer(models.Model):
|
||||
string='Child Actions', help='Child server actions that will be executed. Note that the last return returned action value will be used as global return value.')
|
||||
# Create
|
||||
crud_model_id = fields.Many2one(
|
||||
'ir.model', string='Target Model',
|
||||
'ir.model', string='Record to Create',
|
||||
compute='_compute_crud_model_id', readonly=False, store=True,
|
||||
help="Model for record creation / update. Set this field only to specify a different model than the base model.")
|
||||
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(
|
||||
'ir.model.fields', string='Link Field',
|
||||
compute='_compute_link_field_id', readonly=False, store=True,
|
||||
help="Provide the field used to link the newly created record on the record used by the server action.")
|
||||
fields_lines = fields.One2many('ir.server.object.lines', 'server_id', string='Value Mapping', copy=True)
|
||||
help="Specify a field used to link the newly created record on the record used by the server action.")
|
||||
groups_id = fields.Many2many('res.groups', 'ir_act_server_group_rel',
|
||||
'act_id', 'gid', string='Groups')
|
||||
'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', default=_default_update_field_id, ondelete='cascade')
|
||||
update_related_model_id = fields.Many2one('ir.model', compute='_compute_update_related_model_id')
|
||||
|
||||
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,"
|
||||
"e.g. `env.user.name` to set the current user's name as the value "
|
||||
"or `record.id` to set the ID of the record on which the action is run.\n\n"
|
||||
"For Static values, the value will be used directly without evaluation, e.g."
|
||||
"`42` or `My custom name` or the selected record.")
|
||||
evaluation_type = fields.Selection([
|
||||
('value', 'Static value'),
|
||||
('equation', 'Python expression')
|
||||
], 'Value Type', default='value', change_default=True)
|
||||
resource_ref = fields.Reference(
|
||||
string='Record', selection='_selection_target_model', inverse='_set_resource_ref')
|
||||
selection_value = fields.Many2one('ir.model.fields.selection', string="Selection value", ondelete='cascade',
|
||||
domain='[("field_id", "=", update_field_id)]', inverse='_set_selection_value')
|
||||
|
||||
value_field_to_show = fields.Selection([
|
||||
('value', 'value'),
|
||||
('resource_ref', 'reference'),
|
||||
('selection_value', 'selection_value'),
|
||||
], compute='_compute_value_field_to_show')
|
||||
|
||||
@api.depends('state', 'update_field_id', 'crud_model_id', 'value')
|
||||
def _compute_name(self):
|
||||
for action in self:
|
||||
if not action.state or not self.env.context.get('automatic_action_name'):
|
||||
continue
|
||||
if action.state == 'object_write':
|
||||
action.name = _("Update %s", action.update_field_id.field_description)
|
||||
elif action.state == 'object_create':
|
||||
action.name = _("Create %s with name %s", action.crud_model_id.name, action.value)
|
||||
else:
|
||||
state_name = dict(action._fields['state']._description_selection(self.env))[action.state]
|
||||
action.name = state_name
|
||||
|
||||
@api.onchange('model_id')
|
||||
def _compute_crud_model_id(self):
|
||||
@@ -566,8 +622,8 @@ class IrActionsServer(models.Model):
|
||||
|
||||
def _run_action_object_write(self, eval_context=None):
|
||||
"""Apply specified write changes to active_id."""
|
||||
vals = self.fields_lines.eval_value(eval_context=eval_context)
|
||||
res = {line.col1.name: vals[line.id] for line in self.fields_lines}
|
||||
vals = self._eval_value(eval_context=eval_context)
|
||||
res = {action.update_field_id.name: vals[action.id] for action in self}
|
||||
|
||||
if self._context.get('onchange_self'):
|
||||
record_cached = self._context['onchange_self']
|
||||
@@ -577,12 +633,11 @@ class IrActionsServer(models.Model):
|
||||
self.env[self.model_id.model].browse(self._context.get('active_id')).write(res)
|
||||
|
||||
def _run_action_object_create(self, eval_context=None):
|
||||
"""Create specified model object with specified values.
|
||||
"""Create specified model object with specified name contained in value.
|
||||
|
||||
If applicable, link active_id.<self.link_field_id> to the new record.
|
||||
"""
|
||||
vals = self.fields_lines.eval_value(eval_context=eval_context)
|
||||
res = {line.col1.name: vals[line.id] for line in self.fields_lines}
|
||||
res = {'name': self.value}
|
||||
|
||||
res = self.env[self.crud_model_id.model].create(res)
|
||||
|
||||
@@ -661,11 +716,12 @@ class IrActionsServer(models.Model):
|
||||
if not (action_groups & self.env.user.groups_id):
|
||||
raise AccessError(_("You don't have enough access rights to run this action."))
|
||||
else:
|
||||
model_name = action.model_id.model
|
||||
try:
|
||||
self.env[action.model_name].check_access_rights("write")
|
||||
self.env[model_name].check_access_rights("write")
|
||||
except AccessError:
|
||||
_logger.warning("Forbidden server action %r executed while the user %s does not have access to %s.",
|
||||
action.name, self.env.user.login, action.model_name,
|
||||
action.name, self.env.user.login, model_name,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -707,73 +763,60 @@ 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
|
||||
|
||||
class IrServerObjectLines(models.Model):
|
||||
_name = 'ir.server.object.lines'
|
||||
_description = 'Server Action value mapping'
|
||||
|
||||
server_id = fields.Many2one('ir.actions.server', string='Related Server Action', ondelete='cascade')
|
||||
col1 = fields.Many2one('ir.model.fields', string='Field', required=True, ondelete='cascade')
|
||||
value = fields.Text(required=True, help="Expression containing a value specification. \n"
|
||||
"When Formula type is selected, this field may be a Python expression "
|
||||
" that can use the same values as for the code field on the server action.\n"
|
||||
"If Value type is selected, the value will be used directly without evaluation.")
|
||||
evaluation_type = fields.Selection([
|
||||
('value', 'Value'),
|
||||
('reference', 'Reference'),
|
||||
('equation', 'Python expression')
|
||||
], 'Evaluation Type', default='value', required=True, change_default=True)
|
||||
resource_ref = fields.Reference(
|
||||
string='Record', selection='_selection_target_model',
|
||||
compute='_compute_resource_ref', inverse='_set_resource_ref')
|
||||
@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:
|
||||
if action.update_field_id.ttype == 'many2one':
|
||||
action.value_field_to_show = 'resource_ref'
|
||||
elif action.update_field_id.ttype == 'selection':
|
||||
action.value_field_to_show = 'selection_value'
|
||||
else:
|
||||
action.value_field_to_show = 'value'
|
||||
|
||||
@api.model
|
||||
def _selection_target_model(self):
|
||||
return [(model.model, model.name) for model in self.env['ir.model'].sudo().search([])]
|
||||
|
||||
@api.depends('col1.relation', 'value', 'evaluation_type')
|
||||
def _compute_resource_ref(self):
|
||||
for line in self:
|
||||
if line.evaluation_type in ['reference', 'value'] and line.col1 and line.col1.relation:
|
||||
value = line.value or ''
|
||||
try:
|
||||
value = int(value)
|
||||
if not self.env[line.col1.relation].browse(value).exists():
|
||||
record = self.env[line.col1.relation].search([], limit=1)
|
||||
value = record.id or 0
|
||||
except ValueError:
|
||||
record = self.env[line.col1.relation].search([], limit=1)
|
||||
value = record.id or 0
|
||||
line.resource_ref = '%s,%s' % (line.col1.relation, value)
|
||||
else:
|
||||
line.resource_ref = False
|
||||
|
||||
@api.constrains('col1', 'evaluation_type')
|
||||
@api.constrains('update_field_id', 'evaluation_type')
|
||||
def _raise_many2many_error(self):
|
||||
if self.filtered(lambda line: line.col1.ttype == 'many2many' and line.evaluation_type == 'reference'):
|
||||
if self.filtered(lambda line: line.update_field_id.ttype == 'many2many' and line.evaluation_type == 'reference'):
|
||||
raise ValidationError(_('many2many fields cannot be evaluated by reference'))
|
||||
|
||||
@api.onchange('resource_ref')
|
||||
def _set_resource_ref(self):
|
||||
for line in self.filtered(lambda line: line.evaluation_type == 'reference'):
|
||||
if line.resource_ref:
|
||||
line.value = str(line.resource_ref.id)
|
||||
for action in self.filtered(lambda action: action.value_field_to_show == 'resource_ref'):
|
||||
if action.resource_ref:
|
||||
action.value = str(action.resource_ref.id)
|
||||
|
||||
def eval_value(self, eval_context=None):
|
||||
@api.onchange('selection_value')
|
||||
def _set_selection_value(self):
|
||||
for action in self.filtered(lambda action: action.value_field_to_show == 'selection_value'):
|
||||
if action.selection_value:
|
||||
action.value = action.selection_value.name
|
||||
|
||||
def _eval_value(self, eval_context=None):
|
||||
result = {}
|
||||
for line in self:
|
||||
expr = line.value
|
||||
if line.evaluation_type == 'equation':
|
||||
expr = safe_eval(line.value, eval_context)
|
||||
elif line.col1.ttype in ['many2one', 'integer']:
|
||||
for action in self:
|
||||
expr = action.value
|
||||
if action.evaluation_type == 'equation':
|
||||
expr = safe_eval(action.value, eval_context)
|
||||
elif action.update_field_id.ttype in ['many2one', 'integer']:
|
||||
try:
|
||||
expr = int(line.value)
|
||||
expr = int(action.value)
|
||||
except Exception:
|
||||
pass
|
||||
result[line.id] = expr
|
||||
result[action.id] = expr
|
||||
return result
|
||||
|
||||
|
||||
class IrActionsTodo(models.Model):
|
||||
"""
|
||||
Configuration Wizards
|
||||
|
||||
@@ -104,7 +104,6 @@ access_res_users_settings_user,res.users.settings,model_res_users_settings,group
|
||||
"access_ir_actions_todo_group_system","ir_actions_todo group system","model_ir_actions_todo","group_system",1,1,1,1
|
||||
"access_ir_actions_act_window_view_group_system","ir_actions_act_window_view_group_system","model_ir_actions_act_window_view","group_system",1,1,1,1
|
||||
"access_ir_actions_act_url_group_system","ir_actions_act_url_group_system","model_ir_actions_act_url","group_system",1,1,1,1
|
||||
"access_ir_server_object_lines_group_system","ir_server_object_lines_group_system","model_ir_server_object_lines","group_system",1,1,1,1
|
||||
"access_ir_actions_server_group_system","ir_actions_server_group_system","model_ir_actions_server","group_system",1,1,1,1
|
||||
"access_ir_actions_client","ir_actions_client all","model_ir_actions_client","group_system",1,1,1,1
|
||||
"access_res_bank_group_system","res_bank_group_system","model_res_bank","group_system",1,1,1,1
|
||||
|
||||
|
@@ -123,56 +123,48 @@ ZeroDivisionError: division by zero""" % self.test_server_action.id
|
||||
# Do: create a new record in another model
|
||||
self.action.write({
|
||||
'state': 'object_create',
|
||||
'crud_model_id': self.res_country_model.id,
|
||||
'crud_model_id': self.res_partner_model.id,
|
||||
'link_field_id': False,
|
||||
'fields_lines': [Command.clear(),
|
||||
Command.create({'col1': self.res_country_name_field.id, 'value': 'record.name', 'evaluation_type': 'equation'}),
|
||||
Command.create({'col1': self.res_country_code_field.id, 'value': 'record.name[0:2]', 'evaluation_type': 'equation'})],
|
||||
'value': 'TestingPartner2'
|
||||
})
|
||||
run_res = self.action.with_context(self.context).run()
|
||||
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
|
||||
# Test: new country created
|
||||
country = self.test_country.search([('name', 'ilike', 'TestingPartner')])
|
||||
self.assertEqual(len(country), 1, 'ir_actions_server: TODO')
|
||||
self.assertEqual(country.code, 'TE', 'ir_actions_server: TODO')
|
||||
# Test: new partner created
|
||||
partner = self.test_partner.search([('name', 'ilike', 'TestingPartner2')])
|
||||
self.assertEqual(len(partner), 1, 'ir_actions_server: TODO')
|
||||
|
||||
def test_20_crud_create_link_many2one(self):
|
||||
_city = 'TestCity'
|
||||
_name = 'TestNew'
|
||||
|
||||
# Do: create a new record in the same model and link it with a many2one
|
||||
self.action.write({
|
||||
'state': 'object_create',
|
||||
'crud_model_id': self.action.model_id.id,
|
||||
'crud_model_id': self.res_partner_model.id,
|
||||
'link_field_id': self.res_partner_parent_field.id,
|
||||
'fields_lines': [Command.create({'col1': self.res_partner_name_field.id, 'value': _name}),
|
||||
Command.create({'col1': self.res_partner_city_field.id, 'value': _city})],
|
||||
'value': "TestNew"
|
||||
})
|
||||
run_res = self.action.with_context(self.context).run()
|
||||
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
|
||||
# Test: new partner created
|
||||
partner = self.test_partner.search([('name', 'ilike', _name)])
|
||||
partner = self.test_partner.search([('name', 'ilike', 'TestNew')])
|
||||
self.assertEqual(len(partner), 1, 'ir_actions_server: TODO')
|
||||
self.assertEqual(partner.city, _city, 'ir_actions_server: TODO')
|
||||
# Test: new partner linked
|
||||
self.assertEqual(self.test_partner.parent_id, partner, 'ir_actions_server: TODO')
|
||||
|
||||
def test_20_crud_create_link_one2many(self):
|
||||
_name = 'TestNew'
|
||||
|
||||
# Do: create a new record in the same model and link it with a one2many
|
||||
self.action.write({
|
||||
'state': 'object_create',
|
||||
'crud_model_id': self.action.model_id.id,
|
||||
'crud_model_id': self.res_partner_model.id,
|
||||
'link_field_id': self.res_partner_children_field.id,
|
||||
'fields_lines': [Command.create({'col1': self.res_partner_name_field.id, 'value': _name})],
|
||||
'value': 'TestNew',
|
||||
})
|
||||
run_res = self.action.with_context(self.context).run()
|
||||
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
|
||||
# Test: new partner created
|
||||
partner = self.test_partner.search([('name', 'ilike', _name)])
|
||||
partner = self.test_partner.search([('name', 'ilike', 'TestNew')])
|
||||
self.assertEqual(len(partner), 1, 'ir_actions_server: TODO')
|
||||
self.assertEqual(partner.name, _name, 'ir_actions_server: TODO')
|
||||
self.assertEqual(partner.name, 'TestNew', 'ir_actions_server: TODO')
|
||||
# Test: new partner linked
|
||||
self.assertIn(partner, self.test_partner.child_ids, 'ir_actions_server: TODO')
|
||||
|
||||
@@ -182,7 +174,7 @@ ZeroDivisionError: division by zero""" % self.test_server_action.id
|
||||
'state': 'object_create',
|
||||
'crud_model_id': self.res_partner_category_model.id,
|
||||
'link_field_id': self.res_partner_category_field.id,
|
||||
'fields_lines': [Command.create({'col1': self.res_partner_category_name_field.id, 'value': 'record.name', 'evaluation_type': 'equation'})],
|
||||
'value': 'TestingPartner'
|
||||
})
|
||||
run_res = self.action.with_context(self.context).run()
|
||||
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
|
||||
@@ -192,17 +184,17 @@ ZeroDivisionError: division by zero""" % self.test_server_action.id
|
||||
self.assertIn(category, self.test_partner.category_id)
|
||||
|
||||
def test_30_crud_write(self):
|
||||
_name = 'TestNew'
|
||||
|
||||
# Do: update partner name
|
||||
self.action.write({
|
||||
'state': 'object_write',
|
||||
'fields_lines': [Command.create({'col1': self.res_partner_name_field.id, 'value': _name})],
|
||||
'update_field_id': self.res_partner_name_field.id,
|
||||
'value': 'TestNew',
|
||||
})
|
||||
run_res = self.action.with_context(self.context).run()
|
||||
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
|
||||
# Test: partner updated
|
||||
partner = self.test_partner.search([('name', 'ilike', _name)])
|
||||
partner = self.test_partner.search([('name', 'ilike', 'TestNew')])
|
||||
self.assertEqual(len(partner), 1, 'ir_actions_server: TODO')
|
||||
self.assertEqual(partner.city, 'OrigCity', 'ir_actions_server: TODO')
|
||||
|
||||
@@ -222,19 +214,27 @@ ZeroDivisionError: division by zero""" % self.test_server_action.id
|
||||
'model_id': self.res_partner_model.id,
|
||||
'crud_model_id': self.res_partner_model.id,
|
||||
'state': 'object_create',
|
||||
'fields_lines': [Command.create({'col1': self.res_partner_name_field.id, 'value': 'RaoulettePoiluchette'}),
|
||||
Command.create({'col1': self.res_partner_city_field.id, 'value': 'TestingCity'})],
|
||||
'value': 'RaoulettePoiluchette',
|
||||
})
|
||||
action3 = self.action.create({
|
||||
'name': 'Subaction3',
|
||||
'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,
|
||||
'value': 'RaoulettePoiluchette',
|
||||
})
|
||||
action4 = self.action.create({
|
||||
'name': 'Subaction3',
|
||||
'sequence': 4,
|
||||
'model_id': self.res_partner_model.id,
|
||||
'state': 'code',
|
||||
'code': 'action = {"type": "ir.actions.act_url"}',
|
||||
})
|
||||
self.action.write({
|
||||
'state': 'multi',
|
||||
'child_ids': [Command.set([action1.id, action2.id, action3.id])],
|
||||
'child_ids': [Command.set([action1.id, action2.id, action3.id, action4.id])],
|
||||
})
|
||||
|
||||
# Do: run the action
|
||||
|
||||
@@ -325,23 +325,35 @@
|
||||
help="Run this action manually."/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Update order quantity"/></h1>
|
||||
</div>
|
||||
<h1 class="oe_title">
|
||||
<field name="name" placeholder="e.g. Mass archive contacts"/>
|
||||
</h1>
|
||||
<group name="action_wrapper">
|
||||
<group name="action_content">
|
||||
<field name="model_id" options="{'no_create': True}"/>
|
||||
<field name="model_name" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="state"/>
|
||||
<field name="model_id" invisible="1"/>
|
||||
<field name="model_id" options="{'no_create': True}" groups="base.group_no_one"/>
|
||||
<field name="model_id" options="{'no_create': True}" groups="!base.group_no_one" invisible="context.get('default_model_id')"/>
|
||||
<field name="groups_id" widget="many2many_tags" groups="base.group_no_one" />
|
||||
<field name="state" string="Action Type"/>
|
||||
<field name="update_field_id" invisible="state != 'object_write'"
|
||||
options="{'no_create': True}"
|
||||
domain="['|', ('model_id', '=', crud_model_id), ('model_id', '=', model_id)]"/>
|
||||
<field name="evaluation_type" invisible="state != 'object_write'"/>
|
||||
<field name="value_field_to_show" invisible="1"/>
|
||||
<field name="update_related_model_id" invisible="1"/>
|
||||
<field name="value" invisible="state != 'object_write' or value_field_to_show != 'value' or evaluation_type != 'value'"/>
|
||||
<field name="value" widget="code" options="{'mode': 'python'}" invisible="state != 'object_write' or evaluation_type != 'equation'"/>
|
||||
<field name="resource_ref" string="Value" options="{'model_field': 'update_related_model_id', 'no_create': True}" invisible="state != 'object_write' or value_field_to_show != 'resource_ref' or evaluation_type == 'equation'"/>
|
||||
<field name="selection_value" options="{'no_create': True}" invisible="state != 'object_write' or value_field_to_show != 'selection_value' or evaluation_type == 'equation'"/>
|
||||
|
||||
<field name="type" invisible="1"/>
|
||||
<field name="crud_model_id"
|
||||
options="{'no_create': True}"
|
||||
invisible="state != 'object_create'"
|
||||
required="state == 'object_create'"/>
|
||||
<field name="crud_model_name" invisible="1"/>
|
||||
<field name="value" widget="char" string="Name" invisible="state != 'object_create'"/>
|
||||
<field name="link_field_id"
|
||||
domain="[('model_id', '=', model_id), ('relation', '=', crud_model_name),
|
||||
('ttype', 'in', ['many2one', 'one2many', 'many2many'])]"
|
||||
@@ -349,6 +361,17 @@
|
||||
invisible="state != 'object_create'"
|
||||
context="{'default_model_id': model_id, 'default_relation': crud_model_name}"/>
|
||||
</group>
|
||||
<group invisible="evaluation_type != 'equation' or state != 'object_write'">
|
||||
<span colspan="2" class="text-muted"> Available variables: </span>
|
||||
<ul colspan="2" class="text-muted">
|
||||
<li><code>env</code>: environment on which the action is triggered</li>
|
||||
<li><code>model</code>: model of the record on which the action is triggered; is a void recordset</li>
|
||||
<li><code>record</code>: record on which the action is triggered</li>
|
||||
<li><code>records</code>: recordset of all records on which the action is triggered in multi mode</li>
|
||||
<li><code>time</code>, <code>datetime</code>, <code>dateutil</code>, <code>timezone</code>: useful Python libraries</li>
|
||||
<li><code>float_compare()</code>: utility function to compare floats based on a specific precision</li>
|
||||
</ul>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Python Code" name='code'
|
||||
@@ -356,32 +379,6 @@
|
||||
<field name="code" widget="code" options="{'mode': 'python'}" placeholder="Enter Python code here. Help about Python expression is available in the help tab of this document."/>
|
||||
</page>
|
||||
|
||||
<page string="Data to Write" name="page_object"
|
||||
invisible="state not in ['object_create', 'object_write']">
|
||||
<p invisible="model_id">
|
||||
Please set the Model to Create before choosing values
|
||||
</p>
|
||||
<field name="fields_lines">
|
||||
<tree string="Field Mappings" editable="bottom">
|
||||
<field name="col1"
|
||||
options="{'no_create': True}"
|
||||
domain="['|', ('model_id', '=', parent.crud_model_id), ('model_id', '=', parent.model_id)]"/>
|
||||
<field name="evaluation_type"/>
|
||||
<field name="resource_ref"
|
||||
options="{'hide_model': True, 'no_create': True}"
|
||||
readonly="evaluation_type != 'reference'"/>
|
||||
<field name="value"
|
||||
readonly="evaluation_type == 'reference'"
|
||||
options="{'no_create': True}"
|
||||
force_save="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<page name="security" string="Access Rights">
|
||||
<field name="groups_id"/>
|
||||
</page>
|
||||
|
||||
<page string="Actions" name="actions"
|
||||
invisible="state != 'multi'">
|
||||
<p class="oe_grey">
|
||||
@@ -411,10 +408,10 @@
|
||||
</ul>
|
||||
<div invisible="state != 'code'">
|
||||
<p>Example of Python code</p>
|
||||
<code style='white-space: pre-wrap'>
|
||||
partner_name = record.name + '_code' \n
|
||||
<pre style='white-space: pre-wrap'>
|
||||
partner_name = record.name + '_code'
|
||||
env['res.partner'].create({'name': partner_name})
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</page>
|
||||
@@ -428,7 +425,6 @@ env['res.partner'].create({'name': partner_name})
|
||||
<field name="model">ir.actions.server</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Server Actions">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="state"/>
|
||||
<field name="model_id"/>
|
||||
|
||||
Reference in New Issue
Block a user