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