1 # -*- coding: utf-8 -*-
3 from datetime import datetime
4 from dateutil import relativedelta
8 from openerp import tools
9 from openerp.exceptions import Warning
10 from openerp.tools.safe_eval import safe_eval as eval
11 from openerp.tools.translate import _
12 from openerp.osv import osv, fields
15 class MassMailingCategory(osv.Model):
16 """Model of categories of mass mailing, i.e. marketing, newsletter, ... """
17 _name = 'mail.mass_mailing.category'
18 _description = 'Mass Mailing Category'
22 'name': fields.char('Name', required=True),
26 class MassMailingContact(osv.Model):
27 """Model of a contact. This model is different from the partner model
28 because it holds only some basic information: name, email. The purpose is to
29 be able to deal with large contact list to email without bloating the partner
31 _name = 'mail.mass_mailing.contact'
32 _description = 'Mass Mailing Contact'
37 'name': fields.char('Name'),
38 'email': fields.char('Email', required=True),
39 'create_date': fields.datetime('Create Date'),
40 'list_id': fields.many2one(
41 'mail.mass_mailing.list', string='Mailing List',
42 ondelete='cascade', required=True,
44 'opt_out': fields.boolean('Opt Out', help='The contact has chosen not to receive mails anymore from this list'),
47 def _get_latest_list(self, cr, uid, context={}):
48 lid = self.pool.get('mail.mass_mailing.list').search(cr, uid, [], limit=1, order='id desc', context=context)
49 return lid and lid[0] or False
52 'list_id': _get_latest_list
55 def name_create(self, cr, uid, name, context=None):
56 name, email = self.pool['res.partner']._parse_partner_name(name, context=context)
57 if name and not email:
59 if email and not name:
61 rec_id = self.create(cr, uid, {'name': name, 'email': email}, context=context)
62 return self.name_get(cr, uid, [rec_id], context)[0]
64 def message_get_default_recipients(self, cr, uid, ids, context=None):
66 for record in self.browse(cr, uid, ids, context=context):
67 res[record.id] = {'partner_ids': [], 'email_to': record.email, 'email_cc': False}
71 class MassMailingList(osv.Model):
72 """Model of a contact list. """
73 _name = 'mail.mass_mailing.list'
75 _description = 'Mailing List'
77 def _get_contact_nbr(self, cr, uid, ids, name, arg, context=None):
78 result = dict.fromkeys(ids, 0)
79 Contacts = self.pool.get('mail.mass_mailing.contact')
80 for group in Contacts.read_group(cr, uid, [('list_id', 'in', ids), ('opt_out', '!=', True)], ['list_id'], ['list_id'], context=context):
81 result[group['list_id'][0]] = group['list_id_count']
85 'name': fields.char('Mailing List', required=True),
86 'contact_nbr': fields.function(
87 _get_contact_nbr, type='integer',
88 string='Number of Contacts',
93 class MassMailingStage(osv.Model):
94 """Stage for mass mailing campaigns. """
95 _name = 'mail.mass_mailing.stage'
96 _description = 'Mass Mailing Campaign Stage'
100 'name': fields.char('Name', required=True, translate=True),
101 'sequence': fields.integer('Sequence'),
109 class MassMailingCampaign(osv.Model):
110 """Model of mass mailing campaigns. """
111 _name = "mail.mass_mailing.campaign"
112 _description = 'Mass Mailing Campaign'
114 def _get_statistics(self, cr, uid, ids, name, arg, context=None):
115 """ Compute statistics of the mass mailing campaign """
116 Statistics = self.pool['mail.mail.statistics']
117 results = dict.fromkeys(ids, False)
119 stat_ids = Statistics.search(cr, uid, [('mass_mailing_campaign_id', '=', cid)], context=context)
120 stats = Statistics.browse(cr, uid, stat_ids, context=context)
123 'failed': len([s for s in stats if not s.scheduled is False and s.sent is False and not s.exception is False]),
124 'scheduled': len([s for s in stats if not s.scheduled is False and s.sent is False and s.exception is False]),
125 'sent': len([s for s in stats if not s.sent is False]),
126 'opened': len([s for s in stats if not s.opened is False]),
127 'replied': len([s for s in stats if not s.replied is False]),
128 'bounced': len([s for s in stats if not s.bounced is False]),
130 results[cid]['delivered'] = results[cid]['sent'] - results[cid]['bounced']
131 results[cid]['received_ratio'] = 100.0 * results[cid]['delivered'] / (results[cid]['total'] or 1)
132 results[cid]['opened_ratio'] = 100.0 * results[cid]['opened'] / (results[cid]['total'] or 1)
133 results[cid]['replied_ratio'] = 100.0 * results[cid]['replied'] / (results[cid]['total'] or 1)
137 'name': fields.char('Name', required=True),
138 'stage_id': fields.many2one('mail.mass_mailing.stage', 'Stage', required=True),
139 'user_id': fields.many2one(
140 'res.users', 'Responsible',
143 'category_ids': fields.many2many(
144 'mail.mass_mailing.category', 'mail_mass_mailing_category_rel',
145 'category_id', 'campaign_id', string='Categories'),
146 'mass_mailing_ids': fields.one2many(
147 'mail.mass_mailing', 'mass_mailing_campaign_id',
150 'unique_ab_testing': fields.boolean(
152 help='If checked, recipients will be mailed only once, allowing to send'
153 'various mailings in a single campaign to test the effectiveness'
155 'color': fields.integer('Color Index'),
157 'total': fields.function(
158 _get_statistics, string='Total',
159 type='integer', multi='_get_statistics'
161 'scheduled': fields.function(
162 _get_statistics, string='Scheduled',
163 type='integer', multi='_get_statistics'
165 'failed': fields.function(
166 _get_statistics, string='Failed',
167 type='integer', multi='_get_statistics'
169 'sent': fields.function(
170 _get_statistics, string='Sent Emails',
171 type='integer', multi='_get_statistics'
173 'delivered': fields.function(
174 _get_statistics, string='Delivered',
175 type='integer', multi='_get_statistics',
177 'opened': fields.function(
178 _get_statistics, string='Opened',
179 type='integer', multi='_get_statistics',
181 'replied': fields.function(
182 _get_statistics, string='Replied',
183 type='integer', multi='_get_statistics'
185 'bounced': fields.function(
186 _get_statistics, string='Bounced',
187 type='integer', multi='_get_statistics'
189 'received_ratio': fields.function(
190 _get_statistics, string='Received Ratio',
191 type='integer', multi='_get_statistics',
193 'opened_ratio': fields.function(
194 _get_statistics, string='Opened Ratio',
195 type='integer', multi='_get_statistics',
197 'replied_ratio': fields.function(
198 _get_statistics, string='Replied Ratio',
199 type='integer', multi='_get_statistics',
203 def _get_default_stage_id(self, cr, uid, context=None):
204 stage_ids = self.pool['mail.mass_mailing.stage'].search(cr, uid, [], limit=1, context=context)
205 return stage_ids and stage_ids[0] or False
208 'user_id': lambda self, cr, uid, ctx=None: uid,
209 'stage_id': lambda self, *args: self._get_default_stage_id(*args),
212 def get_recipients(self, cr, uid, ids, model=None, context=None):
213 """Return the recipients of a mailing campaign. This is based on the statistics
214 build for each mailing. """
215 Statistics = self.pool['mail.mail.statistics']
216 res = dict.fromkeys(ids, False)
218 domain = [('mass_mailing_campaign_id', '=', cid)]
220 domain += [('model', '=', model)]
221 stat_ids = Statistics.search(cr, uid, domain, context=context)
222 res[cid] = set(stat.res_id for stat in Statistics.browse(cr, uid, stat_ids, context=context))
226 class MassMailing(osv.Model):
227 """ MassMailing models a wave of emails for a mass mailign campaign.
228 A mass mailing is an occurence of sending emails. """
230 _name = 'mail.mass_mailing'
231 _description = 'Mass Mailing'
232 # number of periods for tracking mail_mail statistics
234 _order = 'sent_date DESC'
236 def __get_bar_values(self, cr, uid, obj, domain, read_fields, value_field, groupby_field, date_begin, context=None):
237 """ Generic method to generate data for bar chart values using SparklineBarWidget.
238 This method performs obj.read_group(cr, uid, domain, read_fields, groupby_field).
240 :param obj: the target model (i.e. crm_lead)
241 :param domain: the domain applied to the read_group
242 :param list read_fields: the list of fields to read in the read_group
243 :param str value_field: the field used to compute the value of the bar slice
244 :param str groupby_field: the fields used to group
246 :return list section_result: a list of dicts: [
247 { 'value': (int) bar_column_value,
248 'tootip': (str) bar_column_tooltip,
252 date_begin = date_begin.date()
253 section_result = [{'value': 0,
254 'tooltip': (date_begin + relativedelta.relativedelta(days=i)).strftime('%d %B %Y'),
255 } for i in range(0, self._period_number)]
256 group_obj = obj.read_group(cr, uid, domain, read_fields, groupby_field, context=context)
257 field_col_info = obj._all_columns.get(groupby_field.split(':')[0])
258 pattern = tools.DEFAULT_SERVER_DATE_FORMAT if field_col_info.column._type == 'date' else tools.DEFAULT_SERVER_DATETIME_FORMAT
259 for group in group_obj:
260 group_begin_date = datetime.strptime(group['__domain'][0][2], pattern).date()
261 timedelta = relativedelta.relativedelta(group_begin_date, date_begin)
262 section_result[timedelta.days] = {'value': group.get(value_field, 0), 'tooltip': group.get(groupby_field)}
263 return section_result
265 def _get_daily_statistics(self, cr, uid, ids, field_name, arg, context=None):
266 """ Get the daily statistics of the mass mailing. This is done by a grouping
267 on opened and replied fields. Using custom format in context, we obtain
268 results for the next 6 days following the mass mailing date. """
269 obj = self.pool['mail.mail.statistics']
271 for mailing in self.browse(cr, uid, ids, context=context):
273 date = mailing.sent_date if mailing.sent_date else mailing.create_date
274 date_begin = datetime.strptime(date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
275 date_end = date_begin + relativedelta.relativedelta(days=self._period_number - 1)
276 date_begin_str = date_begin.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
277 date_end_str = date_end.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
278 domain = [('mass_mailing_id', '=', mailing.id), ('opened', '>=', date_begin_str), ('opened', '<=', date_end_str)]
279 res[mailing.id]['opened_daily'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['opened'], 'opened_count', 'opened:day', date_begin, context=context))
280 domain = [('mass_mailing_id', '=', mailing.id), ('replied', '>=', date_begin_str), ('replied', '<=', date_end_str)]
281 res[mailing.id]['replied_daily'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['replied'], 'replied_count', 'replied:day', date_begin, context=context))
284 def _get_statistics(self, cr, uid, ids, name, arg, context=None):
285 """ Compute statistics of the mass mailing campaign """
286 Statistics = self.pool['mail.mail.statistics']
287 results = dict.fromkeys(ids, False)
289 stat_ids = Statistics.search(cr, uid, [('mass_mailing_id', '=', mid)], context=context)
290 stats = Statistics.browse(cr, uid, stat_ids, context=context)
293 'failed': len([s for s in stats if not s.scheduled is False and s.sent is False and not s.exception is False]),
294 'scheduled': len([s for s in stats if not s.scheduled is False and s.sent is False and s.exception is False]),
295 'sent': len([s for s in stats if not s.sent is False]),
296 'opened': len([s for s in stats if not s.opened is False]),
297 'replied': len([s for s in stats if not s.replied is False]),
298 'bounced': len([s for s in stats if not s.bounced is False]),
300 results[mid]['delivered'] = results[mid]['sent'] - results[mid]['bounced']
301 results[mid]['received_ratio'] = 100.0 * results[mid]['delivered'] / (results[mid]['total'] or 1)
302 results[mid]['opened_ratio'] = 100.0 * results[mid]['opened'] / (results[mid]['total'] or 1)
303 results[mid]['replied_ratio'] = 100.0 * results[mid]['replied'] / (results[mid]['total'] or 1)
306 def _get_mailing_model(self, cr, uid, context=None):
308 for model_name in self.pool:
309 model = self.pool[model_name]
310 if hasattr(model, '_mail_mass_mailing') and getattr(model, '_mail_mass_mailing'):
311 res.append((model._name, getattr(model, '_mail_mass_mailing')))
312 res.append(('mail.mass_mailing.contact', _('Mailing List')))
315 # indirections for inheritance
316 _mailing_model = lambda self, *args, **kwargs: self._get_mailing_model(*args, **kwargs)
319 'name': fields.char('Subject', required=True),
320 'email_from': fields.char('From', required=True),
321 'create_date': fields.datetime('Creation Date'),
322 'sent_date': fields.datetime('Sent Date', oldname='date'),
323 'body_html': fields.html('Body'),
324 'attachment_ids': fields.many2many(
325 'ir.attachment', 'mass_mailing_ir_attachments_rel',
326 'mass_mailing_id', 'attachment_id', 'Attachments'
328 'mass_mailing_campaign_id': fields.many2one(
329 'mail.mass_mailing.campaign', 'Mass Mailing Campaign',
332 'state': fields.selection(
333 [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')],
334 string='Status', required=True,
336 'color': fields.related(
337 'mass_mailing_campaign_id', 'color',
338 type='integer', string='Color Index',
341 'reply_to_mode': fields.selection(
342 [('thread', 'In Document'), ('email', 'Specified Email Address')],
343 string='Reply-To Mode', required=True,
345 'reply_to': fields.char('Reply To', help='Preferred Reply-To Address'),
347 'mailing_model': fields.selection(_mailing_model, string='Recipients Model', required=True),
348 'mailing_domain': fields.char('Domain', oldname='domain'),
349 'contact_list_ids': fields.many2many(
350 'mail.mass_mailing.list', 'mail_mass_mailing_list_rel',
351 string='Mailing Lists',
353 'contact_ab_pc': fields.integer(
354 'AB Testing percentage',
355 help='Percentage of the contacts that will be mailed. Recipients will be taken randomly.'
358 'statistics_ids': fields.one2many(
359 'mail.mail.statistics', 'mass_mailing_id',
362 'total': fields.function(
363 _get_statistics, string='Total',
364 type='integer', multi='_get_statistics',
366 'scheduled': fields.function(
367 _get_statistics, string='Scheduled',
368 type='integer', multi='_get_statistics',
370 'failed': fields.function(
371 _get_statistics, string='Failed',
372 type='integer', multi='_get_statistics',
374 'sent': fields.function(
375 _get_statistics, string='Sent',
376 type='integer', multi='_get_statistics',
378 'delivered': fields.function(
379 _get_statistics, string='Delivered',
380 type='integer', multi='_get_statistics',
382 'opened': fields.function(
383 _get_statistics, string='Opened',
384 type='integer', multi='_get_statistics',
386 'replied': fields.function(
387 _get_statistics, string='Replied',
388 type='integer', multi='_get_statistics',
390 'bounced': fields.function(
391 _get_statistics, string='Bounced',
392 type='integer', multi='_get_statistics',
394 'received_ratio': fields.function(
395 _get_statistics, string='Received Ratio',
396 type='integer', multi='_get_statistics',
398 'opened_ratio': fields.function(
399 _get_statistics, string='Opened Ratio',
400 type='integer', multi='_get_statistics',
402 'replied_ratio': fields.function(
403 _get_statistics, string='Replied Ratio',
404 type='integer', multi='_get_statistics',
407 'opened_daily': fields.function(
408 _get_daily_statistics, string='Opened',
409 type='char', multi='_get_daily_statistics',
411 'replied_daily': fields.function(
412 _get_daily_statistics, string='Replied',
413 type='char', multi='_get_daily_statistics',
417 def default_get(self, cr, uid, fields, context=None):
418 res = super(MassMailing, self).default_get(cr, uid, fields, context=context)
419 if 'reply_to_mode' in fields and not 'reply_to_mode' in res and res.get('mailing_model'):
420 if res['mailing_model'] in ['res.partner', 'mail.mass_mailing.contact']:
421 res['reply_to_mode'] = 'email'
423 res['reply_to_mode'] = 'thread'
428 'email_from': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
429 'reply_to': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
430 'mailing_model': 'mail.mass_mailing.contact',
431 'contact_ab_pc': 100,
434 #------------------------------------------------------
436 #------------------------------------------------------
438 def copy_data(self, cr, uid, id, default=None, context=None):
441 mailing = self.browse(cr, uid, id, context=context)
444 'statistics_ids': [],
445 'name': _('%s (duplicate)') % mailing.name,
448 return super(MassMailing, self).copy_data(cr, uid, id, default, context=context)
450 def read_group(self, cr, uid, domain, fields, groupby, offset=0, limit=None, context=None, orderby=False, lazy=True):
451 """ Override read_group to always display all states. """
452 if groupby and groupby[0] == "state":
453 # Default result structure
454 # states = self._get_state_list(cr, uid, context=context)
455 states = [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')]
456 read_group_all_states = [{
457 '__context': {'group_by': groupby[1:]},
458 '__domain': domain + [('state', '=', state_value)],
459 'state': state_value,
461 } for state_value, state_name in states]
462 # Get standard results
463 read_group_res = super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
464 # Update standard results with default results
466 for state_value, state_name in states:
467 res = filter(lambda x: x['state'] == state_value, read_group_res)
469 res = filter(lambda x: x['state'] == state_value, read_group_all_states)
470 res[0]['state'] = [state_value, state_name]
471 result.append(res[0])
474 return super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
476 #------------------------------------------------------
478 #------------------------------------------------------
480 def on_change_model_and_list(self, cr, uid, ids, mailing_model, list_ids, context=None):
482 if mailing_model == 'mail.mass_mailing.contact':
483 list_ids = map(lambda item: item if isinstance(item, (int, long)) else [lid for lid in item[2]], list_ids)
485 value['mailing_domain'] = "[('list_id', 'in', %s)]" % list_ids
487 value['mailing_domain'] = "[('list_id', '=', False)]"
489 value['mailing_domain'] = False
490 return {'value': value}
492 def action_duplicate(self, cr, uid, ids, context=None):
495 copy_id = self.copy(cr, uid, mid, context=context)
498 'type': 'ir.actions.act_window',
501 'res_model': 'mail.mass_mailing',
507 def action_test_mailing(self, cr, uid, ids, context=None):
508 ctx = dict(context, default_mass_mailing_id=ids[0])
510 'name': _('Test Mailing'),
511 'type': 'ir.actions.act_window',
513 'res_model': 'mail.mass_mailing.test',
518 def action_edit_html(self, cr, uid, ids, context=None):
519 if not len(ids) == 1:
520 raise ValueError('One and only one ID allowed for this action')
521 mail = self.browse(cr, uid, ids[0], context=context)
522 url = '/website_mail/email_designer?model=mail.mass_mailing&res_id=%d&template_model=%s&enable_editor=1' % (ids[0], mail.mailing_model)
524 'name': _('Open with Visual Editor'),
525 'type': 'ir.actions.act_url',
530 #------------------------------------------------------
532 #------------------------------------------------------
534 def get_recipients(self, cr, uid, mailing, context=None):
535 if mailing.mailing_domain:
536 domain = eval(mailing.mailing_domain)
537 res_ids = self.pool[mailing.mailing_model].search(cr, uid, domain, context=context)
540 domain = [('id', 'in', res_ids)]
542 # randomly choose a fragment
543 if mailing.contact_ab_pc < 100:
544 contact_nbr = self.pool[mailing.mailing_model].search(cr, uid, domain, count=True, context=context)
545 topick = int(contact_nbr / 100.0 * mailing.contact_ab_pc)
546 if mailing.mass_mailing_campaign_id and mailing.mass_mailing_campaign_id.unique_ab_testing:
547 already_mailed = self.pool['mail.mass_mailing.campaign'].get_recipients(cr, uid, [mailing.mass_mailing_campaign_id.id], context=context)[mailing.mass_mailing_campaign_id.id]
549 already_mailed = set([])
550 remaining = set(res_ids).difference(already_mailed)
551 if topick > len(remaining):
552 topick = len(remaining)
553 res_ids = random.sample(remaining, topick)
556 def send_mail(self, cr, uid, ids, context=None):
557 author_id = self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id
558 for mailing in self.browse(cr, uid, ids, context=context):
559 # instantiate an email composer + send emails
560 res_ids = self.get_recipients(cr, uid, mailing, context=context)
562 raise Warning('Please select recipients.')
563 comp_ctx = dict(context, active_ids=res_ids)
565 'author_id': author_id,
566 'body': mailing.body_html,
567 'subject': mailing.name,
568 'model': mailing.mailing_model,
569 'email_from': mailing.email_from,
570 'record_name': False,
571 'composition_mode': 'mass_mail',
572 'mass_mailing_id': mailing.id,
573 'mailing_list_ids': [(4, l.id) for l in mailing.contact_list_ids],
575 if mailing.reply_to_mode == 'email':
576 composer_values['reply_to'] = mailing.reply_to
577 composer_id = self.pool['mail.compose.message'].create(cr, uid, composer_values, context=comp_ctx)
578 self.pool['mail.compose.message'].send_mail(cr, uid, [composer_id], context=comp_ctx)
579 self.write(cr, uid, [mailing.id], {'sent_date': fields.datetime.now(), 'state': 'done'}, context=context)