Initial commit: Mail packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit 4e53507711
1948 changed files with 751201 additions and 0 deletions

View file

@ -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

View file

@ -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']

View file

@ -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))

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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
)

View file

@ -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
))