[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:
Bruno Boi
2023-09-05 18:44:13 +00:00
committed by Damien Bouvy
co-authored by Florent Dardenne Julien Carion Lucas Perais
parent e1b0763347
commit 0a744accc2
43 changed files with 3346 additions and 795 deletions
+4 -4
View File
@@ -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>
+383 -214
View File
@@ -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"]);
});
});
});
+38 -23
View File
@@ -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>
+1 -1
View File
@@ -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.)
+2 -2
View File
@@ -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"/>
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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()``;
+1 -1
View File
@@ -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']
+1 -1
View File
@@ -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)
+38 -13
View File
@@ -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.
+41 -26
View File
@@ -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 &gt; 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 &gt; 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>
+1 -1
View File
@@ -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.)
+13 -4
View File
@@ -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):
+19 -4
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_base_automation_linked_test access_base_automation_linked_test model_base_automation_linked_test base.group_user 1 1 1 1
6 access_test_base_automation_project access_test_base_automation_project model_test_base_automation_project base.group_user 1 1 1 1
7 access_test_base_automation_task access_test_base_automation_task model_test_base_automation_task base.group_user 1 1 1 1
8 access_test_base_automation_stage access_test_base_automation_stage model_test_base_automation_stage base.group_user 1 1 1 1
9 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" />
+1 -1
View File
@@ -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:
+106 -63
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
104 access_ir_actions_todo_group_system ir_actions_todo group system model_ir_actions_todo group_system 1 1 1 1
105 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
106 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
107 access_ir_actions_server_group_system ir_actions_server_group_system model_ir_actions_server group_system 1 1 1 1
108 access_ir_actions_client ir_actions_client all model_ir_actions_client group_system 1 1 1 1
109 access_res_bank_group_system res_bank_group_system model_res_bank group_system 1 1 1 1
+28 -28
View File
@@ -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
+34 -38
View File
@@ -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"/>