1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
|
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
import logging
import traceback
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models, SUPERUSER_ID
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
from odoo.tools import safe_eval
_logger = logging.getLogger(__name__)
DATE_RANGE_FUNCTION = {
'minutes': lambda interval: relativedelta(minutes=interval),
'hour': lambda interval: relativedelta(hours=interval),
'day': lambda interval: relativedelta(days=interval),
'month': lambda interval: relativedelta(months=interval),
False: lambda interval: relativedelta(0),
}
DATE_RANGE_FACTOR = {
'minutes': 1,
'hour': 60,
'day': 24 * 60,
'month': 30 * 24 * 60,
False: 0,
}
class BaseAutomation(models.Model):
_name = 'base.automation'
_description = 'Automated Action'
_order = 'sequence'
action_server_id = fields.Many2one(
'ir.actions.server', 'Server Actions',
domain="[('model_id', '=', model_id)]",
delegate=True, required=True, ondelete='restrict')
active = fields.Boolean(default=True, help="When unchecked, the rule is hidden and will not be executed.")
trigger = fields.Selection([
('on_create', 'On Creation'),
('on_write', 'On Update'),
('on_create_or_write', 'On Creation & Update'),
('on_unlink', 'On Deletion'),
('on_change', 'Based on Form Modification'),
('on_time', 'Based on Timed Condition')
], string='Trigger', required=True)
trg_date_id = fields.Many2one('ir.model.fields', string='Trigger Date',
help="""When should the condition be triggered.
If present, will be checked by the scheduler. If empty, will be checked at creation and update.""",
domain="[('model_id', '=', model_id), ('ttype', 'in', ('date', 'datetime'))]")
trg_date_range = fields.Integer(string='Delay after trigger date',
help="""Delay after the trigger date.
You can put a negative number if you need a delay before the
trigger date, like sending a reminder 15 minutes before a meeting.""")
trg_date_range_type = fields.Selection([('minutes', 'Minutes'), ('hour', 'Hours'), ('day', 'Days'), ('month', 'Months')],
string='Delay type', default='hour')
trg_date_calendar_id = fields.Many2one("resource.calendar", string='Use Calendar',
help="When calculating a day-based timed condition, it is possible to use a calendar to compute the date based on working days.")
filter_pre_domain = fields.Char(string='Before Update Domain',
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.")
last_run = fields.Datetime(readonly=True, copy=False)
on_change_field_ids = fields.Many2many(
"ir.model.fields",
relation="base_automation_onchange_fields_rel",
string="On Change Fields Trigger",
help="Fields that trigger the onchange.",
)
trigger_field_ids = fields.Many2many('ir.model.fields', string='Trigger Fields',
help="The action will be triggered if and only if one of these fields is updated."
"If empty, all fields are watched.")
least_delay_msg = fields.Char(compute='_compute_least_delay_msg')
# which fields have an impact on the registry and the cron
CRITICAL_FIELDS = ['model_id', 'active', 'trigger', 'on_change_field_ids']
RANGE_FIELDS = ['trg_date_range', 'trg_date_range_type']
@api.onchange('model_id')
def onchange_model_id(self):
self.model_name = self.model_id.model
@api.onchange('trigger')
def onchange_trigger(self):
if self.trigger in ['on_create', 'on_create_or_write', 'on_unlink']:
self.filter_pre_domain = self.trg_date_id = self.trg_date_range = self.trg_date_range_type = False
elif self.trigger in ['on_write', 'on_create_or_write']:
self.trg_date_id = self.trg_date_range = self.trg_date_range_type = False
elif self.trigger == 'on_time':
self.filter_pre_domain = False
self.trg_date_range_type = 'hour'
@api.onchange('trigger', 'state')
def _onchange_state(self):
if self.trigger == 'on_change' and self.state != 'code':
ff = self.fields_get(['trigger', 'state'])
return {'warning': {
'title': _("Warning"),
'message': _("The \"%(trigger_value)s\" %(trigger_label)s can only be used with the \"%(state_value)s\" action type") % {
'trigger_value': dict(ff['trigger']['selection'])['on_change'],
'trigger_label': ff['trigger']['string'],
'state_value': dict(ff['state']['selection'])['code'],
}
}}
MAIL_STATES = ('email', 'followers', 'next_activity')
if self.trigger == 'on_unlink' and self.state in MAIL_STATES:
return {'warning': {
'title': _("Warning"),
'message': _(
"You cannot send an email, add followers or create an activity "
"for a deleted record. It simply does not work."
),
}}
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
vals['usage'] = 'base_automation'
base_automations = super(BaseAutomation, self).create(vals_list)
self._update_cron()
self._update_registry()
return base_automations
def write(self, vals):
res = super(BaseAutomation, self).write(vals)
if set(vals).intersection(self.CRITICAL_FIELDS):
self._update_cron()
self._update_registry()
elif set(vals).intersection(self.RANGE_FIELDS):
self._update_cron()
return res
def unlink(self):
res = super(BaseAutomation, self).unlink()
self._update_cron()
self._update_registry()
return res
def _update_cron(self):
""" Activate the cron job depending on whether there exists action rules
based on time conditions. Also update its frequency according to
the smallest action delay, or restore the default 4 hours if there
is no time based action.
"""
cron = self.env.ref('base_automation.ir_cron_data_base_automation_check', raise_if_not_found=False)
if cron:
actions = self.with_context(active_test=True).search([('trigger', '=', 'on_time')])
cron.try_write({
'active': bool(actions),
'interval_type': 'minutes',
'interval_number': self._get_cron_interval(actions),
})
def _update_registry(self):
""" Update the registry after a modification on action rules. """
if self.env.registry.ready and not self.env.context.get('import_file'):
# re-install the model patches, and notify other workers
self._unregister_hook()
self._register_hook()
self.env.registry.registry_invalidated = True
def _get_actions(self, records, triggers):
""" Return the actions of the given triggers for records' model. The
returned actions' context contain an object to manage processing.
"""
if '__action_done' not in self._context:
self = self.with_context(__action_done={})
domain = [('model_name', '=', records._name), ('trigger', 'in', triggers)]
actions = self.with_context(active_test=True).sudo().search(domain)
return actions.with_env(self.env)
def _get_eval_context(self):
""" Prepare the context used when evaluating python code
:returns: dict -- evaluation context given to safe_eval
"""
return {
'datetime': safe_eval.datetime,
'dateutil': safe_eval.dateutil,
'time': safe_eval.time,
'uid': self.env.uid,
'user': self.env.user,
}
def _get_cron_interval(self, actions=None):
""" Return the expected time interval used by the cron, in minutes. """
def get_delay(rec):
return rec.trg_date_range * DATE_RANGE_FACTOR[rec.trg_date_range_type]
if actions is None:
actions = self.with_context(active_test=True).search([('trigger', '=', 'on_time')])
# Minimum 1 minute, maximum 4 hours, 10% tolerance
delay = min(actions.mapped(get_delay), default=0)
return min(max(1, delay // 10), 4 * 60) if delay else 4 * 60
def _compute_least_delay_msg(self):
msg = _("Note that this action can be trigged up to %d minutes after its schedule.")
self.least_delay_msg = msg % self._get_cron_interval()
def _filter_pre(self, records):
""" Filter the records that satisfy the precondition of action ``self``. """
self_sudo = self.sudo()
if self_sudo.filter_pre_domain and records:
domain = safe_eval.safe_eval(self_sudo.filter_pre_domain, self._get_eval_context())
return records.sudo().filtered_domain(domain).with_env(records.env)
else:
return records
def _filter_post(self, records):
return self._filter_post_export_domain(records)[0]
def _filter_post_export_domain(self, records):
""" Filter the records that satisfy the postcondition of action ``self``. """
self_sudo = self.sudo()
if self_sudo.filter_domain and records:
domain = [('id', 'in', records.ids)] + safe_eval.safe_eval(self_sudo.filter_domain, self._get_eval_context())
return records.sudo().search(domain).with_env(records.env), domain
else:
return records, None
@api.model
def _add_postmortem_action(self, e):
if self.user_has_groups('base.group_user'):
e.context = {}
e.context['exception_class'] = 'base_automation'
e.context['base_automation'] = {
'id': self.id,
'name': self.name,
}
def _process(self, records, domain_post=None):
""" Process action ``self`` on the ``records`` that have not been done yet. """
# filter out the records on which self has already been done
action_done = self._context['__action_done']
records_done = action_done.get(self, records.browse())
records -= records_done
if not records:
return
# mark the remaining records as done (to avoid recursive processing)
action_done = dict(action_done)
action_done[self] = records_done + records
self = self.with_context(__action_done=action_done)
records = records.with_context(__action_done=action_done)
# modify records
values = {}
if 'date_action_last' in records._fields:
values['date_action_last'] = fields.Datetime.now()
if values:
records.write(values)
# execute server actions
if self.action_server_id:
for record in records:
# we process the action if any watched field has been modified
if self._check_trigger_fields(record):
ctx = {
'active_model': record._name,
'active_ids': record.ids,
'active_id': record.id,
'domain_post': domain_post,
}
try:
self.action_server_id.sudo().with_context(**ctx).run()
except Exception as e:
self._add_postmortem_action(e)
raise e
def _check_trigger_fields(self, record):
""" Return whether any of the trigger fields has been modified on ``record``. """
self_sudo = self.sudo()
if not self_sudo.trigger_field_ids:
# all fields are implicit triggers
return True
if not self._context.get('old_values'):
# this is a create: all fields are considered modified
return True
# Note: old_vals are in the format of read()
old_vals = self._context['old_values'].get(record.id, {})
def differ(name):
field = record._fields[name]
return (
name in old_vals and
field.convert_to_cache(record[name], record, validate=False) !=
field.convert_to_cache(old_vals[name], record, validate=False)
)
return any(differ(field.name) for field in self_sudo.trigger_field_ids)
def _register_hook(self):
""" Patch models that should trigger action rules based on creation,
modification, deletion of records and form onchanges.
"""
#
# Note: the patched methods must be defined inside another function,
# otherwise their closure may be wrong. For instance, the function
# create refers to the outer variable 'create', which you expect to be
# bound to create itself. But that expectation is wrong if create is
# defined inside a loop; in that case, the variable 'create' is bound to
# the last function defined by the loop.
#
def make_create():
""" Instanciate a create method that processes action rules. """
@api.model_create_multi
def create(self, vals_list, **kw):
# retrieve the action rules to possibly execute
actions = self.env['base.automation']._get_actions(self, ['on_create', 'on_create_or_write'])
if not actions:
return create.origin(self, vals_list, **kw)
# call original method
records = create.origin(self.with_env(actions.env), vals_list, **kw)
# check postconditions, and execute actions on the records that satisfy them
for action in actions.with_context(old_values=None):
action._process(action._filter_post(records))
return records.with_env(self.env)
return create
def make_write():
""" Instanciate a write method that processes action rules. """
def write(self, vals, **kw):
# retrieve the action rules to possibly execute
actions = self.env['base.automation']._get_actions(self, ['on_write', 'on_create_or_write'])
if not (actions and self):
return write.origin(self, vals, **kw)
records = self.with_env(actions.env).filtered('id')
# check preconditions on records
pre = {action: action._filter_pre(records) for action in actions}
# read old values before the update
old_values = {
old_vals.pop('id'): old_vals
for old_vals in (records.read(list(vals)) if vals else [])
}
# call original method
write.origin(records, vals, **kw)
# check postconditions, and execute actions on the records that satisfy them
for action in actions.with_context(old_values=old_values):
records, domain_post = action._filter_post_export_domain(pre[action])
action._process(records, domain_post=domain_post)
return True
return write
def make_compute_field_value():
""" Instanciate a compute_field_value method that processes action rules. """
#
# Note: This is to catch updates made by field recomputations.
#
def _compute_field_value(self, field):
# determine fields that may trigger an action
stored_fields = [f for f in self.pool.field_computed[field] if f.store]
if not any(stored_fields):
return _compute_field_value.origin(self, field)
# retrieve the action rules to possibly execute
actions = self.env['base.automation']._get_actions(self, ['on_write', 'on_create_or_write'])
records = self.filtered('id').with_env(actions.env)
if not (actions and records):
_compute_field_value.origin(self, field)
return True
# check preconditions on records
pre = {action: action._filter_pre(records) for action in actions}
# read old values before the update
old_values = {
old_vals.pop('id'): old_vals
for old_vals in (records.read([f.name for f in stored_fields]))
}
# call original method
_compute_field_value.origin(self, field)
# check postconditions, and execute actions on the records that satisfy them
for action in actions.with_context(old_values=old_values):
records, domain_post = action._filter_post_export_domain(pre[action])
action._process(records, domain_post=domain_post)
return True
return _compute_field_value
def make_unlink():
""" Instanciate an unlink method that processes action rules. """
def unlink(self, **kwargs):
# retrieve the action rules to possibly execute
actions = self.env['base.automation']._get_actions(self, ['on_unlink'])
records = self.with_env(actions.env)
# check conditions, and execute actions on the records that satisfy them
for action in actions:
action._process(action._filter_post(records))
# call original method
return unlink.origin(self, **kwargs)
return unlink
def make_onchange(action_rule_id):
""" Instanciate an onchange method for the given action rule. """
def base_automation_onchange(self):
action_rule = self.env['base.automation'].browse(action_rule_id)
result = {}
server_action = action_rule.sudo().action_server_id.with_context(
active_model=self._name,
active_id=self._origin.id,
active_ids=self._origin.ids,
onchange_self=self,
)
try:
res = server_action.run()
except Exception as e:
action_rule._add_postmortem_action(e)
raise e
if res:
if 'value' in res:
res['value'].pop('id', None)
self.update({key: val for key, val in res['value'].items() if key in self._fields})
if 'domain' in res:
result.setdefault('domain', {}).update(res['domain'])
if 'warning' in res:
result['warning'] = res['warning']
return result
return base_automation_onchange
patched_models = defaultdict(set)
def patch(model, name, method):
""" Patch method `name` on `model`, unless it has been patched already. """
if model not in patched_models[name]:
patched_models[name].add(model)
model._patch_method(name, method)
# retrieve all actions, and patch their corresponding model
for action_rule in self.with_context({}).search([]):
Model = self.env.get(action_rule.model_name)
# Do not crash if the model of the base_action_rule was uninstalled
if Model is None:
_logger.warning("Action rule with ID %d depends on model %s" %
(action_rule.id,
action_rule.model_name))
continue
if action_rule.trigger == 'on_create':
patch(Model, 'create', make_create())
elif action_rule.trigger == 'on_create_or_write':
patch(Model, 'create', make_create())
patch(Model, 'write', make_write())
patch(Model, '_compute_field_value', make_compute_field_value())
elif action_rule.trigger == 'on_write':
patch(Model, 'write', make_write())
patch(Model, '_compute_field_value', make_compute_field_value())
elif action_rule.trigger == 'on_unlink':
patch(Model, 'unlink', make_unlink())
elif action_rule.trigger == 'on_change':
# register an onchange method for the action_rule
method = make_onchange(action_rule.id)
for field in action_rule.on_change_field_ids:
Model._onchange_methods[field.name].append(method)
def _unregister_hook(self):
""" Remove the patches installed by _register_hook() """
NAMES = ['create', 'write', '_compute_field_value', 'unlink', '_onchange_methods']
for Model in self.env.registry.values():
for name in NAMES:
try:
delattr(Model, name)
except AttributeError:
pass
@api.model
def _check_delay(self, action, record, record_dt):
if action.trg_date_calendar_id and action.trg_date_range_type == 'day':
return action.trg_date_calendar_id.plan_days(
action.trg_date_range,
fields.Datetime.from_string(record_dt),
compute_leaves=True,
)
else:
delay = DATE_RANGE_FUNCTION[action.trg_date_range_type](action.trg_date_range)
return fields.Datetime.from_string(record_dt) + delay
@api.model
def _check(self, automatic=False, use_new_cursor=False):
""" This Function is called by scheduler. """
if '__action_done' not in self._context:
self = self.with_context(__action_done={})
# retrieve all the action rules to run based on a timed condition
eval_context = self._get_eval_context()
for action in self.with_context(active_test=True).search([('trigger', '=', 'on_time')]):
_logger.info("Starting time-based automated action `%s`.", action.name)
last_run = fields.Datetime.from_string(action.last_run) or datetime.datetime.utcfromtimestamp(0)
# retrieve all the records that satisfy the action's condition
domain = []
context = dict(self._context)
if action.filter_domain:
domain = safe_eval.safe_eval(action.filter_domain, eval_context)
records = self.env[action.model_name].with_context(context).search(domain)
# determine when action should occur for the records
if action.trg_date_id.name == 'date_action_last' and 'create_date' in records._fields:
get_record_dt = lambda record: record[action.trg_date_id.name] or record.create_date
else:
get_record_dt = lambda record: record[action.trg_date_id.name]
# process action on the records that should be executed
now = datetime.datetime.now()
for record in records:
record_dt = get_record_dt(record)
if not record_dt:
continue
action_dt = self._check_delay(action, record, record_dt)
if last_run <= action_dt < now:
try:
action._process(record)
except Exception:
_logger.error(traceback.format_exc())
action.write({'last_run': now.strftime(DEFAULT_SERVER_DATETIME_FORMAT)})
_logger.info("Time-based automated action `%s` done.", action.name)
if automatic:
# auto-commit for batch processing
self._cr.commit()
|