1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2010 OpenERP SA (<http://openerp.com>).
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.
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.
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/>.
20 ##############################################################################
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
32 from openerp.addons.decimal_precision import decimal_precision as dp
34 from openerp.osv import fields, osv
35 from openerp.tools.translate import _
38 'hours': lambda interval: relativedelta(hours=interval),
39 'days': lambda interval: relativedelta(days=interval),
40 'months': lambda interval: relativedelta(months=interval),
41 'years': lambda interval: relativedelta(years=interval),
44 DT_FMT = '%Y-%m-%d %H:%M:%S'
47 return dict((k, f(v)) for k,v in d.items())
49 def _find_fieldname(model, field):
50 inherit_columns = dict_map(itemgetter(2), model._inherit_fields)
51 all_columns = dict(inherit_columns, **model._columns)
52 for fn in all_columns:
53 if all_columns[fn] is field:
55 raise ValueError('Field not found: %r' % (field,))
57 class selection_converter(object):
58 """Format the selection in the browse record objects"""
59 def __init__(self, value):
63 def set_value(self, cr, uid, _self_again, record, field, lang):
64 # this design is terrible
65 # search fieldname from the field
66 fieldname = _find_fieldname(record._table, field)
67 context = dict(lang=lang.code)
68 fg = record._table.fields_get(cr, uid, [fieldname], context=context)
69 selection = dict(fg[fieldname]['selection'])
70 self._str = selection[self.value]
79 translate_selections = {
80 'selection': selection_converter,
84 class marketing_campaign(osv.osv):
85 _name = "marketing.campaign"
86 _description = "Marketing Campaign"
89 'name': fields.char('Name', size=64, required=True),
90 'object_id': fields.many2one('ir.model', 'Resource', required=True,
91 help="Choose the resource on which you want \
92 this campaign to be run"),
93 'partner_field_id': fields.many2one('ir.model.fields', 'Partner Field',
94 domain="[('model_id', '=', object_id), ('ttype', '=', 'many2one'), ('relation', '=', 'res.partner')]",
95 help="The generated workitems will be linked to the partner related to the record. "\
96 "If the record is the partner itself leave this field empty. "\
97 "This is useful for reporting purposes, via the Campaign Analysis or Campaign Follow-up views."),
98 'unique_field_id': fields.many2one('ir.model.fields', 'Unique Field',
99 domain="[('model_id', '=', object_id), ('ttype', 'in', ['char','int','many2one','text','selection'])]",
100 help='If set, this field will help segments that work in "no duplicates" mode to avoid '\
101 'selecting similar records twice. Similar records are records that have the same value for '\
102 'this unique field. For example by choosing the "email_from" field for CRM Leads you would prevent '\
103 'sending the same campaign to the same email address again. If not set, the "no duplicates" segments '\
104 "will only avoid selecting the same record again if it entered the campaign previously. "\
105 "Only easily comparable fields like textfields, integers, selections or single relationships may be used."),
106 'mode': fields.selection([('test', 'Test Directly'),
107 ('test_realtime', 'Test in Realtime'),
108 ('manual', 'With Manual Confirmation'),
109 ('active', 'Normal')],
110 'Mode', required=True, help= \
111 """Test - It creates and process all the activities directly (without waiting for the delay on transitions) but does not send emails or produce reports.
112 Test in Realtime - It creates and processes all the activities directly but does not send emails or produce reports.
113 With Manual Confirmation - the campaigns runs normally, but the user has to validate all workitem manually.
114 Normal - the campaign runs normally and automatically sends all emails and reports (be very careful with this mode, you're live!)"""),
115 'state': fields.selection([('draft', 'New'),
116 ('running', 'Running'),
117 ('cancelled', 'Cancelled'),
120 'activity_ids': fields.one2many('marketing.campaign.activity',
121 'campaign_id', 'Activities'),
122 'fixed_cost': fields.float('Fixed Cost', help="Fixed cost for running this campaign. You may also specify variable cost and revenue on each campaign activity. Cost and Revenue statistics are included in Campaign Reporting.", digits_compute=dp.get_precision('Product Price')),
126 'state': lambda *a: 'draft',
127 'mode': lambda *a: 'test',
130 def state_running_set(self, cr, uid, ids, *args):
131 # TODO check that all subcampaigns are running
132 campaign = self.browse(cr, uid, ids[0])
134 if not campaign.activity_ids:
135 raise osv.except_osv(_("Error"), _("The campaign cannot be started. There are no activities in it."))
138 has_signal_without_from = False
140 for activity in campaign.activity_ids:
143 if activity.signal and len(activity.from_ids) == 0:
144 has_signal_without_from = True
146 if not has_start and not has_signal_without_from:
147 raise osv.except_osv(_("Error"), _("The campaign cannot be started. It does not have any starting activity. Modify campaign's activities to mark one as the starting point."))
149 return self.write(cr, uid, ids, {'state': 'running'})
151 def state_done_set(self, cr, uid, ids, *args):
152 # TODO check that this campaign is not a subcampaign in running mode.
153 segment_ids = self.pool.get('marketing.campaign.segment').search(cr, uid,
154 [('campaign_id', 'in', ids),
155 ('state', '=', 'running')])
157 raise osv.except_osv(_("Error"), _("The campaign cannot be marked as done before all segments are closed."))
158 self.write(cr, uid, ids, {'state': 'done'})
161 def state_cancel_set(self, cr, uid, ids, *args):
162 # TODO check that this campaign is not a subcampaign in running mode.
163 self.write(cr, uid, ids, {'state': 'cancelled'})
167 def signal(self, cr, uid, model, res_id, signal, run_existing=True, context=None):
168 record = self.pool[model].browse(cr, uid, res_id, context)
169 return self._signal(cr, uid, record, signal, run_existing, context)
172 def _signal(self, cr, uid, record, signal, run_existing=True, context=None):
174 raise ValueError('Signal cannot be False.')
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:
185 data = dict(activity_id=activity.id,
188 wi_domain = [(k, '=', v) for k, v in data.items()]
190 wi_ids = Workitems.search(cr, uid, wi_domain, context=context)
195 partner = self._get_partner_for(campaign, record)
197 data['partner_id'] = partner.id
198 wi_id = Workitems.create(cr, uid, data, context=context)
200 Workitems.process(cr, uid, wi_ids, context=context)
203 def _get_partner_for(self, campaign, record):
204 partner_field = campaign.partner_field_id.name
206 return getattr(record, partner_field)
207 elif campaign.object_id.model == 'res.partner':
211 # prevent duplication until the server properly duplicates several levels of nested o2m
212 def copy(self, cr, uid, id, default=None, context=None):
213 raise osv.except_osv(_("Operation not supported"), _("You cannot duplicate a campaign, Not supported yet."))
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
219 :param record: browse_record to find duplicates workitems for.
220 :param campaign_rec: browse_record of campaign
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
227 unique_value = getattr(record, unique_field.name, None)
229 if unique_field.ttype == 'many2one':
230 unique_value = unique_value.id
231 similar_res_ids = self.pool[campaign_rec.object_id.model].search(cr, uid,
232 [(unique_field.name, '=', unique_value)], context=context)
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)
240 class marketing_campaign_segment(osv.osv):
241 _name = "marketing.campaign.segment"
242 _description = "Campaign Segment"
245 def _get_next_sync(self, cr, uid, ids, fn, args, context=None):
246 # next auto sync date is same for all segments
247 sync_job = self.pool.get('ir.model.data').get_object(cr, uid, 'marketing_campaign', 'ir_cron_marketing_campaign_every_day', context=context)
248 next_sync = sync_job and sync_job.nextcall or False
249 return dict.fromkeys(ids, next_sync)
252 'name': fields.char('Name', size=64,required=True),
253 'campaign_id': fields.many2one('marketing.campaign', 'Campaign', required=True, select=1, ondelete="cascade"),
254 'object_id': fields.related('campaign_id','object_id', type='many2one', relation='ir.model', string='Resource'),
255 'ir_filter_id': fields.many2one('ir.filters', 'Filter', ondelete="restrict",
256 help="Filter to select the matching resource records that belong to this segment. "\
257 "New filters can be created and saved using the advanced search on the list view of the Resource. "\
258 "If no filter is set, all records are selected without filtering. "\
259 "The synchronization mode may also add a criterion to the filter."),
260 'sync_last_date': fields.datetime('Last Synchronization', help="Date on which this segment was synchronized last time (automatically or manually)"),
261 'sync_mode': fields.selection([('create_date', 'Only records created after last sync'),
262 ('write_date', 'Only records modified after last sync (no duplicates)'),
263 ('all', 'All records (no duplicates)')],
264 'Synchronization mode',
265 help="Determines an additional criterion to add to the filter when selecting new records to inject in the campaign. "\
266 '"No duplicates" prevents selecting records which have already entered the campaign previously.'\
267 'If the campaign has a "unique field" set, "no duplicates" will also prevent selecting records which have '\
268 'the same value for the unique field as other records that already entered the campaign.'),
269 'state': fields.selection([('draft', 'New'),
270 ('cancelled', 'Cancelled'),
271 ('running', 'Running'),
274 'date_run': fields.datetime('Launch Date', help="Initial start date of this segment."),
275 'date_done': fields.datetime('End Date', help="Date this segment was last closed or cancelled."),
276 'date_next_sync': fields.function(_get_next_sync, string='Next Synchronization', type='datetime', help="Next time the synchronization job is scheduled to run automatically"),
280 'state': lambda *a: 'draft',
281 'sync_mode': lambda *a: 'create_date',
284 def _check_model(self, cr, uid, ids, context=None):
285 for obj in self.browse(cr, uid, ids, context=context):
286 if not obj.ir_filter_id:
288 if obj.campaign_id.object_id.model != obj.ir_filter_id.model_id:
293 (_check_model, 'Model of filter must be same as resource model of Campaign ', ['ir_filter_id,campaign_id']),
296 def onchange_campaign_id(self, cr, uid, ids, campaign_id):
297 res = {'domain':{'ir_filter_id':[]}}
298 campaign_pool = self.pool.get('marketing.campaign')
300 campaign = campaign_pool.browse(cr, uid, campaign_id)
301 model_name = self.pool.get('ir.model').read(cr, uid, [campaign.object_id.id], ['model'])
303 mod_name = model_name[0]['model']
304 res['domain'] = {'ir_filter_id': [('model_id', '=', mod_name)]}
306 res['value'] = {'ir_filter_id': False}
309 def state_running_set(self, cr, uid, ids, *args):
310 segment = self.browse(cr, uid, ids[0])
311 vals = {'state': 'running'}
312 if not segment.date_run:
313 vals['date_run'] = time.strftime('%Y-%m-%d %H:%M:%S')
314 self.write(cr, uid, ids, vals)
317 def state_done_set(self, cr, uid, ids, *args):
318 wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
319 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
320 self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
321 self.write(cr, uid, ids, {'state': 'done','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
324 def state_cancel_set(self, cr, uid, ids, *args):
325 wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
326 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
327 self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
328 self.write(cr, uid, ids, {'state': 'cancelled','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
331 def synchroniz(self, cr, uid, ids, *args):
332 self.process_segment(cr, uid, ids)
335 def process_segment(self, cr, uid, segment_ids=None, context=None):
336 Workitems = self.pool.get('marketing.campaign.workitem')
337 Campaigns = self.pool.get('marketing.campaign')
339 segment_ids = self.search(cr, uid, [('state', '=', 'running')], context=context)
341 action_date = time.strftime('%Y-%m-%d %H:%M:%S')
343 for segment in self.browse(cr, uid, segment_ids, context=context):
344 if segment.campaign_id.state != 'running':
347 campaigns.add(segment.campaign_id.id)
348 act_ids = self.pool.get('marketing.campaign.activity').search(cr,
349 uid, [('start', '=', True), ('campaign_id', '=', segment.campaign_id.id)], context=context)
351 model_obj = self.pool[segment.object_id.model]
353 if segment.sync_last_date and segment.sync_mode != 'all':
354 criteria += [(segment.sync_mode, '>', segment.sync_last_date)]
355 if segment.ir_filter_id:
356 criteria += eval(segment.ir_filter_id.domain)
357 object_ids = model_obj.search(cr, uid, criteria, context=context)
359 # XXX TODO: rewrite this loop more efficiently without doing 1 search per record!
360 for record in model_obj.browse(cr, uid, object_ids, context=context):
361 # avoid duplicate workitem for the same resource
362 if segment.sync_mode in ('write_date','all'):
363 if Campaigns._find_duplicate_workitems(cr, uid, record, segment.campaign_id, context=context):
367 'segment_id': segment.id,
373 partner = self.pool.get('marketing.campaign')._get_partner_for(segment.campaign_id, record)
375 wi_vals['partner_id'] = partner.id
377 for act_id in act_ids:
378 wi_vals['activity_id'] = act_id
379 Workitems.create(cr, uid, wi_vals, context=context)
381 self.write(cr, uid, segment.id, {'sync_last_date':action_date}, context=context)
382 Workitems.process_all(cr, uid, list(campaigns), context=context)
386 class marketing_campaign_activity(osv.osv):
387 _name = "marketing.campaign.activity"
389 _description = "Campaign Activity"
393 ('report', 'Report'),
394 ('action', 'Custom Action'),
395 # TODO implement the subcampaigns.
396 # TODO implement the subcampaign out. disallow out transitions from
397 # subcampaign activities ?
398 #('subcampaign', 'Sub-Campaign'),
402 'name': fields.char('Name', size=128, required=True),
403 'campaign_id': fields.many2one('marketing.campaign', 'Campaign',
404 required = True, ondelete='cascade', select=1),
405 'object_id': fields.related('campaign_id','object_id',
406 type='many2one', relation='ir.model',
407 string='Object', readonly=True),
408 'start': fields.boolean('Start', help= "This activity is launched when the campaign starts.", select=True),
409 'condition': fields.text('Condition', size=256, required=True,
410 help="Python expression to decide whether the activity can be executed, otherwise it will be deleted or cancelled."
411 "The expression may use the following [browsable] variables:\n"
412 " - activity: the campaign activity\n"
413 " - workitem: the campaign workitem\n"
414 " - resource: the resource object this campaign item represents\n"
415 " - transitions: list of campaign transitions outgoing from this activity\n"
416 "...- re: Python regular expression module"),
417 'type': fields.selection(_action_types, 'Type', required=True,
418 help="""The type of action to execute when an item enters this activity, such as:
419 - Email: send an email using a predefined email template
420 - Report: print an existing Report defined on the resource item and save it into a specific directory
421 - Custom Action: execute a predefined action, e.g. to modify the fields of the resource record
423 'email_template_id': fields.many2one('email.template', "Email Template", help='The email to send when this activity is activated'),
424 'report_id': fields.many2one('ir.actions.report.xml', "Report", help='The report to generate when this activity is activated', ),
425 'report_directory_id': fields.many2one('document.directory','Directory',
426 help="This folder is used to store the generated reports"),
427 'server_action_id': fields.many2one('ir.actions.server', string='Action',
428 help= "The action to perform when this activity is activated"),
429 'to_ids': fields.one2many('marketing.campaign.transition',
432 'from_ids': fields.one2many('marketing.campaign.transition',
434 'Previous Activities'),
435 'variable_cost': fields.float('Variable Cost', help="Set a variable cost if you consider that every campaign item that has reached this point has entailed a certain cost. You can get cost statistics in the Reporting section", digits_compute=dp.get_precision('Product Price')),
436 'revenue': fields.float('Revenue', help="Set an expected revenue if you consider that every campaign item that has reached this point has generated a certain revenue. You can get revenue statistics in the Reporting section", digits_compute=dp.get_precision('Account')),
437 'signal': fields.char('Signal', size=128,
438 help='An activity with a signal can be called programmatically. Be careful, the workitem is always created when a signal is sent'),
439 'keep_if_condition_not_met': fields.boolean("Don't Delete Workitems",
440 help="By activating this option, workitems that aren't executed because the condition is not met are marked as cancelled instead of being deleted.")
444 'type': lambda *a: 'email',
445 'condition': lambda *a: 'True',
448 def search(self, cr, uid, args, offset=0, limit=None, order=None,
449 context=None, count=False):
452 if 'segment_id' in context and context['segment_id']:
453 segment_obj = self.pool.get('marketing.campaign.segment').browse(cr,
454 uid, context['segment_id'])
456 for activity in segment_obj.campaign_id.activity_ids:
457 act_ids.append(activity.id)
459 return super(marketing_campaign_activity, self).search(cr, uid, args,
460 offset, limit, order, context, count)
463 def _process_wi_report(self, cr, uid, activity, workitem, context=None):
464 service = netsvc.LocalService('report.%s'%activity.report_id.report_name)
465 (report_data, format) = service.create(cr, uid, [], {}, {})
467 'name': '%s_%s_%s'%(activity.report_id.report_name,
468 activity.name,workitem.partner_id.name),
469 'datas_fname': '%s.%s'%(activity.report_id.report_name,
470 activity.report_id.report_type),
471 'parent_id': activity.report_directory_id.id,
472 'datas': base64.encodestring(report_data),
475 self.pool.get('ir.attachment').create(cr, uid, attach_vals)
478 def _process_wi_email(self, cr, uid, activity, workitem, context=None):
479 return self.pool.get('email.template').send_mail(cr, uid,
480 activity.email_template_id.id,
481 workitem.res_id, context=context)
484 def _process_wi_action(self, cr, uid, activity, workitem, context=None):
487 server_obj = self.pool.get('ir.actions.server')
489 action_context = dict(context,
490 active_id=workitem.res_id,
491 active_ids=[workitem.res_id],
492 active_model=workitem.object_id.model,
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
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)
505 raise NotImplementedError('Method %r is not implemented on %r object.' % (method, self))
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)
512 class marketing_campaign_transition(osv.osv):
513 _name = "marketing.campaign.transition"
514 _description = "Campaign Transition"
517 ('hours', 'Hour(s)'), ('days', 'Day(s)'),
518 ('months', 'Month(s)'), ('years','Year(s)')
521 def _get_name(self, cr, uid, ids, fn, args, context=None):
522 result = dict.fromkeys(ids, False)
524 'auto': _('Automatic transition'),
525 'time': _('After %(interval_nbr)d %(interval_type)s'),
526 'cosmetic': _('Cosmetic'),
528 for tr in self.browse(cr, uid, ids, context=context,
529 fields_process=translate_selections):
530 result[tr.id] = formatters[tr.trigger.value] % tr
534 def _delta(self, cr, uid, ids, context=None):
536 transition = self.browse(cr, uid, ids[0], context=context)
537 if transition.trigger != 'time':
538 raise ValueError('Delta is only relevant for timed transition.')
539 return relativedelta(**{str(transition.interval_type): transition.interval_nbr})
543 'name': fields.function(_get_name, string='Name',
544 type='char', size=128),
545 'activity_from_id': fields.many2one('marketing.campaign.activity',
546 'Previous Activity', select=1,
547 required=True, ondelete="cascade"),
548 'activity_to_id': fields.many2one('marketing.campaign.activity',
550 required=True, ondelete="cascade"),
551 'interval_nbr': fields.integer('Interval Value', required=True),
552 'interval_type': fields.selection(_interval_units, 'Interval Unit',
555 'trigger': fields.selection([('auto', 'Automatic'),
557 ('cosmetic', 'Cosmetic'), # fake plastic transition
559 'Trigger', required=True,
560 help="How is the destination workitem triggered"),
565 'interval_type': 'days',
568 def _check_campaign(self, cr, uid, ids, context=None):
569 for obj in self.browse(cr, uid, ids, context=context):
570 if obj.activity_from_id.campaign_id != obj.activity_to_id.campaign_id:
575 (_check_campaign, 'The To/From Activity of transition must be of the same Campaign ', ['activity_from_id,activity_to_id']),
579 ('interval_positive', 'CHECK(interval_nbr >= 0)', 'The interval must be positive or zero')
583 class marketing_campaign_workitem(osv.osv):
584 _name = "marketing.campaign.workitem"
585 _description = "Campaign Workitem"
587 def _res_name_get(self, cr, uid, ids, field_name, arg, context=None):
588 res = dict.fromkeys(ids, '/')
589 for wi in self.browse(cr, uid, ids, context=context):
593 proxy = self.pool[wi.object_id.model]
594 if not proxy.exists(cr, uid, [wi.res_id]):
596 ng = proxy.name_get(cr, uid, [wi.res_id], context=context)
598 res[wi.id] = ng[0][1]
601 def _resource_search(self, cr, uid, obj, name, args, domain=None, context=None):
602 """Returns id of workitem whose resource_name matches with the given name"""
606 condition_name = None
607 for domain_item in args:
608 # we only use the first domain criterion and ignore all the rest including operators
609 if isinstance(domain_item, (list,tuple)) and len(domain_item) == 3 and domain_item[0] == 'res_name':
610 condition_name = [None, domain_item[1], domain_item[2]]
613 assert condition_name, "Invalid search domain for marketing_campaign_workitem.res_name. It should use 'res_name'"
615 cr.execute("""select w.id, w.res_id, m.model \
616 from marketing_campaign_workitem w \
617 left join marketing_campaign_activity a on (a.id=w.activity_id)\
618 left join marketing_campaign c on (c.id=a.campaign_id)\
619 left join ir_model m on (m.id=c.object_id)
623 matching_workitems = []
624 for id, res_id, model in res:
625 workitem_map.setdefault(model,{}).setdefault(res_id,set()).add(id)
626 for model, id_map in workitem_map.iteritems():
627 model_pool = self.pool[model]
628 condition_name[0] = model_pool._rec_name
629 condition = [('id', 'in', id_map.keys()), condition_name]
630 for res_id in model_pool.search(cr, uid, condition, context=context):
631 matching_workitems.extend(id_map[res_id])
632 return [('id', 'in', list(set(matching_workitems)))]
635 'segment_id': fields.many2one('marketing.campaign.segment', 'Segment', readonly=True),
636 'activity_id': fields.many2one('marketing.campaign.activity','Activity',
637 required=True, readonly=True),
638 'campaign_id': fields.related('activity_id', 'campaign_id',
639 type='many2one', relation='marketing.campaign', string='Campaign', readonly=True, store=True),
640 'object_id': fields.related('activity_id', 'campaign_id', 'object_id',
641 type='many2one', relation='ir.model', string='Resource', select=1, readonly=True, store=True),
642 'res_id': fields.integer('Resource ID', select=1, readonly=True),
643 'res_name': fields.function(_res_name_get, string='Resource Name', fnct_search=_resource_search, type="char", size=64),
644 'date': fields.datetime('Execution Date', help='If date is not set, this workitem has to be run manually', readonly=True),
645 'partner_id': fields.many2one('res.partner', 'Partner', select=1, readonly=True),
646 'state': fields.selection([ ('todo', 'To Do'),
647 ('cancelled', 'Cancelled'),
648 ('exception', 'Exception'),
650 ], 'Status', readonly=True),
651 'error_msg' : fields.text('Error Message', readonly=True)
654 'state': lambda *a: 'todo',
658 def button_draft(self, cr, uid, workitem_ids, context=None):
659 for wi in self.browse(cr, uid, workitem_ids, context=context):
660 if wi.state in ('exception', 'cancelled'):
661 self.write(cr, uid, [wi.id], {'state':'todo'}, context=context)
664 def button_cancel(self, cr, uid, workitem_ids, context=None):
665 for wi in self.browse(cr, uid, workitem_ids, context=context):
666 if wi.state in ('todo','exception'):
667 self.write(cr, uid, [wi.id], {'state':'cancelled'}, context=context)
670 def _process_one(self, cr, uid, workitem, context=None):
671 if workitem.state != 'todo':
674 activity = workitem.activity_id
675 proxy = self.pool[workitem.object_id.model]
676 object_id = proxy.browse(cr, uid, workitem.res_id, context=context)
679 'activity': activity,
680 'workitem': workitem,
682 'resource': object_id,
683 'transitions': activity.to_ids,
687 condition = activity.condition
688 campaign_mode = workitem.campaign_id.mode
690 if not eval(condition, eval_context):
691 if activity.keep_if_condition_not_met:
692 workitem.write({'state': 'cancelled'}, context=context)
694 workitem.unlink(context=context)
697 if campaign_mode in ('manual', 'active'):
698 Activities = self.pool.get('marketing.campaign.activity')
699 result = Activities.process(cr, uid, activity.id, workitem.id,
702 values = dict(state='done')
703 if not workitem.date:
704 values['date'] = datetime.now().strftime(DT_FMT)
705 workitem.write(values, context=context)
709 workitem = workitem.browse(context=context)[0] # reload
710 date = datetime.strptime(workitem.date, DT_FMT)
712 for transition in activity.to_ids:
713 if transition.trigger == 'cosmetic':
716 if transition.trigger == 'auto':
718 elif transition.trigger == 'time':
719 launch_date = date + transition._delta()
722 launch_date = launch_date.strftime(DT_FMT)
725 'segment_id': workitem.segment_id.id,
726 'activity_id': transition.activity_to_id.id,
727 'partner_id': workitem.partner_id.id,
728 'res_id': workitem.res_id,
731 wi_id = self.create(cr, uid, values, context=context)
733 # Now, depending on the trigger and the campaign mode
734 # we know whether we must run the newly created workitem.
736 # rows = transition trigger \ colums = campaign mode
738 # test test_realtime manual normal (active)
744 run = (transition.trigger == 'auto' \
745 and campaign_mode != 'manual') \
746 or (transition.trigger == 'time' \
747 and campaign_mode == 'test')
749 new_wi = self.browse(cr, uid, wi_id, context)
750 self._process_one(cr, uid, new_wi, context)
753 tb = "".join(format_exception(*exc_info()))
754 workitem.write({'state': 'exception', 'error_msg': tb},
757 def process(self, cr, uid, workitem_ids, context=None):
758 for wi in self.browse(cr, uid, workitem_ids, context=context):
759 self._process_one(cr, uid, wi, context=context)
762 def process_all(self, cr, uid, camp_ids=None, context=None):
763 camp_obj = self.pool.get('marketing.campaign')
765 camp_ids = camp_obj.search(cr, uid, [('state','=','running')], context=context)
766 for camp in camp_obj.browse(cr, uid, camp_ids, context=context):
767 if camp.mode == 'manual':
768 # manual states are not processed automatically
771 domain = [('campaign_id', '=', camp.id), ('state', '=', 'todo'), ('date', '!=', False)]
772 if camp.mode in ('test_realtime', 'active'):
773 domain += [('date','<=', time.strftime('%Y-%m-%d %H:%M:%S'))]
775 workitem_ids = self.search(cr, uid, domain, context=context)
779 self.process(cr, uid, workitem_ids, context=context)
782 def preview(self, cr, uid, ids, context=None):
784 wi_obj = self.browse(cr, uid, ids[0], context=context)
785 if wi_obj.activity_id.type == 'email':
786 view_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'email_template', 'email_template_preview_form')
788 'name': _('Email Preview'),
790 'view_mode': 'form,tree',
791 'res_model': 'email_template.preview',
794 'views': [(view_id and view_id[1] or 0, 'form')],
795 'type': 'ir.actions.act_window',
798 'context': "{'template_id':%d,'default_res_id':%d}"%
799 (wi_obj.activity_id.email_template_id.id,
803 elif wi_obj.activity_id.type == 'report':
805 'ids': [wi_obj.res_id],
806 'model': wi_obj.object_id.model
809 'type' : 'ir.actions.report.xml',
810 'report_name': wi_obj.activity_id.report_id.report_name,
814 raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))
818 class email_template(osv.osv):
819 _inherit = "email.template"
821 'model_id': lambda obj, cr, uid, context: context.get('object_id',False),
824 # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign
827 class report_xml(osv.osv):
828 _inherit = 'ir.actions.report.xml'
829 def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
832 object_id = context.get('object_id')
834 model = self.pool.get('ir.model').browse(cr, uid, object_id, context=context).model
835 args.append(('model', '=', model))
836 return super(report_xml, self).search(cr, uid, args, offset, limit, order, context, count)
840 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: