From 0a744accc2aaa965d5353e854317895d822ad954 Mon Sep 17 00:00:00 2001 From: Bruno Boi Date: Tue, 29 Aug 2023 11:39:24 +0200 Subject: [PATCH] [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 Co-authored-by: Julien Carion Co-authored-by: Lucas Perais --- addons/base_automation/__manifest__.py | 8 +- .../data/base_automation_data.xml | 2 +- .../base_automation/models/base_automation.py | 597 ++++++---- .../models/ir_actions_server.py | 3 +- .../static/src/base_automation.scss | 56 + .../base_automation_actions_one2many_field.js | 161 +++ ...base_automation_actions_one2many_field.xml | 42 + .../src/base_automation_error_dialog.js | 26 +- .../src/base_automation_error_dialog.xml | 14 +- ...base_automation_trigger_reference_field.js | 53 + ...base_automation_trigger_selection_field.js | 109 ++ ...ase_automation_trigger_selection_field.xml | 18 + .../static/src/kanban_header_patch.js | 100 ++ addons/base_automation/static/src/utils.js | 24 + .../tests/base_automation_error_dialog.js | 4 +- .../static/tests/kanban_header_patch_tests.js | 152 +++ .../base_automation/tests/test_automation.py | 61 +- .../tests/test_mail_composer.py | 10 +- .../views/base_automation_views.xml | 269 ++++- addons/crm/README.md | 2 +- addons/crm/data/crm_lead_merge_template.xml | 4 +- addons/crm/models/crm_lead.py | 4 +- addons/crm/models/crm_team.py | 4 +- addons/crm/tests/common.py | 2 +- addons/digest/models/digest.py | 2 +- addons/mail/models/ir_actions_server.py | 51 +- addons/mail/views/ir_actions_server_views.xml | 67 +- addons/mass_mailing/README.md | 2 +- addons/sms/models/ir_actions_server.py | 17 +- addons/sms/views/ir_actions_server_views.xml | 23 +- addons/test_base_automation/__manifest__.py | 5 + .../models/test_base_automation.py | 16 +- .../security/ir.model.access.csv | 2 + .../static/tests/tour/base_automation_tour.js | 572 +++++++++ addons/test_base_automation/tests/__init__.py | 1 + .../test_base_automation/tests/test_flow.py | 1051 ++++++++++++----- .../test_base_automation/tests/test_tour.py | 303 +++++ addons/website_forum/data/forum_post_demo.xml | 4 +- addons/website_sale/models/sale_order.py | 2 +- odoo/addons/base/models/ir_actions.py | 169 ++- odoo/addons/base/security/ir.model.access.csv | 1 - odoo/addons/base/tests/test_ir_actions.py | 56 +- odoo/addons/base/views/ir_actions_views.xml | 72 +- 43 files changed, 3346 insertions(+), 795 deletions(-) create mode 100644 addons/base_automation/static/src/base_automation.scss create mode 100644 addons/base_automation/static/src/base_automation_actions_one2many_field.js create mode 100644 addons/base_automation/static/src/base_automation_actions_one2many_field.xml create mode 100644 addons/base_automation/static/src/base_automation_trigger_reference_field.js create mode 100644 addons/base_automation/static/src/base_automation_trigger_selection_field.js create mode 100644 addons/base_automation/static/src/base_automation_trigger_selection_field.xml create mode 100644 addons/base_automation/static/src/kanban_header_patch.js create mode 100644 addons/base_automation/static/src/utils.js create mode 100644 addons/base_automation/static/tests/kanban_header_patch_tests.js create mode 100644 addons/test_base_automation/static/tests/tour/base_automation_tour.js create mode 100644 addons/test_base_automation/tests/test_tour.py diff --git a/addons/base_automation/__manifest__.py b/addons/base_automation/__manifest__.py index bd50ac92f3d..c28fc823021 100644 --- a/addons/base_automation/__manifest__.py +++ b/addons/base_automation/__manifest__.py @@ -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 diff --git a/addons/base_automation/data/base_automation_data.xml b/addons/base_automation/data/base_automation_data.xml index 2cf13f4617e..3a98339fc8b 100644 --- a/addons/base_automation/data/base_automation_data.xml +++ b/addons/base_automation/data/base_automation_data.xml @@ -2,7 +2,7 @@ - Base Action Rule: check and execute + Automation Rules: check and execute code model._check(True) diff --git a/addons/base_automation/models/base_automation.py b/addons/base_automation/models/base_automation.py index dafc746de29..d874a1dc0e8 100644 --- a/addons/base_automation/models/base_automation.py +++ b/addons/base_automation/models/base_automation.py @@ -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 diff --git a/addons/base_automation/models/ir_actions_server.py b/addons/base_automation/models/ir_actions_server.py index 0cf9263c637..0cec609a320 100644 --- a/addons/base_automation/models/ir_actions_server.py +++ b/addons/base_automation/models/ir_actions_server.py @@ -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') diff --git a/addons/base_automation/static/src/base_automation.scss b/addons/base_automation/static/src/base_automation.scss new file mode 100644 index 00000000000..5db520e2013 --- /dev/null +++ b/addons/base_automation/static/src/base_automation.scss @@ -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; + } + } +} diff --git a/addons/base_automation/static/src/base_automation_actions_one2many_field.js b/addons/base_automation/static/src/base_automation_actions_one2many_field.js new file mode 100644 index 00000000000..3c072dfcfff --- /dev/null +++ b/addons/base_automation/static/src/base_automation_actions_one2many_field.js @@ -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); diff --git a/addons/base_automation/static/src/base_automation_actions_one2many_field.xml b/addons/base_automation/static/src/base_automation_actions_one2many_field.xml new file mode 100644 index 00000000000..8aa2fd72527 --- /dev/null +++ b/addons/base_automation/static/src/base_automation_actions_one2many_field.xml @@ -0,0 +1,42 @@ + + + + +
+ + no action defined... + + +
+
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ diff --git a/addons/base_automation/static/src/base_automation_error_dialog.js b/addons/base_automation/static/src/base_automation_error_dialog.js index 971f6eeafe1..9edccb9a584 100644 --- a/addons/base_automation/static/src/base_automation_error_dialog.js +++ b/addons/base_automation/static/src/base_automation_error_dialog.js @@ -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", diff --git a/addons/base_automation/static/src/base_automation_error_dialog.xml b/addons/base_automation/static/src/base_automation_error_dialog.xml index 01e11b5fa01..926ab2c3e33 100644 --- a/addons/base_automation/static/src/base_automation_error_dialog.xml +++ b/addons/base_automation/static/src/base_automation_error_dialog.xml @@ -4,29 +4,29 @@

- The error occurred during the execution of the automated action + The error occurred during the execution of the automation rule "" (ID: ).

- You can disable this automated action or edit it to solve the issue.
- Disabling this automated action will enable you to continue your workflow + You can disable this automation rule or edit it to solve the issue.
+ 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.

- You can ask an administrator to disable or correct this automated action. + You can ask an administrator to disable or correct this automation rule.

- diff --git a/addons/base_automation/static/src/base_automation_trigger_reference_field.js b/addons/base_automation/static/src/base_automation_trigger_reference_field.js new file mode 100644 index 00000000000..af99ea5e971 --- /dev/null +++ b/addons/base_automation/static/src/base_automation_trigger_reference_field.js @@ -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``; + + 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); diff --git a/addons/base_automation/static/src/base_automation_trigger_selection_field.js b/addons/base_automation/static/src/base_automation_trigger_selection_field.js new file mode 100644 index 00000000000..14e12896cc9 --- /dev/null +++ b/addons/base_automation/static/src/base_automation_trigger_selection_field.js @@ -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); diff --git a/addons/base_automation/static/src/base_automation_trigger_selection_field.xml b/addons/base_automation/static/src/base_automation_trigger_selection_field.xml new file mode 100644 index 00000000000..3d652176999 --- /dev/null +++ b/addons/base_automation/static/src/base_automation_trigger_selection_field.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/addons/base_automation/static/src/kanban_header_patch.js b/addons/base_automation/static/src/kanban_header_patch.js new file mode 100644 index 00000000000..3a203babc7c --- /dev/null +++ b/addons/base_automation/static/src/kanban_header_patch.js @@ -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 } +); diff --git a/addons/base_automation/static/src/utils.js b/addons/base_automation/static/src/utils.js new file mode 100644 index 00000000000..beeff335f79 --- /dev/null +++ b/addons/base_automation/static/src/utils.js @@ -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), +}; diff --git a/addons/base_automation/static/tests/base_automation_error_dialog.js b/addons/base_automation/static/tests/base_automation_error_dialog.js index 6a566333ef5..115c1edc1d7 100644 --- a/addons/base_automation/static/tests/base_automation_error_dialog.js +++ b/addons/base_automation/static/tests/base_automation_error_dialog.js @@ -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({ diff --git a/addons/base_automation/static/tests/kanban_header_patch_tests.js b/addons/base_automation/static/tests/kanban_header_patch_tests.js new file mode 100644 index 00000000000..77655f06266 --- /dev/null +++ b/addons/base_automation/static/tests/kanban_header_patch_tests.js @@ -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: ` + + + + +
+ +
+
+
+
`, + 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"]); + }); + }); +}); diff --git a/addons/base_automation/tests/test_automation.py b/addons/base_automation/tests/test_automation.py index 39c9b434221..9ce5ea10046 100644 --- a/addons/base_automation/tests/test_automation.py +++ b/addons/base_automation/tests/test_automation.py @@ -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) diff --git a/addons/base_automation/tests/test_mail_composer.py b/addons/base_automation/tests/test_mail_composer.py index 2609466facd..5400052d47f 100644 --- a/addons/base_automation/tests/test_mail_composer.py +++ b/addons/base_automation/tests/test_mail_composer.py @@ -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() diff --git a/addons/base_automation/views/base_automation_views.xml b/addons/base_automation/views/base_automation_views.xml index 4bcc68aa149..e985280a11f 100644 --- a/addons/base_automation/views/base_automation_views.xml +++ b/addons/base_automation/views/base_automation_views.xml @@ -5,59 +5,151 @@ Automations base.automation + +
+ + + + +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+
+
+
+
+
+
+
+ +
+
+ + + + base.automation.server.action.form + ir.actions.server primary - + + 1 - + + Type - + + 1 - - {'no_create': True} - - - {'default_model_id': model_id} - - - 1 - - - - - - - - - - - - - + + 1 @@ -67,8 +159,7 @@ base.automation.tree base.automation - - + @@ -76,6 +167,67 @@ + + + base.automation.kanban + base.automation + + + + +
+ + + +
+
+
+ + +
+ + + + + +
+
+ + + + based on + +
+
+ + + + +
+ +
+
+
+ + +
+
+
+
+
+
+
+
+ base.automation.search base.automation @@ -91,19 +243,20 @@ - Automated Actions + Automation Rules base.automation - tree,form - + kanban,tree,form + + {'active_test': False}

- Setup a new automated automation + Setup a new automation rule

- 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. +

With Automation Rules, you can automate + any workflow.

diff --git a/addons/crm/README.md b/addons/crm/README.md index 3f2abb3662e..0f9d0b781f6 100644 --- a/addons/crm/README.md +++ b/addons/crm/README.md @@ -58,7 +58,7 @@ Lead Automation and Marketing Campaigns Drive performance by automating tasks with Odoo CRM. 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.) diff --git a/addons/crm/data/crm_lead_merge_template.xml b/addons/crm/data/crm_lead_merge_template.xml index 9f18aea16d0..128e3c5d4e6 100644 --- a/addons/crm/data/crm_lead_merge_template.xml +++ b/addons/crm/data/crm_lead_merge_template.xml @@ -49,8 +49,8 @@
Created on:
-
- Last Action: +
+ Last Automation:
Expected Closing: diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index f164919ef70..a3e4ebbe95f 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -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) diff --git a/addons/crm/models/crm_team.py b/addons/crm/models/crm_team.py index 40ccee93b3d..7a8c483e685 100644 --- a/addons/crm/models/crm_team.py +++ b/addons/crm/models/crm_team.py @@ -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()``; diff --git a/addons/crm/tests/common.py b/addons/crm/tests/common.py index fed6d89ae21..468f8cd46cc 100644 --- a/addons/crm/tests/common.py +++ b/addons/crm/tests/common.py @@ -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'] diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py index 25e0260d4c0..8772b3c581a 100644 --- a/addons/digest/models/digest.py +++ b/addons/digest/models/digest.py @@ -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) diff --git a/addons/mail/models/ir_actions_server.py b/addons/mail/models/ir_actions_server.py index 2192e3eb34b..61937bd3223 100644 --- a/addons/mail/models/ir_actions_server.py +++ b/addons/mail/models/ir_actions_server.py @@ -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. diff --git a/addons/mail/views/ir_actions_server_views.xml b/addons/mail/views/ir_actions_server_views.xml index e62e308fa49..7efcb766796 100644 --- a/addons/mail/views/ir_actions_server_views.xml +++ b/addons/mail/views/ir_actions_server_views.xml @@ -7,39 +7,54 @@ ir.actions.server - - - - - - - - - - - - + + + + + + + + - + + - + diff --git a/addons/mass_mailing/README.md b/addons/mass_mailing/README.md index df77040f53f..867aa016efa 100644 --- a/addons/mass_mailing/README.md +++ b/addons/mass_mailing/README.md @@ -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.) diff --git a/addons/sms/models/ir_actions_server.py b/addons/sms/models/ir_actions_server.py index 3c9f59453f3..a3463ddc483 100644 --- a/addons/sms/models/ir_actions_server.py +++ b/addons/sms/models/ir_actions_server.py @@ -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): diff --git a/addons/sms/views/ir_actions_server_views.xml b/addons/sms/views/ir_actions_server_views.xml index 63ab66bfab4..7b9224f8c69 100644 --- a/addons/sms/views/ir_actions_server_views.xml +++ b/addons/sms/views/ir_actions_server_views.xml @@ -11,10 +11,25 @@ context="{'default_model': model_name}" invisible="state != 'sms'" required="state == 'sms'"/> - + diff --git a/addons/test_base_automation/__manifest__.py b/addons/test_base_automation/__manifest__.py index aa06d311431..5a6b5586fbc 100644 --- a/addons/test_base_automation/__manifest__.py +++ b/addons/test_base_automation/__manifest__.py @@ -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', } diff --git a/addons/test_base_automation/models/test_base_automation.py b/addons/test_base_automation/models/test_base_automation.py index 8a583287aa2..9f2419c5a06 100644 --- a/addons/test_base_automation/models/test_base_automation.py +++ b/addons/test_base_automation/models/test_base_automation.py @@ -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() diff --git a/addons/test_base_automation/security/ir.model.access.csv b/addons/test_base_automation/security/ir.model.access.csv index 8c95f5b3a0e..f3aa6170171 100644 --- a/addons/test_base_automation/security/ir.model.access.csv +++ b/addons/test_base_automation/security/ir.model.access.csv @@ -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 diff --git a/addons/test_base_automation/static/tests/tour/base_automation_tour.js b/addons/test_base_automation/static/tests/tour/base_automation_tour.js new file mode 100644 index 00000000000..dd9c71405c7 --- /dev/null +++ b/addons/test_base_automation/static/tests/tour/base_automation_tour.js @@ -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, + }, + ], +}); diff --git a/addons/test_base_automation/tests/__init__.py b/addons/test_base_automation/tests/__init__.py index b96c2ec87d5..028885450dd 100644 --- a/addons/test_base_automation/tests/__init__.py +++ b/addons/test_base_automation/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_flow +from . import test_tour diff --git a/addons/test_base_automation/tests/test_flow.py b/addons/test_base_automation/tests/test_flow.py index e62f8502799..057c77dd793 100644 --- a/addons/test_base_automation/tests/test_flow.py +++ b/addons/test_base_automation/tests/test_flow.py @@ -7,106 +7,53 @@ import sys from odoo.addons.base.tests.common import TransactionCaseWithUserDemo from odoo.tests import common, tagged from odoo.exceptions import AccessError +from odoo import Command + + +def create_automation(self, **kwargs): + """ + Create a transient automation with the given data and actions + The created automation is cleaned up at the end of the calling test + """ + vals = {'name': 'Automation'} + vals.update(kwargs) + actions_data = vals.pop('_actions', []) + if not isinstance(actions_data, list): + actions_data = [actions_data] + automation_id = self.env['base.automation'].create(vals) + action_ids = self.env['ir.actions.server'].create( + [ + { + 'name': 'Action', + 'base_automation_id': automation_id.id, + 'model_id': automation_id.model_id.id, + 'usage': 'base_automation', + **action, + } + for action in actions_data + ] + ) + automation_id.write({'action_server_ids': [Command.set(action_ids.ids)]}) + self.addCleanup(automation_id.unlink) + return automation_id @tagged('post_install', '-at_install') class BaseAutomationTest(TransactionCaseWithUserDemo): - def setUp(self): super(BaseAutomationTest, self).setUp() self.user_root = self.env.ref('base.user_root') self.user_admin = self.env.ref('base.user_admin') - - self.test_mail_template_automation = self.env['mail.template'].create({ - 'name': 'Template Automation', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'body_html': """<div>Email automation</div>""", - }) - - self.res_partner_1 = self.env['res.partner'].create({'name': 'My Partner'}) - self.env['base.automation'].create([ + self.lead_model = self.env.ref('test_base_automation.model_base_automation_lead_test') + self.project_model = self.env.ref('test_base_automation.model_test_base_automation_project') + self.test_mail_template_automation = self.env['mail.template'].create( { - 'name': 'Base Automation: test rule on create', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': "records.write({'user_id': %s})" % (self.user_demo.id), - 'trigger': 'on_create', - 'active': True, - 'filter_domain': "[('state', '=', 'draft')]", - }, { - 'name': 'Base Automation: test rule on write', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': "records.write({'user_id': %s})" % (self.user_demo.id), - 'trigger': 'on_write', - 'active': True, - 'filter_domain': "[('state', '=', 'done')]", - 'filter_pre_domain': "[('state', '=', 'open')]", - }, { - 'name': 'Base Automation: test rule on recompute', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': "records.write({'user_id': %s})" % (self.user_demo.id), - 'trigger': 'on_write', - 'active': True, - 'filter_domain': "[('employee', '=', True)]", - }, { - 'name': 'Base Automation: test recursive rule', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': """ -record = model.browse(env.context['active_id']) -if 'partner_id' in env.context['old_values'][record.id]: - record.write({'state': 'draft'})""", - 'trigger': 'on_write', - 'active': True, - }, { - 'name': 'Base Automation: test rule on secondary model', - 'model_id': self.env.ref('test_base_automation.model_base_automation_line_test').id, - 'state': 'code', - 'code': "records.write({'user_id': %s})" % (self.user_demo.id), - 'trigger': 'on_create', - 'active': True, - }, { - 'name': 'Base Automation: test rule on write check context', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': """ -record = model.browse(env.context['active_id']) -if 'user_id' in env.context['old_values'][record.id]: - record.write({'is_assigned_to_admin': (record.user_id.id == 1)})""", - 'trigger': 'on_write', - 'active': True, - }, { - 'name': 'Base Automation: test rule with trigger', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'trigger_field_ids': [(4, self.env.ref('test_base_automation.field_base_automation_lead_test__state').id)], - 'state': 'code', - 'code': """ -record = model.browse(env.context['active_id']) -record['name'] = record.name + 'X'""", - 'trigger': 'on_write', - 'active': True, - }, { - 'name': 'Base Automation: test send an email', - 'mail_post_method': 'email', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'template_id': self.test_mail_template_automation.id, - 'trigger_field_ids': [(4, self.env.ref('test_base_automation.field_base_automation_lead_test__deadline').id)], - 'state': 'mail_post', - 'code': """ -record = model.browse(env.context['active_id']) -record['name'] = record.name + 'X'""", - 'trigger': 'on_write', - 'active': True, - 'filter_domain': "[('deadline', '!=', False)]", - 'filter_pre_domain': "[('deadline', '=', False)]", + 'name': 'Template Automation', + 'model_id': self.lead_model.id, + 'body_html': """<div>Email automation</div>""", } - ]) - - def tearDown(self): - super().tearDown() - self.env['base.automation']._unregister_hook() + ) + self.res_partner_1 = self.env['res.partner'].create({'name': 'My Partner'}) def create_lead(self, **kwargs): vals = { @@ -114,164 +61,170 @@ record['name'] = record.name + 'X'""", 'user_id': self.user_root.id, } vals.update(kwargs) - return self.env['base.automation.lead.test'].create(vals) + lead = self.env['base.automation.lead.test'].create(vals) + self.addCleanup(lead.unlink) + return lead - def test_00_check_to_state_open_pre(self): + def create_line(self, **kwargs): + vals = { + 'name': 'Line Test', + 'user_id': self.user_root.id, + } + vals.update(kwargs) + line = self.env['base.automation.line.test'].create(vals) + self.addCleanup(line.unlink) + return line + + def create_project(self, **kwargs): + vals = {'name': 'Project Test'} + vals.update(kwargs) + project = self.env['test_base_automation.project'].create(vals) + self.addCleanup(project.unlink) + return project + + def create_stage(self, **kwargs): + vals = {'name': 'Stage Test'} + vals.update(kwargs) + stage = self.env['test_base_automation.stage'].create(vals) + self.addCleanup(stage.unlink) + return stage + + def create_tag(self, **kwargs): + vals = {'name': 'Tag Test'} + vals.update(kwargs) + tag = self.env['test_base_automation.tag'].create(vals) + self.addCleanup(tag.unlink) + return tag + + def test_000_on_create_or_write(self): """ - Check that a new record (with state = open) doesn't change its responsible - when there is a precondition filter which check that the state is open. + Test case: on save, simple case + - trigger: on_create_or_write """ - lead = self.create_lead(state='open') + # --- Without the automation --- + lead = self.create_lead() + self.assertEqual(lead.state, 'draft') + self.assertEqual(lead.user_id, self.user_root) + + # --- With the automation --- + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + # Write a lead should trigger the automation + lead.write({'state': 'open'}) self.assertEqual(lead.state, 'open') - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.") + self.assertEqual(lead.user_id, self.user_demo) - def test_01_check_to_state_draft_post(self): + # Create a lead should trigger the automation + lead2 = self.create_lead() + self.assertEqual(lead2.state, 'draft') + self.assertEqual(lead2.user_id, self.user_demo) + + def test_001_on_create_or_write(self): """ - Check that a new record changes its responsible when there is a postcondition - filter which check that the state is draft. + Test case: on save, with filter_domain + - trigger: on_create_or_write + - apply when: state is 'draft' """ - lead = self.create_lead() - self.assertEqual(lead.state, 'draft', "Lead state should be 'draft'") - self.assertEqual(lead.user_id, self.user_demo, "Responsible should be change on creation of Lead with state 'draft'.") + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'draft')]", + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) - def test_02_check_from_draft_to_done_with_steps(self): - """ - A new record is created and goes from states 'open' to 'done' via the - other states (open, pending and cancel). We have a rule with: - - precondition: the record is in "open" - - postcondition: that the record is "done". - If the state goes from 'open' to 'done' the responsible is changed. - If those two conditions aren't verified, the responsible remains the same. - """ - lead = self.create_lead(state='open') - self.assertEqual(lead.state, 'open', "Lead state should be 'open'") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.") - # change state to pending and check that responsible has not changed - lead.write({'state': 'pending'}) - self.assertEqual(lead.state, 'pending', "Lead state should be 'pending'") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.") - # change state to done and check that responsible has not changed - lead.write({'state': 'done'}) - self.assertEqual(lead.state, 'done', "Lead state should be 'done'") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.") - - def test_03_check_from_draft_to_done_without_steps(self): - """ - A new record is created and goes from states 'open' to 'done' via the - other states (open, pending and cancel). We have a rule with: - - precondition: the record is in "open" - - postcondition: that the record is "done". - If the state goes from 'open' to 'done' the responsible is changed. - If those two conditions aren't verified, the responsible remains the same. - """ - lead = self.create_lead(state='open') - self.assertEqual(lead.state, 'open', "Lead state should be 'open'") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.") - # change state to done and check that responsible has changed - lead.write({'state': 'done'}) - self.assertEqual(lead.state, 'done', "Lead state should be 'done'") - self.assertEqual(lead.user_id, self.user_demo, "Responsible should be change on write of Lead with state from 'open' to 'done'.") - - def test_10_recomputed_field(self): - """ - Check that a rule is executed whenever a field is recomputed after a - change on another model. - """ - partner = self.res_partner_1 - partner.write({'employee': False}) - lead = self.create_lead(state='open', partner_id=partner.id) - self.assertFalse(lead.employee, "Customer field should updated to False") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.") - # change partner, recompute on lead should trigger the rule - partner.write({'employee': True}) - self.env.flush_all() - self.assertTrue(lead.employee, "Customer field should updated to True") - self.assertEqual(lead.user_id, self.user_demo, "Responsible should be change on write of Lead when Customer becomes True.") - - def test_11_recomputed_field(self): - """ - Check that a rule is executed whenever a field is recomputed and the - context contains the target field - """ - partner = self.res_partner_1 - lead = self.create_lead(state='draft', partner_id=partner.id) - self.assertFalse(lead.deadline, 'There should not be a deadline defined') - # change priority and user; this triggers deadline recomputation, and - # the server action should set the boolean field to True - lead.write({'priority': True, 'user_id': self.user_root.id}) - self.assertTrue(lead.deadline, 'Deadline should be defined') - self.assertTrue(lead.is_assigned_to_admin, 'Lead should be assigned to admin') - - def test_11b_recomputed_field(self): - mail_automation = self.env['base.automation'].search([('name', '=', 'Base Automation: test send an email')]) - send_mail_count = 0 - - def _patched_get_actions(*args, **kwargs): - obj = args[0] - if '__action_done' not in obj._context: - obj = obj.with_context(__action_done={}) - return mail_automation.with_env(obj.env) - - def _patched_send_mail(*args, **kwargs): - nonlocal send_mail_count - send_mail_count += 1 - - patchers = [ - patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._get_actions', _patched_get_actions), - patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail), - ] - - self.startPatcher(patchers[0]) - - lead = self.create_lead() - self.assertFalse(lead.priority) - self.assertFalse(lead.deadline) - - self.startPatcher(patchers[1]) - - lead.write({'priority': True}) - - self.assertTrue(lead.priority) - self.assertTrue(lead.deadline) - - - self.assertEqual(send_mail_count, 1) - - def test_12_recursive(self): - """ Check that a rule is executed recursively by a secondary change. """ + # Create a lead with state=open should not trigger the automation lead = self.create_lead(state='open') self.assertEqual(lead.state, 'open') self.assertEqual(lead.user_id, self.user_root) - # change partner; this should trigger the rule that modifies the state - partner = self.res_partner_1 - lead.write({'partner_id': partner.id}) + + # Write a lead to state=draft should trigger the automation + lead.write({'state': 'draft'}) self.assertEqual(lead.state, 'draft') + self.assertEqual(lead.user_id, self.user_demo) - def test_20_direct_line(self): - """ - Check that a rule is executed after creating a line record. - """ - line = self.env['base.automation.line.test'].create({'name': "Line"}) - self.assertEqual(line.user_id, self.user_demo) + # Create a lead with state=draft should trigger the automation + lead_2 = self.create_lead() + self.assertEqual(lead_2.state, 'draft') + self.assertEqual(lead_2.user_id, self.user_demo) - def test_20_indirect_line(self): + def test_002_on_create_or_write(self): """ - Check that creating a lead with a line executes rules on both records. + Test case: on save, with filter_pre_domain and filter_domain + - trigger: on_create_or_write + - before update filter: state is 'open' + - apply when: state is 'done' """ - lead = self.create_lead(line_ids=[(0, 0, {'name': "Line"})]) - self.assertEqual(lead.state, 'draft', "Lead state should be 'draft'") - self.assertEqual(lead.user_id, self.user_demo, "Responsible should change on creation of Lead test line.") - self.assertEqual(len(lead.line_ids), 1, "New test line is not created") - self.assertEqual(lead.line_ids.user_id, self.user_demo, "Responsible should be change on creation of Lead test line.") + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_pre_domain="[('state', '=', 'open')]", + filter_domain="[('state', '=', 'done')]", + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + # Create a lead with state=open should not trigger the automation + lead = self.create_lead(state='open') + self.assertEqual(lead.state, 'open') + self.assertEqual(lead.user_id, self.user_root) + + # Write a lead to state=pending THEN to state=done should not trigger the automation + lead.write({'state': 'pending'}) + self.assertEqual(lead.state, 'pending') + self.assertEqual(lead.user_id, self.user_root) + lead.write({'state': 'done'}) + self.assertEqual(lead.state, 'done') + self.assertEqual(lead.user_id, self.user_root) + + # Write a lead from state=open to state=done should trigger the automation + lead.write({'state': 'open'}) + self.assertEqual(lead.state, 'open') + self.assertEqual(lead.user_id, self.user_root) + lead.write({'state': 'done'}) + self.assertEqual(lead.state, 'done') + self.assertEqual(lead.user_id, self.user_demo) + + # Create a lead with state=open then write it to state=done should trigger the automation + lead_2 = self.create_lead(state='open') + self.assertEqual(lead_2.state, 'open') + self.assertEqual(lead_2.user_id, self.user_root) + lead_2.write({'state': 'done'}) + self.assertEqual(lead_2.state, 'done') + self.assertEqual(lead_2.user_id, self.user_demo) + + # Create a lead with state=done should trigger the automation, + # as verifying the filter_pre_domain does not make sense on create + lead_3 = self.create_lead(state='done') + self.assertEqual(lead_3.state, 'done') + self.assertEqual(lead_3.user_id, self.user_demo) + + def test_003_on_create_or_write(self): + """ Check that the on_create_or_write trigger works as expected with trigger fields. """ + lead_state_field = self.env.ref('test_base_automation.field_base_automation_lead_test__state') + automation = create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + trigger_field_ids=[Command.link(lead_state_field.id)], + _actions={ + 'state': 'code', + 'code': """ +if env.context.get('old_values', None): # on write only + record = model.browse(env.context['active_id']) + record['name'] = record.name + 'X'""", + }, + ) - def test_21_trigger_fields(self): - """ - Check that the rule with trigger is executed only once per pertinent update. - """ lead = self.create_lead(name="X") lead.priority = True partner1 = self.res_partner_1 - lead.partner_id = partner1.id + lead.partner_id = partner1 self.assertEqual(lead.name, 'X', "No update until now.") lead.state = 'open' @@ -284,8 +237,8 @@ record['name'] = record.name + 'X'""", self.assertEqual(lead.name, 'XXXX', "One update should have happened.") # change the rule to trigger on partner_id - rule = self.env['base.automation'].search([('name', '=', 'Base Automation: test rule with trigger')]) - rule.write({'trigger_field_ids': [(6, 0, [self.env.ref('test_base_automation.field_base_automation_lead_test__partner_id').id])]}) + lead_partner_id_field = self.env.ref('test_base_automation.field_base_automation_lead_test__partner_id') + automation.write({'trigger_field_ids': [Command.set([lead_partner_id_field.id])]}) partner2 = self.env['res.partner'].create({'name': 'A new partner'}) lead.name = 'X' @@ -297,8 +250,261 @@ record['name'] = record.name + 'X'""", self.assertEqual(lead.name, 'XX', "No update should have happened.") lead.partner_id = partner1 self.assertEqual(lead.name, 'XXX', "One update should have happened.") + lead.partner_id = partner1 + self.assertEqual(lead.name, 'XXX', "No update should have happened.") - def test_30_modelwithoutaccess(self): + def test_010_recompute(self): + """ + Test case: automation is applied whenever a field is recomputed + after a change on another model. + - trigger: on_create_or_write + - apply when: employee is True + """ + partner = self.res_partner_1 + partner.write({'employee': False}) + + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('employee', '=', True)]", + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + lead = self.create_lead(partner_id=partner.id) + self.assertEqual(lead.partner_id, partner) + self.assertEqual(lead.employee, False) + self.assertEqual(lead.user_id, self.user_root) + + # change partner, recompute on lead should trigger the rule + partner.write({'employee': True}) + self.env.flush_all() # ensures the recomputation is done + self.assertEqual(lead.partner_id, partner) + self.assertEqual(lead.employee, True) + self.assertEqual(lead.user_id, self.user_demo) + + def test_011_recompute(self): + """ + Test case: automation is applied whenever a field is recomputed. + The context contains the target field. + - trigger: on_create_or_write + """ + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={ + 'state': 'code', + 'code': """ +if env.context.get('old_values', None): # on write + if 'user_id' in env.context['old_values'][record.id]: + record.write({'is_assigned_to_admin': (record.user_id.id == 1)})""", + }, + ) + + partner = self.res_partner_1 + lead = self.create_lead(state='draft', partner_id=partner.id) + self.assertEqual(lead.deadline, False) + self.assertEqual(lead.is_assigned_to_admin, False) + + # change priority and user; this triggers deadline recomputation, and + # the server action should set is_assigned_to_admin field to True + lead.write({'priority': True, 'user_id': self.user_root.id}) + self.assertNotEqual(lead.deadline, False) + self.assertEqual(lead.is_assigned_to_admin, True) + + def test_012_recompute(self): + """ + Test case: automation is applied whenever a field is recomputed. + - trigger: on_create_or_write + - if updating fields: [deadline] + """ + active_field = self.env.ref("test_base_automation.field_base_automation_lead_test__active") + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + trigger_field_ids=[Command.link(active_field.id)], + _actions={ + 'state': 'code', + 'code': """ +if not env.context.get('old_values', None): # on create + record.write({'state': 'open'}) +else: + record.write({'priority': not record.priority})""", + }, + ) + + lead = self.create_lead(state='draft', priority=False) + self.assertEqual(lead.state, 'open') # the rule has set the state to open on create + self.assertEqual(lead.priority, False) + + # change state; the rule should not be triggered + lead.write({'state': 'pending'}) + self.assertEqual(lead.state, 'pending') + self.assertEqual(lead.priority, False) + + # change active; the rule should be triggered + lead.write({'active': False}) + self.assertEqual(lead.state, 'pending') + self.assertEqual(lead.priority, True) + + # change active again; the rule should still be triggered + lead.write({'active': True}) + self.assertEqual(lead.state, 'pending') + self.assertEqual(lead.priority, False) + + def test_013_recompute(self): + """ + Test case: automation is applied whenever a field is recomputed + - trigger: on_create_or_write + - if updating fields: [deadline] + - before update filter: deadline is not set + - apply when: deadline is set + """ + deadline_field = self.env.ref("test_base_automation.field_base_automation_lead_test__deadline") + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + trigger_field_ids=[Command.link(deadline_field.id)], + filter_pre_domain="[('deadline', '=', False)]", + filter_domain="[('deadline', '!=', False)]", + _actions={ + 'state': 'mail_post', + 'mail_post_method': 'email', + 'template_id': self.test_mail_template_automation.id, + }, + ) + + send_mail_count = 0 + + def _patched_send_mail(*args, **kwargs): + nonlocal send_mail_count + send_mail_count += 1 + + patcher = patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail) + self.startPatcher(patcher) + + lead = self.create_lead() + self.assertEqual(lead.priority, False) + self.assertEqual(lead.deadline, False) + self.assertEqual(send_mail_count, 0) + + lead.write({'priority': True}) + self.assertEqual(lead.priority, True) + self.assertNotEqual(lead.deadline, False) + self.assertEqual(send_mail_count, 1) + + def test_020_recursive(self): + """ Check that a rule is executed recursively by a secondary change. """ + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={ + 'state': 'code', + 'code': """ +if env.context.get('old_values', None): # on write + if 'partner_id' in env.context['old_values'][record.id]: + record.write({'state': 'draft'})""", + }, + ) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'draft')]", + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + lead = self.create_lead(state='open') + self.assertEqual(lead.state, 'open') + self.assertEqual(lead.user_id, self.user_root) + + # change partner; this should trigger the rule that modifies the state + # and then the rule that modifies the user + partner = self.res_partner_1 + lead.write({'partner_id': partner.id}) + self.assertEqual(lead.state, 'draft') + self.assertEqual(lead.user_id, self.user_demo) + + def test_021_recursive(self): + """ Check what it does with a recursive infinite loop """ + automations = [ + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'draft')]", + _actions={'state': 'code', 'code': "record.write({'state': 'pending'})"}, + ), + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'pending')]", + _actions={'state': 'code', 'code': "record.write({'state': 'open'})"}, + ), + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'open')]", + _actions={'state': 'code', 'code': "record.write({'state': 'done'})"}, + ), + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'done')]", + _actions={'state': 'code', 'code': "record.write({'state': 'draft'})"}, + ), + ] + + def _patch(*args, **kwargs): + self.assertEqual(args[0], automations.pop(0)) + + patcher = patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._process', _patch) + self.startPatcher(patcher) + + lead = self.create_lead(state='draft') + self.assertEqual(lead.state, 'draft') + self.assertEqual(len(automations), 0) # all automations have been processed # CHECK if proper assertion ? + + def test_030_submodel(self): + """ Check that a rule on a submodel is executed when the parent is modified. """ + # --- Without the automations --- + line = self.create_line() + self.assertEqual(line.user_id, self.user_root) + + lead = self.create_lead(line_ids=[(0, 0, {'name': 'Line', 'user_id': self.user_root.id})]) + self.assertEqual(lead.user_id, self.user_root) + self.assertEqual(lead.line_ids.user_id, self.user_root) + + # --- With the automations --- + comodel = self.env.ref('test_base_automation.model_base_automation_line_test') + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + create_automation( + self, + model_id=comodel.id, + trigger='on_create_or_write', + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + line = self.create_line(user_id=self.user_root.id) + self.assertEqual(line.user_id, self.user_demo) # rule on secondary model + + lead = self.create_lead(line_ids=[(0, 0, {'name': 'Line', 'user_id': self.user_root.id})]) + self.assertEqual(lead.user_id, self.user_demo) # rule on primary model + self.assertEqual(lead.line_ids.user_id, self.user_demo) # rule on secondary model + + def test_040_modelwithoutaccess(self): """ Ensure a domain on a M2O without user access doesn't fail. We create a base automation with a filter on a model the user haven't access to @@ -309,12 +515,12 @@ record['name'] = record.name + 'X'""", - create a record in the non restricted model in demo """ Model = self.env['base.automation.link.test'] + model_id = self.env.ref('test_base_automation.model_base_automation_link_test') Comodel = self.env['base.automation.linked.test'] - - access = self.env.ref("test_base_automation.access_base_automation_linked_test") - access.group_id = self.env['res.groups'].create({ + comodel_access = self.env.ref('test_base_automation.access_base_automation_linked_test') + comodel_access.group_id = self.env['res.groups'].create({ 'name': "Access to base.automation.linked.test", - "users": [(6, 0, [self.user_admin.id,])] + "users": [Command.link(self.user_admin.id)], }) # sanity check: user demo has no access to the comodel of 'linked_id' @@ -322,15 +528,13 @@ record['name'] = record.name + 'X'""", Comodel.with_user(self.user_demo).check_access_rights('read') # check base automation with filter that performs Comodel.search() - self.env['base.automation'].create({ - 'name': 'test no access', - 'model_id': self.env['ir.model']._get_id("base.automation.link.test"), - 'trigger': 'on_create_or_write', - 'filter_pre_domain': "[('linked_id.another_field', '=', 'something')]", - 'state': 'code', - 'active': True, - 'code': "action = [rec.name for rec in records]" - }) + create_automation( + self, + model_id=model_id.id, + trigger='on_create_or_write', + filter_pre_domain="[('linked_id.another_field', '=', 'something')]", + _actions={'state': 'code', 'code': 'action = [rec.name for rec in records]'}, + ) Comodel.create([ {'name': 'a first record', 'another_field': 'something'}, {'name': 'another record', 'another_field': 'something different'}, @@ -341,20 +545,311 @@ record['name'] = record.name + 'X'""", rec2.write({'name': 'another value'}) # check base automation with filter that performs Comodel.name_search() - self.env['base.automation'].create({ - 'name': 'test no name access', - 'model_id': self.env['ir.model']._get_id("base.automation.link.test"), - 'trigger': 'on_create_or_write', - 'filter_pre_domain': "[('linked_id', '=', 'whatever')]", - 'state': 'code', - 'active': True, - 'code': "action = [rec.name for rec in records]" - }) + create_automation( + self, + model_id=model_id.id, + trigger='on_create_or_write', + filter_pre_domain="[('linked_id', '=', 'whatever')]", + _actions={'state': 'code', 'code': 'action = [rec.name for rec in records]'}, + ) rec3 = Model.create({'name': 'a random record'}) rec3.write({'name': 'a first record'}) rec4 = Model.with_user(self.user_demo).create({'name': 'again another record'}) rec4.write({'name': 'another value'}) + def test_050_on_create_or_write_with_create_record(self): + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={ + 'state': 'object_create', + 'crud_model_id': self.project_model.id, + 'value': 'foo', + }, + ) + lead = self.create_lead() + search_result = self.env['test_base_automation.project'].name_search('foo') + self.assertEqual(len(search_result), 1, 'One record on the project model should have been created') + + lead.write({'name': 'renamed lead'}) + search_result = self.env['test_base_automation.project'].name_search('foo') + self.assertEqual(len(search_result), 2, 'Another record on the project model should have been created') + + # ---------------------------- + # The following does not work properly as it is a known + # limitation of the implementation since at least 14.0 + # -> AssertionError: 4 != 3 : Another record on the secondary model should have been created + + # # write on a field that is a dependency of another computed field + # lead.write({'priority': True}) + # search_result = self.env['test_base_automation.project'].name_search('foo') + # self.assertEqual(len(search_result), 3, 'Another record on the secondary model should have been created') + # ---------------------------- + + def test_060_on_stage_set(self): + stage_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.project_model.id), + ('name', '=', 'stage_id'), + ]) + stage1 = self.create_stage() + stage2 = self.create_stage() + create_automation( + self, + model_id=self.project_model.id, + trigger='on_stage_set', + trigger_field_ids=[stage_field.id], + filter_domain="[('stage_id', '=', %s)]" % stage1.id, + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + project = self.create_project() + self.assertEqual(project.name, 'Project Test') + project.write({'stage_id': stage1.id}) + self.assertEqual(project.name, 'Project Test!') + project.write({'stage_id': stage1.id}) + self.assertEqual(project.name, 'Project Test!') + project.write({'stage_id': stage2.id}) + self.assertEqual(project.name, 'Project Test!') + project.write({'stage_id': False}) + self.assertEqual(project.name, 'Project Test!') + project.write({'stage_id': stage1.id}) + self.assertEqual(project.name, 'Project Test!!') + + def test_070_on_user_set(self): + user_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.lead_model.id), + ('name', '=', 'user_id'), + ]) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_user_set', + trigger_field_ids=[user_field.id], + filter_domain="[('user_id', '!=', False)]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + + lead = self.create_lead() + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'user_id': self.user_demo.id}) + self.assertEqual(lead.name, 'Lead Test!!') + lead.write({'user_id': self.user_demo.id}) + self.assertEqual(lead.name, 'Lead Test!!') + lead.write({'user_id': self.user_admin.id}) + self.assertEqual(lead.name, 'Lead Test!!!') + lead.write({'user_id': False}) + self.assertEqual(lead.name, 'Lead Test!!!') + lead.write({'user_id': self.user_demo.id}) + self.assertEqual(lead.name, 'Lead Test!!!!') + + def test_071_on_user_set(self): + # same test as above but with the user_ids many2many on a project + user_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.project_model.id), + ('name', '=', 'user_ids'), + ]) + create_automation( + self, + model_id=self.project_model.id, + trigger='on_user_set', + trigger_field_ids=[user_field.id], + filter_domain="[('user_ids', '!=', False)]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + + project = self.create_project() + self.assertEqual(project.name, 'Project Test') + project.write({'user_ids': [Command.set([self.user_demo.id])]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'user_ids': [Command.set([self.user_demo.id])]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'user_ids': [Command.link(self.user_admin.id)]}) + self.assertEqual(project.name, 'Project Test!!') + # Unlinking a user while there are still other users does trigger the automation + # This behavior could be changed in the future but needs a bit of investigation + project.write({'user_ids': [Command.unlink(self.user_admin.id)]}) + self.assertEqual(project.name, 'Project Test!!!') + project.write({'user_ids': [Command.set([])]}) + self.assertEqual(project.name, 'Project Test!!!') + project.write({'user_ids': [Command.set([self.user_demo.id])]}) + self.assertEqual(project.name, 'Project Test!!!!') + + def test_080_on_tag_set(self): + tag_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.project_model.id), + ('name', '=', 'tag_ids'), + ]) + tag1 = self.create_tag() + create_automation( + self, + model_id=self.project_model.id, + trigger='on_tag_set', + trigger_field_ids=[tag_field.id], + filter_pre_domain="[('tag_ids', 'not in', [%s])]" % tag1.id, + filter_domain="[('tag_ids', 'in', [%s])]" % tag1.id, + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + project = self.create_project() + self.assertEqual(project.name, 'Project Test') + project.write({'tag_ids': [Command.set([tag1.id])]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'tag_ids': [Command.set([tag1.id])]}) + self.assertEqual(project.name, 'Project Test!') + + tag2 = self.create_tag() + project.write({'tag_ids': [Command.link(tag2.id)]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'tag_ids': [Command.clear()]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'tag_ids': [Command.set([tag2.id])]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'tag_ids': [Command.link(tag1.id)]}) + self.assertEqual(project.name, 'Project Test!!') + + def test_090_on_state_set(self): + state_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.lead_model.id), + ('name', '=', 'state'), + ]) + + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_state_set', + trigger_field_ids=[state_field.id], + filter_domain="[('state', '=', 'done')]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + + lead = self.create_lead() + self.assertEqual(lead.name, 'Lead Test') + lead.write({'state': 'open'}) + self.assertEqual(lead.name, 'Lead Test') + lead.write({'state': 'done'}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'state': 'done'}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'state': 'open'}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'state': 'done'}) + self.assertEqual(lead.name, 'Lead Test!!') + + def test_100_on_priority_set(self): + priority_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.project_model.id), + ('name', '=', 'priority'), + ]) + create_automation( + self, + model_id=self.project_model.id, + trigger='on_priority_set', + trigger_field_ids=[priority_field.id], + filter_domain="[('priority', '=', '2')]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + project = self.create_project() + self.assertEqual(project.name, 'Project Test') + self.assertEqual(project.priority, '1') + project.write({'priority': '0'}) + self.assertEqual(project.name, 'Project Test') + project.write({'priority': '2'}) + self.assertEqual(project.name, 'Project Test!') + project.write({'priority': '2'}) + self.assertEqual(project.name, 'Project Test!') + project.write({'priority': '0'}) + self.assertEqual(project.name, 'Project Test!') + project.write({'priority': '2'}) + self.assertEqual(project.name, 'Project Test!!') + + def test_110_on_archive(self): + active_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.lead_model.id), + ('name', '=', 'active'), + ]) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_archive', + trigger_field_ids=[active_field.id], + filter_domain="[('active', '=', False)]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + lead = self.create_lead() + self.assertEqual(lead.name, 'Lead Test') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'active': True}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test!!') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test!!') + + def test_110_on_unarchive(self): + active_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.lead_model.id), + ('name', '=', 'active'), + ]) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_unarchive', + trigger_field_ids=[active_field.id], + filter_domain="[('active', '=', True)]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + lead = self.create_lead() + self.assertEqual(lead.name, 'Lead Test') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test') + lead.write({'active': True}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'active': True}) + self.assertEqual(lead.name, 'Lead Test!!') + lead.write({'active': True}) + self.assertEqual(lead.name, 'Lead Test!!') + + def test_120_on_change(self): + Model = self.env.get(self.lead_model.model) + lead_name_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.lead_model.id), + ('name', '=', 'name'), + ]) + self.assertEqual(lead_name_field.name in Model._onchange_methods, False) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_change', + on_change_field_ids=[lead_name_field.id], + _actions={'state': 'code', 'code': ""}, + ) + self.assertEqual(lead_name_field.name in Model._onchange_methods, True) + + def test_130_on_unlink(self): + automation = create_automation( + self, + model_id=self.lead_model.id, + trigger='on_unlink', + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + + called_count = 0 + + def _patch(*args, **kwargs): + nonlocal called_count + called_count += 1 + self.assertEqual(args[0], automation) + + patcher = patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._process', _patch) + self.startPatcher(patcher) + + lead = self.create_lead() + self.assertEqual(called_count, 0) + lead.unlink() + self.assertEqual(called_count, 1) + @common.tagged('post_install', '-at_install') class TestCompute(common.TransactionCase): @@ -366,7 +861,7 @@ class TestCompute(common.TransactionCase): ??? and _order is affected ??? a flush will be triggered, forcing the computation of B, based on the previous A. - This happens if a rule has has a non-empty filter_pre_domain, even if + This happens if a rule has a non-empty filter_pre_domain, even if it's an empty list (``'[]'`` as opposed to ``False``). """ company1 = self.env['res.partner'].create({ @@ -386,23 +881,23 @@ class TestCompute(common.TransactionCase): r.parent_id = company2 self.assertEqual(r.display_name, 'Awiclo, Bob') - self.env['base.automation'].create({ - 'name': "test rule", - 'filter_pre_domain': False, - 'trigger': 'on_create_or_write', - 'state': 'code', # no-op action - 'model_id': self.env.ref('base.model_res_partner').id, - }) + create_automation( + self, + model_id=self.env.ref('base.model_res_partner').id, + filter_pre_domain=False, + trigger='on_create_or_write', + _actions={'state': 'code'}, # no-op action + ) r.parent_id = company1 self.assertEqual(r.display_name, 'Gorofy, Bob') - self.env['base.automation'].create({ - 'name': "test rule", - 'filter_pre_domain': '[]', - 'trigger': 'on_create_or_write', - 'state': 'code', # no-op action - 'model_id': self.env.ref('base.model_res_partner').id, - }) + create_automation( + self, + model_id=self.env.ref('base.model_res_partner').id, + filter_pre_domain='[]', + trigger='on_create_or_write', + _actions={'state': 'code'}, # no-op action + ) r.parent_id = company2 self.assertEqual(r.display_name, 'Awiclo, Bob') @@ -410,13 +905,13 @@ class TestCompute(common.TransactionCase): project = self.env['test_base_automation.project'].create({}) # this action is executed every time a task is assigned to project - self.env['base.automation'].create({ - 'name': 'dummy', - 'model_id': self.env['ir.model']._get_id('test_base_automation.task'), - 'state': 'code', - 'trigger': 'on_create_or_write', - 'filter_domain': repr([('project_id', '=', project.id)]), - }) + create_automation( + self, + model_id=self.env.ref('test_base_automation.model_test_base_automation_task').id, + trigger='on_create_or_write', + filter_domain=repr([('project_id', '=', project.id)]), + _actions={'state': 'code'}, # no-op action + ) # create one task in project with 10 subtasks; all the subtasks are # automatically assigned to project, too @@ -425,16 +920,16 @@ class TestCompute(common.TransactionCase): subtasks.flush_model() # This test checks what happens when a stored recursive computed field - # is marked to compute on many records, and automated actions are + # is marked to compute on many records, and automation rules are # triggered depending on that field. In this case, we trigger the # recomputation of 'project_id' on 'subtasks' by deleting their parent # task. # - # An issue occurs when the domain of automated actions is evaluated by + # An issue occurs when the domain of automation rules is evaluated by # method search(), because the latter flushes the fields to search on, # which are also the ones being recomputed. Combined with the fact # that recursive fields are not computed in batch, this leads to a huge - # amount of recursive calls between the automated action and flush(). + # amount of recursive calls between the automation rule and flush(). # # The execution of task.unlink() looks like this: # - mark 'project_id' to compute on subtasks diff --git a/addons/test_base_automation/tests/test_tour.py b/addons/test_base_automation/tests/test_tour.py new file mode 100644 index 00000000000..493bd8ce2fb --- /dev/null +++ b/addons/test_base_automation/tests/test_tour.py @@ -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": """ + + + +
+
+ +
+
+
+
+
+ """, + } + ) + 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", + ) diff --git a/addons/website_forum/data/forum_post_demo.xml b/addons/website_forum/data/forum_post_demo.xml index 3f6d4e07f38..17aff8e6776 100644 --- a/addons/website_forum/data/forum_post_demo.xml +++ b/addons/website_forum/data/forum_post_demo.xml @@ -30,9 +30,9 @@ In simple words does Odoo became CMS+ERP platform?

]]>
Re: How to configure alerts for employee contract expiration 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.

    -
  • 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. +
  • 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.
  • 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.
  • -
  • 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.
  • +
  • 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.
  • Step 4. To test, set a contract to expire tomorrow under one of your fleets vehicles. Then Save it.
  • 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.
]]>
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index 08ca68ae3be..7be79b57d75 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -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: diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py index f548b8e1065..41f86091f8a 100644 --- a/odoo/addons/base/models/ir_actions.py +++ b/odoo/addons/base/models/ir_actions.py @@ -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. 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 diff --git a/odoo/addons/base/security/ir.model.access.csv b/odoo/addons/base/security/ir.model.access.csv index 0c58d792446..49b93a2d352 100644 --- a/odoo/addons/base/security/ir.model.access.csv +++ b/odoo/addons/base/security/ir.model.access.csv @@ -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 diff --git a/odoo/addons/base/tests/test_ir_actions.py b/odoo/addons/base/tests/test_ir_actions.py index 7445cd49489..7f9efd23638 100644 --- a/odoo/addons/base/tests/test_ir_actions.py +++ b/odoo/addons/base/tests/test_ir_actions.py @@ -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 diff --git a/odoo/addons/base/views/ir_actions_views.xml b/odoo/addons/base/views/ir_actions_views.xml index 3643267227b..a92e825344d 100644 --- a/odoo/addons/base/views/ir_actions_views.xml +++ b/odoo/addons/base/views/ir_actions_views.xml @@ -325,23 +325,35 @@ help="Run this action manually."/> -
-
+

+ +

- - - - + + + + + + + + + + + + + + + + + Available variables: +
    +
  • env: environment on which the action is triggered
  • +
  • model: model of the record on which the action is triggered; is a void recordset
  • +
  • record: record on which the action is triggered
  • +
  • records: recordset of all records on which the action is triggered in multi mode
  • +
  • time, datetime, dateutil, timezone: useful Python libraries
  • +
  • float_compare(): utility function to compare floats based on a specific precision
  • +
+
- -

- Please set the Model to Create before choosing values -

- - - - - - - - -
- - - - -

@@ -411,10 +408,10 @@

Example of Python code

- -partner_name = record.name + '_code' \n +
+partner_name = record.name + '_code'
 env['res.partner'].create({'name': partner_name})
-
+
@@ -428,7 +425,6 @@ env['res.partner'].create({'name': partner_name}) ir.actions.server -