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 'create_date': fields.datetime('Creation Date'),
123 'contact_nbr': fields.function(
124 _get_contact_nbr, type='integer',
125 string='Number of Contacts',
130 class MassMailingStage(osv.Model):
131 """Stage for mass mailing campaigns. """
132 _name = 'mail.mass_mailing.stage'
133 _description = 'Mass Mailing Campaign Stage'
137 'name': fields.char('Name', required=True, translate=True),
138 'sequence': fields.integer('Sequence'),
146 class MassMailingCampaign(osv.Model):
147 """Model of mass mailing campaigns. """
148 _name = "mail.mass_mailing.campaign"
149 _description = 'Mass Mailing Campaign'
151 def _get_statistics(self, cr, uid, ids, name, arg, context=None):
152 """ Compute statistics of the mass mailing campaign """
157 COUNT(s.id) AS total,
158 COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent,
159 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,
160 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,
161 COUNT(CASE WHEN s.id is not null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
162 COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened,
163 COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied ,
164 COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced
166 mail_mail_statistics s
168 mail_mass_mailing_campaign c
169 ON (c.id = s.mass_mailing_campaign_id)
175 for row in cr.dictfetchall():
176 results[row.pop('campaign_id')] = row
177 total = row['total'] or 1
178 row['delivered'] = row['sent'] - row['bounced']
179 row['received_ratio'] = 100.0 * row['delivered'] / total
180 row['opened_ratio'] = 100.0 * row['opened'] / total
181 row['replied_ratio'] = 100.0 * row['replied'] / total
185 'name': fields.char('Name', required=True),
186 'stage_id': fields.many2one('mail.mass_mailing.stage', 'Stage', required=True),
187 'user_id': fields.many2one(
188 'res.users', 'Responsible',
191 'category_ids': fields.many2many(
192 'mail.mass_mailing.category', 'mail_mass_mailing_category_rel',
193 'category_id', 'campaign_id', string='Categories'),
194 'mass_mailing_ids': fields.one2many(
195 'mail.mass_mailing', 'mass_mailing_campaign_id',
198 'unique_ab_testing': fields.boolean(
200 help='If checked, recipients will be mailed only once, allowing to send'
201 'various mailings in a single campaign to test the effectiveness'
203 'color': fields.integer('Color Index'),
205 'total': fields.function(
206 _get_statistics, string='Total',
207 type='integer', multi='_get_statistics'
209 'scheduled': fields.function(
210 _get_statistics, string='Scheduled',
211 type='integer', multi='_get_statistics'
213 'failed': fields.function(
214 _get_statistics, string='Failed',
215 type='integer', multi='_get_statistics'
217 'sent': fields.function(
218 _get_statistics, string='Sent Emails',
219 type='integer', multi='_get_statistics'
221 'delivered': fields.function(
222 _get_statistics, string='Delivered',
223 type='integer', multi='_get_statistics',
225 'opened': fields.function(
226 _get_statistics, string='Opened',
227 type='integer', multi='_get_statistics',
229 'replied': fields.function(
230 _get_statistics, string='Replied',
231 type='integer', multi='_get_statistics'
233 'bounced': fields.function(
234 _get_statistics, string='Bounced',
235 type='integer', multi='_get_statistics'
237 'received_ratio': fields.function(
238 _get_statistics, string='Received Ratio',
239 type='integer', multi='_get_statistics',
241 'opened_ratio': fields.function(
242 _get_statistics, string='Opened Ratio',
243 type='integer', multi='_get_statistics',
245 'replied_ratio': fields.function(
246 _get_statistics, string='Replied Ratio',
247 type='integer', multi='_get_statistics',
251 def _get_default_stage_id(self, cr, uid, context=None):
252 stage_ids = self.pool['mail.mass_mailing.stage'].search(cr, uid, [], limit=1, context=context)
253 return stage_ids and stage_ids[0] or False
256 'user_id': lambda self, cr, uid, ctx=None: uid,
257 'stage_id': lambda self, *args: self._get_default_stage_id(*args),
260 def get_recipients(self, cr, uid, ids, model=None, context=None):
261 """Return the recipients of a mailing campaign. This is based on the statistics
262 build for each mailing. """
263 Statistics = self.pool['mail.mail.statistics']
264 res = dict.fromkeys(ids, False)
266 domain = [('mass_mailing_campaign_id', '=', cid)]
268 domain += [('model', '=', model)]
269 stat_ids = Statistics.search(cr, uid, domain, context=context)
270 res[cid] = set(stat.res_id for stat in Statistics.browse(cr, uid, stat_ids, context=context))
274 class MassMailing(osv.Model):
275 """ MassMailing models a wave of emails for a mass mailign campaign.
276 A mass mailing is an occurence of sending emails. """
278 _name = 'mail.mass_mailing'
279 _description = 'Mass Mailing'
280 # number of periods for tracking mail_mail statistics
282 _order = 'sent_date DESC'
284 def __get_bar_values(self, cr, uid, obj, domain, read_fields, value_field, groupby_field, date_begin, context=None):
285 """ Generic method to generate data for bar chart values using SparklineBarWidget.
286 This method performs obj.read_group(cr, uid, domain, read_fields, groupby_field).
288 :param obj: the target model (i.e. crm_lead)
289 :param domain: the domain applied to the read_group
290 :param list read_fields: the list of fields to read in the read_group
291 :param str value_field: the field used to compute the value of the bar slice
292 :param str groupby_field: the fields used to group
294 :return list section_result: a list of dicts: [
295 { 'value': (int) bar_column_value,
296 'tootip': (str) bar_column_tooltip,
300 date_begin = date_begin.date()
301 section_result = [{'value': 0,
302 'tooltip': ustr((date_begin + relativedelta.relativedelta(days=i)).strftime('%d %B %Y')),
303 } for i in range(0, self._period_number)]
304 group_obj = obj.read_group(cr, uid, domain, read_fields, groupby_field, context=context)
305 field = obj._fields.get(groupby_field.split(':')[0])
306 pattern = tools.DEFAULT_SERVER_DATE_FORMAT if field.type == 'date' else tools.DEFAULT_SERVER_DATETIME_FORMAT
307 for group in group_obj:
308 group_begin_date = datetime.strptime(group['__domain'][0][2], pattern).date()
309 timedelta = relativedelta.relativedelta(group_begin_date, date_begin)
310 section_result[timedelta.days] = {'value': group.get(value_field, 0), 'tooltip': group.get(groupby_field)}
311 return section_result
313 def _get_daily_statistics(self, cr, uid, ids, field_name, arg, context=None):
314 """ Get the daily statistics of the mass mailing. This is done by a grouping
315 on opened and replied fields. Using custom format in context, we obtain
316 results for the next 6 days following the mass mailing date. """
317 obj = self.pool['mail.mail.statistics']
319 for mailing in self.browse(cr, uid, ids, context=context):
321 date = mailing.sent_date if mailing.sent_date else mailing.create_date
322 date_begin = datetime.strptime(date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
323 date_end = date_begin + relativedelta.relativedelta(days=self._period_number - 1)
324 date_begin_str = date_begin.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
325 date_end_str = date_end.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
326 domain = [('mass_mailing_id', '=', mailing.id), ('opened', '>=', date_begin_str), ('opened', '<=', date_end_str)]
327 res[mailing.id]['opened_daily'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['opened'], 'opened_count', 'opened:day', date_begin, context=context))
328 domain = [('mass_mailing_id', '=', mailing.id), ('replied', '>=', date_begin_str), ('replied', '<=', date_end_str)]
329 res[mailing.id]['replied_daily'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['replied'], 'replied_count', 'replied:day', date_begin, context=context))
332 def _get_statistics(self, cr, uid, ids, name, arg, context=None):
333 """ Compute statistics of the mass mailing """
338 COUNT(s.id) AS total,
339 COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent,
340 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,
341 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,
342 COUNT(CASE WHEN s.sent is not null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
343 COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened,
344 COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied,
345 COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced
347 mail_mail_statistics s
350 ON (m.id = s.mass_mailing_id)
356 for row in cr.dictfetchall():
357 results[row.pop('mailing_id')] = row
358 total = row['total'] or 1
359 row['received_ratio'] = 100.0 * row['delivered'] / total
360 row['opened_ratio'] = 100.0 * row['opened'] / total
361 row['replied_ratio'] = 100.0 * row['replied'] / total
364 def _get_mailing_model(self, cr, uid, context=None):
366 for model_name in self.pool:
367 model = self.pool[model_name]
368 if hasattr(model, '_mail_mass_mailing') and getattr(model, '_mail_mass_mailing'):
369 res.append((model._name, getattr(model, '_mail_mass_mailing')))
370 res.append(('mail.mass_mailing.contact', _('Mailing List')))
373 # indirections for inheritance
374 _mailing_model = lambda self, *args, **kwargs: self._get_mailing_model(*args, **kwargs)
377 'name': fields.char('Subject', required=True),
378 'email_from': fields.char('From', required=True),
379 'create_date': fields.datetime('Creation Date'),
380 'sent_date': fields.datetime('Sent Date', oldname='date', copy=False),
381 'body_html': fields.html('Body'),
382 'attachment_ids': fields.many2many(
383 'ir.attachment', 'mass_mailing_ir_attachments_rel',
384 'mass_mailing_id', 'attachment_id', 'Attachments'
386 'keep_archives': fields.boolean('Keep Archives'),
387 'mass_mailing_campaign_id': fields.many2one(
388 'mail.mass_mailing.campaign', 'Mass Mailing Campaign',
391 'state': fields.selection(
392 [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')],
393 string='Status', required=True, copy=False,
395 'color': fields.related(
396 'mass_mailing_campaign_id', 'color',
397 type='integer', string='Color Index',
400 'reply_to_mode': fields.selection(
401 [('thread', 'In Document'), ('email', 'Specified Email Address')],
402 string='Reply-To Mode', required=True,
404 'reply_to': fields.char('Reply To', help='Preferred Reply-To Address'),
406 'mailing_model': fields.selection(_mailing_model, string='Recipients Model', required=True),
407 'mailing_domain': fields.char('Domain', oldname='domain'),
408 'contact_list_ids': fields.many2many(
409 'mail.mass_mailing.list', 'mail_mass_mailing_list_rel',
410 string='Mailing Lists',
412 'contact_ab_pc': fields.integer(
413 'AB Testing percentage',
414 help='Percentage of the contacts that will be mailed. Recipients will be taken randomly.'
417 'statistics_ids': fields.one2many(
418 'mail.mail.statistics', 'mass_mailing_id',
421 'total': fields.function(
422 _get_statistics, string='Total',
423 type='integer', multi='_get_statistics',
425 'scheduled': fields.function(
426 _get_statistics, string='Scheduled',
427 type='integer', multi='_get_statistics',
429 'failed': fields.function(
430 _get_statistics, string='Failed',
431 type='integer', multi='_get_statistics',
433 'sent': fields.function(
434 _get_statistics, string='Sent',
435 type='integer', multi='_get_statistics',
437 'delivered': fields.function(
438 _get_statistics, string='Delivered',
439 type='integer', multi='_get_statistics',
441 'opened': fields.function(
442 _get_statistics, string='Opened',
443 type='integer', multi='_get_statistics',
445 'replied': fields.function(
446 _get_statistics, string='Replied',
447 type='integer', multi='_get_statistics',
449 'bounced': fields.function(
450 _get_statistics, string='Bounced',
451 type='integer', multi='_get_statistics',
453 'received_ratio': fields.function(
454 _get_statistics, string='Received Ratio',
455 type='integer', multi='_get_statistics',
457 'opened_ratio': fields.function(
458 _get_statistics, string='Opened Ratio',
459 type='integer', multi='_get_statistics',
461 'replied_ratio': fields.function(
462 _get_statistics, string='Replied Ratio',
463 type='integer', multi='_get_statistics',
466 'opened_daily': fields.function(
467 _get_daily_statistics, string='Opened',
468 type='char', multi='_get_daily_statistics',
470 'replied_daily': fields.function(
471 _get_daily_statistics, string='Replied',
472 type='char', multi='_get_daily_statistics',
476 def default_get(self, cr, uid, fields, context=None):
477 res = super(MassMailing, self).default_get(cr, uid, fields, context=context)
478 if 'reply_to_mode' in fields and not 'reply_to_mode' in res and res.get('mailing_model'):
479 if res['mailing_model'] in ['res.partner', 'mail.mass_mailing.contact']:
480 res['reply_to_mode'] = 'email'
482 res['reply_to_mode'] = 'thread'
487 'email_from': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
488 'reply_to': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx),
489 'mailing_model': 'mail.mass_mailing.contact',
490 'contact_ab_pc': 100,
493 #------------------------------------------------------
495 #------------------------------------------------------
497 def copy_data(self, cr, uid, id, default=None, context=None):
498 mailing = self.browse(cr, uid, id, context=context)
499 default = dict(default or {},
500 name=_('%s (copy)') % mailing.name)
501 return super(MassMailing, self).copy_data(cr, uid, id, default, context=context)
503 def read_group(self, cr, uid, domain, fields, groupby, offset=0, limit=None, context=None, orderby=False, lazy=True):
504 """ Override read_group to always display all states. """
505 if groupby and groupby[0] == "state":
506 # Default result structure
507 # states = self._get_state_list(cr, uid, context=context)
508 states = [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')]
509 read_group_all_states = [{
510 '__context': {'group_by': groupby[1:]},
511 '__domain': domain + [('state', '=', state_value)],
512 'state': state_value,
514 } for state_value, state_name in states]
515 # Get standard results
516 read_group_res = super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
517 # Update standard results with default results
519 for state_value, state_name in states:
520 res = filter(lambda x: x['state'] == state_value, read_group_res)
522 res = filter(lambda x: x['state'] == state_value, read_group_all_states)
523 res[0]['state'] = [state_value, state_name]
524 result.append(res[0])
527 return super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby)
529 #------------------------------------------------------
531 #------------------------------------------------------
533 def on_change_model_and_list(self, cr, uid, ids, mailing_model, list_ids, context=None):
535 if mailing_model == 'mail.mass_mailing.contact':
536 mailing_list_ids = set()
537 for item in list_ids:
538 if isinstance(item, (int, long)):
539 mailing_list_ids.add(item)
541 mailing_list_ids |= set(item[2])
543 value['mailing_domain'] = "[('list_id', 'in', %s)]" % list(mailing_list_ids)
545 value['mailing_domain'] = "[('list_id', '=', False)]"
547 value['mailing_domain'] = False
548 return {'value': value}
550 def action_duplicate(self, cr, uid, ids, context=None):
553 copy_id = self.copy(cr, uid, mid, context=context)
556 'type': 'ir.actions.act_window',
559 'res_model': 'mail.mass_mailing',
565 def action_test_mailing(self, cr, uid, ids, context=None):
566 ctx = dict(context, default_mass_mailing_id=ids[0])
568 'name': _('Test Mailing'),
569 'type': 'ir.actions.act_window',
571 'res_model': 'mail.mass_mailing.test',
576 def action_edit_html(self, cr, uid, ids, context=None):
577 if not len(ids) == 1:
578 raise ValueError('One and only one ID allowed for this action')
579 mail = self.browse(cr, uid, ids[0], context=context)
580 url = '/website_mail/email_designer?model=mail.mass_mailing&res_id=%d&template_model=%s&enable_editor=1' % (ids[0], mail.mailing_model)
582 'name': _('Open with Visual Editor'),
583 'type': 'ir.actions.act_url',
588 #------------------------------------------------------
590 #------------------------------------------------------
592 def get_recipients(self, cr, uid, mailing, context=None):
593 if mailing.mailing_domain:
594 domain = eval(mailing.mailing_domain)
595 res_ids = self.pool[mailing.mailing_model].search(cr, uid, domain, context=context)
598 domain = [('id', 'in', res_ids)]
600 # randomly choose a fragment
601 if mailing.contact_ab_pc < 100:
602 contact_nbr = self.pool[mailing.mailing_model].search(cr, uid, domain, count=True, context=context)
603 topick = int(contact_nbr / 100.0 * mailing.contact_ab_pc)
604 if mailing.mass_mailing_campaign_id and mailing.mass_mailing_campaign_id.unique_ab_testing:
605 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]
607 already_mailed = set([])
608 remaining = set(res_ids).difference(already_mailed)
609 if topick > len(remaining):
610 topick = len(remaining)
611 res_ids = random.sample(remaining, topick)
614 def send_mail(self, cr, uid, ids, context=None):
615 author_id = self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id
616 for mailing in self.browse(cr, uid, ids, context=context):
617 # instantiate an email composer + send emails
618 res_ids = self.get_recipients(cr, uid, mailing, context=context)
620 raise Warning('Please select recipients.')
621 comp_ctx = dict(context, active_ids=res_ids)
623 'author_id': author_id,
624 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids],
625 'body': mailing.body_html,
626 'subject': mailing.name,
627 'model': mailing.mailing_model,
628 'email_from': mailing.email_from,
629 'record_name': False,
630 'composition_mode': 'mass_mail',
631 'mass_mailing_id': mailing.id,
632 'mailing_list_ids': [(4, l.id) for l in mailing.contact_list_ids],
633 'no_auto_thread': mailing.reply_to_mode != 'thread',
635 if mailing.reply_to_mode == 'email':
636 composer_values['reply_to'] = mailing.reply_to
637 composer_id = self.pool['mail.compose.message'].create(cr, uid, composer_values, context=comp_ctx)
638 self.pool['mail.compose.message'].send_mail(cr, uid, [composer_id], context=comp_ctx)
639 self.write(cr, uid, [mailing.id], {'sent_date': fields.datetime.now(), 'state': 'done'}, context=context)