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