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 ##############################################################################
24 from datetime import datetime
25 from dateutil.relativedelta import relativedelta
26 from operator import itemgetter
27 from traceback import format_exception
28 from sys import exc_info
29 from tools.safe_eval import safe_eval as eval
31 from osv import fields, osv
33 from tools.translate import _
36 'hours': lambda interval: relativedelta(hours=interval),
37 'days': lambda interval: relativedelta(days=interval),
38 'months': lambda interval: relativedelta(months=interval),
39 'years': lambda interval: relativedelta(years=interval),
42 DT_FMT = '%Y-%m-%d %H:%M:%S'
45 return dict((k, f(v)) for k,v in d.items())
47 def _find_fieldname(model, field):
48 inherit_columns = dict_map(itemgetter(2), model._inherit_fields)
49 all_columns = dict(inherit_columns, **model._columns)
50 for fn in all_columns:
51 if all_columns[fn] is field:
53 raise ValueError('field not found: %r' % (field,))
55 class selection_converter(object):
56 """Format the selection in the browse record objects"""
57 def __init__(self, value):
61 def set_value(self, cr, uid, _self_again, record, field, lang):
62 # this design is terrible
63 # search fieldname from the field
64 fieldname = _find_fieldname(record._table, field)
65 context = dict(lang=lang.code)
66 fg = record._table.fields_get(cr, uid, [fieldname], context=context)
67 selection = dict(fg[fieldname]['selection'])
68 self._str = selection[self.value]
77 translate_selections = {
78 'selection': selection_converter,
82 class marketing_campaign(osv.osv):
83 _name = "marketing.campaign"
84 _description = "Marketing Campaign"
87 'name': fields.char('Name', size=64, required=True),
88 'object_id': fields.many2one('ir.model', 'Model', required=True,
89 help="Choose the model on which you want \
90 this campaign to be run"),
91 'partner_field_id': fields.many2one('ir.model.fields', 'Partner Field',
92 domain="[('model_id', '=', object_id), ('ttype', '=', 'many2one'), ('relation', '=', 'res.partner')]",
93 help="The generated workitems will be linked to the partner related to the record. If the record is the partner itself left this field empty."),
94 'mode': fields.selection([('test', 'Test Directly'),
95 ('test_realtime', 'Test in Realtime'),
96 ('manual', 'With Manual Confirmation'),
97 ('active', 'Normal')],
98 'Mode', required=True, help= \
99 """Test - It creates and process all the activities directly (without waiting for the delay on transitions) but does not send emails or produce reports.
100 Test in Realtime - It creates and processes all the activities directly but does not send emails or produce reports.
101 With Manual Confirmation - the campaigns runs normally, but the user has to validate all workitem manually.
102 Normal - the campaign runs normally and automatically sends all emails and reports (be very careful with this mode, you're live!)"""),
103 'state': fields.selection([('draft', 'Draft'),
104 ('running', 'Running'),
106 ('cancelled', 'Cancelled'),],
108 'activity_ids': fields.one2many('marketing.campaign.activity',
109 'campaign_id', 'Activities'),
110 'fixed_cost': fields.float('Fixed Cost', help="Fixed cost for the campaign (used for campaign analysis), see also variable cost on activities"),
114 'state': lambda *a: 'draft',
115 'mode': lambda *a: 'test',
118 def state_running_set(self, cr, uid, ids, *args):
119 # TODO check that all subcampaigns are running
120 campaign = self.browse(cr, uid, ids[0])
122 if not campaign.activity_ids:
123 raise osv.except_osv(_("Error"), _("The campaign cannot be started: there are no activities in it"))
126 has_signal_without_from = False
128 for activity in campaign.activity_ids:
131 if activity.signal and len(activity.from_ids) == 0:
132 has_signal_without_from = True
134 if activity.type != 'email':
136 if not activity.email_template_id.enforce_from_account:
137 raise osv.except_osv(_("Error"), _("The campaign cannot be started: an email account is missing in the email activity '%s'")%activity.name)
138 if activity.email_template_id.enforce_from_account.state != 'approved':
139 raise osv.except_osv(_("Error"), _("The campaign cannot be started: the email account is not approved in the email activity '%s'")%activity.name)
141 if not has_start and not has_signal_without_from:
142 raise osv.except_osv(_("Error"), _("The campaign hasn't any starting activity nor any activity with a signal and no previous activity."))
144 return self.write(cr, uid, ids, {'state': 'running'})
146 def state_done_set(self, cr, uid, ids, *args):
147 # TODO check that this campaign is not a subcampaign in running mode.
148 segment_ids = self.pool.get('marketing.campaign.segment').search(cr, uid,
149 [('campaign_id', 'in', ids),
150 ('state', '=', 'running')])
152 raise osv.except_osv(_("Error"), _("The campaign cannot be marked as done before all segments are done"))
153 self.write(cr, uid, ids, {'state': 'done'})
156 def state_cancel_set(self, cr, uid, ids, *args):
157 # TODO check that this campaign is not a subcampaign in running mode.
158 self.write(cr, uid, ids, {'state': 'cancelled'})
162 def signal(self, cr, uid, model, res_id, signal, context=None):
163 record = self.pool.get(model).browse(cr, uid, res_id, context)
164 return self._signal(cr, uid, record, signal, context)
166 def _signal(self, cr, uid, record, signal, context=None):
168 raise ValueError('signal cannot be False')
170 Workitems = self.pool.get('marketing.campaign.workitem')
171 domain = [('object_id.model', '=', record._table._name),
172 ('state', '=', 'running')]
173 campaign_ids = self.search(cr, uid, domain, context=context)
174 for campaign in self.browse(cr, uid, campaign_ids, context):
175 for activity in campaign.activity_ids:
176 if activity.signal != signal:
179 data = dict(activity_id=activity.id,
182 wi_domain = [(k, '=', v) for k, v in data.items()]
184 wi_ids = Workitems.search(cr, uid, wi_domain, context=context)
186 partner = self._get_partner_for(campaign, record)
188 data['partner_id'] = partner.id
189 wi_id = Workitems.create(cr, uid, data, context=context)
191 Workitems.process(cr, uid, wi_ids, context=context)
194 def _get_partner_for(self, campaign, record):
195 partner_field = campaign.partner_field_id.name
197 return getattr(record, partner_field)
198 elif campaign.object_id.model == 'res.partner':
204 class marketing_campaign_segment(osv.osv):
205 _name = "marketing.campaign.segment"
206 _description = "Campaign Segment"
209 'name': fields.char('Name', size=64,required=True),
210 'campaign_id': fields.many2one('marketing.campaign', 'Campaign',
211 required=True, select=1),
212 'object_id': fields.related('campaign_id','object_id',
213 type='many2one', relation='ir.model',
215 'ir_filter_id': fields.many2one('ir.filters', 'Filter', help=""),
216 'sync_last_date': fields.datetime('Latest Synchronization'),
217 'sync_mode': fields.selection([('create_date', 'If record created after last sync'),
218 ('write_date', 'If record modified after last sync (no duplicates)')],
219 'Workitem creation mode',
220 help="Determines when new workitems should be created for records matching a segment."),
221 'state': fields.selection([('draft', 'Draft'),
222 ('running', 'Running'),
224 ('cancelled', 'Cancelled')],
226 'date_run': fields.datetime('Launching Date'),
227 'date_done': fields.datetime('End Date'),
231 'state': lambda *a: 'draft',
232 'sync_mode': lambda *a: 'create_date',
235 def state_running_set(self, cr, uid, ids, *args):
236 segment = self.browse(cr, uid, ids[0])
237 vals = {'state': 'running'}
238 if not segment.date_run:
239 vals['date_run'] = time.strftime('%Y-%m-%d %H:%M:%S')
240 self.write(cr, uid, ids, vals)
243 def state_done_set(self, cr, uid, ids, *args):
244 wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
245 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
246 self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
247 self.write(cr, uid, ids, {'state': 'done','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
250 def state_cancel_set(self, cr, uid, ids, *args):
251 wi_ids = self.pool.get("marketing.campaign.workitem").search(cr, uid,
252 [('state', '=', 'todo'), ('segment_id', 'in', ids)])
253 self.pool.get("marketing.campaign.workitem").write(cr, uid, wi_ids, {'state':'cancelled'})
254 self.write(cr, uid, ids, {'state': 'cancelled','date_done': time.strftime('%Y-%m-%d %H:%M:%S')})
257 def synchroniz(self, cr, uid, ids, *args):
258 self.process_segment(cr, uid, ids)
261 def process_segment(self, cr, uid, segment_ids=None, context=None):
262 Workitems = self.pool.get('marketing.campaign.workitem')
264 segment_ids = self.search(cr, uid, [('state', '=', 'running')], context=context)
266 action_date = time.strftime('%Y-%m-%d %H:%M:%S')
268 for segment in self.browse(cr, uid, segment_ids, context=context):
269 campaigns.add(segment.campaign_id.id)
270 act_ids = self.pool.get('marketing.campaign.activity').search(cr,
271 uid, [('start', '=', True), ('campaign_id', '=', segment.campaign_id.id)], context=context)
273 model_obj = self.pool.get(segment.object_id.model)
275 if segment.sync_last_date:
276 criteria += [(segment.sync_mode, '>', segment.sync_last_date)]
277 if segment.ir_filter_id:
278 criteria += eval(segment.ir_filter_id.domain)
279 object_ids = model_obj.search(cr, uid, criteria, context=context)
281 # XXX TODO: rewrite this loop more efficiently without doing 1 search per record!
282 for o_ids in model_obj.browse(cr, uid, object_ids, context=context):
283 # avoid duplicated workitem for the same resource
284 if segment.sync_mode == 'write_date':
285 wi_ids = Workitems.search(cr, uid, [('res_id','=',o_ids.id),('segment_id','=',segment.id)], context=context)
290 'segment_id': segment.id,
296 partner = self.pool.get('marketing.campaign')._get_partner_for(segment.campaign_id, o_ids)
298 wi_vals['partner_id'] = partner.id
300 for act_id in act_ids:
301 wi_vals['activity_id'] = act_id
302 Workitems.create(cr, uid, wi_vals, context=context)
304 self.write(cr, uid, segment.id, {'sync_last_date':action_date}, context=context)
305 Workitems.process_all(cr, uid, list(campaigns), context=context)
308 marketing_campaign_segment()
310 class marketing_campaign_activity(osv.osv):
311 _name = "marketing.campaign.activity"
312 _description = "Campaign Activity"
317 ('action', 'Action'),
318 # TODO implement the subcampaigns.
319 # TODO implement the subcampaign out. disallow out transitions from
320 # subcampaign activities ?
321 #('subcampaign', 'Sub-Campaign'),
325 'name': fields.char('Name', size=128, required=True),
326 'campaign_id': fields.many2one('marketing.campaign', 'Campaign',
327 required = True, ondelete='cascade', select=1),
328 'object_id': fields.related('campaign_id','object_id',
329 type='many2one', relation='ir.model',
330 string='Object', readonly=True),
331 'start': fields.boolean('Start', help= "This activity is launched when the campaign starts.", select=True),
332 'condition': fields.char('Condition', size=256, required=True,
333 help="Python condition to know if the activity can be executed, otherwise it will be deleted or cancelled."),
334 'type': fields.selection(_action_types, 'Type', required=True,
335 help="Describe type of action to be performed on the Activity.Eg : Send email,Send paper.."),
336 'email_template_id': fields.many2one('email.template','Email Template'),
337 'report_id': fields.many2one('ir.actions.report.xml', 'Reports', ),
338 'report_directory_id': fields.many2one('document.directory','Directory',
339 help="Folder is used to store the generated reports"),
340 'server_action_id': fields.many2one('ir.actions.server', string='Action',
341 help= "Describes the action name.\n"
342 "eg:On which object which action to be taken on basis of which condition"),
343 'to_ids': fields.one2many('marketing.campaign.transition',
346 'from_ids': fields.one2many('marketing.campaign.transition',
348 'Previous Activities'),
349 #'subcampaign_id': fields.many2one('marketing.campaign', 'Sub-Campaign',
350 # domain="[('object_id', '=', object_id)]"),
351 'variable_cost': fields.float('Variable Cost'),
352 'revenue': fields.float('Revenue'),
353 'signal': fields.char('Signal', size=128,
354 help='An activity with a signal can be called programmatically. Be careful, the workitem is always created when a signal is sent'),
355 'keep_if_condition_not_met': fields.boolean('Keep as cancelled when condition not met',
356 help="By activating this option, workitems that aren't executed because the condition is not met are marked as cancelled instead of being deleted.")
360 'type': lambda *a: 'email',
361 'condition': lambda *a: 'True',
364 def search(self, cr, uid, args, offset=0, limit=None, order=None,
365 context=None, count=False):
368 if 'segment_id' in context and context['segment_id']:
369 segment_obj = self.pool.get('marketing.campaign.segment').browse(cr,
370 uid, context['segment_id'])
372 for activity in segment_obj.campaign_id.activity_ids:
373 act_ids.append(activity.id)
375 return super(marketing_campaign_activity, self).search(cr, uid, args,
376 offset, limit, order, context, count)
378 def _process_wi_paper(self, cr, uid, activity, workitem, context=None):
379 service = netsvc.LocalService('report.%s'%activity.report_id.report_name)
380 (report_data, format) = service.create(cr, uid, [], {}, {})
382 'name': '%s_%s_%s'%(activity.report_id.report_name,
383 activity.name,workitem.partner_id.name),
384 'datas_fname': '%s.%s'%(activity.report_id.report_name,
385 activity.report_id.report_type),
386 'parent_id': activity.report_directory_id.id,
387 'datas': base64.encodestring(report_data),
390 self.pool.get('ir.attachment').create(cr, uid, attach_vals)
393 def _process_wi_email(self, cr, uid, activity, workitem, context=None):
394 return self.pool.get('email.template').generate_mail(cr, uid,
395 activity.email_template_id.id,
396 [workitem.res_id], context=context)
398 def _process_wi_action(self, cr, uid, activity, workitem, context=None):
401 server_obj = self.pool.get('ir.actions.server')
403 action_context = dict(context,
404 active_id=workitem.res_id,
405 active_ids=[workitem.res_id],
406 active_model=workitem.object_id.model)
407 res = server_obj.run(cr, uid, [activity.server_action_id.id],
408 context=action_context)
409 # server action return False if the action is perfomed
410 # except client_action, other and python code
411 return res == False and True or res
413 def process(self, cr, uid, act_id, wi_id, context=None):
414 activity = self.browse(cr, uid, act_id, context=context)
415 method = '_process_wi_%s' % (activity.type,)
416 action = getattr(self, method, None)
418 raise NotImplementedError('method %r in not implemented on %r object' % (method, self))
420 workitem_obj = self.pool.get('marketing.campaign.workitem')
421 workitem = workitem_obj.browse(cr, uid, wi_id, context=context)
422 return action(cr, uid, activity, workitem, context)
424 marketing_campaign_activity()
426 class marketing_campaign_transition(osv.osv):
427 _name = "marketing.campaign.transition"
428 _description = "Campaign Transition"
430 _interval_units = [('hours', 'Hour(s)'), ('days', 'Day(s)'),
431 ('months', 'Month(s)'), ('years','Year(s)')]
434 def _get_name(self, cr, uid, ids, fn, args, context=None):
435 result = dict.fromkeys(ids, False)
437 'auto': _('Automatic transition'),
438 'time': _('After %(interval_nbr)d %(interval_type)s'),
439 'cosmetic': _('Cosmetic'),
441 for tr in self.browse(cr, uid, ids, context=context,
442 fields_process=translate_selections):
443 result[tr.id] = formatters[tr.trigger.value] % tr
447 def _delta(self, cr, uid, ids, context=None):
449 transition = self.browse(cr, uid, ids[0], context)
450 if transition.trigger != 'time':
451 raise ValueError('Delta is only relevant for timed transiton')
452 return relativedelta(**{transition.interval_type: transition.interval_nbr})
456 'name': fields.function(_get_name, method=True, string='Name',
457 type='char', size=128),
458 'activity_from_id': fields.many2one('marketing.campaign.activity',
459 'Source Activity', select=1,
461 'activity_to_id': fields.many2one('marketing.campaign.activity',
462 'Destination Activity',
464 'interval_nbr': fields.integer('Interval Value', required=True),
465 'interval_type': fields.selection(_interval_units, 'Interval Unit',
468 'trigger': fields.selection([('auto', 'Automatic'),
470 ('cosmetic', 'Cosmetic'), # fake plastic transition
472 'Trigger', required=True,
473 help="How is the destination workitem triggered"),
478 'interval_type': 'days',
483 ('interval_positive', 'CHECK(interval_nbr >= 0)', 'The interval must be positive or zero')
486 marketing_campaign_transition()
488 class marketing_campaign_workitem(osv.osv):
489 _name = "marketing.campaign.workitem"
490 _description = "Campaign Workitem"
492 def _res_name_get(self, cr, uid, ids, field_name, arg, context=None):
493 res = dict.fromkeys(ids, '/')
494 for wi in self.browse(cr, uid, ids, context=context):
498 proxy = self.pool.get(wi.object_id.model)
499 ng = proxy.name_get(cr, uid, [wi.res_id], context=context)
501 res[wi.id] = ng[0][1]
505 'segment_id': fields.many2one('marketing.campaign.segment', 'Segment'),
506 'activity_id': fields.many2one('marketing.campaign.activity','Activity',
508 'campaign_id': fields.related('activity_id', 'campaign_id',
509 type='many2one', relation='marketing.campaign', string='Campaign', readonly=True),
510 'object_id': fields.related('activity_id', 'campaign_id', 'object_id',
511 type='many2one', relation='ir.model', string='Object', select=1),
512 'res_id': fields.integer('Resource ID', select=1, readonly=1),
513 'res_name': fields.function(_res_name_get, method=True, string='Resource Name', type="char", size=64),
514 'date': fields.datetime('Execution Date', help='If date is not set, this workitem have to be run manually'),
515 'partner_id': fields.many2one('res.partner', 'Partner', select=1),
516 'state': fields.selection([('todo', 'To Do'),
517 ('exception', 'Exception'), ('done', 'Done'),
518 ('cancelled', 'Cancelled')], 'State'),
520 'error_msg' : fields.text('Error Message')
523 'state': lambda *a: 'todo',
527 def button_draft(self, cr, uid, workitem_ids, context={}):
528 for wi in self.browse(cr, uid, workitem_ids, context=context):
529 if wi.state in ('exception', 'cancelled'):
530 self.write(cr, uid, [wi.id], {'state':'todo'}, context=context)
533 def button_cancel(self, cr, uid, workitem_ids, context={}):
534 for wi in self.browse(cr, uid, workitem_ids, context=context):
535 if wi.state in ('todo','exception'):
536 self.write(cr, uid, [wi.id], {'state':'cancelled'}, context=context)
539 def _process_one(self, cr, uid, workitem, context=None):
540 if workitem.state != 'todo':
543 activity = workitem.activity_id
544 proxy = self.pool.get(workitem.object_id.model)
545 object_id = proxy.browse(cr, uid, workitem.res_id, context=context)
548 'activity': activity,
549 'workitem': workitem,
551 'transition': activity.to_ids
554 condition = activity.condition
555 campaign_mode = workitem.campaign_id.mode
557 if not eval(condition, eval_context):
558 if activity.keep_if_condition_not_met:
559 workitem.write({'state': 'cancelled'}, context=context)
561 workitem.unlink(context=context)
564 if campaign_mode in ('manual', 'active'):
565 Activities = self.pool.get('marketing.campaign.activity')
566 result = Activities.process(cr, uid, activity.id, workitem.id,
569 values = dict(state='done')
570 if not workitem.date:
571 values['date'] = datetime.now().strftime(DT_FMT)
572 workitem.write(values, context=context)
576 for transition in activity.to_ids:
577 if transition.trigger == 'cosmetic':
580 if transition.trigger == 'auto':
581 launch_date = datetime.now()
582 elif transition.trigger == 'time':
583 launch_date = datetime.now() + transition._delta()
586 launch_date = launch_date.strftime(DT_FMT)
589 'segment_id': workitem.segment_id.id,
590 'activity_id': transition.activity_to_id.id,
591 'partner_id': workitem.partner_id.id,
592 'res_id': workitem.res_id,
595 wi_id = self.create(cr, uid, values, context=context)
597 # Now, depending of the trigger and the campaign mode
598 # we know if must run the newly created workitem.
600 # rows = transition trigger \ colums = campaign mode
602 # test test_realtime manual normal (active)
608 run = transition.trigger == 'auto' \
609 or (transition.trigger == 'time' \
610 and campaign_mode == 'test')
612 new_wi = self.browse(cr, uid, wi_id, context)
613 self._process_one(cr, uid, new_wi, context)
616 tb = "".join(format_exception(*exc_info()))
617 workitem.write({'state': 'exception', 'error_msg': tb},
620 def process(self, cr, uid, workitem_ids, context=None):
621 for wi in self.browse(cr, uid, workitem_ids, context):
622 self._process_one(cr, uid, wi, context)
625 def process_all(self, cr, uid, camp_ids=None, context=None):
626 camp_obj = self.pool.get('marketing.campaign')
628 camp_ids = camp_obj.search(cr, uid, [('state','=','running')], context=context)
629 for camp in camp_obj.browse(cr, uid, camp_ids, context=context):
630 if camp.mode == 'manual':
631 # manual states are not processed automatically
634 domain = [('state', '=', 'todo'), ('date', '!=', False)]
635 if camp.mode in ('test_realtime', 'active'):
636 domain += [('date','<=', time.strftime('%Y-%m-%d %H:%M:%S'))]
638 workitem_ids = self.search(cr, uid, domain, context=context)
642 self.process(cr, uid, workitem_ids, context)
645 def preview(self, cr, uid, ids, context):
647 wi_obj = self.browse(cr, uid, ids[0], context)
648 if wi_obj.activity_id.type == 'email':
649 data_obj = self.pool.get('ir.model.data')
650 data_id = data_obj._get_id(cr, uid, 'email_template', 'email_template_preview_form')
653 view_id = data_obj.browse(cr, uid, data_id, context=context).res_id
655 'name': _('Email Preview'),
657 'view_mode': 'form,tree',
658 'res_model': 'email_template.preview',
661 'views': [(view_id, 'form')],
662 'type': 'ir.actions.act_window',
665 'context': "{'template_id':%d,'default_rel_model_ref':%d}"%
666 (wi_obj.activity_id.email_template_id.id,
670 elif wi_obj.activity_id.type == 'paper':
671 datas = {'ids': [wi_obj.res_id],
672 'model': wi_obj.object_id.model}
674 'type' : 'ir.actions.report.xml',
675 'report_name': wi_obj.activity_id.report_id.report_name,
680 raise osv.except_osv(_('No preview'),_('The current step for this item has no email or report to preview.'))
683 marketing_campaign_workitem()
685 class email_template(osv.osv):
686 _inherit = "email.template"
688 'object_name': lambda obj, cr, uid, context: context.get('object_id',False),
691 # TODO: add constraint to prevent disabling / disapproving an email account used in a running campaign
695 class report_xml(osv.osv):
696 _inherit = 'ir.actions.report.xml'
697 def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
700 object_id = context.get('object_id')
702 model = self.pool.get('ir.model').browse(cr, uid, object_id).model
703 args.append(('model', '=', model))
704 return super(report_xml, self).search(cr, uid, args, offset, limit, order, context, count)