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