[MERGE] lp:~xrg/openobject-addons/trunk-patch18
[odoo/odoo.git] / addons / marketing_campaign / marketing_campaign.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 OpenERP SA (<http://openerp.com>).
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 import time
23 import base64
24 from datetime import datetime
25 from dateutil.relativedelta import relativedelta
26 from operator import itemgetter
27 from traceback import format_exception
28 from sys import exc_info
29 from tools.safe_eval import safe_eval as eval
30 import re
31 from decimal_precision import decimal_precision as dp
32
33 from osv import fields, osv
34 import netsvc
35 from tools.translate import _
36
37 _intervalTypes = {
38     'hours': lambda interval: relativedelta(hours=interval),
39     'days': lambda interval: relativedelta(days=interval),
40     'months': lambda interval: relativedelta(months=interval),
41     'years': lambda interval: relativedelta(years=interval),
42 }
43
44 DT_FMT = '%Y-%m-%d %H:%M:%S'
45
46 def dict_map(f, d):
47     return dict((k, f(v)) for k,v in d.items())
48
49 def _find_fieldname(model, field):
50     inherit_columns = dict_map(itemgetter(2), model._inherit_fields)
51     all_columns = dict(inherit_columns, **model._columns)
52     for fn in all_columns:
53         if all_columns[fn] is field:
54             return fn
55     raise ValueError('field not found: %r' % (field,))
56
57 class selection_converter(object):
58     """Format the selection in the browse record objects"""
59     def __init__(self, value):
60         self._value = value
61         self._str = value
62
63     def set_value(self, cr, uid, _self_again, record, field, lang):
64         # this design is terrible
65         # search fieldname from the field
66         fieldname = _find_fieldname(record._table, field)
67         context = dict(lang=lang.code)
68         fg = record._table.fields_get(cr, uid, [fieldname], context=context)
69         selection = dict(fg[fieldname]['selection'])
70         self._str = selection[self.value]
71
72     @property
73     def value(self):
74         return self._value
75
76     def __str__(self):
77         return self._str
78
79 translate_selections = {
80     'selection': selection_converter,
81 }
82
83
84 class marketing_campaign(osv.osv):
85     _name = "marketing.campaign"
86     _description = "Marketing Campaign"
87
88     _columns = {
89         'name': fields.char('Name', size=64, required=True),
90         'object_id': fields.many2one('ir.model', 'Resource', required=True,
91                                       help="Choose the resource on which you want \
92 this campaign to be run"),
93         'partner_field_id': fields.many2one('ir.model.fields', 'Partner Field',
94                                             domain="[('model_id', '=', object_id), ('ttype', '=', 'many2one'), ('relation', '=', 'res.partner')]",
95                                             help="The generated workitems will be linked to the partner related to the record. If the record is the partner itself leave this field empty."),
96         'mode': fields.selection([('test', 'Test Directly'),
97                                 ('test_realtime', 'Test in Realtime'),
98                                 ('manual', 'With Manual Confirmation'),
99                                 ('active', 'Normal')],
100                                  'Mode', required=True, help= \
101 """Test - It creates and process all the activities directly (without waiting for the delay on transitions) but does not send emails or produce reports.
102 Test in Realtime - It creates and processes all the activities directly but does not send emails or produce reports.
103 With Manual Confirmation - the campaigns runs normally, but the user has to validate all workitem manually.
104 Normal - the campaign runs normally and automatically sends all emails and reports (be very careful with this mode, you're live!)"""),
105         'state': fields.selection([('draft', 'Draft'),
106                                    ('running', 'Running'),
107                                    ('done', 'Done'),
108                                    ('cancelled', 'Cancelled'),],
109                                    'State',),
110         'activity_ids': fields.one2many('marketing.campaign.activity',
111                                        'campaign_id', 'Activities'),
112         'fixed_cost': fields.float('Fixed Cost', help="Fixed cost for running this campaign. You may also specify variable cost and revenue on each campaign activity. Cost and Revenue statistics are included in Campaign Reporting.", digits_compute=dp.get_precision('Purchase Price')),
113     }
114
115     _defaults = {
116         'state': lambda *a: 'draft',
117         'mode': lambda *a: 'test',
118     }
119
120     def state_running_set(self, cr, uid, ids, *args):
121         # TODO check that all subcampaigns are running
122         campaign = self.browse(cr, uid, ids[0])
123
124         if not campaign.activity_ids:
125             raise osv.except_osv(_("Error"), _("The campaign cannot be started: there are no activities in it"))
126
127         has_start = False
128         has_signal_without_from = False
129
130         for activity in campaign.activity_ids:
131             if activity.start:
132                 has_start = True
133             if activity.signal and len(activity.from_ids) == 0:
134                 has_signal_without_from = True
135
136             if activity.type != 'email':
137                 continue
138             if not activity.email_template_id.from_account:
139                 raise osv.except_osv(_("Error"), _("The campaign cannot be started: the email account is missing in email activity '%s'")%activity.name)
140             if activity.email_template_id.from_account.state != 'approved':
141                 raise osv.except_osv(_("Error"), _("The campaign cannot be started: the email account is not approved in email activity '%s'")%activity.name)
142
143         if not has_start and not has_signal_without_from:
144             raise osv.except_osv(_("Error"), _("The campaign cannot be started: it doesn't have any starting activity (or any activity with a signal and no previous activity)"))
145
146         return self.write(cr, uid, ids, {'state': 'running'})
147
148     def state_done_set(self, cr, uid, ids, *args):
149         # TODO check that this campaign is not a subcampaign in running mode.
150         segment_ids = self.pool.get('marketing.campaign.segment').search(cr, uid,
151                                             [('campaign_id', 'in', ids),
152                                             ('state', '=', 'running')])
153         if segment_ids :
154             raise osv.except_osv(_("Error"), _("The campaign cannot be marked as done before all segments are done"))
155         self.write(cr, uid, ids, {'state': 'done'})
156         return True
157
158     def state_cancel_set(self, cr, uid, ids, *args):
159         # TODO check that this campaign is not a subcampaign in running mode.
160         self.write(cr, uid, ids, {'state': 'cancelled'})
161         return True
162
163
164     def signal(self, cr, uid, model, res_id, signal, run_existing=True, context=None):
165         record = self.pool.get(model).browse(cr, uid, res_id, context)
166         return self._signal(cr, uid, record, signal, run_existing, context)
167
168     def _signal(self, cr, uid, record, signal, run_existing=True, context=None):
169         if not signal:
170             raise ValueError('signal cannot be False')
171
172         Workitems = self.pool.get('marketing.campaign.workitem')
173         domain = [('object_id.model', '=', record._table._name),
174                   ('state', '=', 'running')]
175         campaign_ids = self.search(cr, uid, domain, context=context)
176         for campaign in self.browse(cr, uid, campaign_ids, context=context):
177             for activity in campaign.activity_ids:
178                 if activity.signal != signal:
179                     continue
180
181                 data = dict(activity_id=activity.id,
182                             res_id=record.id,
183                             state='todo')
184                 wi_domain = [(k, '=', v) for k, v in data.items()]
185
186                 wi_ids = Workitems.search(cr, uid, wi_domain, context=context)
187                 if wi_ids:
188                     if not run_existing:
189                         continue
190                 else:
191                     partner = self._get_partner_for(campaign, record)
192                     if partner:
193                         data['partner_id'] = partner.id
194                     wi_id = Workitems.create(cr, uid, data, context=context)
195                     wi_ids = [wi_id]
196                 Workitems.process(cr, uid, wi_ids, context=context)
197         return True
198
199     def _get_partner_for(self, campaign, record):
200         partner_field = campaign.partner_field_id.name
201         if partner_field:
202             return getattr(record, partner_field)
203         elif campaign.object_id.model == 'res.partner':
204             return record
205         return None
206
207     # prevent duplication until the server properly duplicates several levels of nested o2m
208     def copy(self, cr, uid, id, default=None, context=None):
209         raise osv.except_osv(_("Operation not supported"), _("Sorry, campaign duplication is not supported at the moment."))
210
211 marketing_campaign()
212
213 class marketing_campaign_segment(osv.osv):
214     _name = "marketing.campaign.segment"
215     _description = "Campaign Segment"
216
217     def _get_next_sync(self, cr, uid, ids, fn, args, context=None):
218         # next auto sync date is same for all segments
219         sync_job = self.pool.get('ir.model.data').get_object(cr, uid, 'marketing_campaign', 'ir_cron_marketing_campaign_every_day', context=context)
220         next_sync = sync_job and sync_job.nextcall or False
221         return dict.fromkeys(ids, next_sync)
222
223     _columns = {
224         'name': fields.char('Name', size=64,required=True),
225         'campaign_id': fields.many2one('marketing.campaign', 'Campaign', required=True, select=1, ondelete="cascade"),
226         'object_id': fields.related('campaign_id','object_id', type='many2one', relation='ir.model', string='Resource'),
227         'ir_filter_id': fields.many2one('ir.filters', 'Filter', ondelete="restrict",
228                             help="Filter to select the matching resource records that belong to this segment. New filters can be created and saved using the advanced search on the list view of the Resource. If no filter is set, all records are selected without filtering. The synchronization mode may also add a criterion to the filter."),
229         'sync_last_date': fields.datetime('Last Synchronization', help="Date on which this segment was synchronized last time (automatically or manually)"),
230         'sync_mode': fields.selection([('create_date', 'Only records created after last sync'),
231                                       ('write_date', 'Only records modified after last sync (no duplicates)'),
232                                       ('all', 'All records (no duplicates)')],
233                                       'Synchronization mode',
234                                       help="Determines an additional criterion to add to the filter when selecting new records to inject in the campaign."),
235         'state': fields.selection([('draft', 'Draft'),
236                                    ('running', 'Running'),
237                                    ('done', 'Done'),
238                                    ('cancelled', 'Cancelled')],
239                                    'State',),
240         'date_run': fields.datetime('Launch Date', help="Initial start date of this segment."),
241         'date_done': fields.datetime('End Date', help="Date this segment was last closed or cancelled."),
242         'date_next_sync': fields.function(_get_next_sync, method=True, string='Next Synchronization', type='datetime', help="Next time the synchronization job is scheduled to run automatically"),
243     }
244
245     _defaults = {
246         'state': lambda *a: 'draft',
247         'sync_mode': lambda *a: 'create_date',
248     }
249
250     def _check_model(self, cr, uid, ids, context=None):
251         for obj in self.browse(cr, uid, ids, context=context):
252             if not obj.ir_filter_id:
253                 return True
254             if obj.campaign_id.object_id.model != obj.ir_filter_id.model_id:
255                 return False
256         return True
257
258     _constraints = [
259         (_check_model, 'Model of filter must be same as resource model of Campaign ', ['ir_filter_id,campaign_id']),
260     ]
261
262     def onchange_campaign_id(self, cr, uid, ids, campaign_id):
263         res = {'domain':{'ir_filter_id':[]}}
264         campaign_pool = self.pool.get('marketing.campaign')
265         if campaign_id:
266             campaign = campaign_pool.browse(cr, uid, campaign_id)
267             model_name = self.pool.get('ir.model').read(cr, uid, [campaign.object_id.id], ['model'])
268             if model_name:
269                 mod_name = model_name[0]['model']
270                 res['domain'] = {'ir_filter_id': [('model_id', '=', mod_name)]}
271         else:
272             res['value'] = {'ir_filter_id': False}
273         return res
274
275     def state_running_set(self, cr, uid, ids, *args):
276         segment = self.browse(cr, uid, ids[0])
277         vals = {'state': 'running'}
278         if not segment.date_run:
279             vals['date_run'] = time.strftime('%Y-%m-%d %H:%M:%S')
280         self.write(cr, uid, ids, vals)
281         return True
282
283     def state_done_set(self, cr, uid, ids, *args):
284         wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
285                                 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
286         self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
287         self.write(cr, uid, ids, {'state': 'done','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
288         return True
289
290     def state_cancel_set(self, cr, uid, ids, *args):
291         wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
292                                 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
293         self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
294         self.write(cr, uid, ids, {'state': 'cancelled','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
295         return True
296
297     def synchroniz(self, cr, uid, ids, *args):
298         self.process_segment(cr, uid, ids)
299         return True
300
301     def process_segment(self, cr, uid, segment_ids=None, context=None):
302         Workitems = self.pool.get('marketing.campaign.workitem')
303         if not segment_ids:
304             segment_ids = self.search(cr, uid, [('state', '=', 'running')], context=context)
305
306         action_date = time.strftime('%Y-%m-%d %H:%M:%S')
307         campaigns = set()
308         for segment in self.browse(cr, uid, segment_ids, context=context):
309             if segment.campaign_id.state != 'running':
310                 continue
311
312             campaigns.add(segment.campaign_id.id)
313             act_ids = self.pool.get('marketing.campaign.activity').search(cr,
314                   uid, [('start', '=', True), ('campaign_id', '=', segment.campaign_id.id)], context=context)
315
316             model_obj = self.pool.get(segment.object_id.model)
317             criteria = []
318             if segment.sync_last_date and segment.sync_mode != 'all':
319                 criteria += [(segment.sync_mode, '>', segment.sync_last_date)]
320             if segment.ir_filter_id:
321                 criteria += eval(segment.ir_filter_id.domain)
322             object_ids = model_obj.search(cr, uid, criteria, context=context)
323
324             # XXX TODO: rewrite this loop more efficiently without doing 1 search per record!
325             for o_ids in model_obj.browse(cr, uid, object_ids, context=context):
326                 # avoid duplicated workitem for the same resource
327                 if segment.sync_mode in ('write_date','all'):
328                     wi_ids = Workitems.search(cr, uid, [('res_id','=',o_ids.id),('segment_id','=',segment.id)], context=context)
329                     if wi_ids:
330                         continue
331
332                 wi_vals = {
333                     'segment_id': segment.id,
334                     'date': action_date,
335                     'state': 'todo',
336                     'res_id': o_ids.id
337                 }
338
339                 partner = self.pool.get('marketing.campaign')._get_partner_for(segment.campaign_id, o_ids)
340                 if partner:
341                     wi_vals['partner_id'] = partner.id
342
343                 for act_id in act_ids:
344                     wi_vals['activity_id'] = act_id
345                     Workitems.create(cr, uid, wi_vals, context=context)
346
347             self.write(cr, uid, segment.id, {'sync_last_date':action_date}, context=context)
348         Workitems.process_all(cr, uid, list(campaigns), context=context)
349         return True
350
351 marketing_campaign_segment()
352
353 class marketing_campaign_activity(osv.osv):
354     _name = "marketing.campaign.activity"
355     _description = "Campaign Activity"
356
357     _action_types = [
358         ('email', 'E-mail'),
359         ('report', 'Report'),
360         ('action', 'Custom Action'),
361         # TODO implement the subcampaigns.
362         # TODO implement the subcampaign out. disallow out transitions from
363         # subcampaign activities ?
364         #('subcampaign', 'Sub-Campaign'),
365     ]
366
367     _columns = {
368         'name': fields.char('Name', size=128, required=True),
369         'campaign_id': fields.many2one('marketing.campaign', 'Campaign',
370                                             required = True, ondelete='cascade', select=1),
371         'object_id': fields.related('campaign_id','object_id',
372                                       type='many2one', relation='ir.model',
373                                       string='Object', readonly=True),
374         'start': fields.boolean('Start', help= "This activity is launched when the campaign starts.", select=True),
375         'condition': fields.text('Condition', size=256, required=True,
376                                  help="Python expression to decide whether the activity can be executed, otherwise it will be deleted or cancelled."
377                                  "The expression may use the following [browsable] variables:\n"
378                                  "   - activity: the campaign activity\n"
379                                  "   - workitem: the campaign workitem\n"
380                                  "   - resource: the resource object this campaign item represents\n"
381                                  "   - transitions: list of campaign transitions outgoing from this activity\n"
382                                  "...- re: Python regular expression module"),
383         'type': fields.selection(_action_types, 'Type', required=True,
384                                   help="""The type of action to execute when an item enters this activity, such as:
385    - Email: send an email using a predefined email template
386    - Report: print an existing Report defined on the resource item and save it into a specific directory
387    - Custom Action: execute a predefined action, e.g. to modify the fields of the resource record
388   """),
389         'email_template_id': fields.many2one('email.template', "Email Template", help='The e-mail to send when this activity is activated'),
390         'report_id': fields.many2one('ir.actions.report.xml', "Report", help='The report to generate when this activity is activated', ),
391         'report_directory_id': fields.many2one('document.directory','Directory',
392                                 help="This folder is used to store the generated reports"),
393         'server_action_id': fields.many2one('ir.actions.server', string='Action',
394                                 help= "The action to perform when this activity is activated"),
395         'to_ids': fields.one2many('marketing.campaign.transition',
396                                             'activity_from_id',
397                                             'Next Activities'),
398         'from_ids': fields.one2many('marketing.campaign.transition',
399                                             'activity_to_id',
400                                             'Previous Activities'),
401         'variable_cost': fields.float('Variable Cost', help="Set a variable cost if you consider that every campaign item that has reached this point has entailed a certain cost. You can get cost statistics in the Reporting section", digits_compute=dp.get_precision('Purchase Price')),
402         'revenue': fields.float('Revenue', help="Set an expected revenue if you consider that every campaign item that has reached this point has generated a certain revenue. You can get revenue statistics in the Reporting section", digits_compute=dp.get_precision('Sale Price')),
403         'signal': fields.char('Signal', size=128,
404                               help='An activity with a signal can be called programmatically. Be careful, the workitem is always created when a signal is sent'),
405         'keep_if_condition_not_met': fields.boolean("Don't delete workitems",
406                                                     help="By activating this option, workitems that aren't executed because the condition is not met are marked as cancelled instead of being deleted.")
407     }
408
409     _defaults = {
410         'type': lambda *a: 'email',
411         'condition': lambda *a: 'True',
412     }
413
414     def search(self, cr, uid, args, offset=0, limit=None, order=None,
415                                         context=None, count=False):
416         if context == None:
417             context = {}
418         if 'segment_id' in context  and context['segment_id']:
419             segment_obj = self.pool.get('marketing.campaign.segment').browse(cr,
420                                                     uid, context['segment_id'])
421             act_ids = []
422             for activity in segment_obj.campaign_id.activity_ids:
423                 act_ids.append(activity.id)
424             return act_ids
425         return super(marketing_campaign_activity, self).search(cr, uid, args,
426                                            offset, limit, order, context, count)
427
428     def _process_wi_report(self, cr, uid, activity, workitem, context=None):
429         service = netsvc.LocalService('report.%s'%activity.report_id.report_name)
430         (report_data, format) = service.create(cr, uid, [], {}, {})
431         attach_vals = {
432             'name': '%s_%s_%s'%(activity.report_id.report_name,
433                                 activity.name,workitem.partner_id.name),
434             'datas_fname': '%s.%s'%(activity.report_id.report_name,
435                                         activity.report_id.report_type),
436             'parent_id': activity.report_directory_id.id,
437             'datas': base64.encodestring(report_data),
438             'file_type': format
439         }
440         self.pool.get('ir.attachment').create(cr, uid, attach_vals)
441         return True
442
443     def _process_wi_email(self, cr, uid, activity, workitem, context=None):
444         return self.pool.get('email.template').generate_mail(cr, uid,
445                                             activity.email_template_id.id,
446                                             [workitem.res_id], context=context)
447
448     def _process_wi_action(self, cr, uid, activity, workitem, context=None):
449         if context is None:
450             context = {}
451         server_obj = self.pool.get('ir.actions.server')
452
453         action_context = dict(context,
454                               active_id=workitem.res_id,
455                               active_ids=[workitem.res_id],
456                               active_model=workitem.object_id.model)
457         res = server_obj.run(cr, uid, [activity.server_action_id.id],
458                              context=action_context)
459         # server action return False if the action is perfomed
460         # except client_action, other and python code
461         return res == False and True or res
462
463     def process(self, cr, uid, act_id, wi_id, context=None):
464         activity = self.browse(cr, uid, act_id, context=context)
465         method = '_process_wi_%s' % (activity.type,)
466         action = getattr(self, method, None)
467         if not action:
468             raise NotImplementedError('method %r in not implemented on %r object' % (method, self))
469
470         workitem_obj = self.pool.get('marketing.campaign.workitem')
471         workitem = workitem_obj.browse(cr, uid, wi_id, context=context)
472         return action(cr, uid, activity, workitem, context=context)
473
474 marketing_campaign_activity()
475
476 class marketing_campaign_transition(osv.osv):
477     _name = "marketing.campaign.transition"
478     _description = "Campaign Transition"
479
480     _interval_units = [
481         ('hours', 'Hour(s)'), ('days', 'Day(s)'),
482         ('months', 'Month(s)'), ('years','Year(s)')
483     ]
484
485     def _get_name(self, cr, uid, ids, fn, args, context=None):
486         result = dict.fromkeys(ids, False)
487         formatters = {
488             'auto': _('Automatic transition'),
489             'time': _('After %(interval_nbr)d %(interval_type)s'),
490             'cosmetic': _('Cosmetic'),
491         }
492         for tr in self.browse(cr, uid, ids, context=context,
493                               fields_process=translate_selections):
494             result[tr.id] = formatters[tr.trigger.value] % tr
495         return result
496
497
498     def _delta(self, cr, uid, ids, context=None):
499         assert len(ids) == 1
500         transition = self.browse(cr, uid, ids[0], context=context)
501         if transition.trigger != 'time':
502             raise ValueError('Delta is only relevant for timed transiton')
503         return relativedelta(**{str(transition.interval_type): transition.interval_nbr})
504
505
506     _columns = {
507         'name': fields.function(_get_name, method=True, string='Name',
508                                 type='char', size=128),
509         'activity_from_id': fields.many2one('marketing.campaign.activity',
510                                             'Previous Activity', select=1,
511                                             required=True, ondelete="cascade"),
512         'activity_to_id': fields.many2one('marketing.campaign.activity',
513                                           'Next Activity',
514                                           required=True, ondelete="cascade"),
515         'interval_nbr': fields.integer('Interval Value', required=True),
516         'interval_type': fields.selection(_interval_units, 'Interval Unit',
517                                           required=True),
518
519         'trigger': fields.selection([('auto', 'Automatic'),
520                                      ('time', 'Time'),
521                                      ('cosmetic', 'Cosmetic'),  # fake plastic transition
522                                     ],
523                                     'Trigger', required=True,
524                                     help="How is the destination workitem triggered"),
525     }
526
527     _defaults = {
528         'interval_nbr': 1,
529         'interval_type': 'days',
530         'trigger': 'time',
531     }
532     def _check_campaign(self, cr, uid, ids, context=None):
533         for obj in self.browse(cr, uid, ids, context=context):
534             if obj.activity_from_id.campaign_id != obj.activity_to_id.campaign_id:
535                 return False
536         return True
537
538     _constraints = [
539             (_check_campaign, 'The To/From Activity of transition must be of the same Campaign ', ['activity_from_id,activity_to_id']),
540         ]
541
542     _sql_constraints = [
543         ('interval_positive', 'CHECK(interval_nbr >= 0)', 'The interval must be positive or zero')
544     ]
545
546 marketing_campaign_transition()
547
548 class marketing_campaign_workitem(osv.osv):
549     _name = "marketing.campaign.workitem"
550     _description = "Campaign Workitem"
551
552     def _res_name_get(self, cr, uid, ids, field_name, arg, context=None):
553         res = dict.fromkeys(ids, '/')
554         for wi in self.browse(cr, uid, ids, context=context):
555             if not wi.res_id:
556                 continue
557
558             proxy = self.pool.get(wi.object_id.model)
559             ng = proxy.name_get(cr, uid, [wi.res_id], context=context)
560             if ng:
561                 res[wi.id] = ng[0][1]
562         return res
563
564     def _resource_search(self, cr, uid, obj, name, args, domain=None, context=None):
565         """Returns id of workitem whose resource_name matches  with the given name"""
566         if not len(args):
567             return []
568
569         condition = []
570         final_ids = []
571
572         cr.execute("""select w.id, w.res_id, m.model  \
573                                 from marketing_campaign_workitem w \
574                                     left join marketing_campaign_activity a on (a.id=w.activity_id)\
575                                     left join marketing_campaign c on (c.id=a.campaign_id)\
576                                     left join ir_model m on (m.id=c.object_id)
577                                     """)
578         res = cr.fetchall()
579         for id, res_id, model in res:
580             model_pool = self.pool.get(model)
581             for arg in args:
582                 if arg[1] == 'ilike':
583                     condition.append((model_pool._rec_name, 'ilike', arg[2]))
584             res_ids = model_pool.search(cr, uid, condition, context=context)
585             if res_id in res_ids:
586                 final_ids.append(id)
587         return [('id', 'in', final_ids)]
588
589     _columns = {
590         'segment_id': fields.many2one('marketing.campaign.segment', 'Segment', readonly=True),
591         'activity_id': fields.many2one('marketing.campaign.activity','Activity',
592              required=True, readonly=True),
593         'campaign_id': fields.related('activity_id', 'campaign_id',
594              type='many2one', relation='marketing.campaign', string='Campaign', readonly=True, store=True),
595         'object_id': fields.related('activity_id', 'campaign_id', 'object_id',
596              type='many2one', relation='ir.model', string='Resource', select=1, readonly=True, store=True),
597         'res_id': fields.integer('Resource ID', select=1, readonly=True),
598         'res_name': fields.function(_res_name_get, method=True, string='Resource Name', fnct_search=_resource_search, type="char", size=64),
599         'date': fields.datetime('Execution Date', help='If date is not set, this workitem has to be run manually', readonly=True),
600         'partner_id': fields.many2one('res.partner', 'Partner', select=1, readonly=True),
601         'state': fields.selection([('todo', 'To Do'),
602                                    ('exception', 'Exception'), ('done', 'Done'),
603                                    ('cancelled', 'Cancelled')], 'State', readonly=True),
604
605         'error_msg' : fields.text('Error Message', readonly=True)
606     }
607     _defaults = {
608         'state': lambda *a: 'todo',
609         'date': False,
610     }
611
612     def button_draft(self, cr, uid, workitem_ids, context=None):
613         for wi in self.browse(cr, uid, workitem_ids, context=context):
614             if wi.state in ('exception', 'cancelled'):
615                 self.write(cr, uid, [wi.id], {'state':'todo'}, context=context)
616         return True
617
618     def button_cancel(self, cr, uid, workitem_ids, context=None):
619         for wi in self.browse(cr, uid, workitem_ids, context=context):
620             if wi.state in ('todo','exception'):
621                 self.write(cr, uid, [wi.id], {'state':'cancelled'}, context=context)
622         return True
623
624     def _process_one(self, cr, uid, workitem, context=None):
625         if workitem.state != 'todo':
626             return
627
628         activity = workitem.activity_id
629         proxy = self.pool.get(workitem.object_id.model)
630         object_id = proxy.browse(cr, uid, workitem.res_id, context=context)
631
632         eval_context = {
633             'activity': activity,
634             'workitem': workitem,
635             'object': object_id,
636             'resource': object_id,
637             'transitions': activity.to_ids,
638             're': re,
639         }
640         try:
641             condition = activity.condition
642             campaign_mode = workitem.campaign_id.mode
643             if condition:
644                 if not eval(condition, eval_context):
645                     if activity.keep_if_condition_not_met:
646                         workitem.write({'state': 'cancelled'}, context=context)
647                     else:
648                         workitem.unlink(context=context)
649                     return
650             result = True
651             if campaign_mode in ('manual', 'active'):
652                 Activities = self.pool.get('marketing.campaign.activity')
653                 result = Activities.process(cr, uid, activity.id, workitem.id,
654                                             context=context)
655
656             values = dict(state='done')
657             if not workitem.date:
658                 values['date'] = datetime.now().strftime(DT_FMT)
659             workitem.write(values, context=context)
660
661             if result:
662                 # process _chain
663                 workitem = workitem.browse(context)[0] # reload
664                 date = datetime.strptime(workitem.date, DT_FMT)
665
666                 for transition in activity.to_ids:
667                     if transition.trigger == 'cosmetic':
668                         continue
669                     launch_date = False
670                     if transition.trigger == 'auto':
671                         launch_date = date
672                     elif transition.trigger == 'time':
673                         launch_date = date + transition._delta()
674
675                     if launch_date:
676                         launch_date = launch_date.strftime(DT_FMT)
677                     values = {
678                         'date': launch_date,
679                         'segment_id': workitem.segment_id.id,
680                         'activity_id': transition.activity_to_id.id,
681                         'partner_id': workitem.partner_id.id,
682                         'res_id': workitem.res_id,
683                         'state': 'todo',
684                     }
685                     wi_id = self.create(cr, uid, values, context=context)
686
687                     # Now, depending on the trigger and the campaign mode
688                     # we know whether we must run the newly created workitem.
689                     #
690                     # rows = transition trigger \ colums = campaign mode
691                     #
692                     #           test    test_realtime     manual      normal (active)
693                     # time       Y            N             N           N
694                     # cosmetic   N            N             N           N
695                     # auto       Y            Y             N           Y
696                     #
697
698                     run = (transition.trigger == 'auto' \
699                             and campaign_mode != 'manual') \
700                           or (transition.trigger == 'time' \
701                               and campaign_mode == 'test')
702                     if run:
703                         new_wi = self.browse(cr, uid, wi_id, context)
704                         self._process_one(cr, uid, new_wi, context)
705
706         except Exception:
707             tb = "".join(format_exception(*exc_info()))
708             workitem.write({'state': 'exception', 'error_msg': tb},
709                      context=context)
710
711     def process(self, cr, uid, workitem_ids, context=None):
712         for wi in self.browse(cr, uid, workitem_ids, context=context):
713             self._process_one(cr, uid, wi, context=context)
714         return True
715
716     def process_all(self, cr, uid, camp_ids=None, context=None):
717         camp_obj = self.pool.get('marketing.campaign')
718         if camp_ids is None:
719             camp_ids = camp_obj.search(cr, uid, [('state','=','running')], context=context)
720         for camp in camp_obj.browse(cr, uid, camp_ids, context=context):
721             if camp.mode == 'manual':
722                 # manual states are not processed automatically
723                 continue
724             while True:
725                 domain = [('state', '=', 'todo'), ('date', '!=', False)]
726                 if camp.mode in ('test_realtime', 'active'):
727                     domain += [('date','<=', time.strftime('%Y-%m-%d %H:%M:%S'))]
728
729                 workitem_ids = self.search(cr, uid, domain, context=context)
730                 if not workitem_ids:
731                     break
732
733                 self.process(cr, uid, workitem_ids, context=context)
734         return True
735
736     def preview(self, cr, uid, ids, context=None):
737         res = {}
738         wi_obj = self.browse(cr, uid, ids[0], context=context)
739         if wi_obj.activity_id.type == 'email':
740             data_obj = self.pool.get('ir.model.data')
741             data_id = data_obj._get_id(cr, uid, 'email_template', 'email_template_preview_form')
742             view_id = 0
743             if data_id:
744                 view_id = data_obj.browse(cr, uid, data_id, context=context).res_id
745             res = {
746                 'name': _('Email Preview'),
747                 'view_type': 'form',
748                 'view_mode': 'form,tree',
749                 'res_model': 'email_template.preview',
750                 'view_id': False,
751                 'context': context,
752                 'views': [(view_id, 'form')],
753                 'type': 'ir.actions.act_window',
754                 'target': 'new',
755                 'nodestroy':True,
756                 'context': "{'template_id':%d,'default_rel_model_ref':%d}"%
757                                 (wi_obj.activity_id.email_template_id.id,
758                                  wi_obj.res_id)
759             }
760
761         elif wi_obj.activity_id.type == 'report':
762             datas = {
763                 'ids': [wi_obj.res_id],
764                 'model': wi_obj.object_id.model
765             }
766             res = {
767                 'type' : 'ir.actions.report.xml',
768                 'report_name': wi_obj.activity_id.report_id.report_name,
769                 'datas' : datas,
770             }
771         else:
772             raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))
773         return res
774
775 marketing_campaign_workitem()
776
777 class email_template(osv.osv):
778     _inherit = "email.template"
779     _defaults = {
780         'object_name': lambda obj, cr, uid, context: context.get('object_id',False),
781     }
782
783     # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign
784
785 email_template()
786
787 class report_xml(osv.osv):
788     _inherit = 'ir.actions.report.xml'
789     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
790         if context is None:
791             context = {}
792         object_id = context.get('object_id')
793         if object_id:
794             model = self.pool.get('ir.model').browse(cr, uid, object_id, context=context).model
795             args.append(('model', '=', model))
796         return super(report_xml, self).search(cr, uid, args, offset, limit, order, context, count)
797
798 report_xml()
799