diff options
Diffstat (limited to 'addons/mass_mailing/models')
| -rw-r--r-- | addons/mass_mailing/models/__init__.py | 7 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mail_mail.py | 98 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mail_thread.py | 87 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mass_mailing.py | 571 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mass_mailing_stats.py | 109 | ||||
| -rw-r--r-- | addons/mass_mailing/models/res_config.py | 15 |
6 files changed, 887 insertions, 0 deletions
diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py new file mode 100644 index 00000000000..2cffe48642b --- /dev/null +++ b/addons/mass_mailing/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +import mass_mailing +import mass_mailing_stats +import mail_mail +import mail_thread +import res_config diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py new file mode 100644 index 00000000000..a67f88a2a28 --- /dev/null +++ b/addons/mass_mailing/models/mail_mail.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2013-Today OpenERP SA (<http://www.openerp.com>) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/> +# +############################################################################## + +import urllib +import urlparse + +from openerp import tools +from openerp import SUPERUSER_ID +from openerp.osv import osv, fields + + +class MailMail(osv.Model): + """Add the mass mailing campaign data to mail""" + _name = 'mail.mail' + _inherit = ['mail.mail'] + + _columns = { + 'mailing_id': fields.many2one('mail.mass_mailing', 'Mass Mailing'), + 'statistics_ids': fields.one2many( + 'mail.mail.statistics', 'mail_mail_id', + string='Statistics', + ), + } + + def create(self, cr, uid, values, context=None): + """ Override mail_mail creation to create an entry in mail.mail.statistics """ + # TDE note: should be after 'all values computed', to have values (FIXME after merging other branch holding create refactoring) + mail_id = super(MailMail, self).create(cr, uid, values, context=context) + if values.get('statistics_ids'): + mail = self.browse(cr, SUPERUSER_ID, mail_id) + for stat in mail.statistics_ids: + self.pool['mail.mail.statistics'].write(cr, uid, [stat.id], {'message_id': mail.message_id}, context=context) + return mail_id + + def _get_tracking_url(self, cr, uid, mail, partner=None, context=None): + base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url') + track_url = urlparse.urljoin( + base_url, 'mail/track/%(mail_id)s/blank.gif?%(params)s' % { + 'mail_id': mail.id, + 'params': urllib.urlencode({'db': cr.dbname}) + } + ) + return '<img src="%s" alt=""/>' % track_url + + def _get_unsubscribe_url(self, cr, uid, mail, email_to, msg=None, context=None): + base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url') + url = urlparse.urljoin( + base_url, 'mail/mailing/%(mailing_id)s/unsubscribe?%(params)s' % { + 'mailing_id': mail.mailing_id.id, + 'params': urllib.urlencode({'db': cr.dbname, 'res_id': mail.res_id, 'email': email_to}) + } + ) + return '<small><a href="%s">%s</a></small>' % (url, msg or 'Click to unsubscribe') + + def send_get_mail_body(self, cr, uid, mail, partner=None, context=None): + """ Override to add the tracking URL to the body. """ + body = super(MailMail, self).send_get_mail_body(cr, uid, mail, partner=partner, context=context) + + # generate tracking URL + if mail.statistics_ids: + tracking_url = self._get_tracking_url(cr, uid, mail, partner, context=context) + if tracking_url: + body = tools.append_content_to_html(body, tracking_url, plaintext=False, container_tag='div') + return body + + def send_get_email_dict(self, cr, uid, mail, partner=None, context=None): + res = super(MailMail, self).send_get_email_dict(cr, uid, mail, partner, context=context) + if mail.mailing_id and res.get('body') and res.get('email_to'): + email_to = tools.email_split(res.get('email_to')[0]) + unsubscribe_url = self._get_unsubscribe_url(cr, uid, mail, email_to, context=context) + if unsubscribe_url: + res['body'] = tools.append_content_to_html(res['body'], unsubscribe_url, plaintext=False, container_tag='p') + return res + + def _postprocess_sent_message(self, cr, uid, mail, context=None, mail_sent=True): + if mail_sent is True and mail.statistics_ids: + self.pool['mail.mail.statistics'].write(cr, uid, [s.id for s in mail.statistics_ids], {'sent': fields.datetime.now()}, context=context) + elif mail_sent is False and mail.statistics_ids: + self.pool['mail.mail.statistics'].write(cr, uid, [s.id for s in mail.statistics_ids], {'exception': fields.datetime.now()}, context=context) + return super(MailMail, self)._postprocess_sent_message(cr, uid, mail, context=context, mail_sent=mail_sent) diff --git a/addons/mass_mailing/models/mail_thread.py b/addons/mass_mailing/models/mail_thread.py new file mode 100644 index 00000000000..524341f88d7 --- /dev/null +++ b/addons/mass_mailing/models/mail_thread.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2013-Today OpenERP SA (<http://www.openerp.com>) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/> +# +############################################################################## + +import logging + +from openerp import tools +from openerp.addons.mail.mail_message import decode +from openerp.addons.mail.mail_thread import decode_header +from openerp.osv import osv + +_logger = logging.getLogger(__name__) + + +class MailThread(osv.AbstractModel): + """ Update MailThread to add the feature of bounced emails and replied emails + in message_process. """ + _name = 'mail.thread' + _inherit = ['mail.thread'] + + def message_route_check_bounce(self, cr, uid, message, context=None): + """ Override to verify that the email_to is the bounce alias. If it is the + case, log the bounce, set the parent and related document as bounced and + return False to end the routing process. """ + bounce_alias = self.pool['ir.config_parameter'].get_param(cr, uid, "mail.bounce.alias", context=context) + message_id = message.get('Message-Id') + email_from = decode_header(message, 'From') + email_to = decode_header(message, 'To') + + # 0. Verify whether this is a bounced email (wrong destination,...) -> use it to collect data, such as dead leads + if bounce_alias in email_to: + bounce_match = tools.bounce_re.search(email_to) + if bounce_match: + bounced_model, bounced_thread_id = None, False + bounced_mail_id = bounce_match.group(1) + stat_ids = self.pool['mail.mail.statistics'].set_bounced(cr, uid, mail_mail_ids=[bounced_mail_id], context=context) + for stat in self.pool['mail.mail.statistics'].browse(cr, uid, stat_ids, context=context): + bounced_model = stat.model + bounced_thread_id = stat.res_id + _logger.info('Routing mail from %s to %s with Message-Id %s: bounced mail from mail %s, model: %s, thread_id: %s', + email_from, email_to, message_id, bounced_mail_id, bounced_model, bounced_thread_id) + if bounced_model and bounced_model in self.pool and hasattr(self.pool[bounced_model], 'message_receive_bounce') and bounced_thread_id: + self.pool[bounced_model].message_receive_bounce(cr, uid, [bounced_thread_id], mail_id=bounced_mail_id, context=context) + return False + + return True + + def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None, + custom_values=None, context=None): + if not self.message_route_check_bounce(cr, uid, message, context=context): + return [] + return super(MailThread, self).message_route(cr, uid, message, message_dict, model, thread_id, custom_values, context) + + def message_receive_bounce(self, cr, uid, ids, mail_id=None, context=None): + """Called by ``message_process`` when a bounce email (such as Undelivered + Mail Returned to Sender) is received for an existing thread. The default + behavior is to check is an integer ``message_bounce`` column exists. + If it is the case, its content is incremented. """ + if self._all_columns.get('message_bounce'): + for obj in self.browse(cr, uid, ids, context=context): + self.write(cr, uid, [obj.id], {'message_bounce': obj.message_bounce + 1}, context=context) + + def message_route_process(self, cr, uid, message, message_dict, routes, context=None): + """ Override to update the parent mail statistics. The parent is found + by using the References header of the incoming message and looking for + matching message_id in mail.mail.statistics. """ + if message.get('References'): + message_ids = [x.strip() for x in decode(message['References']).split()] + self.pool['mail.mail.statistics'].set_replied(cr, uid, mail_message_ids=message_ids, context=context) + return super(MailThread, self).message_route_process(cr, uid, message, message_dict, routes, context=context) diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py new file mode 100644 index 00000000000..bd314f03a3d --- /dev/null +++ b/addons/mass_mailing/models/mass_mailing.py @@ -0,0 +1,571 @@ +# -*- coding: utf-8 -*- + +from datetime import datetime +from dateutil import relativedelta +import json +import random +import urllib +import urlparse + +from openerp import tools +from openerp.tools.safe_eval import safe_eval as eval +from openerp.tools.translate import _ +from openerp.osv import osv, fields + + +class MassMailingCategory(osv.Model): + """Model of categories of mass mailing, i.e. marketing, newsletter, ... """ + _name = 'mail.mass_mailing.category' + _description = 'Mass Mailing Category' + _order = 'name' + + _columns = { + 'name': fields.char('Name', required=True), + } + + +class MassMailingContact(osv.Model): + """Model of a contact. This model is different from the partner model + because it holds only some basic information: name, email. The purpose is to + be able to deal with large contact list to email without bloating the partner + base.""" + _name = 'mail.mass_mailing.contact' + _description = 'Mass Mailing Contact' + _order = 'email' + _rec_name = 'email' + + _columns = { + 'name': fields.char('Name'), + 'email': fields.char('Email', required=True), + 'create_date': fields.datetime('Create Date'), + 'list_id': fields.many2one( + 'mail.mass_mailing.list', string='Mailing List', + ondelete='cascade', required=True, + ), + 'opt_out': fields.boolean('Opt Out', help='The contact has chosen not to receive mails anymore from this list'), + } + + def _get_latest_list(self, cr, uid, context={}): + lid = self.pool.get('mail.mass_mailing.list').search(cr, uid, [], limit=1, order='id desc', context=context) + return lid and lid[0] or False + + _defaults = { + 'list_id': _get_latest_list + } + + def name_create(self, cr, uid, name, context=None): + name, email = self.pool['res.partner']._parse_partner_name(name, context=context) + if name and not email: + email = name + if email and not name: + name = email + rec_id = self.create(cr, uid, {'name': name, 'email': email}, context=context) + return self.name_get(cr, uid, [rec_id], context)[0] + + +class MassMailingList(osv.Model): + """Model of a contact list. """ + _name = 'mail.mass_mailing.list' + _order = 'name' + _description = 'Mailing List' + + def _get_contact_nbr(self, cr, uid, ids, name, arg, context=None): + result = dict.fromkeys(ids, 0) + Contacts = self.pool.get('mail.mass_mailing.contact') + for group in Contacts.read_group(cr, uid, [('list_id', 'in', ids), ('opt_out', '!=', True)], ['list_id'], ['list_id'], context=context): + result[group['list_id'][0]] = group['list_id_count'] + return result + + _columns = { + 'name': fields.char('Mailing List', required=True), + 'contact_nbr': fields.function( + _get_contact_nbr, type='integer', + string='Number of Contacts', + ), + } + + +class MassMailingStage(osv.Model): + """Stage for mass mailing campaigns. """ + _name = 'mail.mass_mailing.stage' + _description = 'Mass Mailing Campaign Stage' + _order = 'sequence' + + _columns = { + 'name': fields.char('Name', required=True, translate=True), + 'sequence': fields.integer('Sequence'), + } + + _defaults = { + 'sequence': 0, + } + + +class MassMailingCampaign(osv.Model): + """Model of mass mailing campaigns. """ + _name = "mail.mass_mailing.campaign" + _description = 'Mass Mailing Campaign' + + def _get_statistics(self, cr, uid, ids, name, arg, context=None): + """ Compute statistics of the mass mailing campaign """ + Statistics = self.pool['mail.mail.statistics'] + results = dict.fromkeys(ids, False) + for cid in ids: + stat_ids = Statistics.search(cr, uid, [('mass_mailing_campaign_id', '=', cid)], context=context) + stats = Statistics.browse(cr, uid, stat_ids, context=context) + results[cid] = { + 'total': len(stats), + 'failed': len([s for s in stats if not s.scheduled is False and s.sent is False and not s.exception is False]), + 'scheduled': len([s for s in stats if not s.scheduled is False and s.sent is False and s.exception is False]), + 'sent': len([s for s in stats if not s.sent is False]), + 'opened': len([s for s in stats if not s.opened is False]), + 'replied': len([s for s in stats if not s.replied is False]), + 'bounced': len([s for s in stats if not s.bounced is False]), + } + results[cid]['delivered'] = results[cid]['sent'] - results[cid]['bounced'] + results[cid]['received_ratio'] = 100.0 * results[cid]['delivered'] / (results[cid]['total'] or 1) + results[cid]['opened_ratio'] = 100.0 * results[cid]['opened'] / (results[cid]['total'] or 1) + results[cid]['replied_ratio'] = 100.0 * results[cid]['replied'] / (results[cid]['total'] or 1) + return results + + _columns = { + 'name': fields.char('Name', required=True), + 'stage_id': fields.many2one('mail.mass_mailing.stage', 'Stage', required=True), + 'user_id': fields.many2one( + 'res.users', 'Responsible', + required=True, + ), + 'category_ids': fields.many2many( + 'mail.mass_mailing.category', 'mail_mass_mailing_category_rel', + 'category_id', 'campaign_id', string='Categories'), + 'mass_mailing_ids': fields.one2many( + 'mail.mass_mailing', 'mass_mailing_campaign_id', + 'Mass Mailings', + ), + 'unique_ab_testing': fields.boolean( + 'AB Testing', + help='If checked, recipients will be mailed only once, allowing to send' + 'various mailings in a single campaign to test the effectiveness' + 'of the mailings.'), + 'color': fields.integer('Color Index'), + # stat fields + 'total': fields.function( + _get_statistics, string='Total', + type='integer', multi='_get_statistics' + ), + 'scheduled': fields.function( + _get_statistics, string='Scheduled', + type='integer', multi='_get_statistics' + ), + 'failed': fields.function( + _get_statistics, string='Failed', + type='integer', multi='_get_statistics' + ), + 'sent': fields.function( + _get_statistics, string='Sent Emails', + type='integer', multi='_get_statistics' + ), + 'delivered': fields.function( + _get_statistics, string='Delivered', + type='integer', multi='_get_statistics', + ), + 'opened': fields.function( + _get_statistics, string='Opened', + type='integer', multi='_get_statistics', + ), + 'replied': fields.function( + _get_statistics, string='Replied', + type='integer', multi='_get_statistics' + ), + 'bounced': fields.function( + _get_statistics, string='Bounced', + type='integer', multi='_get_statistics' + ), + 'received_ratio': fields.function( + _get_statistics, string='Received Ratio', + type='integer', multi='_get_statistics', + ), + 'opened_ratio': fields.function( + _get_statistics, string='Opened Ratio', + type='integer', multi='_get_statistics', + ), + 'replied_ratio': fields.function( + _get_statistics, string='Replied Ratio', + type='integer', multi='_get_statistics', + ), + } + + def _get_default_stage_id(self, cr, uid, context=None): + stage_ids = self.pool['mail.mass_mailing.stage'].search(cr, uid, [], limit=1, context=context) + return stage_ids and stage_ids[0] or False + + _defaults = { + 'user_id': lambda self, cr, uid, ctx=None: uid, + 'stage_id': lambda self, *args: self._get_default_stage_id(*args), + } + + def get_recipients(self, cr, uid, ids, model=None, context=None): + """Return the recipients of a mailing campaign. This is based on the statistics + build for each mailing. """ + Statistics = self.pool['mail.mail.statistics'] + res = dict.fromkeys(ids, False) + for cid in ids: + domain = [('mass_mailing_campaign_id', '=', cid)] + if model: + domain += [('model', '=', model)] + stat_ids = Statistics.search(cr, uid, domain, context=context) + res[cid] = set(stat.res_id for stat in Statistics.browse(cr, uid, stat_ids, context=context)) + return res + + +class MassMailing(osv.Model): + """ MassMailing models a wave of emails for a mass mailign campaign. + A mass mailing is an occurence of sending emails. """ + + _name = 'mail.mass_mailing' + _description = 'Mass Mailing' + # number of periods for tracking mail_mail statistics + _period_number = 6 + _order = 'sent_date DESC' + + def __get_bar_values(self, cr, uid, obj, domain, read_fields, value_field, groupby_field, date_begin, context=None): + """ Generic method to generate data for bar chart values using SparklineBarWidget. + This method performs obj.read_group(cr, uid, domain, read_fields, groupby_field). + + :param obj: the target model (i.e. crm_lead) + :param domain: the domain applied to the read_group + :param list read_fields: the list of fields to read in the read_group + :param str value_field: the field used to compute the value of the bar slice + :param str groupby_field: the fields used to group + + :return list section_result: a list of dicts: [ + { 'value': (int) bar_column_value, + 'tootip': (str) bar_column_tooltip, + } + ] + """ + date_begin = date_begin.date() + section_result = [{'value': 0, + 'tooltip': (date_begin + relativedelta.relativedelta(days=i)).strftime('%d %B %Y'), + } for i in range(0, self._period_number)] + group_obj = obj.read_group(cr, uid, domain, read_fields, groupby_field, context=context) + field_col_info = obj._all_columns.get(groupby_field.split(':')[0]) + pattern = tools.DEFAULT_SERVER_DATE_FORMAT if field_col_info.column._type == 'date' else tools.DEFAULT_SERVER_DATETIME_FORMAT + for group in group_obj: + group_begin_date = datetime.strptime(group['__domain'][0][2], pattern).date() + timedelta = relativedelta.relativedelta(group_begin_date, date_begin) + section_result[timedelta.days] = {'value': group.get(value_field, 0), 'tooltip': group.get(groupby_field)} + return section_result + + def _get_daily_statistics(self, cr, uid, ids, field_name, arg, context=None): + """ Get the daily statistics of the mass mailing. This is done by a grouping + on opened and replied fields. Using custom format in context, we obtain + results for the next 6 days following the mass mailing date. """ + obj = self.pool['mail.mail.statistics'] + res = {} + for mailing in self.browse(cr, uid, ids, context=context): + res[mailing.id] = {} + date = mailing.sent_date if mailing.sent_date else mailing.create_date + date_begin = datetime.strptime(date, tools.DEFAULT_SERVER_DATETIME_FORMAT) + date_end = date_begin + relativedelta.relativedelta(days=self._period_number - 1) + date_begin_str = date_begin.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + date_end_str = date_end.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + domain = [('mass_mailing_id', '=', mailing.id), ('opened', '>=', date_begin_str), ('opened', '<=', date_end_str)] + res[mailing.id]['opened_dayly'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['opened'], 'opened_count', 'opened:day', date_begin, context=context)) + domain = [('mass_mailing_id', '=', mailing.id), ('replied', '>=', date_begin_str), ('replied', '<=', date_end_str)] + res[mailing.id]['replied_dayly'] = json.dumps(self.__get_bar_values(cr, uid, obj, domain, ['replied'], 'replied_count', 'replied:day', date_begin, context=context)) + return res + + def _get_statistics(self, cr, uid, ids, name, arg, context=None): + """ Compute statistics of the mass mailing campaign """ + Statistics = self.pool['mail.mail.statistics'] + results = dict.fromkeys(ids, False) + for mid in ids: + stat_ids = Statistics.search(cr, uid, [('mass_mailing_id', '=', mid)], context=context) + stats = Statistics.browse(cr, uid, stat_ids, context=context) + results[mid] = { + 'total': len(stats), + 'failed': len([s for s in stats if not s.scheduled is False and s.sent is False and not s.exception is False]), + 'scheduled': len([s for s in stats if not s.scheduled is False and s.sent is False and s.exception is False]), + 'sent': len([s for s in stats if not s.sent is False]), + 'opened': len([s for s in stats if not s.opened is False]), + 'replied': len([s for s in stats if not s.replied is False]), + 'bounced': len([s for s in stats if not s.bounced is False]), + } + results[mid]['delivered'] = results[mid]['sent'] - results[mid]['bounced'] + results[mid]['received_ratio'] = 100.0 * results[mid]['delivered'] / (results[mid]['total'] or 1) + results[mid]['opened_ratio'] = 100.0 * results[mid]['opened'] / (results[mid]['total'] or 1) + results[mid]['replied_ratio'] = 100.0 * results[mid]['replied'] / (results[mid]['total'] or 1) + return results + + def _get_mailing_model(self, cr, uid, context=None): + res = [] + for model_name in self.pool: + model = self.pool[model_name] + if hasattr(model, '_mail_mass_mailing') and getattr(model, '_mail_mass_mailing'): + res.append((model._name, getattr(model, '_mail_mass_mailing'))) + res.append(('mail.mass_mailing.contact', _('Mailing List'))) + return res + + # indirections for inheritance + _mailing_model = lambda self, *args, **kwargs: self._get_mailing_model(*args, **kwargs) + + _columns = { + 'name': fields.char('Subject', required=True), + 'email_from': fields.char('From', required=True), + 'create_date': fields.datetime('Creation Date'), + 'sent_date': fields.datetime('Sent Date'), + 'body_html': fields.html('Body'), + 'attachment_ids': fields.many2many( + 'ir.attachment', 'mass_mailing_ir_attachments_rel', + 'mass_mailing_id', 'attachment_id', 'Attachments' + ), + 'mass_mailing_campaign_id': fields.many2one( + 'mail.mass_mailing.campaign', 'Mass Mailing Campaign', + ondelete='set null', + ), + 'state': fields.selection( + [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')], + string='Status', required=True, + ), + 'color': fields.related( + 'mass_mailing_campaign_id', 'color', + type='integer', string='Color Index', + ), + # mailing options + 'reply_to_mode': fields.selection( + [('thread', 'In Document'), ('email', 'Specified Email Address')], + string='Reply-To Mode', required=True, + ), + 'reply_to': fields.char('Reply To', help='Preferred Reply-To Address'), + # recipients + 'mailing_model': fields.selection(_mailing_model, string='Recipients Model', required=True), + 'mailing_domain': fields.char('Domain'), + 'contact_list_ids': fields.many2many( + 'mail.mass_mailing.list', 'mail_mass_mailing_list_rel', + string='Mailing Lists', + ), + 'contact_ab_pc': fields.integer( + 'AB Testing percentage', + help='Percentage of the contacts that will be mailed. Recipients will be taken randomly.' + ), + # statistics data + 'statistics_ids': fields.one2many( + 'mail.mail.statistics', 'mass_mailing_id', + 'Emails Statistics', + ), + 'total': fields.function( + _get_statistics, string='Total', + type='integer', multi='_get_statistics', + ), + 'scheduled': fields.function( + _get_statistics, string='Scheduled', + type='integer', multi='_get_statistics', + ), + 'failed': fields.function( + _get_statistics, string='Failed', + type='integer', multi='_get_statistics', + ), + 'sent': fields.function( + _get_statistics, string='Sent', + type='integer', multi='_get_statistics', + ), + 'delivered': fields.function( + _get_statistics, string='Delivered', + type='integer', multi='_get_statistics', + ), + 'opened': fields.function( + _get_statistics, string='Opened', + type='integer', multi='_get_statistics', + ), + 'replied': fields.function( + _get_statistics, string='Replied', + type='integer', multi='_get_statistics', + ), + 'bounced': fields.function( + _get_statistics, string='Bounced', + type='integer', multi='_get_statistics', + ), + 'received_ratio': fields.function( + _get_statistics, string='Received Ratio', + type='integer', multi='_get_statistics', + ), + 'opened_ratio': fields.function( + _get_statistics, string='Opened Ratio', + type='integer', multi='_get_statistics', + ), + 'replied_ratio': fields.function( + _get_statistics, string='Replied Ratio', + type='integer', multi='_get_statistics', + ), + # dayly ratio + 'opened_dayly': fields.function( + _get_daily_statistics, string='Opened', + type='char', multi='_get_daily_statistics', + oldname='opened_monthly', + ), + 'replied_dayly': fields.function( + _get_daily_statistics, string='Replied', + type='char', multi='_get_daily_statistics', + oldname='replied_monthly', + ) + } + + def default_get(self, cr, uid, fields, context=None): + res = super(MassMailing, self).default_get(cr, uid, fields, context=context) + if 'reply_to_mode' in fields and not 'reply_to_mode' in res and res.get('mailing_model'): + if res['mailing_model'] in ['res.partner', 'mail.mass_mailing.contact']: + res['reply_to_mode'] = 'email' + else: + res['reply_to_mode'] = 'thread' + return res + + _defaults = { + 'state': 'draft', + 'email_from': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx), + 'reply_to': lambda self, cr, uid, ctx=None: self.pool['mail.message']._get_default_from(cr, uid, context=ctx), + 'mailing_model': 'mail.mass_mailing.contact', + 'contact_ab_pc': 100, + } + + #------------------------------------------------------ + # Technical stuff + #------------------------------------------------------ + + def copy_data(self, cr, uid, id, default=None, context=None): + if default is None: + default = {} + mailing = self.browse(cr, uid, id, context=context) + default.update({ + 'state': 'draft', + 'statistics_ids': [], + 'name': _('%s (duplicate)') % mailing.name, + 'sent_date': False, + }) + return super(MassMailing, self).copy_data(cr, uid, id, default, context=context) + + def read_group(self, cr, uid, domain, fields, groupby, offset=0, limit=None, context=None, orderby=False, lazy=True): + """ Override read_group to always display all states. """ + if groupby and groupby[0] == "state": + # Default result structure + # states = self._get_state_list(cr, uid, context=context) + states = [('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')] + read_group_all_states = [{ + '__context': {'group_by': groupby[1:]}, + '__domain': domain + [('state', '=', state_value)], + 'state': state_value, + 'state_count': 0, + } for state_value, state_name in states] + # Get standard results + read_group_res = super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby) + # Update standard results with default results + result = [] + for state_value, state_name in states: + res = filter(lambda x: x['state'] == state_value, read_group_res) + if not res: + res = filter(lambda x: x['state'] == state_value, read_group_all_states) + res[0]['state'] = [state_value, state_name] + result.append(res[0]) + return result + else: + return super(MassMailing, self).read_group(cr, uid, domain, fields, groupby, offset=offset, limit=limit, context=context, orderby=orderby) + + #------------------------------------------------------ + # Views & Actions + #------------------------------------------------------ + + def on_change_model_and_list(self, cr, uid, ids, mailing_model, list_ids, context=None): + value = {} + if mailing_model == 'mail.mass_mailing.contact': + list_ids = map(lambda item: item if isinstance(item, (int, long)) else [lid for lid in item[2]], list_ids) + if list_ids: + value['mailing_domain'] = "[('list_id', 'in', %s)]" % list_ids + else: + value['mailing_domain'] = "[('list_id', '=', False)]" + else: + value['mailing_domain'] = False + return {'value': value} + + def action_duplicate(self, cr, uid, ids, context=None): + copy_id = None + for mid in ids: + copy_id = self.copy(cr, uid, mid, context=context) + if copy_id: + return { + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'mail.mass_mailing', + 'res_id': copy_id, + 'context': context, + } + return False + + def action_test_mailing(self, cr, uid, ids, context=None): + ctx = dict(context, default_mass_mailing_id=ids[0]) + return { + 'name': _('Test Mailing'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'mail.mass_mailing.test', + 'target': 'new', + 'context': ctx, + } + + def action_edit_html(self, cr, uid, ids, context=None): + if not len(ids) == 1: + raise ValueError('One and only one ID allowed for this action') + mail = self.browse(cr, uid, ids[0], context=context) + url = '/website_mail/email_designer?model=mail.mass_mailing&res_id=%d&template_model=%s&enable_editor=1' % (ids[0], mail.mailing_model) + return { + 'name': _('Open with Visual Editor'), + 'type': 'ir.actions.act_url', + 'url': url, + 'target': 'self', + } + + #------------------------------------------------------ + # Email Sending + #------------------------------------------------------ + + def get_recipients(self, cr, uid, mailing, context=None): + domain = eval(mailing.mailing_domain) + res_ids = self.pool[mailing.mailing_model].search(cr, uid, domain, context=context) + + # randomly choose a fragment + if mailing.contact_ab_pc < 100: + contact_nbr = self.pool[mailing.mailing_model].search(cr, uid, domain, count=True, context=context) + topick = int(contact_nbr / 100.0 * mailing.contact_ab_pc) + if mailing.mass_mailing_campaign_id and mailing.mass_mailing_campaign_id.unique_ab_testing: + 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] + else: + already_mailed = set([]) + remaining = set(res_ids).difference(already_mailed) + if topick > len(remaining): + topick = len(remaining) + res_ids = random.sample(remaining, topick) + return res_ids + + def send_mail(self, cr, uid, ids, context=None): + author_id = self.pool['res.users'].browse(cr, uid, uid, context=context).partner_id.id + for mailing in self.browse(cr, uid, ids, context=context): + # instantiate an email composer + send emails + res_ids = self.get_recipients(cr, uid, mailing, context=context) + comp_ctx = dict(context, active_ids=res_ids) + composer_values = { + 'author_id': author_id, + 'body': mailing.body_html, + 'subject': mailing.name, + 'model': mailing.mailing_model, + 'email_from': mailing.email_from, + 'record_name': False, + 'composition_mode': 'mass_mail', + 'mass_mailing_id': mailing.id, + 'mailing_list_ids': [(4, l.id) for l in mailing.contact_list_ids], + } + if mailing.reply_to_mode == 'email': + composer_values['reply_to'] = mailing.reply_to + composer_id = self.pool['mail.compose.message'].create(cr, uid, composer_values, context=comp_ctx) + self.pool['mail.compose.message'].send_mail(cr, uid, [composer_id], context=comp_ctx) + self.write(cr, uid, [mailing.id], {'sent_date': fields.datetime.now(), 'state': 'done'}, context=context) + return True diff --git a/addons/mass_mailing/models/mass_mailing_stats.py b/addons/mass_mailing/models/mass_mailing_stats.py new file mode 100644 index 00000000000..319a6fde14d --- /dev/null +++ b/addons/mass_mailing/models/mass_mailing_stats.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2013-today OpenERP SA (<http://www.openerp.com>) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/> +# +############################################################################## + +from datetime import datetime +from dateutil import relativedelta +import random +try: + import simplejson as json +except ImportError: + import json +import urllib +import urlparse + +from openerp import tools +from openerp.exceptions import Warning +from openerp.tools.safe_eval import safe_eval as eval +from openerp.tools.translate import _ +from openerp.osv import osv, fields + + +class MailMailStats(osv.Model): + """ MailMailStats models the statistics collected about emails. Those statistics + are stored in a separated model and table to avoid bloating the mail_mail table + with statistics values. This also allows to delete emails send with mass mailing + without loosing the statistics about them. """ + + _name = 'mail.mail.statistics' + _description = 'Email Statistics' + _rec_name = 'message_id' + _order = 'message_id' + + _columns = { + 'mail_mail_id': fields.integer( + 'Mail ID', + help='ID of the related mail_mail. This field is an integer field because' + 'the related mail_mail can be deleted separately from its statistics.' + ), + 'message_id': fields.char('Message-ID'), + 'model': fields.char('Document model'), + 'res_id': fields.integer('Document ID'), + # campaign / wave data + 'mass_mailing_id': fields.many2one( + 'mail.mass_mailing', 'Mass Mailing', + ondelete='set null', + ), + 'mass_mailing_campaign_id': fields.related( + 'mass_mailing_id', 'mass_mailing_campaign_id', + type='many2one', ondelete='set null', + relation='mail.mass_mailing.campaign', + string='Mass Mailing Campaign', + store=True, readonly=True, + ), + # Bounce and tracking + 'scheduled': fields.datetime('Scheduled', help='Date when the email has been created'), + 'sent': fields.datetime('Sent', help='Date when the email has been sent'), + 'exception': fields.datetime('Exception', help='Date of technical error leading to the email not being sent'), + 'opened': fields.datetime('Opened', help='Date when the email has been opened the first time'), + 'replied': fields.datetime('Replied', help='Date when this email has been replied for the first time.'), + 'bounced': fields.datetime('Bounced', help='Date when this email has bounced.'), + } + + _defaults = { + 'scheduled': fields.datetime.now, + } + + def _get_ids(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, domain=None, context=None): + if not ids and mail_mail_ids: + base_domain = [('mail_mail_id', 'in', mail_mail_ids)] + elif not ids and mail_message_ids: + base_domain = [('message_id', 'in', mail_message_ids)] + else: + base_domain = [('id', 'in', ids or [])] + if domain: + base_domain = ['&'] + domain + base_domain + return self.search(cr, uid, base_domain, context=context) + + def set_opened(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, context=None): + stat_ids = self._get_ids(cr, uid, ids, mail_mail_ids, mail_message_ids, [('opened', '=', False)], context) + self.write(cr, uid, stat_ids, {'opened': fields.datetime.now()}, context=context) + return stat_ids + + def set_replied(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, context=None): + stat_ids = self._get_ids(cr, uid, ids, mail_mail_ids, mail_message_ids, [('replied', '=', False)], context) + self.write(cr, uid, stat_ids, {'replied': fields.datetime.now()}, context=context) + return stat_ids + + def set_bounced(self, cr, uid, ids=None, mail_mail_ids=None, mail_message_ids=None, context=None): + stat_ids = self._get_ids(cr, uid, ids, mail_mail_ids, mail_message_ids, [('bounced', '=', False)], context) + self.write(cr, uid, stat_ids, {'bounced': fields.datetime.now()}, context=context) + return stat_ids + diff --git a/addons/mass_mailing/models/res_config.py b/addons/mass_mailing/models/res_config.py new file mode 100644 index 00000000000..4b3757b7dca --- /dev/null +++ b/addons/mass_mailing/models/res_config.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +from openerp.osv import fields, osv + + +class MassMailingConfiguration(osv.TransientModel): + _name = 'marketing.config.settings' + _inherit = 'marketing.config.settings' + + _columns = { + 'group_mass_mailing_campaign': fields.boolean( + 'Manage Mass Mailing using Campaign', + implied_group='mass_mailing.group_mass_mailing_campaign', + help="""Manage mass mailign using Campaigns"""), + } |
