[IMP]: marketing_campaign: Improvement in search view of Followup(workitem), Implemen...
[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_paper(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
530     _sql_constraints = [
531         ('interval_positive', 'CHECK(interval_nbr >= 0)', 'The interval must be positive or zero')
532     ]
533
534 marketing_campaign_transition()
535
536 class marketing_campaign_workitem(osv.osv):
537     _name = "marketing.campaign.workitem"
538     _description = "Campaign Workitem"
539
540     def _res_name_get(self, cr, uid, ids, field_name, arg, context=None):
541         res = dict.fromkeys(ids, '/')
542         for wi in self.browse(cr, uid, ids, context=context):
543             if not wi.res_id:
544                 continue
545
546             proxy = self.pool.get(wi.object_id.model)
547             ng = proxy.name_get(cr, uid, [wi.res_id], context=context)
548             if ng:
549                 res[wi.id] = ng[0][1]
550         return res
551
552     def _resource_search(self, cr, uid, obj, name, args, domain=None, context=None):
553         """Returns id of workitem whose resource_name matches  with the given name"""
554         if context is None:
555             context = {}
556         if not len(args):
557             return []
558
559         condition = []
560         final_ids = []
561
562         cr.execute("""select w.id, w.res_id, m.model  \
563                                 from marketing_campaign_workitem w \
564                                     left join marketing_campaign_activity a on (a.id=w.activity_id)\
565                                     left join marketing_campaign c on (c.id=a.campaign_id)\
566                                     left join ir_model m on (m.id=c.object_id)
567                                     """)
568         res = cr.fetchall()
569         for id, res_id, model in res:
570             model_pool = self.pool.get(model)
571             for arg in args:
572                 if arg[1] == 'ilike':
573                     condition.append((model_pool._rec_name, 'ilike', arg[2]))
574             res_ids = model_pool.search(cr, uid, condition, context=context)
575             if res_id in res_ids:
576                 final_ids.append(id)
577         return [('id', 'in', final_ids)]
578
579     _columns = {
580         'segment_id': fields.many2one('marketing.campaign.segment', 'Segment', readonly=True),
581         'activity_id': fields.many2one('marketing.campaign.activity','Activity',
582              required=True, readonly=True),
583         'campaign_id': fields.related('activity_id', 'campaign_id',
584              type='many2one', relation='marketing.campaign', string='Campaign', readonly=True),
585         'object_id': fields.related('activity_id', 'campaign_id', 'object_id',
586              type='many2one', relation='ir.model', string='Resource', select=1, readonly=True),
587         'res_id': fields.integer('Resource ID', select=1, readonly=True),
588         'res_name': fields.function(_res_name_get, method=True, string='Resource Name', fnct_search=_resource_search, type="char", size=64),
589         'date': fields.datetime('Execution Date', help='If date is not set, this workitem has to be run manually', readonly=True),
590         'partner_id': fields.many2one('res.partner', 'Partner', select=1, readonly=True),
591         'state': fields.selection([('todo', 'To Do'),
592                                    ('exception', 'Exception'), ('done', 'Done'),
593                                    ('cancelled', 'Cancelled')], 'State', readonly=True),
594
595         'error_msg' : fields.text('Error Message', readonly=True)
596     }
597     _defaults = {
598         'state': lambda *a: 'todo',
599         'date': False,
600     }
601
602     def button_draft(self, cr, uid, workitem_ids, context={}):
603         for wi in self.browse(cr, uid, workitem_ids, context=context):
604             if wi.state in ('exception', 'cancelled'):
605                 self.write(cr, uid, [wi.id], {'state':'todo'}, context=context)
606         return True
607
608     def button_cancel(self, cr, uid, workitem_ids, context={}):
609         for wi in self.browse(cr, uid, workitem_ids, context=context):
610             if wi.state in ('todo','exception'):
611                 self.write(cr, uid, [wi.id], {'state':'cancelled'}, context=context)
612         return True
613
614     def _process_one(self, cr, uid, workitem, context=None):
615         if workitem.state != 'todo':
616             return
617
618         activity = workitem.activity_id
619         proxy = self.pool.get(workitem.object_id.model)
620         object_id = proxy.browse(cr, uid, workitem.res_id, context=context)
621
622         eval_context = {
623             'activity': activity,
624             'workitem': workitem,
625             'object': object_id,
626             'resource': object_id, 
627             'transitions': activity.to_ids,
628             're': re,
629         }
630         try:
631             condition = activity.condition
632             campaign_mode = workitem.campaign_id.mode
633             if condition:
634                 if not eval(condition, eval_context):
635                     if activity.keep_if_condition_not_met:
636                         workitem.write({'state': 'cancelled'}, context=context)
637                     else:
638                         workitem.unlink(context=context)
639                     return
640             result = True
641             if campaign_mode in ('manual', 'active'):
642                 Activities = self.pool.get('marketing.campaign.activity')
643                 result = Activities.process(cr, uid, activity.id, workitem.id,
644                                             context=context)
645
646             values = dict(state='done')
647             if not workitem.date:
648                 values['date'] = datetime.now().strftime(DT_FMT)
649             workitem.write(values, context=context)
650
651             if result:
652                 # process _chain
653                 workitem = workitem.browse(context)[0] # reload
654                 date = datetime.strptime(workitem.date, DT_FMT)
655
656                 for transition in activity.to_ids:
657                     if transition.trigger == 'cosmetic':
658                         continue
659                     launch_date = False
660                     if transition.trigger == 'auto':
661                         launch_date = date
662                     elif transition.trigger == 'time':
663                         launch_date = date + transition._delta()
664
665                     if launch_date:
666                         launch_date = launch_date.strftime(DT_FMT)
667                     values = {
668                         'date': launch_date,
669                         'segment_id': workitem.segment_id.id,
670                         'activity_id': transition.activity_to_id.id,
671                         'partner_id': workitem.partner_id.id,
672                         'res_id': workitem.res_id,
673                         'state': 'todo',
674                     }
675                     wi_id = self.create(cr, uid, values, context=context)
676
677                     # Now, depending on the trigger and the campaign mode
678                     # we know whether we must run the newly created workitem.
679                     #
680                     # rows = transition trigger \ colums = campaign mode
681                     #
682                     #           test    test_realtime     manual      normal (active)
683                     # time       Y            N             N           N
684                     # cosmetic   N            N             N           N
685                     # auto       Y            Y             N           Y
686                     #
687
688                     run = (transition.trigger == 'auto' \
689                             and campaign_mode != 'manual') \
690                           or (transition.trigger == 'time' \
691                               and campaign_mode == 'test')
692                     if run:
693                         new_wi = self.browse(cr, uid, wi_id, context)
694                         self._process_one(cr, uid, new_wi, context)
695
696         except Exception:
697             tb = "".join(format_exception(*exc_info()))
698             workitem.write({'state': 'exception', 'error_msg': tb},
699                      context=context)
700
701     def process(self, cr, uid, workitem_ids, context=None):
702         for wi in self.browse(cr, uid, workitem_ids, context):
703             self._process_one(cr, uid, wi, context)
704         return True
705
706     def process_all(self, cr, uid, camp_ids=None, context=None):
707         camp_obj = self.pool.get('marketing.campaign')
708         if camp_ids is None:
709             camp_ids = camp_obj.search(cr, uid, [('state','=','running')], context=context)
710         for camp in camp_obj.browse(cr, uid, camp_ids, context=context):
711             if camp.mode == 'manual':
712                 # manual states are not processed automatically
713                 continue
714             while True:
715                 domain = [('state', '=', 'todo'), ('date', '!=', False)]
716                 if camp.mode in ('test_realtime', 'active'):
717                     domain += [('date','<=', time.strftime('%Y-%m-%d %H:%M:%S'))]
718
719                 workitem_ids = self.search(cr, uid, domain, context=context)
720                 if not workitem_ids:
721                     break
722
723                 self.process(cr, uid, workitem_ids, context)
724         return True
725
726     def preview(self, cr, uid, ids, context):
727         res = {}
728         wi_obj = self.browse(cr, uid, ids[0], context)
729         if wi_obj.activity_id.type == 'email':
730             data_obj = self.pool.get('ir.model.data')
731             data_id = data_obj._get_id(cr, uid, 'email_template', 'email_template_preview_form')
732             view_id = 0
733             if data_id:
734                 view_id = data_obj.browse(cr, uid, data_id, context=context).res_id
735             res = {
736                 'name': _('Email Preview'),
737                 'view_type': 'form',
738                 'view_mode': 'form,tree',
739                 'res_model': 'email_template.preview',
740                 'view_id': False,
741                 'context': context,
742                 'views': [(view_id, 'form')],
743                 'type': 'ir.actions.act_window',
744                 'target': 'new',
745                 'nodestroy':True,
746                 'context': "{'template_id':%d,'default_rel_model_ref':%d}"%
747                                 (wi_obj.activity_id.email_template_id.id,
748                                  wi_obj.res_id)
749             }
750
751         elif wi_obj.activity_id.type == 'report':
752             datas = {
753                 'ids': [wi_obj.res_id],
754                 'model': wi_obj.object_id.model
755             }
756             res = {
757                 'type' : 'ir.actions.report.xml',
758                 'report_name': wi_obj.activity_id.report_id.report_name,
759                 'datas' : datas,
760             }
761         else:
762             raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))
763         return res
764
765 marketing_campaign_workitem()
766
767 class email_template(osv.osv):
768     _inherit = "email.template"
769     _defaults = {
770         'object_name': lambda obj, cr, uid, context: context.get('object_id',False),
771     }
772
773     # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign
774
775 email_template()
776
777 class report_xml(osv.osv):
778     _inherit = 'ir.actions.report.xml'
779     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
780         if context is None:
781             context = {}
782         object_id = context.get('object_id')
783         if object_id:
784             model = self.pool.get('ir.model').browse(cr, uid, object_id).model
785             args.append(('model', '=', model))
786         return super(report_xml, self).search(cr, uid, args, offset, limit, order, context, count)
787
788 report_xml()
789