[FIX] base_action_rule: typo when committing base action rule
[odoo/odoo.git] / addons / base_action_rule / base_action_rule.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 from datetime import datetime, timedelta
23 import time
24 import logging
25
26 import openerp
27 from openerp import SUPERUSER_ID
28 from openerp.osv import fields, osv
29 from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT
30
31 _logger = logging.getLogger(__name__)
32
33 DATE_RANGE_FUNCTION = {
34     'minutes': lambda interval: timedelta(minutes=interval),
35     'hour': lambda interval: timedelta(hours=interval),
36     'day': lambda interval: timedelta(days=interval),
37     'month': lambda interval: timedelta(months=interval),
38     False: lambda interval: timedelta(0),
39 }
40
41 def get_datetime(date_str):
42     '''Return a datetime from a date string or a datetime string'''
43     # complete date time if date_str contains only a date
44     if ' ' not in date_str:
45         date_str = date_str + " 00:00:00"
46     return datetime.strptime(date_str, DEFAULT_SERVER_DATETIME_FORMAT)
47
48
49 class base_action_rule(osv.osv):
50     """ Base Action Rules """
51
52     _name = 'base.action.rule'
53     _description = 'Action Rules'
54     _order = 'sequence'
55
56     _columns = {
57         'name':  fields.char('Rule Name', required=True),
58         'model_id': fields.many2one('ir.model', 'Related Document Model',
59             required=True, domain=[('osv_memory', '=', False)]),
60         'model': fields.related('model_id', 'model', type="char", string='Model'),
61         'create_date': fields.datetime('Create Date', readonly=1),
62         'active': fields.boolean('Active',
63             help="When unchecked, the rule is hidden and will not be executed."),
64         'sequence': fields.integer('Sequence',
65             help="Gives the sequence order when displaying a list of rules."),
66         'kind': fields.selection(
67             [('on_create', 'On Creation'),
68              ('on_write', 'On Update'),
69              ('on_create_or_write', 'On Creation & Update'),
70              ('on_time', 'Based on Timed Condition')],
71             string='When to Run'),
72         'trg_date_id': fields.many2one('ir.model.fields', string='Trigger Date',
73             help="When should the condition be triggered. If present, will be checked by the scheduler. If empty, will be checked at creation and update.",
74             domain="[('model_id', '=', model_id), ('ttype', 'in', ('date', 'datetime'))]"),
75         'trg_date_range': fields.integer('Delay after trigger date',
76             help="Delay after the trigger date." \
77             "You can put a negative number if you need a delay before the" \
78             "trigger date, like sending a reminder 15 minutes before a meeting."),
79         'trg_date_range_type': fields.selection([('minutes', 'Minutes'), ('hour', 'Hours'),
80                                 ('day', 'Days'), ('month', 'Months')], 'Delay type'),
81         'trg_date_calendar_id': fields.many2one(
82             'resource.calendar', 'Use Calendar',
83             help='When calculating a day-based timed condition, it is possible to use a calendar to compute the date based on working days.',
84             ondelete='set null',
85         ),
86         'act_user_id': fields.many2one('res.users', 'Set Responsible'),
87         'act_followers': fields.many2many("res.partner", string="Add Followers"),
88         'server_action_ids': fields.many2many('ir.actions.server', string='Server Actions',
89             domain="[('model_id', '=', model_id)]",
90             help="Examples: email reminders, call object service, etc."),
91         'filter_pre_id': fields.many2one(
92             'ir.filters', string='Before Update Filter',
93             ondelete='restrict', domain="[('model_id', '=', model_id.model)]",
94             help="If present, this condition must be satisfied before the update of the record."),
95         'filter_pre_domain': fields.char(string='Before Update Domain', help="If present, this condition must be satisfied before the update of the record."),
96         'filter_id': fields.many2one(
97             'ir.filters', string='Filter',
98             ondelete='restrict', domain="[('model_id', '=', model_id.model)]",
99             help="If present, this condition must be satisfied before executing the action rule."),
100         'filter_domain': fields.char(string='Domain', help="If present, this condition must be satisfied before executing the action rule."),
101         'last_run': fields.datetime('Last Run', readonly=1, copy=False),
102     }
103
104     _defaults = {
105         'active': True,
106         'trg_date_range_type': 'day',
107     }
108
109     def onchange_kind(self, cr, uid, ids, kind, context=None):
110         clear_fields = []
111         if kind in ['on_create', 'on_create_or_write']:
112             clear_fields = ['filter_pre_id', 'trg_date_id', 'trg_date_range', 'trg_date_range_type']
113         elif kind in ['on_write', 'on_create_or_write']:
114             clear_fields = ['trg_date_id', 'trg_date_range', 'trg_date_range_type']
115         elif kind == 'on_time':
116             clear_fields = ['filter_pre_id']
117         return {'value': dict.fromkeys(clear_fields, False)}
118
119     def onchange_filter_pre_id(self, cr, uid, ids, filter_pre_id, context=None):
120         ir_filter = self.pool['ir.filters'].browse(cr, uid, filter_pre_id, context=context)
121         return {'value': {'filter_pre_domain': ir_filter.domain}}
122
123     def onchange_filter_id(self, cr, uid, ids, filter_id, context=None):
124         ir_filter = self.pool['ir.filters'].browse(cr, uid, filter_id, context=context)
125         return {'value': {'filter_domain': ir_filter.domain}}
126
127     def _filter(self, cr, uid, action, action_filter, record_ids, domain=False, context=None):
128         """ Filter the list record_ids that satisfy the domain or the action filter. """
129         if record_ids and (domain is not False or action_filter):
130             if domain is not False:
131                 new_domain = [('id', 'in', record_ids)] + eval(domain)
132                 ctx = context
133             elif action_filter:
134                 assert action.model == action_filter.model_id, "Filter model different from action rule model"
135                 new_domain = [('id', 'in', record_ids)] + eval(action_filter.domain)
136                 ctx = dict(context or {})
137                 ctx.update(eval(action_filter.context))
138             record_ids = self.pool[action.model].search(cr, uid, new_domain, context=ctx)
139         return record_ids
140
141     def _process(self, cr, uid, action, record_ids, context=None):
142         """ process the given action on the records """
143         model = self.pool[action.model_id.model]
144         # modify records
145         values = {}
146         if 'date_action_last' in model._all_columns:
147             values['date_action_last'] = time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
148         if action.act_user_id and 'user_id' in model._all_columns:
149             values['user_id'] = action.act_user_id.id
150         if values:
151             model.write(cr, uid, record_ids, values, context=context)
152
153         if action.act_followers and hasattr(model, 'message_subscribe'):
154             follower_ids = map(int, action.act_followers)
155             model.message_subscribe(cr, uid, record_ids, follower_ids, context=context)
156
157         # execute server actions
158         if action.server_action_ids:
159             server_action_ids = map(int, action.server_action_ids)
160             for record in model.browse(cr, uid, record_ids, context):
161                 action_server_obj = self.pool.get('ir.actions.server')
162                 ctx = dict(context, active_model=model._name, active_ids=[record.id], active_id=record.id)
163                 action_server_obj.run(cr, uid, server_action_ids, context=ctx)
164
165         return True
166
167     def _register_hook(self, cr, ids=None):
168         """ Wrap the methods `create` and `write` of the models specified by
169             the rules given by `ids` (or all existing rules if `ids` is `None`.)
170         """
171         #
172         # Note: the patched methods create and write must be defined inside
173         # another function, otherwise their closure may be wrong. For instance,
174         # the function create refers to the outer variable 'create', which you
175         # expect to be bound to create itself. But that expectation is wrong if
176         # create is defined inside a loop; in that case, the variable 'create'
177         # is bound to the last function defined by the loop.
178         #
179
180         def make_create():
181             """ instanciate a create method that processes action rules """
182             def create(self, cr, uid, vals, context=None, **kwargs):
183                 # avoid loops or cascading actions
184                 if context and context.get('action'):
185                     return create.origin(self, cr, uid, vals, context=context)
186
187                 # call original method with a modified context
188                 context = dict(context or {}, action=True)
189                 new_id = create.origin(self, cr, uid, vals, context=context, **kwargs)
190
191                 # as it is a new record, we do not consider the actions that have a prefilter
192                 action_model = self.pool.get('base.action.rule')
193                 action_dom = [('model', '=', self._name),
194                               ('kind', 'in', ['on_create', 'on_create_or_write'])]
195                 action_ids = action_model.search(cr, uid, action_dom, context=context)
196
197                 # check postconditions, and execute actions on the records that satisfy them
198                 for action in action_model.browse(cr, uid, action_ids, context=context):
199                     if action_model._filter(cr, uid, action, action.filter_id, [new_id], domain=action.filter_domain, context=context):
200                         action_model._process(cr, uid, action, [new_id], context=context)
201                 return new_id
202
203             return create
204
205         def make_write():
206             """ instanciate a write method that processes action rules """
207             def write(self, cr, uid, ids, vals, context=None, **kwargs):
208                 # avoid loops or cascading actions
209                 if context and context.get('action'):
210                     return write.origin(self, cr, uid, ids, vals, context=context)
211
212                 # modify context
213                 context = dict(context or {}, action=True)
214                 ids = [ids] if isinstance(ids, (int, long, str)) else ids
215
216                 # retrieve the action rules to possibly execute
217                 action_model = self.pool.get('base.action.rule')
218                 action_dom = [('model', '=', self._name),
219                               ('kind', 'in', ['on_write', 'on_create_or_write'])]
220                 action_ids = action_model.search(cr, uid, action_dom, context=context)
221                 actions = action_model.browse(cr, uid, action_ids, context=context)
222
223                 # check preconditions
224                 pre_ids = {}
225                 for action in actions:
226                     pre_ids[action] = action_model._filter(cr, uid, action, action.filter_pre_id, ids, domain=action.filter_pre_domain, context=context)
227
228                 # call original method
229                 write.origin(self, cr, uid, ids, vals, context=context, **kwargs)
230
231                 # check postconditions, and execute actions on the records that satisfy them
232                 for action in actions:
233                     post_ids = action_model._filter(cr, uid, action, action.filter_id, pre_ids[action], domain=action.filter_domain, context=context)
234                     if post_ids:
235                         action_model._process(cr, uid, action, post_ids, context=context)
236                 return True
237
238             return write
239
240         updated = False
241         if ids is None:
242             ids = self.search(cr, SUPERUSER_ID, [])
243         for action_rule in self.browse(cr, SUPERUSER_ID, ids):
244             model = action_rule.model_id.model
245             model_obj = self.pool[model]
246             if not hasattr(model_obj, 'base_action_ruled'):
247                 # monkey-patch methods create and write
248                 model_obj._patch_method('create', make_create())
249                 model_obj._patch_method('write', make_write())
250                 model_obj.base_action_ruled = True
251                 updated = True
252
253         return updated
254
255     def _update_cron(self, cr, uid, context=None):
256         try:
257             cron = self.pool['ir.model.data'].get_object(
258                 cr, uid, 'base_action_rule', 'ir_cron_crm_action', context=context)
259         except ValueError:
260             return False
261
262         return cron.toggle(model=self._name, domain=[('kind', '=', 'on_time')])
263
264     def create(self, cr, uid, vals, context=None):
265         res_id = super(base_action_rule, self).create(cr, uid, vals, context=context)
266         if self._register_hook(cr, [res_id]):
267             openerp.modules.registry.RegistryManager.signal_registry_change(cr.dbname)
268         self._update_cron(cr, uid, context=context)
269         return res_id
270
271     def write(self, cr, uid, ids, vals, context=None):
272         if isinstance(ids, (int, long)):
273             ids = [ids]
274         super(base_action_rule, self).write(cr, uid, ids, vals, context=context)
275         if self._register_hook(cr, ids):
276             openerp.modules.registry.RegistryManager.signal_registry_change(cr.dbname)
277         self._update_cron(cr, uid, context=context)
278         return True
279
280     def unlink(self, cr, uid, ids, context=None):
281         res = super(base_action_rule, self).unlink(cr, uid, ids, context=context)
282         self._update_cron(cr, uid, context=context)
283         return res
284
285     def onchange_model_id(self, cr, uid, ids, model_id, context=None):
286         data = {'model': False, 'filter_pre_id': False, 'filter_id': False}
287         if model_id:
288             model = self.pool.get('ir.model').browse(cr, uid, model_id, context=context)
289             data.update({'model': model.model})
290         return {'value': data}
291
292     def _check_delay(self, cr, uid, action, record, record_dt, context=None):
293         if action.trg_date_calendar_id and action.trg_date_range_type == 'day':
294             start_dt = get_datetime(record_dt)
295             action_dt = self.pool['resource.calendar'].schedule_days_get_date(
296                 cr, uid, action.trg_date_calendar_id.id, action.trg_date_range,
297                 day_date=start_dt, compute_leaves=True, context=context
298             )
299         else:
300             delay = DATE_RANGE_FUNCTION[action.trg_date_range_type](action.trg_date_range)
301             action_dt = get_datetime(record_dt) + delay
302         return action_dt
303
304     def _check(self, cr, uid, automatic=False, use_new_cursor=False, context=None):
305         """ This Function is called by scheduler. """
306         context = context or {}
307         # retrieve all the action rules to run based on a timed condition
308         action_dom = [('kind', '=', 'on_time')]
309         action_ids = self.search(cr, uid, action_dom, context=context)
310         for action in self.browse(cr, uid, action_ids, context=context):
311             now = datetime.now()
312             if action.last_run:
313                 last_run = get_datetime(action.last_run)
314             else:
315                 last_run = datetime.utcfromtimestamp(0)
316             # retrieve all the records that satisfy the action's condition
317             model = self.pool[action.model_id.model]
318             domain = []
319             ctx = dict(context)
320             if action.filter_domain is not False:
321                 domain = eval(action.filter_domain)
322             elif action.filter_id:
323                 domain = eval(action.filter_id.domain)
324                 ctx.update(eval(action.filter_id.context))
325                 if 'lang' not in ctx:
326                     # Filters might be language-sensitive, attempt to reuse creator lang
327                     # as we are usually running this as super-user in background
328                     [filter_meta] = action.filter_id.get_metadata()
329                     user_id = filter_meta['write_uid'] and filter_meta['write_uid'][0] or \
330                                     filter_meta['create_uid'][0]
331                     ctx['lang'] = self.pool['res.users'].browse(cr, uid, user_id).lang
332             record_ids = model.search(cr, uid, domain, context=ctx)
333
334             # determine when action should occur for the records
335             date_field = action.trg_date_id.name
336             if date_field == 'date_action_last' and 'create_date' in model._all_columns:
337                 get_record_dt = lambda record: record[date_field] or record.create_date
338             else:
339                 get_record_dt = lambda record: record[date_field]
340
341             # process action on the records that should be executed
342             for record in model.browse(cr, uid, record_ids, context=context):
343                 record_dt = get_record_dt(record)
344                 if not record_dt:
345                     continue
346                 action_dt = self._check_delay(cr, uid, action, record, record_dt, context=context)
347                 if last_run <= action_dt < now:
348                     try:
349                         context = dict(context or {}, action=True)
350                         self._process(cr, uid, action, [record.id], context=context)
351                     except Exception:
352                         import traceback
353                         _logger.error(traceback.format_exc())
354
355             action.write({'last_run': now.strftime(DEFAULT_SERVER_DATETIME_FORMAT)})
356
357             if automatic:
358                 # auto-commit for batch processing
359                 cr.commit()