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