Launchpad automatic translations update.
[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: the email account is missing in 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 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 cannot be started: it doesn't have any starting activity (or 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     def _get_next_sync(self, cr, uid, ids, fn, args, context=None):
217         # next auto sync date is same for all segments
218         sync_job = self.pool.get('ir.model.data').get_object(cr, uid, 'marketing_campaign', 'ir_cron_marketing_campaign_every_day', context=context)
219         next_sync = sync_job and sync_job.nextcall or False
220         return dict.fromkeys(ids, next_sync)
221
222     _columns = {
223         'name': fields.char('Name', size=64,required=True),
224         'campaign_id': fields.many2one('marketing.campaign', 'Campaign', required=True, select=1, ondelete="cascade"),
225         'object_id': fields.related('campaign_id','object_id', type='many2one', relation='ir.model', string='Resource'),
226         'ir_filter_id': fields.many2one('ir.filters', 'Filter', ondelete="restrict",
227                             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."),
228         'sync_last_date': fields.datetime('Last Synchronization', help="Date on which this segment was synchronized last time (automatically or manually)"),
229         'sync_mode': fields.selection([('create_date', 'Only records created after last sync'),
230                                       ('write_date', 'Only records modified after last sync (no duplicates)'),
231                                       ('all', 'All records (no duplicates)')],
232                                       'Synchronization mode',
233                                       help="Determines an additional criterion to add to the filter when selecting new records to inject in the campaign."),
234         'state': fields.selection([('draft', 'Draft'),
235                                    ('running', 'Running'),
236                                    ('done', 'Done'),
237                                    ('cancelled', 'Cancelled')],
238                                    'State',),
239         'date_run': fields.datetime('Launch Date', help="Initial start date of this segment."),
240         'date_done': fields.datetime('End Date', help="Date this segment was last closed or cancelled."),
241         '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"),
242     }
243
244     _defaults = {
245         'state': lambda *a: 'draft',
246         'sync_mode': lambda *a: 'create_date',
247     }
248
249     def _check_model(self, cr, uid, ids, context=None):
250         if not context:
251             context = {}
252         for obj in self.browse(cr, uid, ids, context=context):
253             if not obj.ir_filter_id:
254                 return True
255             if obj.campaign_id.object_id.model != obj.ir_filter_id.model_id:
256                 return False
257         return True
258
259     _constraints = [
260         (_check_model, _('Model of filter must be same as resource model of Campaign '), ['ir_filter_id,campaign_id']),
261     ]
262
263     def onchange_campaign_id(self, cr, uid, ids, campaign_id):
264         res = {'domain':{'ir_filter_id':[]}}
265         campaign_pool = self.pool.get('marketing.campaign')
266         if campaign_id:
267             campaign = campaign_pool.browse(cr, uid, campaign_id)
268             model_name = self.pool.get('ir.model').read(cr, uid, [campaign.object_id.id], ['model'])
269             if model_name:
270                 mod_name = model_name[0]['model']
271                 res['domain'] = {'ir_filter_id': [('model_id', '=', mod_name)]}
272         else:
273             res['value'] = {'ir_filter_id': False}
274         return res
275
276     def state_running_set(self, cr, uid, ids, *args):
277         segment = self.browse(cr, uid, ids[0])
278         vals = {'state': 'running'}
279         if not segment.date_run:
280             vals['date_run'] = time.strftime('%Y-%m-%d %H:%M:%S')
281         self.write(cr, uid, ids, vals)
282         return True
283
284     def state_done_set(self, cr, uid, ids, *args):
285         wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
286                                 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
287         self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
288         self.write(cr, uid, ids, {'state': 'done','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
289         return True
290
291     def state_cancel_set(self, cr, uid, ids, *args):
292         wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
293                                 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
294         self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
295         self.write(cr, uid, ids, {'state': 'cancelled','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
296         return True
297
298     def synchroniz(self, cr, uid, ids, *args):
299         self.process_segment(cr, uid, ids)
300         return True
301
302     def process_segment(self, cr, uid, segment_ids=None, context=None):
303         Workitems = self.pool.get('marketing.campaign.workitem')
304         if not segment_ids:
305             segment_ids = self.search(cr, uid, [('state', '=', 'running')], context=context)
306
307         action_date = time.strftime('%Y-%m-%d %H:%M:%S')
308         campaigns = set()
309         for segment in self.browse(cr, uid, segment_ids, context=context):
310             if segment.campaign_id.state != 'running':
311                 continue
312             
313             campaigns.add(segment.campaign_id.id)
314             act_ids = self.pool.get('marketing.campaign.activity').search(cr,
315                   uid, [('start', '=', True), ('campaign_id', '=', segment.campaign_id.id)], context=context)
316
317             model_obj = self.pool.get(segment.object_id.model)
318             criteria = []
319             if segment.sync_last_date and segment.sync_mode != 'all':
320                 criteria += [(segment.sync_mode, '>', segment.sync_last_date)]
321             if segment.ir_filter_id:
322                 criteria += eval(segment.ir_filter_id.domain)
323             object_ids = model_obj.search(cr, uid, criteria, context=context)
324
325             # XXX TODO: rewrite this loop more efficiently without doing 1 search per record!
326             for o_ids in model_obj.browse(cr, uid, object_ids, context=context):
327                 # avoid duplicated workitem for the same resource
328                 if segment.sync_mode in ('write_date','all'):
329                     wi_ids = Workitems.search(cr, uid, [('res_id','=',o_ids.id),('segment_id','=',segment.id)], context=context)
330                     if wi_ids:
331                         continue
332
333                 wi_vals = {
334                     'segment_id': segment.id,
335                     'date': action_date,
336                     'state': 'todo',
337                     'res_id': o_ids.id
338                 }
339
340                 partner = self.pool.get('marketing.campaign')._get_partner_for(segment.campaign_id, o_ids)
341                 if partner:
342                     wi_vals['partner_id'] = partner.id
343
344                 for act_id in act_ids:
345                     wi_vals['activity_id'] = act_id
346                     Workitems.create(cr, uid, wi_vals, context=context)
347
348             self.write(cr, uid, segment.id, {'sync_last_date':action_date}, context=context)
349         Workitems.process_all(cr, uid, list(campaigns), context=context)
350         return True
351
352 marketing_campaign_segment()
353
354 class marketing_campaign_activity(osv.osv):
355     _name = "marketing.campaign.activity"
356     _description = "Campaign Activity"
357
358     _action_types = [
359         ('email', 'E-mail'),
360         ('report', 'Report'),
361         ('action', 'Custom Action'),
362         # TODO implement the subcampaigns.
363         # TODO implement the subcampaign out. disallow out transitions from
364         # subcampaign activities ?
365         #('subcampaign', 'Sub-Campaign'),
366     ]
367
368     _columns = {
369         'name': fields.char('Name', size=128, required=True),
370         'campaign_id': fields.many2one('marketing.campaign', 'Campaign',
371                                             required = True, ondelete='cascade', select=1),
372         'object_id': fields.related('campaign_id','object_id',
373                                       type='many2one', relation='ir.model',
374                                       string='Object', readonly=True),
375         'start': fields.boolean('Start', help= "This activity is launched when the campaign starts.", select=True),
376         'condition': fields.text('Condition', size=256, required=True,
377                                  help="Python expression to decide whether the activity can be executed, otherwise it will be deleted or cancelled."
378                                  "The expression may use the following [browsable] variables:\n"
379                                  "   - activity: the campaign activity\n"
380                                  "   - workitem: the campaign workitem\n" 
381                                  "   - resource: the resource object this campaign item represents\n"
382                                  "   - transitions: list of campaign transitions outgoing from this activity\n"
383                                  "...- re: Python regular expression module"),
384         'type': fields.selection(_action_types, 'Type', required=True,
385                                   help="""The type of action to execute when an item enters this activity, such as:
386    - Email: send an email using a predefined email template
387    - Report: print an existing Report defined on the resource item and save it into a specific directory
388    - Custom Action: execute a predefined action, e.g. to modify the fields of the resource record
389   """),
390         'email_template_id': fields.many2one('email.template', "Email Template", help='The e-mail to send when this activity is activated'),
391         'report_id': fields.many2one('ir.actions.report.xml', "Report", help='The report to generate when this activity is activated', ),
392         'report_directory_id': fields.many2one('document.directory','Directory',
393                                 help="This folder is used to store the generated reports"),
394         'server_action_id': fields.many2one('ir.actions.server', string='Action',
395                                 help= "The action to perform when this activity is activated"),
396         'to_ids': fields.one2many('marketing.campaign.transition',
397                                             'activity_from_id',
398                                             'Next Activities'),
399         'from_ids': fields.one2many('marketing.campaign.transition',
400                                             'activity_to_id',
401                                             'Previous Activities'),
402         '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"),
403         '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"),
404         'signal': fields.char('Signal', size=128,
405                               help='An activity with a signal can be called programmatically. Be careful, the workitem is always created when a signal is sent'),
406         'keep_if_condition_not_met': fields.boolean("Don't delete workitems",
407                                                     help="By activating this option, workitems that aren't executed because the condition is not met are marked as cancelled instead of being deleted.")
408     }
409
410     _defaults = {
411         'type': lambda *a: 'email',
412         'condition': lambda *a: 'True',
413     }
414
415     def search(self, cr, uid, args, offset=0, limit=None, order=None,
416                                         context=None, count=False):
417         if context == None:
418             context = {}
419         if 'segment_id' in context  and context['segment_id']:
420             segment_obj = self.pool.get('marketing.campaign.segment').browse(cr,
421                                                     uid, context['segment_id'])
422             act_ids = []
423             for activity in segment_obj.campaign_id.activity_ids:
424                 act_ids.append(activity.id)
425             return act_ids
426         return super(marketing_campaign_activity, self).search(cr, uid, args,
427                                            offset, limit, order, context, count)
428
429     def _process_wi_report(self, cr, uid, activity, workitem, context=None):
430         service = netsvc.LocalService('report.%s'%activity.report_id.report_name)
431         (report_data, format) = service.create(cr, uid, [], {}, {})
432         attach_vals = {
433             'name': '%s_%s_%s'%(activity.report_id.report_name,
434                                 activity.name,workitem.partner_id.name),
435             'datas_fname': '%s.%s'%(activity.report_id.report_name,
436                                         activity.report_id.report_type),
437             'parent_id': activity.report_directory_id.id,
438             'datas': base64.encodestring(report_data),
439             'file_type': format
440         }
441         self.pool.get('ir.attachment').create(cr, uid, attach_vals)
442         return True
443
444     def _process_wi_email(self, cr, uid, activity, workitem, context=None):
445         return self.pool.get('email.template').generate_mail(cr, uid,
446                                             activity.email_template_id.id,
447                                             [workitem.res_id], context=context)
448
449     def _process_wi_action(self, cr, uid, activity, workitem, context=None):
450         if context is None:
451             context = {}
452         server_obj = self.pool.get('ir.actions.server')
453
454         action_context = dict(context,
455                               active_id=workitem.res_id,
456                               active_ids=[workitem.res_id],
457                               active_model=workitem.object_id.model)
458         res = server_obj.run(cr, uid, [activity.server_action_id.id],
459                              context=action_context)
460         # server action return False if the action is perfomed
461         # except client_action, other and python code
462         return res == False and True or res
463
464     def process(self, cr, uid, act_id, wi_id, context=None):
465         activity = self.browse(cr, uid, act_id, context=context)
466         method = '_process_wi_%s' % (activity.type,)
467         action = getattr(self, method, None)
468         if not action:
469             raise NotImplementedError('method %r in not implemented on %r object' % (method, self))
470
471         workitem_obj = self.pool.get('marketing.campaign.workitem')
472         workitem = workitem_obj.browse(cr, uid, wi_id, context=context)
473         return action(cr, uid, activity, workitem, context)
474
475 marketing_campaign_activity()
476
477 class marketing_campaign_transition(osv.osv):
478     _name = "marketing.campaign.transition"
479     _description = "Campaign Transition"
480
481     _interval_units = [
482         ('hours', 'Hour(s)'), ('days', 'Day(s)'),
483         ('months', 'Month(s)'), ('years','Year(s)')
484     ]
485
486     def _get_name(self, cr, uid, ids, fn, args, context=None):
487         result = dict.fromkeys(ids, False)
488         formatters = {
489             'auto': _('Automatic transition'),
490             'time': _('After %(interval_nbr)d %(interval_type)s'),
491             'cosmetic': _('Cosmetic'),
492         }
493         for tr in self.browse(cr, uid, ids, context=context,
494                               fields_process=translate_selections):
495             result[tr.id] = formatters[tr.trigger.value] % tr
496         return result
497
498
499     def _delta(self, cr, uid, ids, context=None):
500         assert len(ids) == 1
501         transition = self.browse(cr, uid, ids[0], context)
502         if transition.trigger != 'time':
503             raise ValueError('Delta is only relevant for timed transiton')
504         return relativedelta(**{str(transition.interval_type): transition.interval_nbr})
505
506
507     _columns = {
508         'name': fields.function(_get_name, method=True, string='Name',
509                                 type='char', size=128),
510         'activity_from_id': fields.many2one('marketing.campaign.activity',
511                                             'Previous Activity', select=1,
512                                             required=True, ondelete="cascade"),
513         'activity_to_id': fields.many2one('marketing.campaign.activity',
514                                           'Next Activity',
515                                           required=True, ondelete="cascade"),
516         'interval_nbr': fields.integer('Interval Value', required=True),
517         'interval_type': fields.selection(_interval_units, 'Interval Unit',
518                                           required=True),
519
520         'trigger': fields.selection([('auto', 'Automatic'),
521                                      ('time', 'Time'),
522                                      ('cosmetic', 'Cosmetic'),  # fake plastic transition
523                                     ],
524                                     'Trigger', required=True,
525                                     help="How is the destination workitem triggered"),
526     }
527
528     _defaults = {
529         'interval_nbr': 1,
530         'interval_type': 'days',
531         'trigger': 'time',
532     }
533     def _check_campaign(self, cr, uid, ids, context=None):
534         if not context:
535             context = {}
536         for obj in self.browse(cr, uid, ids, context=context):
537             if obj.activity_from_id.campaign_id != obj.activity_to_id.campaign_id:
538                 return False
539         return True
540
541     _constraints = [
542             (_check_campaign, _('The To/From Activity of transition must be of the same Campaign '), ['activity_from_id,activity_to_id']),
543         ]
544  
545     _sql_constraints = [
546         ('interval_positive', 'CHECK(interval_nbr >= 0)', 'The interval must be positive or zero')
547     ]
548
549 marketing_campaign_transition()
550
551 class marketing_campaign_workitem(osv.osv):
552     _name = "marketing.campaign.workitem"
553     _description = "Campaign Workitem"
554
555     def _res_name_get(self, cr, uid, ids, field_name, arg, context=None):
556         res = dict.fromkeys(ids, '/')
557         for wi in self.browse(cr, uid, ids, context=context):
558             if not wi.res_id:
559                 continue
560
561             proxy = self.pool.get(wi.object_id.model)
562             ng = proxy.name_get(cr, uid, [wi.res_id], context=context)
563             if ng:
564                 res[wi.id] = ng[0][1]
565         return res
566
567     def _resource_search(self, cr, uid, obj, name, args, domain=None, context=None):
568         """Returns id of workitem whose resource_name matches  with the given name"""
569         if context is None:
570             context = {}
571         if not len(args):
572             return []
573
574         condition = []
575         final_ids = []
576
577         cr.execute("""select w.id, w.res_id, m.model  \
578                                 from marketing_campaign_workitem w \
579                                     left join marketing_campaign_activity a on (a.id=w.activity_id)\
580                                     left join marketing_campaign c on (c.id=a.campaign_id)\
581                                     left join ir_model m on (m.id=c.object_id)
582                                     """)
583         res = cr.fetchall()
584         for id, res_id, model in res:
585             model_pool = self.pool.get(model)
586             for arg in args:
587                 if arg[1] == 'ilike':
588                     condition.append((model_pool._rec_name, 'ilike', arg[2]))
589             res_ids = model_pool.search(cr, uid, condition, context=context)
590             if res_id in res_ids:
591                 final_ids.append(id)
592         return [('id', 'in', final_ids)]
593
594     _columns = {
595         'segment_id': fields.many2one('marketing.campaign.segment', 'Segment', readonly=True),
596         'activity_id': fields.many2one('marketing.campaign.activity','Activity',
597              required=True, readonly=True),
598         'campaign_id': fields.related('activity_id', 'campaign_id',
599              type='many2one', relation='marketing.campaign', string='Campaign', readonly=True, store=True),
600         'object_id': fields.related('activity_id', 'campaign_id', 'object_id',
601              type='many2one', relation='ir.model', string='Resource', select=1, readonly=True, store=True),
602         'res_id': fields.integer('Resource ID', select=1, readonly=True),
603         'res_name': fields.function(_res_name_get, method=True, string='Resource Name', fnct_search=_resource_search, type="char", size=64),
604         'date': fields.datetime('Execution Date', help='If date is not set, this workitem has to be run manually', readonly=True),
605         'partner_id': fields.many2one('res.partner', 'Partner', select=1, readonly=True),
606         'state': fields.selection([('todo', 'To Do'),
607                                    ('exception', 'Exception'), ('done', 'Done'),
608                                    ('cancelled', 'Cancelled')], 'State', readonly=True),
609
610         'error_msg' : fields.text('Error Message', readonly=True)
611     }
612     _defaults = {
613         'state': lambda *a: 'todo',
614         'date': False,
615     }
616
617     def button_draft(self, cr, uid, workitem_ids, context={}):
618         for wi in self.browse(cr, uid, workitem_ids, context=context):
619             if wi.state in ('exception', 'cancelled'):
620                 self.write(cr, uid, [wi.id], {'state':'todo'}, context=context)
621         return True
622
623     def button_cancel(self, cr, uid, workitem_ids, context={}):
624         for wi in self.browse(cr, uid, workitem_ids, context=context):
625             if wi.state in ('todo','exception'):
626                 self.write(cr, uid, [wi.id], {'state':'cancelled'}, context=context)
627         return True
628
629     def _process_one(self, cr, uid, workitem, context=None):
630         if workitem.state != 'todo':
631             return
632
633         activity = workitem.activity_id
634         proxy = self.pool.get(workitem.object_id.model)
635         object_id = proxy.browse(cr, uid, workitem.res_id, context=context)
636
637         eval_context = {
638             'activity': activity,
639             'workitem': workitem,
640             'object': object_id,
641             'resource': object_id, 
642             'transitions': activity.to_ids,
643             're': re,
644         }
645         try:
646             condition = activity.condition
647             campaign_mode = workitem.campaign_id.mode
648             if condition:
649                 if not eval(condition, eval_context):
650                     if activity.keep_if_condition_not_met:
651                         workitem.write({'state': 'cancelled'}, context=context)
652                     else:
653                         workitem.unlink(context=context)
654                     return
655             result = True
656             if campaign_mode in ('manual', 'active'):
657                 Activities = self.pool.get('marketing.campaign.activity')
658                 result = Activities.process(cr, uid, activity.id, workitem.id,
659                                             context=context)
660
661             values = dict(state='done')
662             if not workitem.date:
663                 values['date'] = datetime.now().strftime(DT_FMT)
664             workitem.write(values, context=context)
665
666             if result:
667                 # process _chain
668                 workitem = workitem.browse(context)[0] # reload
669                 date = datetime.strptime(workitem.date, DT_FMT)
670
671                 for transition in activity.to_ids:
672                     if transition.trigger == 'cosmetic':
673                         continue
674                     launch_date = False
675                     if transition.trigger == 'auto':
676                         launch_date = date
677                     elif transition.trigger == 'time':
678                         launch_date = date + transition._delta()
679
680                     if launch_date:
681                         launch_date = launch_date.strftime(DT_FMT)
682                     values = {
683                         'date': launch_date,
684                         'segment_id': workitem.segment_id.id,
685                         'activity_id': transition.activity_to_id.id,
686                         'partner_id': workitem.partner_id.id,
687                         'res_id': workitem.res_id,
688                         'state': 'todo',
689                     }
690                     wi_id = self.create(cr, uid, values, context=context)
691
692                     # Now, depending on the trigger and the campaign mode
693                     # we know whether we must run the newly created workitem.
694                     #
695                     # rows = transition trigger \ colums = campaign mode
696                     #
697                     #           test    test_realtime     manual      normal (active)
698                     # time       Y            N             N           N
699                     # cosmetic   N            N             N           N
700                     # auto       Y            Y             N           Y
701                     #
702
703                     run = (transition.trigger == 'auto' \
704                             and campaign_mode != 'manual') \
705                           or (transition.trigger == 'time' \
706                               and campaign_mode == 'test')
707                     if run:
708                         new_wi = self.browse(cr, uid, wi_id, context)
709                         self._process_one(cr, uid, new_wi, context)
710
711         except Exception:
712             tb = "".join(format_exception(*exc_info()))
713             workitem.write({'state': 'exception', 'error_msg': tb},
714                      context=context)
715
716     def process(self, cr, uid, workitem_ids, context=None):
717         for wi in self.browse(cr, uid, workitem_ids, context):
718             self._process_one(cr, uid, wi, context)
719         return True
720
721     def process_all(self, cr, uid, camp_ids=None, context=None):
722         camp_obj = self.pool.get('marketing.campaign')
723         if camp_ids is None:
724             camp_ids = camp_obj.search(cr, uid, [('state','=','running')], context=context)
725         for camp in camp_obj.browse(cr, uid, camp_ids, context=context):
726             if camp.mode == 'manual':
727                 # manual states are not processed automatically
728                 continue
729             while True:
730                 domain = [('state', '=', 'todo'), ('date', '!=', False)]
731                 if camp.mode in ('test_realtime', 'active'):
732                     domain += [('date','<=', time.strftime('%Y-%m-%d %H:%M:%S'))]
733
734                 workitem_ids = self.search(cr, uid, domain, context=context)
735                 if not workitem_ids:
736                     break
737
738                 self.process(cr, uid, workitem_ids, context)
739         return True
740
741     def preview(self, cr, uid, ids, context):
742         res = {}
743         wi_obj = self.browse(cr, uid, ids[0], context)
744         if wi_obj.activity_id.type == 'email':
745             data_obj = self.pool.get('ir.model.data')
746             data_id = data_obj._get_id(cr, uid, 'email_template', 'email_template_preview_form')
747             view_id = 0
748             if data_id:
749                 view_id = data_obj.browse(cr, uid, data_id, context=context).res_id
750             res = {
751                 'name': _('Email Preview'),
752                 'view_type': 'form',
753                 'view_mode': 'form,tree',
754                 'res_model': 'email_template.preview',
755                 'view_id': False,
756                 'context': context,
757                 'views': [(view_id, 'form')],
758                 'type': 'ir.actions.act_window',
759                 'target': 'new',
760                 'nodestroy':True,
761                 'context': "{'template_id':%d,'default_rel_model_ref':%d}"%
762                                 (wi_obj.activity_id.email_template_id.id,
763                                  wi_obj.res_id)
764             }
765
766         elif wi_obj.activity_id.type == 'report':
767             datas = {
768                 'ids': [wi_obj.res_id],
769                 'model': wi_obj.object_id.model
770             }
771             res = {
772                 'type' : 'ir.actions.report.xml',
773                 'report_name': wi_obj.activity_id.report_id.report_name,
774                 'datas' : datas,
775             }
776         else:
777             raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))
778         return res
779
780 marketing_campaign_workitem()
781
782 class email_template(osv.osv):
783     _inherit = "email.template"
784     _defaults = {
785         'object_name': lambda obj, cr, uid, context: context.get('object_id',False),
786     }
787
788     # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign
789
790 email_template()
791
792 class report_xml(osv.osv):
793     _inherit = 'ir.actions.report.xml'
794     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
795         if context is None:
796             context = {}
797         object_id = context.get('object_id')
798         if object_id:
799             model = self.pool.get('ir.model').browse(cr, uid, object_id).model
800             args.append(('model', '=', model))
801         return super(report_xml, self).search(cr, uid, args, offset, limit, order, context, count)
802
803 report_xml()
804