[REM] completion in Gantt chart tasks and projects, not supported by OpenERP and...
[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. 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 can not duplicate a campaign, it's 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                                    ('running', 'Running'),
273                                    ('done', 'Done'),
274                                    ('cancelled', 'Cancelled')],
275                                    'State',),
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', 'E-mail'),
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 e-mail 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('Purchase 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('Sale Price')),
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 in 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 transiton')
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                                    ('exception', 'Exception'), ('done', 'Done'),
653                                    ('cancelled', 'Cancelled')], 'State', readonly=True),
654
655         'error_msg' : fields.text('Error Message', readonly=True)
656     }
657     _defaults = {
658         'state': lambda *a: 'todo',
659         'date': False,
660     }
661
662     def button_draft(self, cr, uid, workitem_ids, context=None):
663         for wi in self.browse(cr, uid, workitem_ids, context=context):
664             if wi.state in ('exception', 'cancelled'):
665                 self.write(cr, uid, [wi.id], {'state':'todo'}, context=context)
666         return True
667
668     def button_cancel(self, cr, uid, workitem_ids, context=None):
669         for wi in self.browse(cr, uid, workitem_ids, context=context):
670             if wi.state in ('todo','exception'):
671                 self.write(cr, uid, [wi.id], {'state':'cancelled'}, context=context)
672         return True
673
674     def _process_one(self, cr, uid, workitem, context=None):
675         if workitem.state != 'todo':
676             return False
677
678         activity = workitem.activity_id
679         proxy = self.pool.get(workitem.object_id.model)
680         object_id = proxy.browse(cr, uid, workitem.res_id, context=context)
681
682         eval_context = {
683             'activity': activity,
684             'workitem': workitem,
685             'object': object_id,
686             'resource': object_id,
687             'transitions': activity.to_ids,
688             're': re,
689         }
690         try:
691             condition = activity.condition
692             campaign_mode = workitem.campaign_id.mode
693             if condition:
694                 if not eval(condition, eval_context):
695                     if activity.keep_if_condition_not_met:
696                         workitem.write({'state': 'cancelled'}, context=context)
697                     else:
698                         workitem.unlink(context=context)
699                     return
700             result = True
701             if campaign_mode in ('manual', 'active'):
702                 Activities = self.pool.get('marketing.campaign.activity')
703                 result = Activities.process(cr, uid, activity.id, workitem.id,
704                                             context=context)
705
706             values = dict(state='done')
707             if not workitem.date:
708                 values['date'] = datetime.now().strftime(DT_FMT)
709             workitem.write(values, context=context)
710
711             if result:
712                 # process _chain
713                 workitem = workitem.browse(context=context)[0] # reload
714                 date = datetime.strptime(workitem.date, DT_FMT)
715
716                 for transition in activity.to_ids:
717                     if transition.trigger == 'cosmetic':
718                         continue
719                     launch_date = False
720                     if transition.trigger == 'auto':
721                         launch_date = date
722                     elif transition.trigger == 'time':
723                         launch_date = date + transition._delta()
724
725                     if launch_date:
726                         launch_date = launch_date.strftime(DT_FMT)
727                     values = {
728                         'date': launch_date,
729                         'segment_id': workitem.segment_id.id,
730                         'activity_id': transition.activity_to_id.id,
731                         'partner_id': workitem.partner_id.id,
732                         'res_id': workitem.res_id,
733                         'state': 'todo',
734                     }
735                     wi_id = self.create(cr, uid, values, context=context)
736
737                     # Now, depending on the trigger and the campaign mode
738                     # we know whether we must run the newly created workitem.
739                     #
740                     # rows = transition trigger \ colums = campaign mode
741                     #
742                     #           test    test_realtime     manual      normal (active)
743                     # time       Y            N             N           N
744                     # cosmetic   N            N             N           N
745                     # auto       Y            Y             N           Y
746                     #
747
748                     run = (transition.trigger == 'auto' \
749                             and campaign_mode != 'manual') \
750                           or (transition.trigger == 'time' \
751                               and campaign_mode == 'test')
752                     if run:
753                         new_wi = self.browse(cr, uid, wi_id, context)
754                         self._process_one(cr, uid, new_wi, context)
755
756         except Exception:
757             tb = "".join(format_exception(*exc_info()))
758             workitem.write({'state': 'exception', 'error_msg': tb},
759                      context=context)
760
761     def process(self, cr, uid, workitem_ids, context=None):
762         for wi in self.browse(cr, uid, workitem_ids, context=context):
763             self._process_one(cr, uid, wi, context=context)
764         return True
765
766     def process_all(self, cr, uid, camp_ids=None, context=None):
767         camp_obj = self.pool.get('marketing.campaign')
768         if camp_ids is None:
769             camp_ids = camp_obj.search(cr, uid, [('state','=','running')], context=context)
770         for camp in camp_obj.browse(cr, uid, camp_ids, context=context):
771             if camp.mode == 'manual':
772                 # manual states are not processed automatically
773                 continue
774             while True:
775                 domain = [('campaign_id', '=', camp.id), ('state', '=', 'todo'), ('date', '!=', False)]
776                 if camp.mode in ('test_realtime', 'active'):
777                     domain += [('date','<=', time.strftime('%Y-%m-%d %H:%M:%S'))]
778
779                 workitem_ids = self.search(cr, uid, domain, context=context)
780                 if not workitem_ids:
781                     break
782
783                 self.process(cr, uid, workitem_ids, context=context)
784         return True
785
786     def preview(self, cr, uid, ids, context=None):
787         res = {}
788         wi_obj = self.browse(cr, uid, ids[0], context=context)
789         if wi_obj.activity_id.type == 'email':
790             view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'email_template', 'email_template_preview_form')
791             res = {
792                 'name': _('Email Preview'),
793                 'view_type': 'form',
794                 'view_mode': 'form,tree',
795                 'res_model': 'email_template.preview',
796                 'view_id': False,
797                 'context': context,
798                 'views': [(view_id and view_id[1] or 0, 'form')],
799                 'type': 'ir.actions.act_window',
800                 'target': 'new',
801                 'nodestroy':True,
802                 'context': "{'template_id':%d,'default_res_id':%d}"%
803                                 (wi_obj.activity_id.email_template_id.id,
804                                  wi_obj.res_id)
805             }
806
807         elif wi_obj.activity_id.type == 'report':
808             datas = {
809                 'ids': [wi_obj.res_id],
810                 'model': wi_obj.object_id.model
811             }
812             res = {
813                 'type' : 'ir.actions.report.xml',
814                 'report_name': wi_obj.activity_id.report_id.report_name,
815                 'datas' : datas,
816             }
817         else:
818             raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))
819         return res
820
821 marketing_campaign_workitem()
822
823 class email_template(osv.osv):
824     _inherit = "email.template"
825     _defaults = {
826         'model_id': lambda obj, cr, uid, context: context.get('object_id',False),
827     }
828
829     # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign
830
831 email_template()
832
833 class report_xml(osv.osv):
834     _inherit = 'ir.actions.report.xml'
835     def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
836         if context is None:
837             context = {}
838         object_id = context.get('object_id')
839         if object_id:
840             model = self.pool.get('ir.model').browse(cr, uid, object_id, context=context).model
841             args.append(('model', '=', model))
842         return super(report_xml, self).search(cr, uid, args, offset, limit, order, context, count)
843
844 report_xml()
845
846
847 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: