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.tools import ustr
13 from openerp.osv import osv, fields
16 class MassMailingCategory(osv.Model):
17 """Model of categories of mass mailing, i.e. marketing, newsletter, ... """
18 _name = 'mail.mass_mailing.category'
19 _description = 'Mass Mailing Category'
23 'name': fields.char('Name', required=True),
27 class MassMailingContact(osv.Model):
28 """Model of a contact. This model is different from the partner model
29 because it holds only some basic information: name, email. The purpose is to
30 be able to deal with large contact list to email without bloating the partner
32 _name = 'mail.mass_mailing.contact'
33 _inherit = 'mail.thread'
34 _description = 'Mass Mailing Contact'
39 'name': fields.char('Name'),
40 'email': fields.char('Email', required=True),
41 'create_date': fields.datetime('Create Date'),
42 'list_id': fields.many2one(
43 'mail.mass_mailing.list', string='Mailing List',
44 ondelete='cascade', required=True,
46 'opt_out': fields.boolean('Opt Out', help='The contact has chosen not to receive mails anymore from this list'),
47 'unsubscription_date': fields.datetime('Unsubscription Date'),
48 'message_bounce': fields.integer('Bounce', help='Counter of the number of bounced emails for this contact.'),
51 def _get_latest_list(self, cr, uid, context={}):
52 lid = self.pool.get('mail.mass_mailing.list').search(cr, uid, [], limit=1, order='id desc', context=context)
53 return lid and lid[0] or False
56 'list_id': _get_latest_list
59 def on_change_opt_out(self, cr, uid, id, opt_out, context=None):
61 'unsubscription_date': opt_out and fields.datetime.now() or False,
64 def create(self, cr, uid, vals, context=None):
66 vals['unsubscription_date'] = vals['opt_out'] and fields.datetime.now() or False
67 return super(MassMailingContact, self).create(cr, uid, vals, context=context)
69 def write(self, cr, uid, ids, vals, context=None):
71 vals['unsubscription_date'] = vals['opt_out'] and fields.datetime.now() or False
72 return super(MassMailingContact, self).write(cr, uid, ids, vals, context=context)
74 def get_name_email(self, name, context):
75 name, email = self.pool['res.partner']._parse_partner_name(name, context=context)
76 if name and not email:
78 if email and not name:
82 def name_create(self, cr, uid, name, context=None):
83 name, email = self.get_name_email(name, context=context)
84 rec_id = self.create(cr, uid, {'name': name, 'email': email}, context=context)
85 return self.name_get(cr, uid, [rec_id], context)[0]
87 def add_to_list(self, cr, uid, name, list_id, context=None):
88 name, email = self.get_name_email(name, context=context)
89 rec_id = self.create(cr, uid, {'name': name, 'email': email, 'list_id': list_id}, context=context)
90 return self.name_get(cr, uid, [rec_id], context)[0]
92 def message_get_default_recipients(self, cr, uid, ids, context=None):
94 for record in self.browse(cr, uid, ids, context=context):
95 res[record.id] = {'partner_ids': [], 'email_to': record.email, 'email_cc': False}
98 def message_receive_bounce(self, cr, uid, ids, mail_id=None, context=None):
99 """Called by ``message_process`` when a bounce email (such as Undelivered
100 Mail Returned to Sender) is received for an existing thread. As contacts
101 do not inherit form mail.thread, we have to define this method to be able
102 to track bounces (see mail.thread for more details). """
103 for obj in self.browse(cr, uid, ids, context=context):
104 self.write(cr, uid, [obj.id], {'message_bounce': obj.message_bounce + 1}, context=context)
107 class MassMailingList(osv.Model):
108 """Model of a contact list. """
109 _name = 'mail.mass_mailing.list'
111 _description = 'Mailing List'
113 def _get_contact_nbr(self, cr, uid, ids, name, arg, context=None):
114 result = dict.fromkeys(ids, 0)
115 Contacts = self.pool.get('mail.mass_mailing.contact')
116 for group in Contacts.read_group(cr, uid, [('list_id', 'in', ids), ('opt_out', '!=', True)], ['list_id'], ['list_id'], context=context):
117 result[group['list_id'][0]] = group['list_id_count']
121 'name': fields.char('Mailing List', required=True),
122 'contact_nbr': fields.function(
123 _get_contact_nbr, type='integer',
124 string='Number of Contacts',
129 class MassMailingStage(osv.Model):
130 """Stage for mass mailing campaigns. """
131 _name = 'mail.mass_mailing.stage'
132 _description = 'Mass Mailing Campaign Stage'
136 'name': fields.char('Name', required=True, translate=True),
137 'sequence': fields.integer('Sequence'),
145 class MassMailingCampaign(osv.Model):
146 """Model of mass mailing campaigns. """
147 _name = "mail.mass_mailing.campaign"
148 _description = 'Mass Mailing Campaign'
150 def _get_statistics(self, cr, uid, ids, name, arg, context=None):
151 """ Compute statistics of the mass mailing campaign """
156 COUNT(s.id) AS total,
157 COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent,
158 COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null THEN 1 ELSE null END) AS scheduled,
159 COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is not null THEN 1 ELSE null END) AS failed,
160 COUNT(CASE WHEN s.id is not null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
161 COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened,
162 COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied ,
163 COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced
165 mail_mail_statistics s
167 mail_mass_mailing_campaign c
168 ON (c.id = s.mass_mailing_campaign_id)
174 for row in cr.dictfetchall():
175 results[row.pop('campaign_id')] = row
176 total = row['total'] or 1
177 row['delivered'] = row['sent'] - row['bounced']
178 row['received_ratio'] = 100.0 * row['delivered'] / total
179 row['opened_ratio'] = 100.0 * row['opened'] / total
180 row['replied_ratio'] = 100.0 * row['replied'] / total
184 'name': fields.char('Name', required=True),
185 'stage_id': fields.many2one('mail.mass_mailing.stage', 'Stage', required=True),
186 'user_id': fields.many2one(
187 'res.users', 'Responsible',
190 'category_ids': fields.many2many(
191 'mail.mass_mailing.category', 'mail_mass_mailing_category_rel',
192 'category_id', 'campaign_id', string='Categories'),
193 'mass_mailing_ids': fields.one2many(
194 'mail.mass_mailing', 'mass_mailing_campaign_id',
197 'unique_ab_testing': fields.boolean(
199 help='If checked, recipients will be mailed only once, allowing to send'
200 'various mailings in a single campaign to test the effectiveness'
202 'color': fields.integer('Color Index'),
204 'total': fields.function(
205 _get_statistics, string='Total',
206 type='integer', multi='_get_statistics'
208 'scheduled': fields.function(
209 _get_statistics, string='Scheduled',
210 type='integer', multi='_get_statistics'
212 'failed': fields.function(
213 _get_statistics, string='Failed',
214 type='integer', multi='_get_statistics'
216 'sent': fields.function(
217 _get_statistics, string='Sent Emails',
218 type='integer', multi='_get_statistics'
220 'delivered': fields.function(
221 _get_statistics, string='Delivered',
222 type='integer', multi='_get_statistics',
224 'opened': fields.function(
225 _get_statistics, string='Opened',
226 type='integer', multi='_get_statistics',
228 'replied': fields.function(
229 _get_statistics, string='Replied',
230 type='integer', multi='_get_statistics'
232 'bounced': fields.function(
233 _get_statistics, string='Bounced',
234 type='integer', multi='_get_statistics'
236 'received_ratio': fields.function(
237 _get_statistics, string='Received Ratio',
238 type='integer', multi='_get_statistics',
240 'opened_ratio': fields.function(
241 _get_statistics, string='Opened Ratio',
242 type='integer', multi='_get_statistics',
244 'replied_ratio': fields.function(
245 _get_statistics, string='Replied Ratio',
246 type='integer', multi='_get_statistics',
250 def _get_default_stage_id(self, cr, uid, context=None):
251 stage_ids = self.pool['mail.mass_mailing.stage'].search(cr, uid, [], limit=1, context=context)
252 return stage_ids and stage_ids[0] or False
255 'user_id': lambda self, cr, uid, ctx=None: uid,
256 'stage_id': lambda self, *args: self._get_default_stage_id(*args),
259 def get_recipients(self, cr, uid, ids, model=None, context=None):
260 """Return the recipients of a mailing campaign. This is based on the statistics
261 build for each mailing. """
262 Statistics = self.pool['mail.mail.statistics']
263 res = dict.fromkeys(ids, False)
265 domain = [('mass_mailing_campaign_id', '=', cid)]
267 domain += [('model', '=', model)]
268 stat_ids = Statistics.search(cr, uid, domain, context=context)
269 res[cid] = set(stat.res_id for stat in Statistics.browse(cr, uid, stat_ids, context=context))
273 class MassMailing(osv.Model):
274 """ MassMailing models a wave of emails for a mass mailign campaign.
275 A mass mailing is an occurence of sending emails. """
277 _name = 'mail.mass_mailing'
278 _description = 'Mass Mailing'
279 # number of periods for tracking mail_mail statistics
281 _order = 'sent_date DESC'
283 def __get_bar_values(self, cr, uid, obj, domain, read_fields, value_field, groupby_field, date_begin, context=None):
284 """ Generic method to generate data for bar chart values using SparklineBarWidget.
285 This method performs obj.read_group(cr, uid, domain, read_fields, groupby_field).
287 :param obj: the target model (i.e. crm_lead)
288 :param domain: the domain applied to the read_group
289 :param list read_fields: the list of fields to read in the read_group
290 :param str value_field: the field used to compute the value of the bar slice
291 :param str groupby_field: the fields used to group
293 :return list section_result: a list of dicts: [
294 { 'value': (int) bar_column_value,
295 'tootip': (str) bar_column_tooltip,
299 date_begin = date_begin.date()
300 section_result = [{'value': 0,
301 'tooltip': ustr((date_begin + relativedelta.relativedelta(days=i)).strftime('%d %B %Y')),
302 } for i in range(0, self._period_number)]
303 group_obj = obj.read_group(cr, uid, domain, read_fields, groupby_field, context=context)
304 field = obj._fields.get(groupby_field.split(':')[0])
305 pattern = tools.DEFAULT_SERVER_DATE_FORMAT if field.type == 'date' else tools.DEFAULT_SERVER_DATETIME_FORMAT
306 for group in group_obj:
307 group_begin_date = datetime.strptime(group['__domain'][0][2], pattern).date()
308 timedelta = relativedelta.relativedelta(group_begin_date, date_begin)
309 section_result[timedelta.days] = {'value': group.get(value_field, 0), 'tooltip': group.get(groupby_field)}
310 return section_result
312 def _get_daily_statistics(self, cr, uid, ids, field_name, arg, context=None):
313 """ Get the daily statistics of the mass mailing. This is done by a grouping
314 on opened and replied fields. Using custom format in context, we obtain
315 results for the next 6 days following the mass mailing date. """
316 obj = self.pool['mail.mail.statistics']
318 for mailing in self.browse(cr, uid, ids, context=context):
320 date = mailing.sent_date if mailing.sent_date else mailing.create_date
321 date_begin = datetime.strptime(date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
322 date_end = date_begin + relativedelta.relativedelta(days=self._period_number - 1)
323 date_begin_str = date_begin.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
324 date_end_str = date_end.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
325 domain = [('mass_mailing_id', '=', mailing.id), ('opened', '>=', date_begin_str), ('opened', '<=', date_end_str)]
326 res[mailing.id]['opened_daily'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['opened'], 'opened_count', 'opened:day', date_begin, context=context))
327 domain = [('mass_mailing_id', '=', mailing.id), ('replied', '>=', date_begin_str), ('replied', '<=', date_end_str)]
328 res[mailing.id]['replied_daily'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['replied'], 'replied_count', 'replied:day', date_begin, context=context))
331 def _get_statistics(self, cr, uid, ids, name, arg, context=None):
332 """ Compute statistics of the mass mailing """
337 COUNT(s.id) AS total,
338 COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent,
339 COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null THEN 1 ELSE null END) AS scheduled,
340 COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is not null THEN 1 ELSE null END) AS failed,
341 COUNT(CASE WHEN s.sent is not null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
342 COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened,
343 COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied,
344 COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced
346 mail_mail_statistics s
349 ON (m.id = s.mass_mailing_id)
355 for row in cr.dictfetchall():
356 results[row.pop('mailing_id')] = row
357 total = row['total'] or 1
358 row['received_ratio'] = 100.0 * row['delivered'] / total
359 row['opened_ratio'] = 100.0 * row['opened'] / total
360 row['replied_ratio'] = 100.0 * row['replied'] / total
363 def _get_mailing_model(self, cr, uid, context=None):
365 for model_name in self.pool:
366 model = self.pool[model_name]
367 if hasattr(model, '_mail_mass_mailing') and getattr(model, '_mail_mass_mailing'):
368 res.append((model._name, getattr(model, '_mail_mass_mailing')))
369 res.append(('mail.mass_mailing.contact', _('Mailing List')))
372 # indirections for inheritance
373 _mailing_model = lambda self, *args, **kwargs: self._get_mailing_model(*args, **kwargs)
376 'name': fields.char('Subject', required=True),
377 'email_from': fields.char('From', required=True),
378 'create_date': fields.datetime('Creation Date'),
379 'sent_date': fields.datetime('Sent Date', oldname='date', copy=False),
380 'body_html': fields.html('Body'),
381 'attachment_ids': fields.many2many(
382 'ir.attachment', 'mass_mailing_ir_attachments_rel',
383 'mass_mailing_id', 'attachment_id', 'Attachments'
385 'keep_archives': fields.boolean('Keep Archives'),
386 'mass_mailing_campaign_id': fields.many2one(
387 'mail.mass_mailing.campaign', 'Mass Mailing Campaign',
390 'state': fields.selection(
391 [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')],
392 string='Status', required=True, copy=False,
394 'color': fields.related(
395 'mass_mailing_campaign_id', 'color',
396 type='integer', string='Color Index',
399 'reply_to_mode': fields.selection(
400 [('thread', 'In Document'), ('email', 'Specified Email Address')],
401 string='Reply-To Mode', required=True,
403 'reply_to': fields.char('Reply To', help='Preferred Reply-To Address'),
405 'mailing_model': fields.selection(_mailing_model, string='Recipients Model', required=True),
406 'mailing_domain': fields.char('Domain', oldname='domain'),
407 'contact_list_ids': fields.many2many(
408 'mail.mass_mailing.list', 'mail_mass_mailing_list_rel',
409 string='Mailing Lists',
411 'contact_ab_pc': fields.integer(
412 'AB Testing percentage',
413 help='Percentage of the contacts that will be mailed. Recipients will be taken randomly.'
416 'statistics_ids': fields.one2many(
417 'mail.mail.statistics', 'mass_mailing_id',
420 'total': fields.function(
421 _get_statistics, string='Total',
422 type='integer', multi='_get_statistics',
424 'scheduled': fields.function(
425 _get_statistics, string='Scheduled',
426 type='integer', multi='_get_statistics',
428 'failed': fields.function(
429 _get_statistics, string='Failed',
430 type='integer', multi='_get_statistics',
432 'sent': fields.function(
433 _get_statistics, string='Sent',
434 type='integer', multi='_get_statistics',
436 'delivered': fields.function(
437 _get_statistics, string='Delivered',
438 type='integer', multi='_get_statistics',
440 'opened': fields.function(
441 _get_statistics, string='Opened',
442 type='integer', multi='_get_statistics',
444 'replied': fields.function(
445 _get_statistics, string='Replied',
446 type='integer', multi='_get_statistics',
448 'bounced': fields.function(
449 _get_statistics, string='Bounced',
450 type='integer', multi='_get_statistics',
452 'received_ratio': fields.function(
453 _get_statistics, string='Received Ratio',
454 type='integer', multi='_get_statistics',
456 'opened_ratio': fields.function(
457 _get_statistics, string='Opened Ratio',
458 type='integer', multi='_get_statistics',
460 'replied_ratio': fields.function(
461 _get_statistics, string='Replied Ratio',
462 type='integer', multi='_get_statistics',
465 'opened_daily': fields.function(
466 _get_daily_statistics, string='Opened',
467 type='char', multi='_get_daily_statistics',
469 'replied_daily': fields.function(
470 _get_daily_statistics, string='Replied',
471 type='char', multi='_get_daily_statistics',
475 def default_get(self, cr, uid, fields, context=None):
476 res = super(MassMailing, self).default_get(cr, uid, fields, context=context)
477 if 'reply_to_mode' in fields and not 'reply_to_mode' in res and res.get('mailing_model'):
478 if res['mailing_model'] in ['res.partner', 'mail.mass_mailing.contact']:
479 res['reply_to_mode'] = 'email'
481 res['reply_to_mode'] = 'thread'
486 'email_from': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
487 'reply_to': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
488 'mailing_model': 'mail.mass_mailing.contact',
489 'contact_ab_pc': 100,
492 #------------------------------------------------------
494 #------------------------------------------------------
496 def copy_data(self, cr, uid, id, default=None, context=None):
497 mailing = self.browse(cr, uid, id, context=context)
498 default = dict(default or {},
499 name=_('%s (copy)') % mailing.name)
500 return super(MassMailing, self).copy_data(cr, uid, id, default, context=context)
502 def read_group(self, cr, uid, domain, fields, groupby, offset=0, limit=None, context=None, orderby=False, lazy=True):
503 """ Override read_group to always display all states. """
504 if groupby and groupby[0] == "state":
505 # Default result structure
506 # states = self._get_state_list(cr, uid, context=context)
507 states = [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')]
508 read_group_all_states = [{
509 '__context': {'group_by': groupby[1:]},
510 '__domain': domain + [('state', '=', state_value)],
511 'state': state_value,
513 } for state_value, state_name in states]
514 # Get standard results
515 read_group_res = super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
516 # Update standard results with default results
518 for state_value, state_name in states:
519 res = filter(lambda x: x['state'] == state_value, read_group_res)
521 res = filter(lambda x: x['state'] == state_value, read_group_all_states)
522 res[0]['state'] = [state_value, state_name]
523 result.append(res[0])
526 return super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
528 #------------------------------------------------------
530 #------------------------------------------------------
532 def on_change_model_and_list(self, cr, uid, ids, mailing_model, list_ids, context=None):
534 if mailing_model == 'mail.mass_mailing.contact':
535 mailing_list_ids = set()
536 for item in list_ids:
537 if isinstance(item, (int, long)):
538 mailing_list_ids.add(item)
540 mailing_list_ids |= set(item[2])
542 value['mailing_domain'] = "[('list_id', 'in', %s)]" % list(mailing_list_ids)
544 value['mailing_domain'] = "[('list_id', '=', False)]"
546 value['mailing_domain'] = False
547 return {'value': value}
549 def action_duplicate(self, cr, uid, ids, context=None):
552 copy_id = self.copy(cr, uid, mid, context=context)
555 'type': 'ir.actions.act_window',
558 'res_model': 'mail.mass_mailing',
564 def action_test_mailing(self, cr, uid, ids, context=None):
565 ctx = dict(context, default_mass_mailing_id=ids[0])
567 'name': _('Test Mailing'),
568 'type': 'ir.actions.act_window',
570 'res_model': 'mail.mass_mailing.test',
575 def action_edit_html(self, cr, uid, ids, context=None):
576 if not len(ids) == 1:
577 raise ValueError('One and only one ID allowed for this action')
578 mail = self.browse(cr, uid, ids[0], context=context)
579 url = '/website_mail/email_designer?model=mail.mass_mailing&res_id=%d&template_model=%s&enable_editor=1' % (ids[0], mail.mailing_model)
581 'name': _('Open with Visual Editor'),
582 'type': 'ir.actions.act_url',
587 #------------------------------------------------------
589 #------------------------------------------------------
591 def get_recipients(self, cr, uid, mailing, context=None):
592 if mailing.mailing_domain:
593 domain = eval(mailing.mailing_domain)
594 res_ids = self.pool[mailing.mailing_model].search(cr, uid, domain, context=context)
597 domain = [('id', 'in', res_ids)]
599 # randomly choose a fragment
600 if mailing.contact_ab_pc < 100:
601 contact_nbr = self.pool[mailing.mailing_model].search(cr, uid, domain, count=True, context=context)
602 topick = int(contact_nbr / 100.0 * mailing.contact_ab_pc)
603 if mailing.mass_mailing_campaign_id and mailing.mass_mailing_campaign_id.unique_ab_testing:
604 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]
606 already_mailed = set([])
607 remaining = set(res_ids).difference(already_mailed)
608 if topick > len(remaining):
609 topick = len(remaining)
610 res_ids = random.sample(remaining, topick)
613 def send_mail(self, cr, uid, ids, context=None):
614 author_id = self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id
615 for mailing in self.browse(cr, uid, ids, context=context):
616 # instantiate an email composer + send emails
617 res_ids = self.get_recipients(cr, uid, mailing, context=context)
619 raise Warning('Please select recipients.')
620 comp_ctx = dict(context, active_ids=res_ids)
622 'author_id': author_id,
623 'body': mailing.body_html,
624 'subject': mailing.name,
625 'model': mailing.mailing_model,
626 'email_from': mailing.email_from,
627 'record_name': False,
628 'composition_mode': 'mass_mail',
629 'mass_mailing_id': mailing.id,
630 'mailing_list_ids': [(4, l.id) for l in mailing.contact_list_ids],
631 'no_auto_thread': mailing.reply_to_mode != 'thread',
633 if mailing.reply_to_mode == 'email':
634 composer_values['reply_to'] = mailing.reply_to
635 composer_id = self.pool['mail.compose.message'].create(cr, uid, composer_values, context=comp_ctx)
636 self.pool['mail.compose.message'].send_mail(cr, uid, [composer_id], context=comp_ctx)
637 self.write(cr, uid, [mailing.id], {'sent_date': fields.datetime.now(), 'state': 'done'}, context=context)