mirror of
https://github.com/bringout/oca-ocb-mail.git
synced 2026-04-21 13:22:00 +02:00
Initial commit: Mail packages
This commit is contained in:
commit
4e53507711
1948 changed files with 751201 additions and 0 deletions
|
|
@ -0,0 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import mailing_contact
|
||||
from . import mailing_list
|
||||
from . import mailing_mailing
|
||||
from . import mailing_trace
|
||||
from . import res_users
|
||||
from . import sms_sms
|
||||
from . import utm
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class MailingContact(models.Model):
|
||||
_name = 'mailing.contact'
|
||||
_inherit = ['mailing.contact', 'mail.thread.phone']
|
||||
|
||||
mobile = fields.Char(string='Mobile')
|
||||
|
||||
def _phone_get_number_fields(self):
|
||||
return ['mobile']
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class MailingList(models.Model):
|
||||
_inherit = 'mailing.list'
|
||||
|
||||
contact_count_sms = fields.Integer(compute="_compute_mailing_list_statistics", string="SMS Contacts")
|
||||
|
||||
def action_view_mailings(self):
|
||||
if self.env.context.get('mailing_sms'):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id('mass_mailing_sms.mailing_mailing_action_sms')
|
||||
action['domain'] = [('id', 'in', self.mailing_ids.ids)]
|
||||
action['context'] = {
|
||||
'default_mailing_type': 'sms',
|
||||
'default_contact_list_ids': self.ids,
|
||||
'mailing_sms': True
|
||||
}
|
||||
return action
|
||||
else:
|
||||
return super(MailingList, self).action_view_mailings()
|
||||
|
||||
def action_view_contacts_sms(self):
|
||||
action = self.action_view_contacts()
|
||||
action['context'] = dict(action.get('context', {}), search_default_filter_valid_sms_recipient=1)
|
||||
return action
|
||||
|
||||
def _get_contact_statistics_fields(self):
|
||||
""" See super method docstring for more info.
|
||||
Adds:
|
||||
- contact_count_sms: all valid sms
|
||||
- contact_count_blacklisted: override the dict entry to add SMS blacklist condition """
|
||||
|
||||
values = super(MailingList, self)._get_contact_statistics_fields()
|
||||
values.update({
|
||||
'contact_count_sms': '''
|
||||
SUM(CASE WHEN
|
||||
(c.phone_sanitized IS NOT NULL
|
||||
AND COALESCE(r.opt_out,FALSE) = FALSE
|
||||
AND bl_sms.id IS NULL)
|
||||
THEN 1 ELSE 0 END) AS contact_count_sms''',
|
||||
'contact_count_blacklisted': '''
|
||||
SUM(CASE WHEN (bl.id IS NOT NULL OR bl_sms.id IS NOT NULL)
|
||||
THEN 1 ELSE 0 END) AS contact_count_blacklisted'''
|
||||
})
|
||||
return values
|
||||
|
||||
def _get_contact_statistics_joins(self):
|
||||
return super(MailingList, self)._get_contact_statistics_joins() + '''
|
||||
LEFT JOIN phone_blacklist bl_sms ON c.phone_sanitized = bl_sms.number and bl_sms.active
|
||||
'''
|
||||
|
||||
def _mailing_get_opt_out_list_sms(self, mailing):
|
||||
""" Check subscription on all involved mailing lists. If user is opt_out
|
||||
on one list but not on another, one opted in and the other one opted out,
|
||||
send mailing anyway.
|
||||
|
||||
:return list: opt-outed record IDs
|
||||
"""
|
||||
subscriptions = self.subscription_ids if self else mailing.contact_list_ids.subscription_ids
|
||||
opt_out_contacts = subscriptions.filtered(lambda sub: sub.opt_out).mapped('contact_id')
|
||||
opt_in_contacts = subscriptions.filtered(lambda sub: not sub.opt_out).mapped('contact_id')
|
||||
return list(set(c.id for c in opt_out_contacts if c not in opt_in_contacts))
|
||||
|
|
@ -0,0 +1,411 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.link_tracker.models.link_tracker import LINK_TRACKER_MIN_CODE_LENGTH
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.osv import expression
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Mailing(models.Model):
|
||||
_inherit = 'mailing.mailing'
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super(Mailing, self).default_get(fields)
|
||||
if fields is not None and 'keep_archives' in fields and res.get('mailing_type') == 'sms':
|
||||
res['keep_archives'] = True
|
||||
return res
|
||||
|
||||
# mailing options
|
||||
mailing_type = fields.Selection(selection_add=[
|
||||
('sms', 'SMS')
|
||||
], ondelete={'sms': 'set default'})
|
||||
|
||||
# 'sms_subject' added to override 'subject' field (string attribute should be labelled "Title" when mailing_type == 'sms').
|
||||
# 'sms_subject' should have the same helper as 'subject' field when 'mass_mailing_sms' installed.
|
||||
# otherwise 'sms_subject' will get the old helper from 'mass_mailing' module.
|
||||
# overriding 'subject' field helper in this model is not working, since the helper will keep the new value
|
||||
# even when 'mass_mailing_sms' removed (see 'mailing_mailing_view_form_sms' for more details).
|
||||
sms_subject = fields.Char(
|
||||
'Title', related='subject',
|
||||
readonly=False, translate=False,
|
||||
help='For an email, the subject your recipients will see in their inbox.\n'
|
||||
'For an SMS, the internal title of the message.')
|
||||
# sms options
|
||||
body_plaintext = fields.Text(
|
||||
'SMS Body', compute='_compute_body_plaintext',
|
||||
store=True, readonly=False)
|
||||
sms_template_id = fields.Many2one('sms.template', string='SMS Template', ondelete='set null')
|
||||
sms_has_insufficient_credit = fields.Boolean(
|
||||
'Insufficient IAP credits', compute='_compute_sms_has_iap_failure') # used to propose buying IAP credits
|
||||
sms_has_unregistered_account = fields.Boolean(
|
||||
'Unregistered IAP account', compute='_compute_sms_has_iap_failure') # used to propose to Register the SMS IAP account
|
||||
sms_force_send = fields.Boolean(
|
||||
'Send Directly', help='Immediately send the SMS Mailing instead of queuing up. Use at your own risk.')
|
||||
# opt_out_link
|
||||
sms_allow_unsubscribe = fields.Boolean('Include opt-out link', default=False)
|
||||
# A/B Testing
|
||||
ab_testing_sms_winner_selection = fields.Selection(
|
||||
related="campaign_id.ab_testing_sms_winner_selection",
|
||||
default="clicks_ratio", readonly=False, copy=True)
|
||||
|
||||
@api.depends('mailing_type')
|
||||
def _compute_medium_id(self):
|
||||
super(Mailing, self)._compute_medium_id()
|
||||
for mailing in self:
|
||||
if mailing.mailing_type == 'sms' and (not mailing.medium_id or mailing.medium_id == self.env.ref('utm.utm_medium_email')):
|
||||
mailing.medium_id = self.env.ref('mass_mailing_sms.utm_medium_sms').id
|
||||
elif mailing.mailing_type == 'mail' and (not mailing.medium_id or mailing.medium_id == self.env.ref('mass_mailing_sms.utm_medium_sms')):
|
||||
mailing.medium_id = self.env.ref('utm.utm_medium_email').id
|
||||
|
||||
@api.depends('sms_template_id', 'mailing_type')
|
||||
def _compute_body_plaintext(self):
|
||||
for mailing in self:
|
||||
if mailing.mailing_type == 'sms' and mailing.sms_template_id:
|
||||
mailing.body_plaintext = mailing.sms_template_id.body
|
||||
|
||||
@api.depends('mailing_trace_ids.failure_type')
|
||||
def _compute_sms_has_iap_failure(self):
|
||||
failures = ['sms_acc', 'sms_credit']
|
||||
if not self.ids:
|
||||
self.sms_has_insufficient_credit = self.sms_has_unregistered_account = False
|
||||
else:
|
||||
traces = self.env['mailing.trace'].sudo().read_group([
|
||||
('mass_mailing_id', 'in', self.ids),
|
||||
('trace_type', '=', 'sms'),
|
||||
('failure_type', 'in', failures)
|
||||
], ['mass_mailing_id', 'failure_type'], ['mass_mailing_id', 'failure_type'], lazy=False)
|
||||
|
||||
trace_dict = dict.fromkeys(self.ids, {key: False for key in failures})
|
||||
for t in traces:
|
||||
trace_dict[t['mass_mailing_id'][0]][t['failure_type']] = bool(t['__count'])
|
||||
|
||||
for mail in self:
|
||||
mail.sms_has_insufficient_credit = trace_dict[mail.id]['sms_credit']
|
||||
mail.sms_has_unregistered_account = trace_dict[mail.id]['sms_acc']
|
||||
|
||||
# --------------------------------------------------
|
||||
# ORM OVERRIDES
|
||||
# --------------------------------------------------
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
# Get subject from "sms_subject" field when SMS installed (used to
|
||||
# build the name of record in the super 'create' method)
|
||||
if vals.get('mailing_type') == 'sms' and vals.get('sms_subject'):
|
||||
vals['subject'] = vals['sms_subject']
|
||||
return super().create(vals_list)
|
||||
|
||||
# --------------------------------------------------
|
||||
# BUSINESS / VIEWS ACTIONS
|
||||
# --------------------------------------------------
|
||||
|
||||
def action_retry_failed(self):
|
||||
mass_sms = self.filtered(lambda m: m.mailing_type == 'sms')
|
||||
if mass_sms:
|
||||
mass_sms.action_retry_failed_sms()
|
||||
return super(Mailing, self - mass_sms).action_retry_failed()
|
||||
|
||||
def action_retry_failed_sms(self):
|
||||
failed_sms = self.env['sms.sms'].sudo().search([
|
||||
('mailing_id', 'in', self.ids),
|
||||
('state', '=', 'error')
|
||||
])
|
||||
failed_sms.mapped('mailing_trace_ids').unlink()
|
||||
failed_sms.unlink()
|
||||
self.action_put_in_queue()
|
||||
|
||||
def action_test(self):
|
||||
if self.mailing_type == 'sms':
|
||||
ctx = dict(self.env.context, default_mailing_id=self.id)
|
||||
return {
|
||||
'name': _('Test SMS marketing'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'mailing.sms.test',
|
||||
'target': 'new',
|
||||
'context': ctx,
|
||||
}
|
||||
return super(Mailing, self).action_test()
|
||||
|
||||
def _action_view_traces_filtered(self, view_filter):
|
||||
action = super(Mailing, self)._action_view_traces_filtered(view_filter)
|
||||
if self.mailing_type == 'sms':
|
||||
action['views'] = [(self.env.ref('mass_mailing_sms.mailing_trace_view_tree_sms').id, 'tree'),
|
||||
(self.env.ref('mass_mailing_sms.mailing_trace_view_form_sms').id, 'form')]
|
||||
return action
|
||||
|
||||
def action_buy_sms_credits(self):
|
||||
url = self.env['iap.account'].get_credits_url(service_name='sms')
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': url,
|
||||
}
|
||||
|
||||
# --------------------------------------------------
|
||||
# SMS SEND
|
||||
# --------------------------------------------------
|
||||
|
||||
def _get_opt_out_list_sms(self):
|
||||
""" Give list of opt-outed records, depending on specific model-based
|
||||
computation if available.
|
||||
|
||||
:return list: opt-outed record IDs
|
||||
"""
|
||||
self.ensure_one()
|
||||
opt_out = []
|
||||
target = self.env[self.mailing_model_real]
|
||||
if hasattr(self.env[self.mailing_model_name], '_mailing_get_opt_out_list_sms'):
|
||||
opt_out = self.env[self.mailing_model_name]._mailing_get_opt_out_list_sms(self)
|
||||
_logger.info("Mass SMS %s targets %s: optout: %s contacts", self, target._name, len(opt_out))
|
||||
else:
|
||||
_logger.info("Mass SMS %s targets %s: no opt out list available", self, target._name)
|
||||
return opt_out
|
||||
|
||||
def _get_seen_list_sms(self):
|
||||
"""Returns a set of emails already targeted by current mailing/campaign (no duplicates)"""
|
||||
self.ensure_one()
|
||||
target = self.env[self.mailing_model_real]
|
||||
|
||||
partner_fields = []
|
||||
if isinstance(target, self.pool['mail.thread.phone']):
|
||||
phone_fields = ['phone_sanitized']
|
||||
elif isinstance(target, self.pool['mail.thread']):
|
||||
phone_fields = [
|
||||
fname for fname in target._sms_get_number_fields()
|
||||
if fname in target._fields and target._fields[fname].store
|
||||
]
|
||||
partner_fields = target._sms_get_partner_fields()
|
||||
else:
|
||||
phone_fields = []
|
||||
if 'mobile' in target._fields and target._fields['mobile'].store:
|
||||
phone_fields.append('mobile')
|
||||
if 'phone' in target._fields and target._fields['phone'].store:
|
||||
phone_fields.append('phone')
|
||||
partner_field = next(
|
||||
(fname for fname in partner_fields if target._fields[fname].store and target._fields[fname].type == 'many2one'),
|
||||
False
|
||||
)
|
||||
if not phone_fields and not partner_field:
|
||||
raise UserError(_("Unsupported %s for mass SMS", self.mailing_model_id.name))
|
||||
|
||||
query = """
|
||||
SELECT %(select_query)s
|
||||
FROM mailing_trace trace
|
||||
JOIN %(target_table)s target ON (trace.res_id = target.id)
|
||||
%(join_add_query)s
|
||||
WHERE (%(where_query)s)
|
||||
AND trace.mass_mailing_id = %%(mailing_id)s
|
||||
AND trace.model = %%(target_model)s
|
||||
"""
|
||||
if phone_fields:
|
||||
# phone fields are checked on target mailed model
|
||||
select_query = 'target.id, ' + ', '.join('target.%s' % fname for fname in phone_fields)
|
||||
where_query = ' OR '.join('target.%s IS NOT NULL' % fname for fname in phone_fields)
|
||||
join_add_query = ''
|
||||
else:
|
||||
# phone fields are checked on res.partner model
|
||||
partner_phone_fields = ['mobile', 'phone']
|
||||
select_query = 'target.id, ' + ', '.join('partner.%s' % fname for fname in partner_phone_fields)
|
||||
where_query = ' OR '.join('partner.%s IS NOT NULL' % fname for fname in partner_phone_fields)
|
||||
join_add_query = 'JOIN res_partner partner ON (target.%s = partner.id)' % partner_field
|
||||
|
||||
query = query % {
|
||||
'select_query': select_query,
|
||||
'where_query': where_query,
|
||||
'target_table': target._table,
|
||||
'join_add_query': join_add_query,
|
||||
}
|
||||
params = {'mailing_id': self.id, 'target_model': self.mailing_model_real}
|
||||
self._cr.execute(query, params)
|
||||
query_res = self._cr.fetchall()
|
||||
seen_list = set(number for item in query_res for number in item[1:] if number)
|
||||
seen_ids = set(item[0] for item in query_res)
|
||||
_logger.info("Mass SMS %s targets %s: already reached %s SMS", self, target._name, len(seen_list))
|
||||
return list(seen_ids), list(seen_list)
|
||||
|
||||
def _send_sms_get_composer_values(self, res_ids):
|
||||
return {
|
||||
# content
|
||||
'body': self.body_plaintext,
|
||||
'template_id': self.sms_template_id.id,
|
||||
'res_model': self.mailing_model_real,
|
||||
'res_ids': repr(res_ids),
|
||||
# options
|
||||
'composition_mode': 'mass',
|
||||
'mailing_id': self.id,
|
||||
'mass_keep_log': self.keep_archives,
|
||||
'mass_force_send': self.sms_force_send,
|
||||
'mass_sms_allow_unsubscribe': self.sms_allow_unsubscribe,
|
||||
}
|
||||
|
||||
def action_send_mail(self, res_ids=None):
|
||||
mass_sms = self.filtered(lambda m: m.mailing_type == 'sms')
|
||||
if mass_sms:
|
||||
mass_sms.action_send_sms(res_ids=res_ids)
|
||||
return super(Mailing, self - mass_sms).action_send_mail(res_ids=res_ids)
|
||||
|
||||
def action_send_sms(self, res_ids=None):
|
||||
for mailing in self:
|
||||
if not res_ids:
|
||||
res_ids = mailing._get_remaining_recipients()
|
||||
if res_ids:
|
||||
composer = self.env['sms.composer'].with_context(active_id=False).create(mailing._send_sms_get_composer_values(res_ids))
|
||||
composer._action_send_sms()
|
||||
|
||||
mailing.write({
|
||||
'state': 'done',
|
||||
'sent_date': fields.Datetime.now(),
|
||||
'kpi_mail_required': not mailing.sent_date,
|
||||
})
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------
|
||||
# STATISTICS
|
||||
# ------------------------------------------------------
|
||||
|
||||
def _prepare_statistics_email_values(self):
|
||||
"""Return some statistics that will be displayed in the mailing statistics email.
|
||||
|
||||
Each item in the returned list will be displayed as a table, with a title and
|
||||
1, 2 or 3 columns.
|
||||
"""
|
||||
values = super(Mailing, self)._prepare_statistics_email_values()
|
||||
if self.mailing_type == 'sms':
|
||||
mailing_type = self._get_pretty_mailing_type()
|
||||
values['title'] = _('24H Stats of %(mailing_type)s "%(mailing_name)s"',
|
||||
mailing_type=mailing_type,
|
||||
mailing_name=self.subject
|
||||
)
|
||||
values['kpi_data'][0] = {
|
||||
'kpi_fullname': _('Report for %(expected)i %(mailing_type)s Sent',
|
||||
expected=self.expected,
|
||||
mailing_type=mailing_type
|
||||
),
|
||||
'kpi_col1': {
|
||||
'value': f'{self.received_ratio}%',
|
||||
'col_subtitle': _('RECEIVED (%i)', self.delivered),
|
||||
},
|
||||
'kpi_col2': {
|
||||
'value': f'{self.clicks_ratio}%',
|
||||
'col_subtitle': _('CLICKED (%i)', self.clicked),
|
||||
},
|
||||
'kpi_col3': {
|
||||
'value': f'{self.bounced_ratio}%',
|
||||
'col_subtitle': _('BOUNCED (%i)', self.bounced),
|
||||
},
|
||||
'kpi_action': None,
|
||||
'kpi_name': self.mailing_type,
|
||||
}
|
||||
return values
|
||||
|
||||
def _get_pretty_mailing_type(self):
|
||||
if self.mailing_type == 'sms':
|
||||
return _('SMS Text Message')
|
||||
return super(Mailing, self)._get_pretty_mailing_type()
|
||||
|
||||
# --------------------------------------------------
|
||||
# TOOLS
|
||||
# --------------------------------------------------
|
||||
|
||||
def _get_default_mailing_domain(self):
|
||||
mailing_domain = super(Mailing, self)._get_default_mailing_domain()
|
||||
if self.mailing_type == 'sms' and 'phone_sanitized_blacklisted' in self.env[self.mailing_model_name]._fields:
|
||||
mailing_domain = expression.AND([mailing_domain, [('phone_sanitized_blacklisted', '=', False)]])
|
||||
|
||||
return mailing_domain
|
||||
|
||||
def convert_links(self):
|
||||
sms_mailings = self.filtered(lambda m: m.mailing_type == 'sms')
|
||||
res = {}
|
||||
for mailing in sms_mailings:
|
||||
tracker_values = mailing._get_link_tracker_values()
|
||||
body = mailing._shorten_links_text(mailing.body_plaintext, tracker_values)
|
||||
res[mailing.id] = body
|
||||
res.update(super(Mailing, self - sms_mailings).convert_links())
|
||||
return res
|
||||
|
||||
def get_sms_link_replacements_placeholders(self):
|
||||
"""Get placeholders for replaced links in sms widget for accurate computation of sms counts.
|
||||
|
||||
Reminders and assumptions:
|
||||
* Links wille be transformed to the format "[base_url]/r/[link_tracker_code]/s/[sms_id]".
|
||||
* unsubscribe is formatted as: "\nSTOP SMS : [base_url]/sms/[mailing_id]/[trace_code]".
|
||||
|
||||
:return: Character counts used for links, formatted as `{link: str, unsubscribe: str}`.
|
||||
"""
|
||||
if self:
|
||||
self.ensure_one()
|
||||
|
||||
self.check_access_rights('write')
|
||||
|
||||
max_sms = self.env['sms.sms'].sudo().search_read([], ['id'], order='id desc', limit=1)
|
||||
sms_id_length = max(len(str(max_sms[0]['id'])), 5) if max_sms else 5 # Assumes a mailing won't be more than 10⁵ sms at once
|
||||
max_code = self.env['link.tracker.code'].sudo().search_read([], ['code'], order='id DESC', limit=1)
|
||||
code_length = len(max_code[0]['code']) + 1 if max_code else LINK_TRACKER_MIN_CODE_LENGTH
|
||||
|
||||
if self.id:
|
||||
mailing_id_placeholder_length = len(str(self.id))
|
||||
else:
|
||||
max_mailing = self.env['mailing.mailing'].sudo().search_read([], ['id'], order='id DESC', limit=1)
|
||||
mailing_id_placeholder_length = len(str(max_mailing[0]['id'] + 1)) if max_mailing else 1
|
||||
mailing_id_placeholder = 'x' * mailing_id_placeholder_length
|
||||
|
||||
base_url = self.get_base_url()
|
||||
opt_out_url = urljoin(base_url, f"sms/{mailing_id_placeholder}/{'x' * self.env['mailing.trace'].CODE_SIZE}")
|
||||
return {
|
||||
'link': urljoin(base_url, f"r/{'x' * code_length}/s/{'x' * sms_id_length}"),
|
||||
'unsubscribe': f"\n{self.env['sms.composer']._get_unsubscribe_info(opt_out_url)}"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------
|
||||
# A/B Test Override
|
||||
# ------------------------------------------------------
|
||||
|
||||
def _get_ab_testing_description_modifying_fields(self):
|
||||
fields_list = super()._get_ab_testing_description_modifying_fields()
|
||||
return fields_list + ['ab_testing_sms_winner_selection']
|
||||
|
||||
def _get_ab_testing_description_values(self):
|
||||
values = super()._get_ab_testing_description_values()
|
||||
if self.mailing_type == 'sms':
|
||||
values.update({
|
||||
'ab_testing_winner_selection': self.ab_testing_sms_winner_selection,
|
||||
})
|
||||
return values
|
||||
|
||||
def _get_ab_testing_winner_selection(self):
|
||||
result = super()._get_ab_testing_winner_selection()
|
||||
if self.mailing_type == 'sms':
|
||||
ab_testing_winner_selection_description = dict(
|
||||
self._fields.get('ab_testing_sms_winner_selection').related_field.selection
|
||||
).get(self.ab_testing_sms_winner_selection)
|
||||
result.update({
|
||||
'value': self.campaign_id.ab_testing_sms_winner_selection,
|
||||
'description': ab_testing_winner_selection_description
|
||||
})
|
||||
return result
|
||||
|
||||
def _get_ab_testing_siblings_mailings(self):
|
||||
mailings = super()._get_ab_testing_siblings_mailings()
|
||||
if self.mailing_type == 'sms':
|
||||
mailings = self.campaign_id.mailing_sms_ids.filtered('ab_testing_enabled')
|
||||
return mailings
|
||||
|
||||
def _get_default_ab_testing_campaign_values(self, values=None):
|
||||
campaign_values = super()._get_default_ab_testing_campaign_values(values)
|
||||
values = values or dict()
|
||||
if self.mailing_type == 'sms':
|
||||
sms_subject = values.get('sms_subject') or self.sms_subject
|
||||
if sms_subject:
|
||||
campaign_values['name'] = _("A/B Test: %s", sms_subject)
|
||||
campaign_values['ab_testing_sms_winner_selection'] = self.ab_testing_sms_winner_selection
|
||||
return campaign_values
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
class MailingTrace(models.Model):
|
||||
""" Improve statistics model to add SMS support. Main attributes of
|
||||
statistics model are used, only some specific data is required. """
|
||||
_inherit = 'mailing.trace'
|
||||
CODE_SIZE = 3
|
||||
|
||||
trace_type = fields.Selection(selection_add=[
|
||||
('sms', 'SMS')
|
||||
], ondelete={'sms': 'set default'})
|
||||
sms_sms_id = fields.Many2one('sms.sms', string='SMS', index='btree_not_null', ondelete='set null')
|
||||
sms_sms_id_int = fields.Integer(
|
||||
string='SMS ID (tech)',
|
||||
index='btree_not_null'
|
||||
# Integer because the related sms.sms can be deleted separately from its statistics.
|
||||
# However the ID is needed for several action and controllers.
|
||||
)
|
||||
sms_number = fields.Char('Number')
|
||||
sms_code = fields.Char('Code')
|
||||
failure_type = fields.Selection(selection_add=[
|
||||
('sms_number_missing', 'Missing Number'),
|
||||
('sms_number_format', 'Wrong Number Format'),
|
||||
('sms_credit', 'Insufficient Credit'),
|
||||
('sms_server', 'Server Error'),
|
||||
('sms_acc', 'Unregistered Account'),
|
||||
# mass mode specific codes
|
||||
('sms_blacklist', 'Blacklisted'),
|
||||
('sms_duplicate', 'Duplicate'),
|
||||
('sms_optout', 'Opted Out'),
|
||||
])
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, values_list):
|
||||
for values in values_list:
|
||||
if 'sms_sms_id' in values:
|
||||
values['sms_sms_id_int'] = values['sms_sms_id']
|
||||
if values.get('trace_type') == 'sms' and not values.get('sms_code'):
|
||||
values['sms_code'] = self._get_random_code()
|
||||
return super(MailingTrace, self).create(values_list)
|
||||
|
||||
def _get_random_code(self):
|
||||
""" Generate a random code for trace. Uniqueness is not really necessary
|
||||
as it serves as obfuscation when unsubscribing. A valid trio
|
||||
code / mailing_id / number will be requested. """
|
||||
return ''.join(random.choice(string.ascii_letters + string.digits) for dummy in range(self.CODE_SIZE))
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
|
||||
from odoo import api, fields, models, modules, _
|
||||
|
||||
|
||||
class Users(models.Model):
|
||||
_name = 'res.users'
|
||||
_inherit = ['res.users']
|
||||
|
||||
@api.model
|
||||
def systray_get_activities(self):
|
||||
""" Split mass_mailing and mass_mailing_sms activities in systray by
|
||||
removing the single mailing.mailing activity represented and
|
||||
doing a new query to split them by mailing_type.
|
||||
"""
|
||||
activities = super(Users, self).systray_get_activities()
|
||||
for activity in activities:
|
||||
if activity.get('model') == 'mailing.mailing':
|
||||
activities.remove(activity)
|
||||
query = """SELECT m.mailing_type, count(*), act.res_model as model, act.res_id,
|
||||
CASE
|
||||
WHEN %(today)s::date - act.date_deadline::date = 0 Then 'today'
|
||||
WHEN %(today)s::date - act.date_deadline::date > 0 Then 'overdue'
|
||||
WHEN %(today)s::date - act.date_deadline::date < 0 Then 'planned'
|
||||
END AS states
|
||||
FROM mail_activity AS act
|
||||
JOIN mailing_mailing AS m ON act.res_id = m.id
|
||||
WHERE act.res_model = 'mailing.mailing' AND act.user_id = %(user_id)s
|
||||
GROUP BY m.mailing_type, states, act.res_model, act.res_id;
|
||||
"""
|
||||
self.env.cr.execute(query, {
|
||||
'today': fields.Date.context_today(self),
|
||||
'user_id': self.env.uid,
|
||||
})
|
||||
activity_data = self.env.cr.dictfetchall()
|
||||
|
||||
user_activities = {}
|
||||
for act in activity_data:
|
||||
if not user_activities.get(act['mailing_type']):
|
||||
if act['mailing_type'] == 'sms':
|
||||
module = 'mass_mailing_sms'
|
||||
name = _('SMS Marketing')
|
||||
else:
|
||||
module = 'mass_mailing'
|
||||
name = _('Email Marketing')
|
||||
icon = module and modules.module.get_module_icon(module)
|
||||
res_ids = set()
|
||||
user_activities[act['mailing_type']] = {
|
||||
'id': self.env['ir.model']._get('mailing.mailing').id,
|
||||
'name': name,
|
||||
'model': 'mailing.mailing',
|
||||
'type': 'activity',
|
||||
'icon': icon,
|
||||
'total_count': 0, 'today_count': 0, 'overdue_count': 0, 'planned_count': 0,
|
||||
'res_ids': res_ids,
|
||||
}
|
||||
user_activities[act['mailing_type']]['res_ids'].add(act['res_id'])
|
||||
user_activities[act['mailing_type']]['%s_count' % act['states']] += act['count']
|
||||
if act['states'] in ('today', 'overdue'):
|
||||
user_activities[act['mailing_type']]['total_count'] += act['count']
|
||||
|
||||
for mailing_type in user_activities.keys():
|
||||
user_activities[mailing_type].update({
|
||||
'actions': [{'icon': 'fa-clock-o', 'name': 'Summary',}],
|
||||
'domain': json.dumps([['activity_ids.res_id', 'in', list(user_activities[mailing_type]['res_ids'])]])
|
||||
})
|
||||
activities.extend(list(user_activities.values()))
|
||||
break
|
||||
|
||||
return activities
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
|
||||
from odoo import fields, models, tools
|
||||
|
||||
|
||||
class SmsSms(models.Model):
|
||||
_inherit = ['sms.sms']
|
||||
|
||||
mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing')
|
||||
mailing_trace_ids = fields.One2many('mailing.trace', 'sms_sms_id', string='Statistics')
|
||||
|
||||
def _update_body_short_links(self):
|
||||
""" Override to tweak shortened URLs by adding statistics ids, allowing to
|
||||
find customer back once clicked. """
|
||||
res = dict.fromkeys(self.ids, False)
|
||||
for sms in self:
|
||||
if not sms.mailing_id or not sms.body:
|
||||
res[sms.id] = sms.body
|
||||
continue
|
||||
|
||||
body = sms.body
|
||||
for url in set(re.findall(tools.TEXT_URL_REGEX, body)):
|
||||
if url.startswith(sms.get_base_url() + '/r/'):
|
||||
body = re.sub(re.escape(url) + r'(?![\w@:%.+&~#=/-])', url + f'/s/{sms.id}', body)
|
||||
res[sms.id] = body
|
||||
return res
|
||||
|
||||
def _postprocess_iap_sent_sms(self, iap_results, failure_reason=None, unlink_failed=False, unlink_sent=True):
|
||||
all_sms_ids = [item['res_id'] for item in iap_results]
|
||||
if any(sms.mailing_id for sms in self.env['sms.sms'].sudo().browse(all_sms_ids)):
|
||||
for state in self.IAP_TO_SMS_STATE.keys():
|
||||
sms_ids = [item['res_id'] for item in iap_results if item['state'] == state]
|
||||
traces = self.env['mailing.trace'].sudo().search([
|
||||
('sms_sms_id_int', 'in', sms_ids)
|
||||
])
|
||||
if traces and state == 'success':
|
||||
traces.set_sent()
|
||||
elif traces:
|
||||
traces.set_failed(failure_type=self.IAP_TO_SMS_STATE[state])
|
||||
return super(SmsSms, self)._postprocess_iap_sent_sms(
|
||||
iap_results, failure_reason=failure_reason,
|
||||
unlink_failed=unlink_failed, unlink_sent=unlink_sent
|
||||
)
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class UtmCampaign(models.Model):
|
||||
_inherit = 'utm.campaign'
|
||||
|
||||
mailing_sms_ids = fields.One2many(
|
||||
'mailing.mailing', 'campaign_id',
|
||||
domain=[('mailing_type', '=', 'sms')],
|
||||
string='Mass SMS',
|
||||
groups="mass_mailing.group_mass_mailing_user")
|
||||
mailing_sms_count = fields.Integer('Number of Mass SMS',
|
||||
compute="_compute_mailing_sms_count",
|
||||
groups="mass_mailing.group_mass_mailing_user")
|
||||
|
||||
# A/B Testing
|
||||
ab_testing_mailings_sms_count = fields.Integer("A/B Test Mailings SMS #", compute="_compute_mailing_sms_count")
|
||||
ab_testing_sms_winner_selection = fields.Selection([
|
||||
('manual', 'Manual'),
|
||||
('clicks_ratio', 'Highest Click Rate')], string="SMS Winner Selection", default="clicks_ratio")
|
||||
|
||||
@api.depends('mailing_mail_ids', 'mailing_sms_ids')
|
||||
def _compute_ab_testing_total_pc(self):
|
||||
super()._compute_ab_testing_total_pc()
|
||||
for campaign in self:
|
||||
campaign.ab_testing_total_pc += sum([
|
||||
mailing.ab_testing_pc for mailing in campaign.mailing_sms_ids.filtered('ab_testing_enabled')
|
||||
])
|
||||
|
||||
@api.depends('mailing_sms_ids')
|
||||
def _compute_mailing_sms_count(self):
|
||||
if self.ids:
|
||||
mailing_sms_data = self.env['mailing.mailing'].read_group(
|
||||
[('campaign_id', 'in', self.ids), ('mailing_type', '=', 'sms')],
|
||||
['campaign_id', 'ab_testing_enabled'],
|
||||
['campaign_id', 'ab_testing_enabled'],
|
||||
lazy=False,
|
||||
)
|
||||
ab_testing_mapped_sms_data = {}
|
||||
mapped_sms_data = {}
|
||||
for data in mailing_sms_data:
|
||||
if data['ab_testing_enabled']:
|
||||
ab_testing_mapped_sms_data.setdefault(data['campaign_id'][0], []).append(data['__count'])
|
||||
mapped_sms_data.setdefault(data['campaign_id'][0], []).append(data['__count'])
|
||||
else:
|
||||
mapped_sms_data = dict()
|
||||
ab_testing_mapped_sms_data = dict()
|
||||
for campaign in self:
|
||||
campaign.mailing_sms_count = sum(mapped_sms_data.get(campaign._origin.id or campaign.id, []))
|
||||
campaign.ab_testing_mailings_sms_count = sum(ab_testing_mapped_sms_data.get(campaign._origin.id or campaign.id, []))
|
||||
|
||||
def action_create_mass_sms(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.action_create_mass_mailings_from_campaign")
|
||||
action['context'] = {
|
||||
'default_campaign_id': self.id,
|
||||
'default_mailing_type': 'sms',
|
||||
'search_default_assigned_to_me': 1,
|
||||
'search_default_campaign_id': self.id,
|
||||
'default_user_id': self.env.user.id,
|
||||
}
|
||||
return action
|
||||
|
||||
def action_redirect_to_mailing_sms(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing_sms.mailing_mailing_action_sms")
|
||||
action['context'] = {
|
||||
'default_campaign_id': self.id,
|
||||
'default_mailing_type': 'sms',
|
||||
'search_default_assigned_to_me': 1,
|
||||
'search_default_campaign_id': self.id,
|
||||
'default_user_id': self.env.user.id,
|
||||
}
|
||||
action['domain'] = [('mailing_type', '=', 'sms')]
|
||||
return action
|
||||
|
||||
@api.model
|
||||
def _cron_process_mass_mailing_ab_testing(self):
|
||||
ab_testing_campaign = super()._cron_process_mass_mailing_ab_testing()
|
||||
for campaign in ab_testing_campaign:
|
||||
ab_testing_mailings = campaign.mailing_sms_ids.filtered(lambda m: m.ab_testing_enabled)
|
||||
if not ab_testing_mailings.filtered(lambda m: m.state == 'done'):
|
||||
continue
|
||||
ab_testing_mailings.action_send_winner_mailing()
|
||||
return ab_testing_campaign
|
||||
|
||||
|
||||
class UtmMedium(models.Model):
|
||||
_inherit = 'utm.medium'
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_utm_medium_sms(self):
|
||||
utm_medium_sms = self.env.ref('mass_mailing_sms.utm_medium_sms', raise_if_not_found=False)
|
||||
if utm_medium_sms and utm_medium_sms in self:
|
||||
raise UserError(_(
|
||||
"The UTM medium '%s' cannot be deleted as it is used in some main "
|
||||
"functional flows, such as the SMS Marketing.",
|
||||
utm_medium_sms.name
|
||||
))
|
||||
Loading…
Add table
Add a link
Reference in a new issue