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