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