[MERGE][ADD] mass_mailing_sms: allow to perform SMS batch sending

PURPOSE

SMS are a powerful marketing tool. For instance it is perfect to announce a
sale or to communicate a coupon code, to welcome a new customer in a fidelity
program, ...

Purpose of this task is to integrate SMS sending in batch in mass mailing. It
will use same mailing objects but sending SMS instead of emails. Some metrics
and flows will have to be slightly updated at the same time.

SPECIFICATIONS

Purpose of this merge is to add Mass SMS application allowing to send SMS
in batch. It is build on mass mailing application but with its own menus,
views and flows.

See sub commits for more details. Here is a summary of main changes
in this merge.

PHONE BLACKLIST

Add a blacklist mechanism for phone numbers used to send SMS like
what already exists for email addresses when sending emails.

Define a new phone.blacklist model, holding a number and the state of the
blacklist (active field), as well as tools methods to access it. Make it
as private as possible, accessing it in sudo once access are granted.

Define a new mail.thread.phone mixin computing the blacklist status of a
record.

BLACKLIST / ERROR IN COMPOSER

Support blacklisted and already-done numbers when sending SMS :

* blacklist support: necessary to avoid sending SMS in batch to people that
asked to stop being spammed;
* already-done support: when performing mailing in batch or doing A/B testing
we may have already-done SMS;

When sending SMS in batch, already update state and error code of SMS that
should not be sent

* blacklisted number: set to canceled, with blacklist error code;
* duplicated number: set to canceled, with duplicated error code;
* invalid number (cannot sanitize): set to error, with either missing number
if void or wrong format if not void;

SPEEDUP BATCH LOGGING

Allow quick message creation by allowing create multi on mail.message.
Also introduce a new helper tool method to log in batch on records.

IMPROVE SMS COMPOSER ONBOARDING AND USE

Clean the use and options of sms composer after FP feedback, notably

* see https://s.nimbusweb.me/share/3167586/ap1xpy95rz29a5cys576 as basis;
* globally, do not display invalid recipients, only valid / invalid count
as well as current selection / active domain counts;
* consider logging a note as default behavior when using the composer;
* simplify code: when doing a mass SMS, send SMS and attach a simple note
to the document;

Limit use of templates to models that are really capable of sending
SMS. Templates are now available on models that inherit from mail.thread
and effectively have fields used in SMS sending.

MASS MAILING PREPARATION

Prepare mass mailing module to addition of mass_mailing_sms.

Mailing model: mailing type
* add a mailing_type selection field;
* mass mailing contains only 'mail';
* synchronize medium accordingly;
* update actions of mass mailing application to add a domain on mailing
type being 'mail';

Mailing contact
* remove is_email_valid field as its purpose is achieved by email_normalized
field coming from address mixin (added by blacklist management);

Mailing contact subscription
* clean a bit fields and views as this should stay a technical model;

Trace model and report: trace type
* add a trace_type selection field;
* mass mailing contains only 'mail';

ADD MASS MAILING SMS

Create a new application called "Mass SMS" that follows the structure of
mass_mailing application.

General guidelines

* use the fa-comment icon for this module;
* add a "Notification type" field to the mailings: email or sms. Set it
invisible and set as domain from context when choosing mass mailing or
mass sms application (menus / action specific);
* adapt the metrics from Mass Mailing to SMS
* sent = received = whether the SMS was sent or not;
* opened => we don't have this information;
* replied => we don't have this information;
* clicks = from link tracker (no change of behavior);
* bounced = SMS that failed to be delivered because the format is wrong;
* exception = any issue when sending SMS;
* keep the Mailing Lists menu, hide email related fields on contact model;
* settings: hide the "Specific Mail Server" feature

Opt-out and blacklist handling

* we do support mailing lists because they could come from different sources
and are a way to communicate with a specific audience (and not just a list
we bought), e.g. subscribe to this list to be kept updated whenever this
product is back in stock, or to receive results of this ...
* if the SMSing is from a list, opt-out removes ourselves from the list;
* if it is from the contact ==> Sent to blacklist;
* allow to attach unsubscription links in sent SMS, redirecting to a new
route in mass mailing SMS allowing quick unsubscribe and/or blacklist
of phone numbers;

Error management

* if the user clicks on Send Now / Schedule / Test and does not have enough
credits, open the "Insufficient credits" modal in a lazy option (aka when
having failed statistics linked to insufficient credits);
* on the mailing, we however display this failure as :
* a message on the mailing kanban view
* a message on the top of the form view + same behavior as the mailing and
"Could not be sent" https://www.screencast.com/t/BExgd1Gc;

LINKS

Task 1997464
PR #34424
Original SMS addition: Task 1922163 (4287481)

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Robot Odoo
2019-08-12 17:01:40 +02:00
committed by GitHub
101 changed files with 3457 additions and 685 deletions
+1 -1
View File
@@ -545,7 +545,7 @@ class Meeting(models.Model):
"""
_name = 'calendar.event'
_description = "Event"
_description = "Calendar Event"
_order = "id desc"
_inherit = ["mail.thread"]
+2 -1
View File
@@ -2,7 +2,7 @@
<odoo>
<!-- Add action entry in the Action Menu for Events -->
<act_window id="sms_message_send_action_mutli"
<act_window id="calendar_event_act_window_sms_composer_single"
name="Send SMS to attendees"
binding_model="calendar.event"
res_model="sms.composer"
@@ -10,6 +10,7 @@
target="new"
context="{
'default_composition_mode': 'comment',
'default_res_id': active_id,
}"/>
</odoo>
+1
View File
@@ -22,6 +22,7 @@
target="new"
context="{
'default_composition_mode': 'comment',
'default_res_id': active_id,
}"
/>
</odoo>
+2 -2
View File
@@ -59,7 +59,7 @@ class TestSMSSchedule(TestEventCommon, MockSMS):
for registration in self.event_0.registration_ids:
reg_sanitized_number = phone_validation.phone_format(registration.phone, 'BE', '32', force_format='E164')
sanitized_numbers.append(reg_sanitized_number)
self.assertSMSNotification([{'number': reg_sanitized_number, 'state': 'ready'}], '%s registration confirmation.' % self.event_0.organizer_id.name)
self.assertSMSOutgoing(self.env['res.partner'], reg_sanitized_number, '%s registration confirmation.' % self.event_0.organizer_id.name)
# clear notification queue to avoid conflicts when checking next notifications
self.env['mail.notification'].search([('sms_number', 'in', sanitized_numbers)]).unlink()
@@ -76,4 +76,4 @@ class TestSMSSchedule(TestEventCommon, MockSMS):
# verify that subscription scheduler was auto-executed after each registration
for registration in self.event_0.registration_ids:
reg_sanitized_number = phone_validation.phone_format(registration.phone, 'BE', '32', force_format='E164')
self.assertSMSNotification([{'number': reg_sanitized_number, 'state': 'ready'}], '%s reminder' % self.event_0.organizer_id.name)
self.assertSMSOutgoing(self.env['res.partner'], reg_sanitized_number, '%s reminder' % self.event_0.organizer_id.name)
+2 -2
View File
@@ -381,8 +381,8 @@ class HrEmployeePrivate(models.Model):
def message_post(self, *args, **kwargs):
return super(HrEmployeePrivate, self._post_author()).message_post(*args, **kwargs)
def _sms_get_default_partners(self):
return self.mapped('user_partner_id')
def _sms_get_partner_fields(self):
return ['user_partner_id']
def _sms_get_number_fields(self):
return ['mobile_phone']
+1 -1
View File
@@ -152,7 +152,7 @@ Do not hesitate to contact your manager or the human resource department.""")
"res_model": "sms.composer",
"view_mode": 'form',
"context": context,
"name": "Send SMS",
"name": "Send SMS Text Message",
"target": "new",
}
+62 -63
View File
@@ -985,78 +985,77 @@ class Message(models.Model):
'message_needaction_counter',
], ids=[res_id])
@api.model
def create(self, values):
# coming from mail.js that does not have pid in its values
if self.env.context.get('default_starred'):
self = self.with_context({'default_starred_partner_ids': [(4, self.env.user.partner_id.id)]})
@api.model_create_multi
def create(self, values_list):
tracking_values_list = []
for values in values_list:
if 'email_from' not in values: # needed to compute reply_to
values['email_from'] = self._get_default_from()
if not values.get('message_id'):
values['message_id'] = self._get_message_id(values)
if 'reply_to' not in values:
values['reply_to'] = self._get_reply_to(values)
if 'record_name' not in values and 'default_record_name' not in self.env.context:
values['record_name'] = self._get_record_name(values)
if 'email_from' not in values: # needed to compute reply_to
values['email_from'] = self._get_default_from()
if not values.get('message_id'):
values['message_id'] = self._get_message_id(values)
if 'reply_to' not in values:
values['reply_to'] = self._get_reply_to(values)
if 'record_name' not in values and 'default_record_name' not in self.env.context:
values['record_name'] = self._get_record_name(values)
if 'attachment_ids' not in values:
values['attachment_ids'] = []
# extract base64 images
if 'body' in values:
Attachments = self.env['ir.attachment']
data_to_url = {}
def base64_to_boundary(match):
key = match.group(2)
if not data_to_url.get(key):
name = match.group(4) if match.group(4) else 'image%s' % len(data_to_url)
try:
attachment = Attachments.create({
'name': name,
'datas': match.group(2),
'res_model': values.get('model'),
'res_id': values.get('res_id'),
})
except binascii_error:
_logger.warning("Impossible to create an attachment out of badly formated base64 embedded image. Image has been removed.")
return match.group(3) # group(3) is the url ending single/double quote matched by the regexp
else:
attachment.generate_access_token()
values['attachment_ids'].append((4, attachment.id))
data_to_url[key] = ['/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token), name]
return '%s%s alt="%s"' % (data_to_url[key][0], match.group(3), data_to_url[key][1])
values['body'] = _image_dataurl.sub(base64_to_boundary, tools.ustr(values['body']))
if 'attachment_ids' not in values:
values['attachment_ids'] = []
# extract base64 images
if 'body' in values:
Attachments = self.env['ir.attachment']
data_to_url = {}
def base64_to_boundary(match):
key = match.group(2)
if not data_to_url.get(key):
name = match.group(4) if match.group(4) else 'image%s' % len(data_to_url)
try:
attachment = Attachments.create({
'name': name,
'datas': match.group(2),
'res_model': values.get('model'),
'res_id': values.get('res_id'),
})
except binascii_error:
_logger.warning("Impossible to create an attachment out of badly formated base64 embedded image. Image has been removed.")
return match.group(3) # group(3) is the url ending single/double quote matched by the regexp
else:
attachment.generate_access_token()
values['attachment_ids'].append((4, attachment.id))
data_to_url[key] = ['/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token), name]
return '%s%s alt="%s"' % (data_to_url[key][0], match.group(3), data_to_url[key][1])
values['body'] = _image_dataurl.sub(base64_to_boundary, tools.ustr(values['body']))
# delegate creation of tracking after the create as sudo to avoid access rights issues
tracking_values_list.append(values.pop('tracking_value_ids', False))
# delegate creation of tracking after the create as sudo to avoid access rights issues
tracking_values_cmd = values.pop('tracking_value_ids', False)
message = super(Message, self).create(values)
messages = super(Message, self).create(values_list)
# check attachement access
if values.get('attachment_ids'):
attachment_ids = []
if all(isinstance(command, int) or command[0] in (4, 6) for command in values.get('attachment_ids')):
attachment_ids = []
check_attachment_access = []
if all(isinstance(command, int) or command[0] in (4, 6) for values in values_list for command in values.get('attachment_ids')):
for values in values_list:
for command in values.get('attachment_ids'):
if isinstance(command, int):
attachment_ids += [command]
check_attachment_access += [command]
elif command[0] == 6:
attachment_ids += command[2]
else: # command[0] == 4:
attachment_ids += [command[1]]
else:
attachment_ids = message.mapped('attachment_ids').ids # fallback on read if any unknow command
self.env['ir.attachment'].browse(attachment_ids).check(mode='read')
check_attachment_access += command[2]
else: # command[0] == 4:
check_attachment_access += [command[1]]
else:
check_attachment_access = messages.mapped('attachment_ids').ids # fallback on read if any unknow command
if check_attachment_access:
self.env['ir.attachment'].browse(check_attachment_access).check(mode='read')
if tracking_values_cmd:
vals_lst = [dict(cmd[2], mail_message_id=message.id) for cmd in tracking_values_cmd if len(cmd) == 3 and cmd[0] == 0]
other_cmd = [cmd for cmd in tracking_values_cmd if len(cmd) != 3 or cmd[0] != 0]
if vals_lst:
self.env['mail.tracking.value'].sudo().create(vals_lst)
if other_cmd:
message.sudo().write({'tracking_value_ids': tracking_values_cmd})
for message, values, tracking_values_cmd in zip(messages, values_list, tracking_values_list):
if tracking_values_cmd:
vals_lst = [dict(cmd[2], mail_message_id=message.id) for cmd in tracking_values_cmd if len(cmd) == 3 and cmd[0] == 0]
other_cmd = [cmd for cmd in tracking_values_cmd if len(cmd) != 3 or cmd[0] != 0]
if vals_lst:
self.env['mail.tracking.value'].sudo().create(vals_lst)
if other_cmd:
message.sudo().write({'tracking_value_ids': tracking_values_cmd})
if message.is_thread_message(values):
message._invalidate_documents(values.get('model'), values.get('res_id'))
if message.is_thread_message(values):
message._invalidate_documents(values.get('model'), values.get('res_id'))
return message
+78 -40
View File
@@ -1699,7 +1699,7 @@ class MailThread(models.AbstractModel):
@api.returns('mail.message', lambda value: value.id)
def message_post(self,
body='', subject=None, message_type='notification',
email_from=False, author_id=None, parent_id=False,
email_from=None, author_id=None, parent_id=False,
subtype_id=False, subtype=None, partner_ids=None, channel_ids=None,
attachments=None, attachment_ids=None,
add_sign=True, record_name=False,
@@ -1750,10 +1750,8 @@ class MailThread(models.AbstractModel):
raise ValueError('message_post partner_ids and channel_ids must be integer list, not commands')
# Find the message's author
if author_id is None: # keep False values
author_id = self.env.user.partner_id.id
if not email_from:
email_from = self.env['res.partner'].browse(author_id).sudo().email_formatted if author_id else self.env['mail.message']._get_default_from()
author_info = self._message_compute_author(author_id, email_from, raise_exception=True)
author_id, email_from = author_info['author_id'], author_info['email_from']
if not subtype_id:
subtype = subtype or 'mt_note'
@@ -1786,6 +1784,7 @@ class MailThread(models.AbstractModel):
values = dict(msg_kwargs)
values.update({
'author_id': author_id,
'email_from': email_from,
'model': self._name,
'res_id': self.id,
'body': body,
@@ -1797,7 +1796,6 @@ class MailThread(models.AbstractModel):
'channel_ids': channel_ids,
'add_sign': add_sign,
'record_name': record_name,
'email_from': email_from,
})
attachments = attachments or []
attachment_ids = attachment_ids or []
@@ -1887,7 +1885,7 @@ class MailThread(models.AbstractModel):
return composer.send_mail()
def message_notify(self, partner_ids=False, parent_id=False, model=False, res_id=False,
author_id=False, body='', subject=False, **kwargs):
author_id=None, email_from=None, body='', subject=False, **kwargs):
""" Shortcut allowing to notify partners of messages that shouldn't be
displayed on a document. It pushes notifications on inbox or by email depending
on the user configuration, like other notifications. """
@@ -1897,16 +1895,8 @@ class MailThread(models.AbstractModel):
msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields)
notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs)
if author_id:
author = self.env['res.partner'].sudo().browse(author_id)
else:
author = self.env.user.partner_id
if not author.email:
raise exceptions.UserError(_("Unable to notify message, please configure the sender's email address."))
email_from = author.email_formatted
partner_ids = partner_ids or set()
author_info = self._message_compute_author(author_id, email_from, raise_exception=True)
author_id, email_from = author_info['author_id'], author_info['email_from']
if not partner_ids:
_logger.warning('Message notify called without recipient_ids, skipping')
@@ -1924,7 +1914,7 @@ class MailThread(models.AbstractModel):
'message_type': 'user_notification',
'subject': subject,
'body': body,
'author_id': author.id,
'author_id': author_id,
'email_from': email_from,
'partner_ids': partner_ids,
'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
@@ -1937,7 +1927,7 @@ class MailThread(models.AbstractModel):
MailThread._notify_thread(new_message, values, **notif_kwargs)
return new_message
def _message_log(self, body='', author_id=None, subject=False, message_type='notification', **kwargs):
def _message_log(self, body='', author_id=None, email_from=None, subject=False, message_type='notification', **kwargs):
""" Shortcut allowing to post note on a document. It does not perform
any notification and pre-computes some values to have a short code
as optimized as possible. This method is private as it does not check
@@ -1945,24 +1935,13 @@ class MailThread(models.AbstractModel):
the log process. This method should be called within methods where
access rights are already granted to avoid privilege escalation. """
self.ensure_one()
if author_id:
author = self.env['res.partner'].sudo().browse(author_id)
else:
author = self.env.user.partner_id
author_id = author.id
if author.email:
email_from = author.email_formatted
elif self.env.su:
# superuser mode without author email -> probably public user; anyway we don't want to crash
email_from = False
else:
raise exceptions.UserError(_("Unable to log message, please configure the sender's email address."))
author_info = self._message_compute_author(author_id, email_from, raise_exception=False)
author_id, email_from = author_info['author_id'], author_info['email_from']
message_values = {
'subject': subject,
'body': body,
'author_id': author.id,
'author_id': author_id,
'email_from': email_from,
'message_type': message_type,
'model': kwargs.get('model', self._name),
@@ -1973,14 +1952,73 @@ class MailThread(models.AbstractModel):
'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify
}
message_values.update(kwargs)
message = self.sudo()._message_create(message_values)
return message
return self.sudo()._message_create(message_values)
def _message_create(self, values):
create_values = dict(values)
create_values['partner_ids'] = [(4, pid) for pid in create_values.get('partner_ids', [])]
create_values['channel_ids'] = [(4, cid) for cid in create_values.get('channel_ids', [])]
return self.env['mail.message'].create(create_values)
def _message_log_batch(self, bodies, author_id=None, email_from=None, subject=False, message_type='notification'):
""" Shortcut allowing to post notes on a batch of documents. It achieve the
same purpose as _message_log, done in batch to speedup quick note log.
:param bodies: dict {record_id: body}
"""
author_info = self._message_compute_author(author_id, email_from, raise_exception=False)
author_id, email_from = author_info['author_id'], author_info['email_from']
base_message_values = {
'subject': subject,
'author_id': author_id,
'email_from': email_from,
'message_type': message_type,
'model': self._name,
'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
'record_name': False,
'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from, records=None)[False],
'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify
}
values_list = [dict(base_message_values,
res_id=record.id,
body=bodies.get(record.id, ''))
for record in self]
return self.sudo()._message_create(values_list)
def _message_compute_author(self, author_id=None, email_from=None, raise_exception=True):
""" Tool method computing author information for messages. Purpose is
to ensure maximum coherence between author / current user / email_from
when sending emails. """
if author_id is None:
if email_from:
author = self._mail_find_partner_from_emails([email_from])[0]
else:
author = self.env.user.partner_id
email_from = author.email_formatted
author_id = author.id
if email_from is None:
if author_id:
author = self.env['res.partner'].browse(author_id)
email_from = author.email_formatted
# superuser mode without author email -> probably public user; anyway we don't want to crash
if not email_from and not self.env.su and raise_exception:
raise exceptions.UserError(_("Unable to log message, please configure the sender's email address."))
return {
'author_id': author_id,
'email_from': email_from,
}
def _message_create(self, values_list):
if not isinstance(values_list, (list)):
values_list = [values_list]
create_values_list = []
for values in values_list:
create_values = dict(values)
# Avoid warnings about non-existing fields
for x in ('from', 'to', 'cc', 'canned_response_ids'):
create_values.pop(x, None)
create_values['partner_ids'] = [(4, pid) for pid in create_values.get('partner_ids', [])]
create_values['channel_ids'] = [(4, cid) for cid in create_values.get('channel_ids', [])]
create_values_list.append(create_values)
return self.env['mail.message'].create(create_values_list)
# ------------------------------------------------------
# Notification API
+8 -25
View File
@@ -129,27 +129,6 @@
<div class="o_mail_snippet_general" style="margin:0px auto 0px auto;background-color:rgb(255, 255, 255);max-width:600px;width:100%;">
<table align="center" cellspacing="0" cellpadding="0" class="o_mail_table_styles o_mail_full_width_padding" style="border-style:solid none none none;padding:20px 0 20px 0;border-top-color:rgb(245, 245, 245);border-top-width:2px;width:100%;border-collapse:separate;">
<tbody>
<tr>
<td class="o_mail_footer_social" style="text-align:center;vertical-align:middle;">
<a title="Facebook" href="https://www.facebook.com/Odoo" style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);">
<img src="/web_editor/font_to_img/61594/rgb(100,89,116)/16" data-class="fa fa-facebook" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/> ​
</a>
<a style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);margin-left:10px" title="Google Plus" href="https://plus.google.com/+Odooapps">
<img src="/web_editor/font_to_img/61653/rgb(100,89,116)/16" data-class="fa fa-google-plus" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/>
</a>
<a style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);margin-left:10px" title="LinkedIn" href="https://www.linkedin.com/company/odoo">
<img src="/web_editor/font_to_img/61665/rgb(100,89,116)/16" data-class="fa fa-linkedin" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/>
</a>
<a style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);margin-left:10px" title="Twitter" href="https://twitter.com/Odoo">
<img src="/web_editor/font_to_img/61593/rgb(100,89,116)/16" data-class="fa fa-twitter" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/>
</a>
<a style="text-decoration:none;font-weight:bold;background-color:transparent;color:rgb(100, 89, 116);margin-left:10px" title="Instagram" href="https://www.instagram.com/explore/tags/odoo/">
<img src="/web_editor/font_to_img/61805/rgb(100,89,116)/16" data-class="fa fa-instagram" style="border-style:none;max-width:100%;width:100%;vertical-align:middle;height: auto; width: auto;"/> ​
</a>
</td>
</tr>
<tr>
<td class="o_mail_footer_links o_default_snippet_text" style="padding:10px 0 10px 0;text-align:center;vertical-align:middle;">
<a href="/unsubscribe_from_list" class="btn btn-link o_default_snippet_text" style="text-decoration:none;border-radius:0.25rem;border-style:solid;padding:0px;cursor:pointer;line-height:1.5;font-size:12px;border-left-color:transparent;border-bottom-color:transparent;border-right-color:transparent;border-top-color:transparent;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;user-select:none;vertical-align:middle;white-space:nowrap;text-align:center;font-weight:bold;display:inline-block;background-color:transparent;color:rgb(100, 89, 116);">Unsubscribe</a> |
@@ -176,9 +155,7 @@
<tbody>
<tr>
<td align="center" style="padding:16px 0 16px 0;" class="pt16 pb16">
Powered by
<a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=email" style="text-decoration:none;background-color:transparent;color:#875A7B;">Odoo</a>
Powered by <a target="_blank" href="https://www.odoo.com" style="text-decoration:none;background-color:transparent;color:#875A7B;">Odoo</a>
</td>
</tr>
</tbody>
@@ -195,6 +172,7 @@
<field name="message_id">1111000@odoo.com</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_7"/>
<field name="email">billy.fox45@example.com</field>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="opened" eval="(DateTime.today() - relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="replied" eval="(DateTime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
@@ -204,6 +182,7 @@
<field name="message_id">1111001@odoo.com</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_13"/>
<field name="email">kim.snyder96@example.com</field>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="opened" eval="(DateTime.today() - relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="replied" eval="(DateTime.today() - relativedelta(days=0)).strftime('%Y-%m-%d %H:%M:%S')"/>
@@ -213,6 +192,7 @@
<field name="message_id">1111002@odoo.com</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_14"/>
<field name="email">edith.sanchez68@example.com</field>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="opened" eval="(DateTime.today() - relativedelta(days=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
@@ -221,6 +201,7 @@
<field name="message_id">1111003@odoo.com</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_24"/>
<field name="email">theodore.gardner36@example.com</field>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="opened" eval="(DateTime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
@@ -229,14 +210,15 @@
<field name="message_id">1111004@odoo.com</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_32"/>
<field name="email">sandra.neal80@example.com</field>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="opened" eval="(DateTime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mass_mail_1_stat_5" model="mailing.trace">
<field name="mass_mailing_id" eval="ref('mass_mail_1')"/>
<field name="message_id">1111004@odoo.com</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_33"/>
<field name="email">julie.richards84@example.com</field>
<field name="sent" eval="False"/>
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
@@ -245,6 +227,7 @@
<field name="message_id">1111004@odoo.com</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_34"/>
<field name="email">travis.mendoza24@example.com</field>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="bounced" eval="(DateTime.today() - relativedelta(days=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
+1 -1
View File
@@ -99,5 +99,5 @@ class MailMail(models.Model):
if mail_sent is True and mail.mailing_trace_ids:
mail.mailing_trace_ids.write({'sent': fields.Datetime.now(), 'exception': False})
elif mail_sent is False and mail.mailing_trace_ids:
mail.mailing_trace_ids.write({'exception': fields.Datetime.now()})
mail.mailing_trace_ids.write({'exception': fields.Datetime.now(), 'failure_type': failure_type})
return super(MailMail, self)._postprocess_sent_message(success_pids, failure_reason=failure_reason, failure_type=failure_type)
+44 -19
View File
@@ -33,6 +33,7 @@ MASS_MAILING_BUSINESS_MODELS = [
# Used to find inline images
image_re = re.compile(r"data:(image/[A-Za-z]+);base64,(.*)")
class MassMailing(models.Model):
""" MassMailing models a wave of emails for a mass mailign campaign.
A mass mailing is an occurence of sending emails. """
@@ -85,6 +86,7 @@ class MassMailing(models.Model):
color = fields.Integer(string='Color Index')
user_id = fields.Many2one('res.users', string='Responsible', default=lambda self: self.env.user)
# mailing options
mailing_type = fields.Selection([('mail', 'Email')], string="Mailing Type", default="mail", required=True)
reply_to_mode = fields.Selection(
[('thread', 'Recipient Followers'), ('email', 'Specified Email Address')], string='Reply-To Mode', required=True)
reply_to = fields.Char(string='Reply To', help='Preferred Reply-To Address',
@@ -155,10 +157,9 @@ class MassMailing(models.Model):
m.id as mailing_id,
COUNT(s.id) AS expected,
COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent,
COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is null THEN 1 ELSE null END) AS scheduled,
COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is not null THEN 1 ELSE null END) AS failed,
COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is null AND s.bounced is null THEN 1 ELSE null END) AS scheduled,
COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is not null THEN 1 ELSE null END) AS ignored,
COUNT(CASE WHEN s.sent is not null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
COUNT(CASE WHEN s.sent is not null AND s.exception is null AND s.bounced is null THEN 1 ELSE null END) AS delivered,
COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened,
COUNT(CASE WHEN s.clicked is not null THEN 1 ELSE null END) AS clicked,
COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied,
@@ -217,11 +218,21 @@ class MassMailing(models.Model):
mailing_domain = [(0, '=', 1)]
self.mailing_domain = repr(mailing_domain)
@api.onchange('mailing_type')
def _onchange_mailing_type(self):
if self.mailing_type == 'mail' and not self.medium_id:
self.medium_id = self.env.ref('utm.utm_medium_email').id
@api.onchange('subject')
def _onchange_subject(self):
if self.subject and not self.name:
self.name = self.subject
@api.onchange('name')
def _onchange_name(self):
if self.name and not self.subject:
self.subject = self.name
# ------------------------------------------------------
# ORM
# ------------------------------------------------------
@@ -232,7 +243,7 @@ class MassMailing(models.Model):
values['subject'] = values['name']
if values.get('body_html'):
values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
if 'medium_id' not in values:
if 'medium_id' not in values and values.get('mailing_type', 'mail') == 'mail':
values['medium_id'] = self.env.ref('utm.utm_medium_email').id
return super(MassMailing, self).create(values)
@@ -270,7 +281,7 @@ class MassMailing(models.Model):
}
return False
def action_test_mailing(self):
def action_test(self):
self.ensure_one()
ctx = dict(self.env.context, default_mass_mailing_id=self.id)
return {
@@ -282,30 +293,44 @@ class MassMailing(models.Model):
'context': ctx,
}
def action_schedule_date(self):
def action_schedule(self):
self.ensure_one()
action = self.env.ref('mass_mailing.mailing_mailing_schedule_date_action').read()[0]
action['context'] = dict(self.env.context, default_mass_mailing_id=self.id)
return action
def put_in_queue(self):
def action_put_in_queue(self):
self.write({'state': 'in_queue'})
def cancel_mass_mailing(self):
def action_cancel(self):
self.write({'state': 'draft', 'schedule_date': False})
def retry_failed_mail(self):
failed_mails = self.env['mail.mail'].search([('mailing_id', 'in', self.ids), ('state', '=', 'exception')])
def action_retry_failed(self):
failed_mails = self.env['mail.mail'].sudo().search([
('mailing_id', 'in', self.ids),
('state', '=', 'exception')
])
failed_mails.mapped('mailing_trace_ids').unlink()
failed_mails.sudo().unlink()
res_ids = self._get_recipients()
except_mailed = self.env['mailing.trace'].search([
('model', '=', self.mailing_model_real),
('res_id', 'in', res_ids),
('exception', '!=', False),
('mass_mailing_id', '=', self.id)]).unlink()
failed_mails.unlink()
self.write({'state': 'in_queue'})
def action_view_traces_scheduled(self):
return self._action_view_traces_filtered('scheduled')
def action_view_traces_ignored(self):
return self._action_view_traces_filtered('ignored')
def action_view_traces_failed(self):
return self._action_view_traces_filtered('failed')
def _action_view_traces_filtered(self, view_filter):
action = self.env.ref('mass_mailing.mailing_trace_action').read()[0]
action['name'] = _('%s Traces') % (self.name)
action['context'] = {'search_default_mass_mailing_id': self.id,}
filter_key = 'search_default_filter_%s' % (view_filter)
action['context'][filter_key] = True
return action
def action_view_sent(self):
return self._action_view_documents_filtered('sent')
@@ -387,7 +412,7 @@ class MassMailing(models.Model):
_logger.info("Mass-mailing %s targets %s, no opt out list available", self, target._name)
return opt_out
def _get_convert_links(self):
def _get_link_tracker_values(self):
self.ensure_one()
vals = {'mass_mailing_id': self.id}
@@ -456,7 +481,7 @@ class MassMailing(models.Model):
return {
'mass_mailing_opt_out_list': self._get_opt_out_list(),
'mass_mailing_seen_list': self._get_seen_list(),
'post_convert_links': self._get_convert_links(),
'post_convert_links': self._get_link_tracker_values(),
}
def _get_recipients(self):
+1 -22
View File
@@ -19,7 +19,6 @@ class MassMailingContactListRel(models.Model):
opt_out = fields.Boolean(string='Opt Out',
help='The contact has chosen not to receive mails anymore from this list', default=False)
unsubscription_date = fields.Datetime(string='Unsubscription Date')
contact_count = fields.Integer(related='list_id.contact_nbr', store=False, readonly=False)
message_bounce = fields.Integer(related='contact_id.message_bounce', store=False, readonly=False)
is_blacklisted = fields.Boolean(related='contact_id.is_blacklisted', store=False, readonly=False)
@@ -39,19 +38,6 @@ class MassMailingContactListRel(models.Model):
vals['unsubscription_date'] = vals['opt_out'] and fields.Datetime.now()
return super(MassMailingContactListRel, self).write(vals)
def action_open_mailing_list_contact(self):
"""TODO DBE : To remove - Deprecated"""
contact_id = self.contact_id
action = {
'name': _(contact_id.name),
'type': 'ir.actions.act_window',
'res_model': 'mailing.contact',
'view_mode': 'form',
'target': 'current',
'res_id': contact_id.id
}
return action
class MassMailingContact(models.Model):
"""Model of a contact. This model is different from the partner model
@@ -67,8 +53,7 @@ class MassMailingContact(models.Model):
name = fields.Char()
company_name = fields.Char(string='Company Name')
title_id = fields.Many2one('res.partner.title', string='Title')
email = fields.Char(required=True)
is_email_valid = fields.Boolean(compute='_compute_is_email_valid', store=True)
email = fields.Char('Email')
list_ids = fields.Many2many(
'mailing.list', 'mailing_contact_list_rel',
'contact_id', 'list_id', string='Mailing Lists')
@@ -79,12 +64,6 @@ class MassMailingContact(models.Model):
help='Opt out flag for a specific mailing list.'
'This field should not be used in a view without a unique and active mailing list context.')
@api.depends('email')
def _compute_is_email_valid(self):
for record in self:
normalized = tools.email_normalize(record.email)
record.is_email_valid = normalized if not normalized else True
@api.model
def _search_opt_out(self, operator, value):
# Assumes operator is '=' or '!=' and value is True or False
@@ -45,6 +45,13 @@ class MassMailingList(models.Model):
def name_get(self):
return [(list.id, "%s (%s)" % (list.name, list.contact_nbr)) for list in self]
def action_view_contacts(self):
action = self.env.ref('mass_mailing.action_view_mass_mailing_contacts').read()[0]
action['domain'] = [('list_ids', 'in', self.ids)]
context = dict(self.env.context, search_default_filter_valid_email_recipient=1, default_list_ids=self.ids)
action['context'] = context
return action
def action_merge(self, src_lists, archive):
"""
Insert all the contact from the mailing lists 'src_lists' to the
+24 -11
View File
@@ -11,9 +11,11 @@ class MailingTrace(models.Model):
without loosing the statistics about them. """
_name = 'mailing.trace'
_description = 'Mailing Statistics'
_rec_name = 'message_id'
_order = 'message_id'
_rec_name = 'id'
_order = 'scheduled DESC'
trace_type = fields.Selection([('mail', 'Mail')], string='Type', default='mail', required=True)
display_name = fields.Char(compute='_compute_display_name')
# mail data
mail_mail_id = fields.Many2one('mail.mail', string='Mail', index=True)
mail_mail_id_int = fields.Integer(
@@ -23,16 +25,16 @@ class MailingTrace(models.Model):
'However the ID is needed for several action and controllers.',
index=True,
)
email = fields.Char(string="Recipient email address")
email = fields.Char(string="Email")
message_id = fields.Char(string='Message-ID')
# document
model = fields.Char(string='Document model')
res_id = fields.Integer(string='Document ID')
# campaign / wave data
mass_mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing', index=True)
mass_mailing_id = fields.Many2one('mailing.mailing', string='Mailing', index=True)
campaign_id = fields.Many2one(
related='mass_mailing_id.campaign_id',
string='Mass Mailing Campaign',
string='Campaign',
store=True, readonly=True, index=True)
# Bounce and tracking
ignored = fields.Datetime(help='Date when the email has been invalidated. '
@@ -55,10 +57,21 @@ class MailingTrace(models.Model):
('replied', 'Replied'),
('bounced', 'Bounced'),
('ignored', 'Ignored')], store=True)
failure_type = fields.Selection(selection=[
("SMTP", "Connection failed (outgoing mail server problem)"),
("RECIPIENT", "Invalid email address"),
("BOUNCE", "Email address rejected by destination"),
("UNKNOWN", "Unknown error"),
], string='Failure type')
state_update = fields.Datetime(compute="_compute_state", string='State Update',
help='Last state update of the mail',
store=True)
@api.depends('trace_type', 'mass_mailing_id')
def _compute_display_name(self):
for trace in self:
trace.display_name = '%s: %s (%s)' % (trace.trace_type, trace.mass_mailing_id.name, trace.id)
@api.depends('sent', 'opened', 'clicked', 'replied', 'bounced', 'exception', 'ignored')
def _compute_state(self):
self.update({'state_update': fields.Datetime.now()})
@@ -78,12 +91,12 @@ class MailingTrace(models.Model):
else:
stat.state = 'outgoing'
@api.model
def create(self, values):
if 'mail_mail_id' in values:
values['mail_mail_id_int'] = values['mail_mail_id']
res = super(MailingTrace, self).create(values)
return res
@api.model_create_multi
def create(self, values_list):
for values in values_list:
if 'mail_mail_id' in values:
values['mail_mail_id_int'] = values['mail_mail_id']
return super(MailingTrace, self).create(values_list)
def _get_records(self, mail_mail_ids=None, mail_message_ids=None, domain=None):
if not self.ids and mail_mail_ids:
+1
View File
@@ -3,6 +3,7 @@
from odoo import api, fields, models
class UtmCampaign(models.Model):
_inherit = 'utm.campaign'
@@ -9,18 +9,22 @@ class MailingTraceReport(models.Model):
_auto = False
_description = 'Mass Mailing Statistics'
scheduled_date = fields.Datetime(string='Scheduled Date', readonly=True)
# mailing
name = fields.Char(string='Mass Mail', readonly=True)
mailing_type = fields.Selection([('mail', 'Mail')], string='Type', default='mail', required=True)
campaign = fields.Char(string='Mass Mail Campaign', readonly=True)
scheduled_date = fields.Datetime(string='Scheduled Date', readonly=True)
state = fields.Selection(
[('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')],
string='Status', readonly=True)
email_from = fields.Char('From', readonly=True)
# traces
sent = fields.Integer(readonly=True)
delivered = fields.Integer(readonly=True)
opened = fields.Integer(readonly=True)
bounced = fields.Integer(readonly=True)
replied = fields.Integer(readonly=True)
clicked = fields.Integer(readonly=True)
state = fields.Selection([('draft', 'Draft'), ('test', 'Tested'), ('done', 'Sent')],
string='Status', readonly=True)
email_from = fields.Char('From', readonly=True)
bounced = fields.Integer(readonly=True)
def init(self):
"""Mass Mail Statistical Report: based on mailing.trace that models the various
@@ -30,22 +34,23 @@ class MailingTraceReport(models.Model):
self.env.cr.execute("""
CREATE OR REPLACE VIEW mailing_trace_report AS (
SELECT
min(ms.id) as id,
ms.scheduled as scheduled_date,
min(trace.id) as id,
utm_source.name as name,
mailing.mailing_type,
utm_campaign.name as campaign,
count(ms.bounced) as bounced,
count(ms.sent) as sent,
(count(ms.sent) - count(ms.bounced)) as delivered,
count(ms.opened) as opened,
count(ms.replied) as replied,
count(ms.clicked) as clicked,
mm.state,
mm.email_from
trace.scheduled as scheduled_date,
mailing.state,
mailing.email_from,
count(trace.sent) as sent,
(count(trace.sent) - count(trace.bounced)) as delivered,
count(trace.opened) as opened,
count(trace.replied) as replied,
count(trace.clicked) as clicked,
count(trace.bounced) as bounced
FROM
mailing_trace as ms
left join mailing_mailing as mm ON (ms.mass_mailing_id=mm.id)
left join utm_campaign as utm_campaign ON (mm.campaign_id = utm_campaign.id)
left join utm_source as utm_source ON (mm.source_id = utm_source.id)
GROUP BY ms.scheduled, utm_source.name, utm_campaign.name, mm.state, mm.email_from
mailing_trace as trace
left join mailing_mailing as mailing ON (trace.mass_mailing_id=mailing.id)
left join utm_campaign as utm_campaign ON (mailing.campaign_id = utm_campaign.id)
left join utm_source as utm_source ON (mailing.source_id = utm_source.id)
GROUP BY trace.scheduled, utm_source.name, utm_campaign.name, mailing.mailing_type, mailing.state, mailing.email_from
)""")
@@ -51,14 +51,16 @@
</record>
<!-- Actions and Menuitems -->
<record id="mailing_trace_report_action" model="ir.actions.act_window">
<record id="mailing_trace_report_action_mail" model="ir.actions.act_window">
<field name="name">Mass Mailing Analysis</field>
<field name="res_model">mailing.trace.report</field>
<field name="domain">[('mailing_type', '=', 'mail')]</field>
<field name="view_mode">graph,pivot</field>
<field name="help">Mass Mailing Statistics allows you to check different mailing related information like number of bounced mails, opened mails, replied mails. You can sort out your analysis by different groups to get accurate grained analysis.</field>
<field name="help" type="html"><p>Mass Mailing Statistics allows you to check different mailing related information like number of bounced mails, opened mails, replied mails. You can sort out your analysis by different groups to get accurate grained analysis.</p></field>
</record>
<menuitem name="Mass Mailing" id="mass_mailing_menu_root" sequence="60"/>
<menuitem name="Reporting" id="menu_mass_mailing_report" sequence="99"
parent="mass_mailing_menu_root" action="mailing_trace_report_action" groups="mass_mailing.group_mass_mailing_user"/>
parent="mass_mailing_menu_root"
action="mailing_trace_report_action_mail"
groups="mass_mailing.group_mass_mailing_user"/>
</odoo>
@@ -156,7 +156,7 @@ QUnit.test('save and edit arch wysiwyg', async function (assert) {
data: this.data,
arch: '<form>' +
' <header style="min-height:31px;">' +
' <button name="put_in_queue" type="object" class="oe_highlight" string="Send Now"/>' +
' <button name="action_put_in_queue" type="object" class="oe_highlight" string="Send Now"/>' +
' </header>' +
' <field name="body_arch" class="oe_edit_only" widget="mass_mailing_html"' +
' options="{' +
@@ -174,14 +174,14 @@ QUnit.test('save and edit arch wysiwyg', async function (assert) {
},
});
await testUtils.dom.click($('[name=put_in_queue]'));
await testUtils.dom.click($('[name=action_put_in_queue]'));
await testUtils.form.clickEdit(form);
var $fieldEdit = form.$('.oe_form_field[name="body_arch"]');
var $iframe = $fieldEdit.find('iframe');
await $iframe.data('loadDef');
await testUtils.nextTick();
await testUtils.dom.click($('[name=put_in_queue]'));
await testUtils.dom.click($('[name=action_put_in_queue]'));
assert.verifySteps(["MassMailingFieldHtml", "FieldHtml", "MassMailingFieldHtml"]);
testUtils.mock.unpatch(FieldHtml);
@@ -72,7 +72,7 @@ Email: <a id="url4" href="mailto:test@odoo.com">test@odoo.com</h1>
"keep_archives": True,
})
mass_mailing.put_in_queue()
mass_mailing.action_put_in_queue()
mass_mailing._process_mass_mailing_queue()
sent_mails = self.env['mail.mail'].search([('mailing_id', '=', mass_mailing.id)])
@@ -12,7 +12,6 @@
</xpath>
<xpath expr="//group" position="inside">
<filter string="Mass Mailing" name="groupby_mass_mailing_id" context="{'group_by': 'mass_mailing_id'}"/>
<filter string="Mailing Campaign" name="groupby_mass_mailing_campaign_id" domain="[]" context="{'group_by': 'campaign_id'}"/>
</xpath>
</field>
</record>
@@ -66,7 +65,6 @@
</xpath>
<xpath expr="//filter[@name='groupby_country_id']" position="after">
<filter string="Mass Mailing" name="groupby_mass_mailing_id" context="{'group_by': 'mass_mailing_id'}"/>
<filter string="Mailing Campaign" name="groupby_mass_mailing_campaign_id" domain="[]" context="{'group_by': 'campaign_id'}"/>
</xpath>
</field>
</record>
@@ -6,7 +6,7 @@
<field name="model">mailing.contact.subscription</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<form string="Mailing List contacts">
<form string="Mailing List Subscription">
<sheet>
<group>
<field name="list_id"/>
@@ -30,13 +30,12 @@
<field name="name">mailing.contact.subscription.view.tree</field>
<field name="model">mailing.contact.subscription</field>
<field name="arch" type="xml">
<tree string="Mailing Lists">
<tree string="Mailing List Subscriptions">
<field name="contact_id"/>
<field name="unsubscription_date"/>
<field name="opt_out"/>
<field name="message_bounce"/>
<field name="is_blacklisted"/>
<button name="action_open_mailing_list_contact" string="Open Contact" type="object" icon="fa-user"/>
</tree>
</field>
</record>
@@ -45,24 +44,10 @@
<field name="name">mailing.contact.subscription.view.search</field>
<field name="model">mailing.contact.subscription</field>
<field name="arch" type="xml">
<search string="Mailing List Contacts">
<search string="Mailing List Subscriptions">
<field name="contact_id"/>
<field name="opt_out"/>
<field name="list_id"/>
<separator/>
<filter string="Valid Recipients" name="valid_recipients" domain="[('opt_out', '=', False), ('contact_id.is_blacklisted', '=', False), ('contact_id.is_email_valid', '=', True)]"/>
<separator/>
<filter string="Exclude Opt Out" name="not_opt_out" domain="[('opt_out', '=', False)]"/>
<separator/>
<filter string="Exclude Blacklisted" name="not_blacklisted" domain="[('contact_id.is_blacklisted', '=', False)]"/>
<separator/>
<filter string="Exclude Invalid Emails" name="valid_email" domain="[('contact_id.is_email_valid', '=', True)]"/>
<separator/>
<filter string="Exclude Bounced" name="not_bounced" domain="[('message_bounce', '&lt;', 3)]"/>
<group expand="0" string="Group By">
<filter string="Creation Date" name="group_create_date"
context="{'group_by': 'create_date'}"/>
</group>
</search>
</field>
</record>
@@ -72,21 +57,25 @@
<field name="model">mailing.contact</field>
<field name="arch" type="xml">
<search string="Mailing List Contacts">
<field name="name" filter_domain="['|','|',('name','ilike',self),('company_name','ilike',self), ('email','ilike',self)]"
<field name="name"
filter_domain="['|', '|', ('name','ilike',self), ('company_name','ilike',self), ('email_normalized','ilike',self)]"
string="Name / Email"/>
<field name="tag_ids"/>
<field name="list_ids"/>
<separator/>
<filter string="Valid Recipients" name="valid_recipients" domain="[('opt_out', '=', False), ('is_blacklisted', '=', False), ('is_email_valid', '=', True)]"
invisible="'default_list_ids' not in context"/>
<separator/>
<filter string="Exclude Blacklisted" name="not_blacklisted" domain="[('is_blacklisted', '=', False)]"/>
<separator/>
<filter string="Exclude Opt Out" name="not_opt_out" domain="[('opt_out', '=', False)]"
invisible="'default_list_ids' not in context"/>
<filter string="Valid Email Recipients"
name="filter_valid_email_recipient"
domain="[('opt_out', '=', False), ('is_blacklisted', '=', False), ('email_normalized', '!=', False)]"
invisible="not context.get('default_list_ids')"/>
<separator/>
<filter string="Exclude Blacklisted Emails"
name="filter_not_email_bl"
domain="[('is_blacklisted', '=', False)]"/>
<separator/>
<filter string="Exclude Bounced" name="not_bounced" domain="[('message_bounce', '&lt;', 3)]"/>
<filter string="Exclude Opt Out"
name="filter_not_optout"
domain="[('opt_out', '=', False)]"
invisible="not context.get('default_list_ids')"/>
<group expand="0" string="Group By">
<filter string="Creation Date" name="group_create_date"
context="{'group_by': 'create_date'}"/>
@@ -173,12 +162,12 @@
</div>
<group>
<group>
<field name="is_blacklisted" invisible="1"/>
<label for="email" class="oe_inline"/>
<div class="o_row o_row_readonly">
<div class="o_row o_row_readonly" name="email_details">
<i class="fa fa-ban" style="color: red;" role="img" title="This email is blacklisted for mass mailing"
aria-label="Blacklisted" attrs="{'invisible': [('is_blacklisted', '=', False)]}" groups="base.group_user"></i>
<field name="email" widget="email"/>
<field name="is_blacklisted" invisible="1"/>
</div>
<field name="title_id"/>
<field name="company_name"/>
@@ -186,11 +175,7 @@
</group>
<group>
<field name="create_date" readonly="1"/>
<label for="message_bounce"/>
<div class="o_row">
<field name="message_bounce"/>
<span> times</span>
</div>
<field name="message_bounce"/>
</group>
</group>
<field name="subscription_list_ids">
@@ -235,26 +220,13 @@
<field name="name">Mailing List Contacts</field>
<field name="res_model">mailing.contact</field>
<field name="view_mode">tree,kanban,form,graph,pivot</field>
<field name="context">{'search_default_valid_recipient': 1}</field>
<field name="context">{'search_default_filter_not_email_bl': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a contact in your address book
</p><p>
Odoo helps you to easily track all activities related to a customer.
</p>
</field>
</record>
<record model="ir.actions.act_window" id="action_view_mass_mailing_contacts_from_list">
<field name="name">Recipients</field>
<field name="res_model">mailing.contact</field>
<field name="view_mode">tree,form,graph</field>
<field name="domain">[('list_ids', 'in', [active_id])]</field>
<field name="context">{'search_default_valid_recipients': 1, 'default_list_ids': [active_id]}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new recipient
</p>
<p class="o_view_nocontent_smiling_face">
Create a contact in your address book
</p><p>
Odoo helps you to easily track all activities related to a customer.
</p>
</field>
</record>
@@ -40,8 +40,8 @@
<sheet>
<widget name="web_ribbon" text="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_button_box" name="button_box">
<button name="%(mass_mailing.action_view_mass_mailing_contacts_from_list)d"
type="action" icon="fa-user" class="oe_stat_button">
<button name="action_view_contacts"
type="object" icon="fa-user" class="oe_stat_button">
<field name="contact_nbr" string="Recipients" widget="statinfo"/>
</button>
</div>
@@ -131,7 +131,7 @@
</div>
</div>
<div class="o_mailing_list_kanban_boxes">
<a class="o_project_kanban_box" name="%(action_view_mass_mailing_contacts_from_list)d" type="action">
<a name="action_view_contacts" type="object">
<div>
<span class="badge badge-pill">
<i class="fa fa-user" role="img" aria-label="Contacts" title="Contacts"/>
@@ -7,6 +7,7 @@
<field name="arch" type="xml">
<search string="Mailings">
<field name="name" string="Mailings"/>
<field name="mailing_type" invisible="1"/>
<field name="campaign_id"/>
<filter string="My Mailings" name="assigned_to_me"
domain="[('user_id', '=', uid)]"
@@ -32,6 +33,7 @@
<field name="arch" type="xml">
<tree string="Mailings">
<field name="name"/>
<field name="mailing_type" invisible="1"/>
<field name="sent"/>
<field name="delivered"/>
<field name="opened"/>
@@ -48,55 +50,52 @@
<field name="arch" type="xml">
<form string="Mailing">
<header style="min-height:31px;">
<button name="put_in_queue" type="object" attrs="{'invisible': [('state', 'in', ('in_queue', 'done'))]}" class="oe_highlight" string="Send Now"
<button name="action_put_in_queue" type="object" attrs="{'invisible': [('state', 'in', ('in_queue', 'done'))]}" class="oe_highlight" string="Send Now"
confirm="This will send the email to all recipients. Do you still want to proceed ?"/>
<button name="action_schedule_date" type="object" attrs="{'invisible': [('state', 'in', ('in_queue', 'done'))]}" class="btn-secondary" string="Schedule"/>
<button name="action_test_mailing" type="object" class="btn-secondary" string="Test"/>
<button name="cancel_mass_mailing" type="object" attrs="{'invisible': [('state', '!=', 'in_queue')]}" class="btn-secondary" string="Cancel"/>
<button name="retry_failed_mail" type="object" attrs="{'invisible': ['|', ('state', '!=', 'done'), ('failed', '=', 0)]}" class="oe_highlight" string="Retry"/>
<button name="action_schedule" type="object" attrs="{'invisible': [('state', 'in', ('in_queue', 'done'))]}" class="btn-secondary" string="Schedule"/>
<button name="action_test" type="object" class="btn-secondary" string="Test"/>
<button name="action_cancel" type="object" attrs="{'invisible': [('state', '!=', 'in_queue')]}" class="btn-secondary" string="Cancel"/>
<button name="action_retry_failed" type="object" attrs="{'invisible': ['|', ('state', '!=', 'done'), ('failed', '=', 0)]}" class="oe_highlight" string="Retry"/>
<field name="state" readonly="1" widget="statusbar"/>
</header>
<div class="oe_form_box_info bg-info oe_text_center" attrs="{'invisible': [('ignored', '=', 0)]}">
<div class="alert alert-info text-center" attrs="{'invisible': [('ignored', '=', 0)]}" role="alert">
<p>
<button class="btn-link"
name="%(action_view_mail_mail_statistics_mailing_list)d"
type="action"
context="{'search_default_filter_ignored': True}">
name="action_view_traces_ignored"
type="object">
<strong>
<field name="ignored" class="oe_inline"/>
email(s) have been ignored and will not be sent.
<field name="ignored" class="oe_inline mr-2"/>
<span name="ignored_text">emails have been ignored and will not be sent.</span>
</strong>
</button>
</p>
</div>
<div class="oe_form_box_info bg-info oe_text_center" attrs="{'invisible': ['|', ('scheduled', '=', 0), ('ignored','!=',0)]}">
<div class="alert alert-info text-center" attrs="{'invisible': [('scheduled', '=', 0)]}" role="alert">
<p>
<button class="btn-link"
name="%(action_view_mail_mail_statistics_mailing_list)d"
type="action"
context="{'search_default_filter_scheduled': True}">
name="action_view_traces_scheduled"
type="object">
<strong>
<field name="scheduled" class="oe_inline"/>
email(s) are in queue and will be sent soon.
<field name="scheduled" class="oe_inline mr-2"/>
<span name="scheduled_text">emails are in queue and will be sent soon.</span>
</strong>
</button>
</p>
</div>
<div class="oe_form_box_info bg-info oe_text_center alert-info" attrs="{'invisible': ['|', ('state', '!=', 'done'), ('failed', '=', 0)]}" role="alert">
<div class="alert alert-info text-center" attrs="{'invisible': ['|', ('state', '!=', 'done'), ('failed', '=', 0)]}" role="alert">
<p>
<button class="btn-link"
name="%(action_view_mail_mail_statistics_mailing_list)d"
type="action"
context="{'search_default_filter_failed': True}">
name="action_view_traces_failed"
type="object">
<strong>
<field name="failed" class="oe_inline"/>
email(s) could not be sent.
<field name="failed" class="oe_inline mr-2"/>
<span name="failed_text">emails could not be sent.</span>
</strong>
</button>
</p>
</div>
<div class="oe_form_box_info bg-info oe_text_center" attrs="{'invisible': [('state', '!=', 'in_queue')]}">
<div class="alert alert-info text-center" attrs="{'invisible': [('state', '!=', 'in_queue')]}" role="alert">
<p><strong>
This mailing is scheduled for
<field name="next_departure" class="oe_inline"/>.
@@ -106,15 +105,16 @@
<widget name="web_ribbon" text="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_button_box" name="button_box">
<button name="action_view_sent"
id="button_view_sent"
type="object"
context="{'search_default_filter_sent': True}"
icon="fa-envelope-o" class="oe_stat_button o_mass_mailing_button"
attrs="{'invisible': ['&amp;', ('sent', '=', 0), ('state', 'in', ('draft','test'))]}" >
<field name="sent" string="Emails Sent" widget="statinfo"/>
icon="fa-envelope-o" class="oe_stat_button"
attrs="{'invisible': ['&amp;', ('sent', '=', 0), ('state', 'in', ('draft', 'test'))]}" >
<field name="sent" string="Sent" widget="statinfo"/>
</button>
<button name="action_view_delivered"
type="object"
context="{'search_default_filter_received': True}"
context="{'search_default_filter_delivered': True}"
attrs="{'invisible': [('state', 'in', ('draft','test'))]}"
class="oe_stat_button">
<field name="received_ratio" string="Received" widget="percentpie"/>
@@ -144,8 +144,9 @@
<group>
<field name="active" invisible="1"/>
<field name="subject" string="Subject"/>
<field name="name" required="1" string="Mailing Name"/>
<field name="mailing_type" widget="radio" options="{'horizontal': true}" invisible="1"/>
<field name="subject" string="Subject"/>
<label for="mailing_model_id" string="Recipients"/>
<div>
<field name="mailing_model_id" widget="selection" required="1"/>
@@ -163,7 +164,7 @@
</div>
</group>
<notebook>
<page string="Mail Body">
<page string="Mail Body" name="mail_body">
<field name="body_html" class="oe_read_only" widget="html" options="{
'cssReadonly': 'mass_mailing.iframe_css_assets_readonly',
}"/>
@@ -180,14 +181,14 @@
<field name="unique_ab_testing" groups="base.group_no_one"/>
<field name="email_from"/>
<label for="reply_to"/>
<div>
<div name="reply_to_details">
<field name="reply_to_mode" widget="radio"
attrs="{'invisible': [('mailing_model_name', 'in', ['mailing.contact', 'res.partner', 'mailing.list'])]}"/>
<field name="reply_to"
attrs="{'required': [('reply_to_mode', '=', 'email')], 'invisible': [('reply_to_mode', '=', 'thread')]}"/>
</div>
<label for="attachment_ids"/>
<div>
<div name="attachment_ids_details">
<field name="attachment_ids" widget="many2many_binary" string="Attach a file" class="oe_inline"/>
</div>
<field name="mail_server_id" groups="base.group_no_one" options="{'no_create': True, 'no_open': True}"/>
@@ -256,6 +257,7 @@
<div class="o_kanban_record_top">
<div class="o_kanban_record_headings">
<h3 class="mt4 mb4"><field name="name"/></h3>
<field name="mailing_type" invisible="1"/>
<div class="o_kanban_record_subtitle" attrs="{'invisible': [('sent_date', '=', False)]}">
<h5 style="display: inline;"><field name="campaign_id" groups="mass_mailing.group_mass_mailing_campaign"/></h5>
<t t-if="record.campaign_id.raw_value" groups="mass_mailing.group_mass_mailing_campaign"> - </t><field name="sent_date"/>
@@ -264,7 +266,7 @@
</div>
<div class="o_kanban_record_body" t-if="!selection_mode" attrs="{'invisible': [('sent_date', '=', False)]}">
<t t-if="record.sent_date">
<div class="row">
<div class="row" name="stat_opened">
<div class="col-8">
<a name="%(action_view_mail_mail_statistics_mailing)d"
context="{'search_default_filter_opened': True}"
@@ -274,7 +276,7 @@
<field name="opened_ratio"/> %
</div>
</div>
<div class="row">
<div class="row" name="stat_replied">
<div class="col-8">
<a name="%(action_view_mail_mail_statistics_mailing)d"
context="{'search_default_filter_replied': True}"
@@ -284,7 +286,7 @@
<field name="replied_ratio"/> %
</div>
</div>
<div class="row">
<div class="row" name="stat_clicks">
<div class="col-8">
<a name="%(link_tracker_action_mass_mailing)d"
type="action">Clicks</a>
@@ -293,7 +295,7 @@
<field name="clicks_ratio"/> %
</div>
</div>
<div class='o_kanban_primary_bottom'>
<div class='o_kanban_primary_bottom' name="stat_delivered">
<field name="delivered" widget="progressbar" title="Delivered" options="{'current_value': 'delivered', 'max_value': 'expected', 'editable': false}"/>
</div>
</t>
@@ -317,13 +319,15 @@
</field>
</record>
<record id="action_view_mass_mailings" model="ir.actions.act_window">
<record id="mailing_mailing_action_mail" model="ir.actions.act_window">
<field name="name">Mailings</field>
<field name="res_model">mailing.mailing</field>
<field name="view_mode">kanban,tree,form,graph</field>
<field name="domain">[('mailing_type', '=', 'mail')]</field>
<field name="context">{
'search_default_assigned_to_me': 1,
'default_user_id': uid,
'default_mailing_type': 'mail',
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -399,7 +403,10 @@
</field>
</record>
<menuitem name="Mailings" id="mass_mailing_menu" parent="mass_mailing_menu_root"
sequence="1" action="action_view_mass_mailings" groups="mass_mailing.group_mass_mailing_user"/>
<menuitem name="Mailings" id="mass_mailing_menu"
parent="mass_mailing_menu_root"
sequence="1"
action="mailing_mailing_action_mail"
groups="mass_mailing.group_mass_mailing_user"/>
</odoo>
@@ -15,4 +15,10 @@
<menuitem id="mail_blacklist_mm_menu" name="Blacklist"
action="mail.mail_blacklist_action"
parent="mass_mailing_configuration"/>
<!-- Technical / Mass Mailing -->
<menuitem id="mailing_mailing_menu_technical"
name="Mass Mailing"
sequence="3"
parent="base.menu_custom"/>
</odoo>
@@ -8,17 +8,18 @@
<search string="Mail Statistics">
<field name="mail_mail_id_int"/>
<field name="message_id"/>
<field name="email"/>
<field name="mass_mailing_id"/>
<filter string="Sent" name="filter_sent" domain="[('sent', '!=', False)]"/>
<filter string="Received" name="filter_received" domain="[('sent', '!=', False), ('bounced', '=', False)]"/>
<filter string="Scheduled" name="filter_scheduled" domain="[('scheduled', '!=', False), ('sent', '=', False), ('exception', '=', False)]"/>
<filter string="Failed" name="filter_failed" domain="[('scheduled', '!=', False), ('sent', '=', False), ('exception', '!=', False)]"/>
<filter string="Scheduled" name="filter_scheduled" domain="[('scheduled', '!=', False), ('sent', '=', False), ('exception', '=', False), ('ignored', '=', False), ('bounced', '=', False)]"/>
<filter string="Ignored" name="filter_ignored" domain="[('scheduled', '!=', False), ('sent', '=', False), ('exception', '=', False), ('ignored', '!=', False)]"/>
<filter string="Sent" name="filter_sent" domain="[('sent', '!=', False)]"/>
<filter string="Delivered" name="filter_delivered" domain="[('sent', '!=', False), ('exception', '=', False), ('bounced', '=', False)]"/>
<separator/>
<filter string="Opened" name="filter_opened" domain="[('opened', '!=', False)]"/>
<filter string="Clicked" name="filter_clicked" domain="[('clicked', '!=', False)]"/>
<filter string="Replied" name="filter_replied" domain="[('replied', '!=', False)]"/>
<filter string="Bounced" name="filter_bounced" domain="[('bounced', '!=', False)]"/>
<filter string="Failed" name="filter_failed" domain="[('exception', '!=', False)]"/>
<group expand="0" string="Group By">
<filter string="State" name="state" domain="[]" context="{'group_by':'state'}"/>
<filter string="Open Date" name="group_open_date" context="{'group_by': 'opened:day'}"/>
@@ -35,9 +36,11 @@
<field name="model">mailing.trace</field>
<field name="arch" type="xml">
<tree string="Mail Statistics" create="0">
<field name="mass_mailing_id"/>
<field name="email"/>
<field name="state"/>
<field name="message_id"/>
<field name="scheduled"/>
<field name="sent"/>
<field name="exception"/>
<field name="opened"/>
@@ -58,27 +61,42 @@
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_form_box_info bg-info oe_text_center alert-info" attrs="{'invisible': [('exception', '=', False)]}" role="alert">
<p><strong>This email could not be sent.</strong></p>
<div class="alert alert-info text-center" role="alert" name="alert_mail_exception"
attrs="{'invisible': [('exception', '=', False), ('bounced', '=', False)]}">
<p>
<strong><span name="trace_type_name_mail">This email</span>
<span attrs="{'invisible': [('exception', '=', False)]}"> could not be sent</span>
<span attrs="{'invisible': [('bounced', '=', False)]}"> appears to be invalid</span>
</strong>
</p>
</div>
<group>
<group>
<field name="email" string="Email"/>
<field name="mass_mailing_id"/>
<field name="campaign_id" groups="mass_mailing.group_mass_mailing_campaign"/>
<field name="mail_mail_id_int" groups="base.group_no_one"/>
<field name="message_id" groups="base.group_no_one"/>
<field name="model" groups="base.group_no_one"/>
<field name="res_id" groups="base.group_no_one"/>
<group string="Recipient">
<field name="trace_type" invisible="1"/>
<field name="email"/>
<field name="mail_mail_id_int"/>
<field name="message_id"/>
</group>
<group string="Document">
<field name="model"/>
<field name="res_id"/>
<field name="state_update"/>
</group>
</group>
<group string="Marketing">
<group>
<field name="exception"/>
<field name="mass_mailing_id"/>
<field name="campaign_id"/>
<field name="sent"/>
<field name="opened"/>
<field name="clicked"/>
<field name="replied"/>
<field name="bounced"/>
</group>
<group>
<field name="exception"/>
<field name="ignored"/>
<field name="bounced"/>
<field name="failure_type"/>
</group>
</group>
</sheet>
@@ -86,28 +104,6 @@
</field>
</record>
<record id="action_view_mail_mail_statistics" model="ir.actions.act_window">
<field name="name">Mail Statistics</field>
<field name="res_model">mailing.trace</field>
<field name="view_mode">tree,form</field>
</record>
<record id="action_view_mail_mail_statistics_mailing" model="ir.actions.act_window">
<field name="name">Mail Statistics</field>
<field name="res_model">mailing.trace</field>
<field name="view_mode">graph,tree,form,pivot</field>
<field name="domain">[]</field>
<field name="context">{'search_default_mass_mailing_id': active_id}</field>
</record>
<record id="action_view_mail_mail_statistics_mailing_list" model="ir.actions.act_window">
<field name="name">Emails Sent</field>
<field name="res_model">mailing.trace</field>
<field name="view_mode">tree,graph,form,pivot</field>
<field name="domain">[]</field>
<field name="context">{'search_default_mass_mailing_id': active_id}</field>
</record>
<record id="view_mail_mail_statistics_graph" model="ir.ui.view">
<field name="name">Mail Statistics Graph</field>
<field name="model">mailing.trace</field>
@@ -119,8 +115,24 @@
</field>
</record>
<record id="mailing_trace_action" model="ir.actions.act_window">
<field name="name">Mailing Traces</field>
<field name="res_model">mailing.trace</field>
<field name="view_mode">tree,form,graph,pivot</field>
<field name="domain">[]</field>
</record>
<record id="action_view_mail_mail_statistics_mailing" model="ir.actions.act_window">
<field name="name">Mail Statistics</field>
<field name="res_model">mailing.trace</field>
<field name="view_mode">graph,tree,form,pivot</field>
<field name="domain">[]</field>
<field name="context">{'search_default_mass_mailing_id': active_id}</field>
</record>
<!-- Add in Technical/Email -->
<menuitem name="Mail Statistics" id="menu_email_statistics"
parent="base.menu_email" sequence="50"
action="action_view_mail_mail_statistics"/>
<menuitem id="menu_email_statistics"
name="Mailing Traces"
parent="mass_mailing.mailing_mailing_menu_technical" sequence="2"
action="mailing_trace_action"/>
</odoo>
@@ -119,8 +119,8 @@
action="utm.action_view_utm_stage"/>
<menuitem id="mass_mailing_tag_menu"
parent="mass_mailing_configuration"
action="utm.action_view_utm_tag"
sequence="2"
groups="base.group_no_one"/>
parent="mass_mailing_configuration"
action="utm.action_view_utm_tag"
sequence="2"
groups="mass_mailing.group_mass_mailing_campaign"/>
</odoo>
@@ -5,7 +5,7 @@
<field name="model">mailing.mailing</field>
<field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_form"/>
<field name="arch" type="xml">
<xpath expr="//button[hasclass('o_mass_mailing_button')]" position="before">
<xpath expr="//button[@id='button_view_sent']" position="before">
<field name="crm_lead_activated" invisible="1"/>
<button name="action_redirect_to_leads"
type="object"
@@ -5,7 +5,7 @@
<field name="model">mailing.mailing</field>
<field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_form"/>
<field name="arch" type="xml">
<xpath expr="//button[hasclass('o_mass_mailing_button')]" position="before">
<xpath expr="//button[@id='button_view_sent']" position="before">
<button name="action_redirect_to_quotations"
type="object"
icon="fa-pencil"
+7
View File
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import tests
from . import wizard
+35
View File
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'SMS Marketing',
'summary': 'Design, send and track SMS',
'description': '',
'version': '1.0',
'category': 'Marketing/Email Marketing',
'depends': [
'portal',
'mass_mailing',
'sms',
],
'data': [
'data/utm_data.xml',
'security/ir.model.access.csv',
'views/mailing_sms_menus.xml',
'views/mailing_list_views.xml',
'views/mailing_contact_views.xml',
'views/mailing_trace_views.xml',
'views/mailing_mailing_views.xml',
'views/link_tracker_views.xml',
'views/mass_mailing_sms_templates_portal.xml',
'report/mailing_trace_report_views.xml',
'wizard/sms_composer_views.xml',
'wizard/mailing_sms_test_views.xml',
],
'demo': [
'data/utm_demo.xml',
'data/mailing_list_demo.xml',
'data/mailing_demo.xml',
],
'application': True,
}
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+105
View File
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import werkzeug
from odoo import http, _
from odoo.addons.phone_validation.tools import phone_validation
from odoo.http import request
class MailingSMSController(http.Controller):
def _check_trace(self, mailing_id, trace_code):
try:
mailing = request.env['mailing.mailing'].sudo().search([('id', '=', mailing_id)])
except:
mailing = False
if not mailing:
return {'error': 'mailing_error'}
trace = request.env['mailing.trace'].sudo().search([
('trace_type', '=', 'sms'),
('sms_code', '=', trace_code),
('mass_mailing_id', '=', mailing.id)
])
if not trace:
return {'error': 'trace_error'}
return {'trace': trace}
@http.route(['/sms/<int:mailing_id>/<string:trace_code>'], type='http', website=True, auth='public')
def blacklist_page(self, mailing_id, trace_code, **post):
check_res = self._check_trace(mailing_id, trace_code)
if not check_res.get('trace'):
return werkzeug.utils.redirect('/web')
return request.render('mass_mailing_sms.blacklist_main', {
'mailing_id': mailing_id,
'trace_code': trace_code,
})
@http.route(['/sms/<int:mailing_id>/unsubscribe/<string:trace_code>'], type='http', website=True, auth='public')
def blacklist_number(self, mailing_id, trace_code, **post):
check_res = self._check_trace(mailing_id, trace_code)
if not check_res.get('trace'):
return werkzeug.utils.redirect('/web')
country_code = request.session.get('geoip', False) and request.session.geoip.get('country_code', False) if request.session.get('geoip') else None
trace = check_res['trace']
mailing_list_ids = trace.mass_mailing_id.contact_list_ids
# parse and validate number
sms_number = post.get('sms_number', '').strip(' ')
sanitize_res = phone_validation.phone_sanitize_numbers([sms_number], country_code, None)[sms_number]
tocheck_number = sanitize_res['sanitized'] or sms_number
# compute opt-out / blacklist information
lists_optout = request.env['mailing.list'].sudo()
lists_optin = request.env['mailing.list'].sudo()
unsubscribe_error = False
if tocheck_number and trace.sms_number == tocheck_number:
if mailing_list_ids:
subscriptions = request.env['mailing.contact.subscription'].sudo().search([
('list_id', 'in', mailing_list_ids.ids),
('contact_id.phone_sanitized', '=', tocheck_number),
])
subscriptions.write({'opt_out': True})
lists_optout = subscriptions.mapped('list_id')
else:
blacklist_rec = request.env['phone.blacklist'].sudo().add(tocheck_number)
blacklist_rec._message_log(
body=_('Blacklist through mass sms unsubscribe (mailing ID: %s - model: %s)') %
(trace.mass_mailing_id.id, trace.mass_mailing_id.mailing_model_id.display_name))
lists_optin = request.env['mailing.contact.subscription'].sudo().search([
('contact_id.phone_sanitized', '=', tocheck_number),
('list_id', 'not in', mailing_list_ids.ids),
('opt_out', '=', False),
]).mapped('list_id')
elif tocheck_number and trace.sms_number != tocheck_number:
unsubscribe_error = _('Number %s not found' % tocheck_number)
else:
unsubscribe_error = sanitize_res['msg']
return request.render('mass_mailing_sms.blacklist_number', {
'mailing_id': mailing_id,
'trace_code': trace_code,
'sms_number': sms_number,
'lists_optin': lists_optin,
'lists_optout': lists_optout,
'unsubscribe_error': unsubscribe_error,
})
@http.route('/r/<string:code>/s/<int:sms_sms_id>', type='http', auth="public")
def sms_short_link_redirect(self, code, sms_sms_id, **post):
# don't assume geoip is set, it is part of the website module
# which mass_mailing doesn't depend on
country_code = request.session.get('geoip', False) and request.session.geoip.get('country_code', False)
if sms_sms_id:
trace_id = request.env['mailing.trace'].sudo().search([('sms_sms_id_int', '=', int(sms_sms_id))]).id
else:
trace_id = False
request.env['link.tracker.click'].sudo().add_click(
code,
ip=request.httprequest.remote_addr,
country_code=country_code,
mailing_trace_id=trace_id
)
return werkzeug.utils.redirect(request.env['link.tracker'].get_url_from_code(code), 301)
@@ -0,0 +1,166 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="0">
<record id="mailing_sms_0" model="mailing.mailing">
<field name="name">XMas Promo</field>
<field name="mailing_type">sms</field>
<field name="state">done</field>
<field name="user_id" ref="base.user_admin"/>
<field name="sent_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="campaign_id" ref="utm_campaign_0"/>
<field name="mailing_model_id" ref="base.model_res_partner"/>
<field name="mailing_domain" eval="[('parent_id', '=', ref('base.res_partner_4'))]"/>
<field name="body_plaintext">This week-end, incredible promotion for XMas ! See http://sms.example.com !</field>
</record>
<!-- Generate link tracker information from it -->
<function model="mailing.mailing" name="convert_links" eval="[ref('mass_mailing_sms.mailing_sms_0')]"/>
<!-- Simulate traces -->
<record id="mailing_sms_0_trace_0" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_0"/>
<field name="trace_type">sms</field>
<field name="sms_number">+32465000000</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_7"/>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mailing_sms_0_trace_1" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_0"/>
<field name="trace_type">sms</field>
<field name="sms_number">+32465000001</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_13"/>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mailing_sms_0_trace_2" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_0"/>
<field name="trace_type">sms</field>
<field name="sms_number">+32465000002</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_14"/>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mailing_sms_0_trace_3" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_0"/>
<field name="trace_type">sms</field>
<field name="sms_number">+32465000003</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_24"/>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mailing_sms_0_trace_4" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_0"/>
<field name="trace_type">sms</field>
<field name="sms_number">+32465000004</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_33"/>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mailing_sms_0_trace_5" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_0"/>
<field name="trace_type">sms</field>
<field name="sms_number">123123</field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_33"/>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="bounced" eval="(DateTime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mailing_sms_0_trace_6" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_0"/>
<field name="trace_type">sms</field>
<field name="sms_number"></field>
<field name="model">res.partner</field>
<field name="res_id" ref="base.res_partner_address_34"/>
<field name="sent" eval="False"/>
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<!-- Generate some clicks -->
<function model="link.tracker.click" name="add_click">
<value model="link.tracker.code"
search="[('link_id.url', '=', 'http://sms.example.com')]"
use="code"/>
<value name="ip">100.01.02.03</value>
<value name="country_code">BE</value>
<value name="mailing_trace_id" eval="ref('mailing_sms_0_trace_0')"/>
</function>
<function model="link.tracker.click" name="add_click">
<value model="link.tracker.code"
search="[('link_id.url', '=', 'http://sms.example.com')]"
use="code"/>
<value name="ip">100.01.02.04</value>
<value name="country_code">BE</value>
<value name="mailing_trace_id" eval="ref('mailing_sms_0_trace_0')"/>
</function>
<function model="link.tracker.click" name="add_click">
<value model="link.tracker.code"
search="[('link_id.url', '=', 'http://sms.example.com')]"
use="code"/>
<value name="ip">100.01.02.05</value>
<value name="country_code">BE</value>
<value name="mailing_trace_id" eval="ref('mailing_sms_0_trace_1')"/>
</function>
<function model="link.tracker.click" name="add_click">
<value model="link.tracker.code"
search="[('link_id.url', '=', 'http://sms.example')]"
use="code"/>
<value name="ip">100.01.02.06</value>
<value name="country_code">BE</value>
<value name="mailing_trace_id" eval="ref('mailing_sms_0_trace_2')"/>
</function>
<record id="mailing_sms_1" model="mailing.mailing">
<field name="name">Extra Promo</field>
<field name="mailing_type">sms</field>
<field name="state">done</field>
<field name="user_id" ref="base.user_admin"/>
<field name="sent_date" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="campaign_id" ref="utm_campaign_0"/>
<field name="mailing_model_id" ref="mass_mailing.model_mailing_list"/>
<field name="contact_list_ids" eval="[(5, 0), (4, ref('mass_mailing_sms.mailing_list_sms_0'))]"/>
<field name="body_plaintext">Extra promotion for you !</field>
</record>
<!-- Simulate traces -->
<record id="mailing_sms_1_trace_0" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_1"/>
<field name="trace_type">sms</field>
<field name="sms_number">+32456001100</field>
<field name="model">mailing.contact</field>
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_0"/>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mailing_sms_1_trace_1" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_1"/>
<field name="trace_type">sms</field>
<field name="sms_number">+32456001111</field>
<field name="model">mailing.contact</field>
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_1"/>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mailing_sms_1_trace_2" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_1"/>
<field name="trace_type">sms</field>
<field name="sms_number">+32456001122</field>
<field name="model">mailing.contact</field>
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_2"/>
<field name="sent" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mailing_sms_1_trace_3" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_1"/>
<field name="trace_type">sms</field>
<field name="sms_number">+32456001133</field>
<field name="model">mailing.contact</field>
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_3"/>
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="failure_type">sms_credit</field>
</record>
<record id="mailing_sms_1_trace_4" model="mailing.trace">
<field name="mass_mailing_id" ref="mailing_sms_1"/>
<field name="trace_type">sms</field>
<field name="sms_number">dummy</field>
<field name="model">mailing.contact</field>
<field name="res_id" ref="mass_mailing_sms.mailing_contact_0_4"/>
<field name="exception" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="failure_type">sms_number_format</field>
</record>
</data></odoo>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="0">
<record id="mailing_list_sms_0" model="mailing.list">
<field name="name">Interested in Tree Promotions</field>
</record>
<record id="mailing_contact_0_0" model="mailing.contact">
<field name="name">Hubert Farnsworth</field>
<field name="mobile">+32456001100</field>
<field name="list_ids" eval="[(4, ref('mass_mailing_sms.mailing_list_sms_0'))]"/>
</record>
<record id="mailing_contact_0_1" model="mailing.contact">
<field name="name">Philip Fry</field>
<field name="mobile">+32456001111</field>
<field name="list_ids" eval="[(4, ref('mass_mailing_sms.mailing_list_sms_0'))]"/>
</record>
<record id="mailing_contact_0_2" model="mailing.contact">
<field name="name">Turanga Leela</field>
<field name="mobile">+32456001122</field>
<field name="list_ids" eval="[(4, ref('mass_mailing_sms.mailing_list_sms_0'))]"/>
</record>
<record id="mailing_contact_0_3" model="mailing.contact">
<field name="name">John Zoidberg</field>
<field name="mobile">+32456001133</field>
<field name="list_ids" eval="[(4, ref('mass_mailing_sms.mailing_list_sms_0'))]"/>
</record>
<record id="mailing_contact_0_4" model="mailing.contact">
<field name="name">Zapp Brannigan</field>
<field name="mobile">dummy</field>
<field name="list_ids" eval="[(4, ref('mass_mailing_sms.mailing_list_sms_0'))]"/>
</record>
</data></odoo>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record model="utm.medium" id="utm_medium_sms">
<field name="name">SMS</field>
</record>
</data></odoo>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="0">
<record id="mailing_tag_0" model="utm.tag">
<field name="name">Bioutifoul SMS</field>
<field name="color" eval="2"/>
</record>
<record id="utm_campaign_0" model="utm.campaign">
<field name="name">XMas Promo</field>
<field name="stage_id" ref="utm.campaign_stage_1"/>
<field name="user_id" ref="base.user_admin"/>
<field name="tag_ids" eval="[(4, ref('mailing_tag_0')), (4, ref('utm.utm_tag_1'))]"/>
</record>
</data></odoo>
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import link_tracker
from . import mailing_contact
from . import mailing_list
from . import mailing_mailing
from . import mailing_trace
from . import sms_sms
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import werkzeug
from odoo import models
from odoo.addons.mass_mailing_sms.models.sms_sms import TEXT_URL_REGEX
class LinkTracker(models.Model):
_inherit = "link.tracker"
def _convert_links_text(self, body, vals, blacklist=None):
shortened_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/'
unsubscribe_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/sms/'
for match in re.findall(TEXT_URL_REGEX, body):
original_url = match[0]
# don't shorten already-shortened links or links towards unsubscribe page
if original_url.startswith(shortened_schema) or original_url.startswith(unsubscribe_schema):
continue
# support blacklist items in path, like /u/
parsed = werkzeug.urls.url_parse(original_url, scheme='http')
if blacklist and any(item in parsed.path for item in blacklist):
continue
vals['url'] = werkzeug.utils.unescape(original_url)
link = self.create(vals)
shortened_url = link.short_url
if shortened_url:
body = body.replace(original_url, shortened_url)
return body
@@ -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 _sms_get_number_fields(self):
return ['mobile']
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class MailingList(models.Model):
_inherit = 'mailing.list'
def _compute_contact_nbr(self):
if self.env.context.get('mailing_sms'):
self.env.cr.execute('''
select list_id, count(*)
from mailing_contact_list_rel r
left join mailing_contact c on (r.contact_id=c.id)
left join phone_blacklist bl on c.phone_sanitized = bl.number and bl.active
where
list_id in %s
AND COALESCE(r.opt_out,FALSE) = FALSE
AND c.phone_sanitized IS NOT NULL
AND bl.id IS NULL
group by list_id''', (tuple(self.ids), ))
data = dict(self.env.cr.fetchall())
for mailing_list in self:
mailing_list.contact_nbr = data.get(mailing_list.id, 0)
return
return super(MailingList, self)._compute_contact_nbr()
def action_view_contacts(self):
if self.env.context.get('mailing_sms'):
action = self.env.ref('mass_mailing_sms.mailing_contact_action_sms').read()[0]
action['domain'] = [('list_ids', 'in', self.ids)]
context = dict(self.env.context, search_default_filter_valid_sms_recipient=1, default_list_ids=self.ids)
action['context'] = context
return action
return super(MailingList, self).action_view_contacts()
@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class Mailing(models.Model):
_inherit = 'mailing.mailing'
# mailing options
mailing_type = fields.Selection(selection_add=[('sms', 'SMS')])
# sms options
body_plaintext = fields.Text('SMS Body')
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_insufficient_credit',
help='UX Field to propose to buy IAP credits')
# opt_out_link
sms_allow_unsubscribe = fields.Boolean('Include opt-out link', default=True)
@api.onchange('mailing_type')
def _onchange_mailing_type(self):
if self.mailing_type == 'sms' and (not self.medium_id or self.medium_id == self.env.ref('utm.utm_medium_email')):
self.medium_id = self.env.ref('mass_mailing_sms.utm_medium_sms').id
elif self.mailing_type == 'mail' and (not self.medium_id or self.medium_id == self.env.ref('mass_mailing_sms.utm_medium_sms')):
self.medium_id = self.env.ref('utm.utm_medium_email').id
@api.onchange('sms_template_id', 'mailing_type')
def _onchange_sms_template_id(self):
if self.mailing_type == 'sms' and self.sms_template_id:
self.body_plaintext = self.sms_template_id.body
@api.depends('mailing_trace_ids.failure_type')
def _compute_sms_has_insufficient_credit(self):
mailing_ids = self.env['mailing.trace'].sudo().search([
('mass_mailing_id', 'in', self.ids),
('trace_type', '=', 'sms'),
('failure_type', '=', 'sms_credit')
]).mapped('mass_mailing_id')
for mailing in self:
mailing.sms_has_insufficient_credit = mailing in mailing_ids
# --------------------------------------------------
# CRUD
# --------------------------------------------------
@api.model
def create(self, values):
if values.get('mailing_type') == 'sms':
if not values.get('medium_id'):
values['medium_id'] = self.env.ref('mass_mailing_sms.utm_medium_sms').id
if values.get('sms_template_id') and not values.get('body_plaintext'):
values['body_plaintext'] = self.env['sms.template'].browse(values['sms_template_id']).body
return super(Mailing, self).create(values)
# --------------------------------------------------
# BUSINESS / VIEWS ACTIONS
# --------------------------------------------------
def action_put_in_queue_sms(self):
return 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 Mailing'),
'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):
"""Returns a set of emails opted-out in target model"""
self.ensure_one()
opt_out = []
target = self.env[self.mailing_model_real]
if self.mailing_model_real == "mail.mass_mailing.contact":
# if user is opt_out on One list but not on another
# or if two user with same email address, one opted in and the other one opted out, send the mail anyway
# TODO DBE Fixme : Optimise the following to get real opt_out and opt_in
subscriptions = self.env['mailing.contact.subscription'].sudo().search(
[('list_id', 'in', self.contact_list_ids.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')
opt_out = list(set(c.id for c in opt_out_contacts if c not in opt_in_contacts))
_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]
if issubclass(type(target), self.pool['mail.thread.phone']):
phone_fields = ['phone_sanitized']
elif issubclass(type(target), self.pool['mail.thread']):
phone_fields = target._sms_get_number_fields()
else:
phone_fields = []
if 'mobile' in target._fields:
phone_fields.append('mobile')
if 'phone' in target._fields:
phone_fields.append('phone')
if not phone_fields:
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)
WHERE (%(where_query)s)
AND trace.mass_mailing_id = %%(mailing_id)s
AND trace.model = %%(target_model)s
"""
query = query % {
'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),
'target_table': target._table
}
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': False,
'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 not res_ids:
raise UserError(_('There is no recipients selected.'))
composer = self.env['sms.composer'].create(mailing._send_sms_get_composer_values(res_ids))
# extra_context = self._get_mass_mailing_context()
# auto-commit except in testing mode
# auto_commit = not getattr(threading.currentThread(), 'testing', False)
# composer.send_mail(auto_commit=auto_commit)
composer._action_send_sms()
mailing.write({'state': 'done', 'sent_date': fields.Datetime.now()})
return True
@@ -0,0 +1,80 @@
# -*- 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')])
sms_sms_id = fields.Many2one('sms.sms', string='SMS', index=True, ondelete='set null')
sms_sms_id_int = fields.Integer(
string='SMS ID (tech)',
help='ID of the related sms.sms. This field is an integer field because '
'the related sms.sms can be deleted separately from its statistics. '
'However the ID is needed for several action and controllers.',
index=True,
)
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'),
# mass mode specific codes
('sms_blacklist', 'Blacklisted'),
('sms_duplicate', 'Duplicate'),
])
@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))
def _get_records_from_sms(self, sms_sms_ids=None, additional_domain=None):
if not self.ids and sms_sms_ids:
domain = [('sms_sms_id_int', 'in', sms_sms_ids)]
else:
domain = [('id', 'in', self.ids)]
if additional_domain:
domain = expression.AND([domain, additional_domain])
return self.search(domain)
def set_sms_sent(self, sms_sms_ids=None):
statistics = self._get_records_from_sms(sms_sms_ids, [('sent', '=', False)])
statistics.write({'sent': fields.Datetime.now()})
return statistics
def set_sms_clicked(self, sms_sms_ids=None):
statistics = self._get_records_from_sms(sms_sms_ids, [('clicked', '=', False)])
statistics.write({'clicked': fields.Datetime.now()})
return statistics
def set_sms_ignored(self, sms_sms_ids=None):
statistics = self._get_records_from_sms(sms_sms_ids, [('ignored', '=', False)])
statistics.write({'ignored': fields.Datetime.now()})
return statistics
def set_sms_exception(self, sms_sms_ids=None):
statistics = self._get_records_from_sms(sms_sms_ids, [('exception', '=', False)])
statistics.write({'exception': fields.Datetime.now()})
return statistics
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import fields, models
TEXT_URL_REGEX = r'(https?:\/\/(www\.)?[a-zA-Z0-9@:%._\+~#=/-]{1,64})'
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. """
shortened_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/'
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 match in re.findall(TEXT_URL_REGEX, body):
url = match[0]
if url.startswith(shortened_schema):
body = body.replace(url, url + '/s/%s' % sms.id)
res[sms.id] = body
return res
def _postprocess_iap_sent_sms(self, iap_results, failure_reason=None, delete_all=False):
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.write({'sent': fields.Datetime.now(), 'exception': False})
elif traces:
traces.write({'exception': fields.Datetime.now(), 'failure_type': self.IAP_TO_SMS_STATE[state]})
return super(SmsSms, self)._postprocess_iap_sent_sms(iap_results, failure_reason=failure_reason, delete_all=delete_all)
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mailing_trace_report_view_pivot" model="ir.ui.view">
<field name="name">mailing.trace.report.view.pivot</field>
<field name="model">mailing.trace.report</field>
<field name="arch" type="xml">
<pivot string="Mass Mailing Statistics" disable_linking="True">
<field name="campaign" type="row"/>
<field name="sent" type="measure"/>
<field name="delivered" type="measure"/>
<field name="opened" type="measure"/>
<field name="bounced" type="measure"/>
<field name="replied" type="measure"/>
<field name="clicked" type="measure"/>
</pivot>
</field>
</record>
<record id="mailing_trace_report_view_graph" model="ir.ui.view">
<field name="name">mailing.trace.report.view.graph</field>
<field name="model">mailing.trace.report</field>
<field name="arch" type="xml">
<graph string="Mass Mailing Statistics">
<field name="campaign"/>
<field name="sent" type="measure"/>
</graph>
</field>
</record>
<record id="mailing_trace_report_view_search" model="ir.ui.view">
<field name="name">mailing.trace.report.view.search</field>
<field name="model">mailing.trace.report</field>
<field name="arch" type="xml">
<search string="Mass Mailing Statistics">
<group expand="0" string="Extended Filters...">
<field name="scheduled_date"/>
</group>
<group expand="1" string="Group By...">
<filter string="Mass Mailing Campaign" name="mass_mailing_campaign"
domain="[]" context="{'group_by':'campaign'}"/>
<filter string="State" domain="[]" name="state"
context="{'group_by':'state'}"/>
<filter string="Sent By" domain="[]" name="sent_by"
context="{'group_by':'email_from'}"/>
<separator/>
<filter string="Scheduled Period" name="scheduled_date"
domain="[]" context="{'group_by':'scheduled_date'}"/>
</group>
</search>
</field>
</record>
<!-- Actions and Menuitems -->
<record id="mailing_trace_report_action_sms" model="ir.actions.act_window">
<field name="name">Mass SMS Analysis</field>
<field name="res_model">mailing.trace.report</field>
<field name="view_mode">graph,pivot</field>
<field name="domain">[('mailing_type', '=', 'sms')]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Mass SMS Statistics allows you to check different mailing related information like number of sent SMS or bounced SMS.
You can sort out your analysis by different groups to get accurate grained analysis.
</p>
</field>
</record>
<!-- Mass SMS / Reporting -->
<record id="mass_mailing_sms_menu_reporting" model="ir.ui.menu">
<field name="action" ref="mailing_trace_report_action_sms"/>
</record>
</odoo>
@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<style type="text/css">
.st0{filter:url(#Adobe_OpacityMaskFilter);}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st2{mask:url(#icon-b_1_);}
.st3{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);}
.st4{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;fill-opacity:0.383;}
.st5{fill-rule:evenodd;clip-rule:evenodd;fill-opacity:0.151;}
.st6{fill-rule:evenodd;clip-rule:evenodd;fill-opacity:0.383;}
.st7{opacity:0.5;}
.st8{fill:#FFFFFF;}
.st9{filter:url(#Adobe_OpacityMaskFilter_1_);}
.st10{mask:url(#icon-b_2_);}
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);}
.st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);}
</style>
<defs>
<filter id="Adobe_OpacityMaskFilter" filterUnits="userSpaceOnUse" x="-0.1" y="-0.2" width="70.1" height="70.2">
<feColorMatrix type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="-0.1" y="-0.2" width="70.1" height="70.2" id="icon-b_2_">
<g class="st0">
<path id="icon-a_2_" class="st1" d="M4,0c32.4,0,54.1,0,61,0c4,0,5,1,5,5c0,38,0,57.4,0,60c0,4-1,5-5,5S9,70,4,70c-3,0-4-1-4-5
c0-2.5,0-23.2,0-60C0,1,1,0,4,0z"/>
</g>
</mask>
<g class="st10">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-636.7537" y1="415.0251" x2="-637.7537" y2="414.0389" gradientTransform="matrix(70 0 0 -70 44643 29052)">
<stop offset="0" style="stop-color:#797C79"/>
<stop offset="1" style="stop-color:#545554"/>
</linearGradient>
<path class="st3" d="M0,0h70v70H0V0z"/>
<path class="st6" d="M4,69h61c2.7,0,4.3-1,5-3v4H0v-4C0.7,68,2,69,4,69z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="-636.7571" y1="415.0286" x2="-637.7571" y2="414.0286" gradientTransform="matrix(70 0 0 -70 44643 29052)">
<stop offset="0" style="stop-color:#94B6C8"/>
<stop offset="1" style="stop-color:#6A9EBA"/>
</linearGradient>
<path class="st11" d="M0,0h70v70H0V0z"/>
<path class="st4" d="M4,1h61c2.7,0,4.3,0.7,5,2c0-2.7,0-3.7,0-3H0c0-0.7,0,0.3,0,3C0.7,1.7,2,1,4,1z"/>
<path class="st6" d="M4,69h61c2.7,0,4.3-1,5-3v4H0v-4C0.7,68,2,69,4,69z"/>
<g transform="translate(0 22)">
<g>
<path id="icon-d_1_" class="st5" d="M4,46.9c-2,0-4-1-4-4.1V7l18.6-18.9h25.7l1.2,8.5l-1.9,1.9l11.1,7.6v8.3l-9.1,9.3l-0.7,11.7
L32.2,46.9H4z"/>
</g>
</g>
<path class="st7" d="M45.4,12.1c0-1.1-0.9-2-2-2H20.1c-1.1,0-2,0.9-2,2v44.6c0,1.1,0.9,2,2,2h23.3c1.1,0,2-0.9,2-2V42.7
c-0.7,0.5-1.5,1-2.3,1.4v9.3c0,0.2-0.1,0.3-0.3,0.3H20.7c-0.2,0-0.3-0.1-0.3-0.3v-37c0-0.2,0.1-0.3,0.3-0.3h22.1
c0.2,0,0.3,0.1,0.3,0.3v2.8c0.5,0,1.1-0.1,1.6-0.1c0.2,0,0.5,0,0.7,0V12.1z M31.8,54.5c0.8,0,1.5,0.7,1.5,1.5
c0,0.8-0.7,1.5-1.5,1.5c-0.8,0-1.5-0.7-1.5-1.5C30.3,55.2,31,54.5,31.8,54.5z M34.9,14.6h-6.1c-0.3,0-0.5-0.2-0.5-0.5
s0.2-0.5,0.5-0.5h6.1c0.3,0,0.5,0.2,0.5,0.5S35.1,14.6,34.9,14.6z"/>
<path class="st8" d="M44.7,41.7c-0.5,0-1.1,0-1.6-0.1v11.1c0,0.2-0.1,0.3-0.3,0.3H20.7c-0.2,0-0.3-0.1-0.3-0.3v-37
c0-0.2,0.1-0.3,0.3-0.3h22.1c0.2,0,0.3,0.1,0.3,0.3v2.9c0.5,0,1.1-0.1,1.6-0.1c0.2,0,0.5,0,0.7,0v-7.2c0-1.1-0.9-2-2-2H20.1
c-1.1,0-2,0.9-2,2V56c0,1.1,0.9,2,2,2h23.3c1.1,0,2-0.9,2-2V41.7C45.2,41.7,44.9,41.7,44.7,41.7z M28.7,13h6.1
c0.3,0,0.5,0.2,0.5,0.5S35.1,14,34.9,14h-6.1c-0.3,0-0.5-0.2-0.5-0.5S28.4,13,28.7,13z M31.8,56.9c-0.8,0-1.5-0.7-1.5-1.5
c0-0.8,0.7-1.5,1.5-1.5c0.8,0,1.5,0.7,1.5,1.5C33.3,56.3,32.6,56.9,31.8,56.9z"/>
</g>
<path id="icon-e" class="st7" d="M44.7,40.6c-2.2,0-4.3-0.4-6.1-1.1c-1.8,1.5-4.1,2.3-6.5,2.6c0,0,0,0-0.1,0c-0.2,0-0.4-0.1-0.5-0.4
c-0.1-0.2,0.1-0.4,0.3-0.5c0.9-0.9,1.9-1.5,2.4-4.4c-1.7-1.6-2.7-3.6-2.7-5.8c0-5.3,5.9-9.7,13.1-9.7s13.1,4.3,13.1,9.7
C57.8,36.3,51.9,40.6,44.7,40.6z"/>
<path class="st8" d="M44.7,39.7c-2.2,0-4.3-0.4-6.1-1.1c-1.8,1.5-4.1,2.3-6.5,2.6c0,0,0,0-0.1,0c-0.2,0-0.4-0.1-0.5-0.4
c-0.1-0.2,0.1-0.4,0.3-0.5c0.9-0.9,1.9-1.5,2.4-4.4c-1.7-1.6-2.7-3.6-2.7-5.8c0-5.3,5.9-9.7,13.1-9.7s13.1,4.3,13.1,9.7
C57.8,35.4,51.9,39.7,44.7,39.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
+73
View File
@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
from contextlib import contextmanager
from unittest.mock import patch
from odoo import exceptions, tools
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.sms.tests import common as sms_common
from odoo.tests import common
from odoo.addons.sms.models.sms_api import SmsApi
class MockSMS(sms_common.MockSMS):
def assertSMSOutgoingStatistics(self, partners, numbers, records, mailing):
found_sms = self.env['sms.sms'].sudo().search([
'|', ('partner_id', 'in', partners.ids), ('number', 'in', numbers),
('state', '=', 'outgoing')
])
self.assertEqual(found_sms.filtered(lambda s: s.partner_id).mapped('partner_id'), partners)
self.assertEqual(set(found_sms.filtered(lambda s: not s.partner_id).mapped('number')), set(numbers))
found_traces = self.env['mailing.trace'].sudo().search([
('sms_sms_id_int', 'in', found_sms.ids),
])
self.assertEqual(len(found_sms), len(found_traces))
self.assertTrue(all(s.state == 'outgoing' for s in found_traces))
self.assertTrue(all(s.res_model == records._name for s in found_traces))
self.assertEqual(set(found_traces.mapped('res_id')), set(records.ids))
self.assertTrue(all(s.mass_mailing_id == mailing for s in found_traces))
def assertSMSStatistics(self, recipients_info, mailing, records, check_sms=True):
""" Check content of notifications.
:param recipients_info: list[{
'partner': res.partner record (may be empty),
'number': number used for notification (may be empty, computed based on partner),
'state': outgoing / sent / ignored / exception / opened (sent by default),
'record: linked record,
'failure_type': optional: sms_number_missing / sms_number_format / sms_credit / sms_server
}, { ... }]
"""
traces = self.env['mailing.trace'].search([
('mass_mailing_id', 'in', mailing.ids),
('res_id', 'in', records.ids)
])
self.assertTrue(all(s.model == records._name for s in traces))
# self.assertTrue(all(s.utm_campaign_id == mailing.campaign_id for s in traces))
self.assertEqual(set(s.res_id for s in traces), set(records.ids))
for recipient_info in recipients_info:
partner = recipient_info.get('partner', self.env['res.partner'])
number = recipient_info.get('number')
state = recipient_info.get('state', 'outgoing')
content = recipient_info.get('content', None)
if number is None and partner:
number = phone_validation.phone_get_sanitized_record_number(partner)
notif = traces.filtered(lambda s: s.sms_number == number and s.state == state)
self.assertTrue(notif, 'SMS: not found notification for number %s, (state: %s)' % (number, state))
if check_sms:
if state == 'sent':
self.assertSMSSent([number], content)
elif state == 'outgoing':
self.assertSMSOutgoing(partner, number, content)
elif state == 'exception':
self.assertSMSFailed(partner, number, recipient_info.get('failure_type'), content)
elif state == 'ignored':
self.assertSMSCanceled(partner, number, recipient_info.get('failure_type', False), content)
else:
raise NotImplementedError()
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="link_tracker_menu"
name="Link Tracker"
parent="mass_mailing_sms_menu_configuration"
sequence="2"
action="link_tracker.link_tracker_action"/>
</odoo>
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mailing_contact_view_search" model="ir.ui.view">
<field name="name">mailing.contact.view.search.inherit.sms</field>
<field name="model">mailing.contact</field>
<field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_contact_search"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="phone_sanitized"/>
</xpath>
<xpath expr="//filter[@name='filter_not_optout']" position="after">
<separator/>
<filter string="Valid SMS Recipients"
name="filter_valid_sms_recipient"
domain="[('opt_out', '=', False), ('phone_blacklisted', '=', False), ('phone_sanitized', '!=', False)]"
invisible="not context.get('default_list_ids')"/>
<separator/>
<filter string="Exclude Blacklisted Phone"
name="filter_not_phone_bl"
domain="[('phone_blacklisted', '=', False)]"/>
</xpath>
</field>
</record>
<record id="mailing_contact_view_tree_sms" model="ir.ui.view">
<field name="name">mailing.contact.view.tree.sms</field>
<field name="model">mailing.contact</field>
<field name="priority">20</field>
<field name="arch" type="xml">
<tree string="Contacts">
<field name="create_date"/>
<field name="name"/>
<field name="company_name"/>
<field name="phone_sanitized"/>
<field name="phone_blacklisted"/>
<field name="opt_out" invisible="'default_list_ids' not in context"/>
</tree>
</field>
</record>
<record id="mailing_contact_view_form" model="ir.ui.view">
<field name="name">mailing.contact.view.form.inherit.sms</field>
<field name="model">mailing.contact</field>
<field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_contact_form"/>
<field name="arch" type="xml">
<xpath expr="//label[@for='email']" position="attributes">
<attribute name="invisible">context.get('mailing_sms') == True</attribute>
</xpath>
<xpath expr="//div[@name='email_details']" position="attributes">
<attribute name="invisible">context.get('mailing_sms') == True</attribute>
</xpath>
<xpath expr="//div[@name='email_details']" position="after">
<label for="phone_sanitized" class="oe_inline"/>
<div class="o_row o_row_readonly" name="phone_details">
<i class="fa fa-ban" style="color: red;" role="img" title="This number is blacklisted for mass SMS"
aria-label="Phone Blacklisted" attrs="{'invisible': [('phone_blacklisted', '=', False)]}" groups="base.group_user"></i>
<field name="phone_sanitized" widget="phone"/>
<field name="phone_blacklisted" invisible="1"/>
</div>
</xpath>
</field>
</record>
<record id="mailing_contact_view_kanban" model="ir.ui.view">
<field name="name">mailing.contact.view.kanban.inherit.sms</field>
<field name="model">mailing.contact</field>
<field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_contact_kanban"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='email']" position="after">
<field name="phone_sanitized"/>
</xpath>
<xpath expr="//t[@t-esc='record.email.value']" position="after">
<t t-esc="record.phone_sanitized.value"/>
</xpath>
</field>
</record>
<record id="mailing_contact_action_sms" model="ir.actions.act_window">
<field name="name">Contacts</field>
<field name="res_model">mailing.contact</field>
<field name="view_mode">tree,form</field>
<field name="context">{'mailing_sms': True, 'search_default_filter_not_phone_bl': 1, }</field>
<field name="view_id" ref="mailing_contact_view_tree_sms"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new contact
</p>
</field>
</record>
<!-- Mass SMS / Contacts Lists / Contacts Lists -->
<record id="mass_mailing_sms.mailing_contact_menu_sms" model="ir.ui.menu">
<field name="action" ref="mailing_contact_action_sms"/>
</record>
</odoo>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mailing_list_action_sms" model="ir.actions.act_window">
<field name="name">Contact Lists</field>
<field name="res_model">mailing.list</field>
<field name="view_mode">kanban,tree,form</field>
<field name="context">{'mailing_sms': True}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new contacts list
</p><p>
You don't need to import your contacts lists, you can easily
send SMS to any contact saved in other Odoo apps.
</p>
</field>
</record>
<!-- Mass SMS / Contacts Lists / Contacts Lists -->
<record id="mass_mailing_sms.mailing_list_menu_sms" model="ir.ui.menu">
<field name="action" ref="mailing_list_action_sms"/>
</record>
</odoo>
@@ -0,0 +1,183 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<record id="mailing_mailing_view_form" model="ir.ui.view">
<field name="name">mailing.mailing.view.form.inherit.sms</field>
<field name="model">mailing.mailing</field>
<field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_form"/>
<field name="arch" type="xml">
<!-- Buttons / Actions -->
<xpath expr="//button[@name='action_put_in_queue']" position="attributes">
<attribute name="attrs">{'invisible': ['|', ('state', 'in', ('in_queue', 'done')), ('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//button[@name='action_put_in_queue']" position="after">
<button name="action_put_in_queue" type="object"
class="oe_highlight" string="Send Now"
attrs="{'invisible': ['|', ('mailing_type', '=', 'mail'), ('state', 'in', ('in_queue', 'done'))]}"
confirm="This will send SMS to all recipients. Do you still want to proceed ?"/>
</xpath>
<!-- Headers / Warnings -->
<xpath expr="//header" position="after">
<field name="sms_has_insufficient_credit" invisible="1"/>
<div class="alert alert-warning text-center" attrs="{'invisible': [('sms_has_insufficient_credit', '=', False)]}" role="alert">
<p>
<button class="btn-link"
name="action_buy_sms_credits"
type="object">
<strong>
It appears you don't have enough IAP credits. Click here to buy credits.
</strong>
</button>
</p>
</div>
</xpath>
<xpath expr="//span[@name='ignored_text']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//span[@name='ignored_text']" position="after">
<span name="ignored_text_sms" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}">SMS Text Message have been ignored and will not be sent.</span>
</xpath>
<xpath expr="//span[@name='scheduled_text']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//span[@name='scheduled_text']" position="after">
<span name="scheduled_text_sms" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}">SMS Text Message are in queue and will be sent soon.</span>
</xpath>
<xpath expr="//span[@name='failed_text']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//span[@name='failed_text']" position="after">
<span name="failed_text_sms" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}">SMS Text Message could not be sent.</span>
</xpath>
<!-- Stat Buttons -->
<xpath expr="//button[@id='button_view_sent']" position="attributes">
<attribute name="attrs">{'invisible': ['|', '&amp;', ('sent', '=', 0), ('state', 'in', ('draft', 'test')), ('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//button[@id='button_view_sent']" position="after">
<button name="action_view_sent"
type="object"
context="{'search_default_filter_sent': True}"
icon="fa-comment-o" class="oe_stat_button"
attrs="{'invisible': ['|', '&amp;', ('sent', '=', 0), ('state', 'in', ('draft', 'test')), ('mailing_type', '!=', 'sms')]}" >
<field name="sent" string="Sent" widget="statinfo"/>
</button>
</xpath>
<xpath expr="//button[@name='action_view_opened']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//button[@name='action_view_replied']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<!-- Form -->
<xpath expr="//field[@name='subject']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//page[@name='mail_body']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//page[@name='mail_body']" position="after">
<page string="SMS Content" name="sms_body" attrs="{'invisible': [('mailing_type', '!=', 'sms')]}">
<field name="body_plaintext" widget="sms_widget"/>
<group>
<field name="sms_template_id" domain="[('model', '=', mailing_model_real)]"/>
<field name="sms_allow_unsubscribe"/>
</group>
</page>
</xpath>
<!-- Option page tweaks -->
<xpath expr="//field[@name='email_from']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//label[@for='reply_to']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//div[@name='reply_to_details']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//label[@for='attachment_ids']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//div[@name='attachment_ids_details']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//field[@name='mail_server_id']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
</field>
</record>
<record id="mailing_mailing_view_kanban" model="ir.ui.view">
<field name="name">mailing.mailing.view.kanban.inherit.sms</field>
<field name="model">mailing.mailing</field>
<field name="inherit_id" ref="mass_mailing.view_mail_mass_mailing_kanban"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='active']" position="after">
<field name="sms_has_insufficient_credit"/>
</xpath>
<xpath expr="//h3" position="after">
<div class="alert alert-warning" role="alert" attrs="{'invisible': [('sms_has_insufficient_credit', '=', False)]}">
<a name="action_buy_sms_credits"
type="object">Insufficient credits</a>
</div>
</xpath>
<xpath expr="//div[@name='stat_opened']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//div[@name='stat_replied']" position="attributes">
<attribute name="attrs">{'invisible': [('mailing_type', '!=', 'mail')]}</attribute>
</xpath>
</field>
</record>
<record id="mailing_mailing_view_tree_sms" model="ir.ui.view">
<field name="name">mailing.mailing.view.tree.sms</field>
<field name="model">mailing.mailing</field>
<field name="priority">20</field>
<field name="arch" type="xml">
<tree string="Mass SMS">
<field name="name"/>
<field name="mailing_type" invisible="1"/>
<field name="sent"/>
<field name="clicked"/>
<field name="bounced"/>
<field name="campaign_id" groups="mass_mailing.group_mass_mailing_campaign"/>
</tree>
</field>
</record>
<record id="mailing_mailing_action_sms" model="ir.actions.act_window">
<field name="name">Mass SMS</field>
<field name="res_model">mailing.mailing</field>
<field name="view_mode">kanban,tree,form,graph</field>
<field name="domain">[('mailing_type', '=', 'sms')]</field>
<field name="context">{
'search_default_assigned_to_me': 1,
'default_user_id': uid,
'default_mailing_type': 'sms',
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new mass SMS
</p><p>
You can easily send SMS to any contact saved in other Odoo apps.
</p>
</field>
</record>
<record id="mailing_mailing_action_sms_view_kanban" model="ir.actions.act_window.view">
<field name="sequence">1</field>
<field name="view_mode">kanban</field>
<field name="view_id" ref="mass_mailing.view_mail_mass_mailing_kanban"/>
<field name="act_window_id" ref="mailing_mailing_action_sms"/>
</record>
<record id="mailing_mailing_action_sms_view_tree" model="ir.actions.act_window.view">
<field name="sequence">2</field>
<field name="view_mode">tree</field>
<field name="view_id" ref="mailing_mailing_view_tree_sms"/>
<field name="act_window_id" ref="mailing_mailing_action_sms"/>
</record>
<!-- Mass SMS / Mass SMS -->
<record id="mass_mailing_sms_menu_mass_sms" model="ir.ui.menu">
<field name="action" ref="mailing_mailing_action_sms"/>
</record>
</data></odoo>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Mass SMS -->
<menuitem id="mass_mailing_sms_menu_root"
name="Mass SMS"
sequence="60"
web_icon="mass_mailing_sms,static/description/icon.png"
groups="mass_mailing.group_mass_mailing_user"/>
<!-- Mass SMS / Mass SMS -->
<menuitem id="mass_mailing_sms_menu_mass_sms"
name="Mass SMS"
parent="mass_mailing_sms_menu_root"
sequence="1"
groups="mass_mailing.group_mass_mailing_user"/>
<!-- Mass SMS / Contacts Lists -->
<menuitem id="mass_mailing_sms_menu_contacts"
name="Contacts Lists"
parent="mass_mailing_sms_menu_root"
sequence="2"
groups="mass_mailing.group_mass_mailing_user"/>
<!-- Mass SMS / Contacts Lists / Contacts Lists -->
<menuitem id="mailing_list_menu_sms"
name="Contacts Lists"
parent="mass_mailing_sms_menu_contacts"
sequence="1"
groups="mass_mailing.group_mass_mailing_user"/>
<!-- Mass SMS / Contacts Lists / Contacts -->
<menuitem id="mailing_contact_menu_sms"
name="Contacts"
parent="mass_mailing_sms_menu_contacts"
sequence="2"
groups="mass_mailing.group_mass_mailing_user"/>
<!-- Mass SMS / Reporting -->
<menuitem id="mass_mailing_sms_menu_reporting"
name="Reporting"
parent="mass_mailing_sms_menu_root"
sequence="80"
groups="mass_mailing.group_mass_mailing_user"/>
<!-- Mass SMS / Configuration -->
<menuitem id="mass_mailing_sms_menu_configuration"
name="Configuration"
parent="mass_mailing_sms_menu_root"
sequence="100"
groups="mass_mailing.group_mass_mailing_user"/>
<!-- Mass SMS / Configuration / Blacklist -->
<menuitem id="phone_blacklist_menu"
name="Phone Blacklist"
parent="mass_mailing_sms_menu_configuration"
sequence="1"
action="phone_validation.phone_blacklist_action"
groups="mass_mailing.group_mass_mailing_user"/>
<!-- Mass SMS / Configuration / Link Tracker -->
<menuitem id="link_tracker_menu"
name="Link Tracker Blacklist"
parent="mass_mailing_sms_menu_configuration"
sequence="2"
action="link_tracker.link_tracker_action"
groups="mass_mailing.group_mass_mailing_user"/>
</odoo>
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mailing_trace_view_search" model="ir.ui.view">
<field name="name">mailing.trace.view.search.inherit.sms</field>
<field name="model">mailing.trace</field>
<field name="inherit_id" ref="mass_mailing.mailing_trace_view_search"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='email']" position="after">
<field name="sms_sms_id_int"/>
<field name="sms_sms_id"/>
<field name="sms_number"/>
</xpath>
</field>
</record>
<record id="mailing_trace_view_tree" model="ir.ui.view">
<field name="name">mailing.trace.view.tree.inherit.sms</field>
<field name="model">mailing.trace</field>
<field name="inherit_id" ref="mass_mailing.mailing_trace_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='email']" position="before">
<field name="trace_type"/>
</xpath>
<xpath expr="//field[@name='email']" position="after">
<field name="sms_number"/>
</xpath>
</field>
</record>
<record id="mailing_trace_view_tree_sms" model="ir.ui.view">
<field name="name">mailing.trace.view.tree.sms</field>
<field name="model">mailing.trace</field>
<field name="priority">20</field>
<field name="arch" type="xml">
<tree string="SMS Traces" create="0">
<field name="sms_number"/>
<field name="state"/>
<field name="scheduled"/>
<field name="sent"/>
<field name="clicked"/>
<field name="bounced"/>
<field name="exception"/>
<field name="ignored"/>
<field name="failure_type"/>
</tree>
</field>
</record>
<record id="mailing_trace_view_form" model="ir.ui.view">
<field name="name">mailing.trace.view.form.inherit.sms</field>
<field name="model">mailing.trace</field>
<field name="inherit_id" ref="mass_mailing.mailing_trace_view_form"/>
<field name="arch" type="xml">
<xpath expr="//span[@name='trace_type_name_mail']" position="attributes">
<attribute name="attrs">{'invisible': [('trace_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//span[@name='trace_type_name_mail']" position="after">
<span name="trace_type_name_sms" attrs="{'invisible': [('trace_type', '!=', 'sms')]}">This sms</span>
</xpath>
<xpath expr="//field[@name='email']" position="attributes">
<attribute name="attrs">{'invisible': [('trace_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//field[@name='mail_mail_id_int']" position="attributes">
<attribute name="attrs">{'invisible': [('trace_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//field[@name='message_id']" position="attributes">
<attribute name="attrs">{'invisible': [('trace_type', '!=', 'mail')]}</attribute>
</xpath>
<xpath expr="//field[@name='opened']" position="attributes">
<attribute name="attrs">{'invisible': [('trace_type', '=', 'sms')]}</attribute>
</xpath>
<xpath expr="//field[@name='replied']" position="attributes">
<attribute name="attrs">{'invisible': [('trace_type', '=', 'sms')]}</attribute>
</xpath>
<xpath expr="//field[@name='email']" position="after">
<field name="sms_number" attrs="{'invisible': [('trace_type', '!=', 'sms')]}"/>
<field name="sms_sms_id_int" attrs="{'invisible': [('trace_type', '!=', 'sms')]}"/>
<field name="sms_code" attrs="{'invisible': [('trace_type', '!=', 'sms')]}"/>
</xpath>
</field>
</record>
<record id="mailing_trace_view_form_sms" model="ir.ui.view">
<field name="name">mailing.trace.view.form.sms</field>
<field name="model">mailing.trace</field>
<field name="priority">20</field>
<field name="arch" type="xml">
<form string="SMS Trace" create="0">
<header>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="alert alert-info text-center" attrs="{'invisible': [('exception', '=', False)]}" role="alert">
<strong>This SMS could not be sent.</strong>
</div>
<div class="alert alert-info text-center" attrs="{'invisible': [('bounced', '=', False)]}" role="alert">
<strong>This number appears to be invalid.</strong>
</div>
<group>
<group>
<field name="sms_number"/>
<field name="mass_mailing_id"/>
<field name="campaign_id" groups="mass_mailing.group_mass_mailing_campaign"/>
<field name="sms_sms_id_int" groups="base.group_no_one"/>
<field name="model" groups="base.group_no_one"/>
<field name="res_id" groups="base.group_no_one"/>
</group>
<group>
<field name="scheduled"/>
<field name="sent"/>
<field name="clicked"/>
<field name="bounced"/>
<field name="exception"/>
<field name="ignored"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
</odoo>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="blacklist_main" name="Unsubscribed">
<t t-call="portal.frontend_layout">
<div class="container mb64">
<div class="row">
<div class="col-lg-6 offset-lg-3">
<h3>SMS Subscription</h3>
<form t-att-action="'/sms/%s/unsubscribe/%s' % (mailing_id, trace_code)" method="post">
<p>Please enter your phone number</p>
<div class="form-group row">
<label for="sms_number" class="col-sm-2 col-form-label">Number</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="sms_number" id="sms_number"/>
</div>
</div>
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="trace_code" t-att-value="trace_code"/>
<input type="hidden" name="mailing_id" t-att-value="mailing_id"/>
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<button type="submit" class="btn btn-primary">Unsubscribe me</button>
</div>
</div>
</form>
</div>
</div>
</div>
</t>
</template>
<template id="blacklist_number" name="Unsubscribed">
<t t-call="portal.frontend_layout">
<div class="container mb64">
<div class="row">
<div class="col-lg-6 offset-lg-3">
<h3>SMS Subscription</h3>
<div t-if="unsubscribe_error" class="alert alert-danger text-center" role="alert">
<p>There was an error when trying to unsubscribe <strong t-esc="sms_number"/></p>
<p t-esc="unsubscribe_error"/>
</div>
<div t-else="" class="alert alert-success text-center" role="status">
<t t-if="lists_optout">
<p><strong t-esc="sms_number"/> has been successfully removed from</p>
<t t-foreach="lists_optout" t-as="list_id">
<strong t-esc="list_id.name"/><br />
</t>
</t>
<p t-else="">
<strong t-esc="sms_number"/> has been successfully blacklisted
</p>
</div>
</div>
</div>
</div>
</t>
</template>
</odoo>
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mailing_sms_test
from . import sms_composer
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, exceptions, fields, models, _
from odoo.addons.phone_validation.tools import phone_validation
class MassSMSTest(models.TransientModel):
_name = 'mailing.sms.test'
_description = 'Test SMS Mailing'
def _default_numbers(self):
return self.env.user.partner_id.phone_sanitized or ""
numbers = fields.Char(string='Number', required=True,
default=_default_numbers, help='Comma-separated list of phone numbers')
sanitized_numbers = fields.Char(compute='_compute_sanitized_numbers')
mailing_id = fields.Many2one('mailing.mailing', string='Mailing', required=True, ondelete='cascade')
@api.depends('numbers')
def _compute_sanitized_numbers(self):
if self.numbers:
numbers = [number.strip() for number in self.numbers.split(',')]
sanitize_res = phone_validation.phone_sanitize_numbers_w_record(numbers, self.env.user)
sanitized_numbers = [info['sanitized'] for info in sanitize_res.values() if info['sanitized']]
invalid_numbers = [number for number, info in sanitize_res.items() if info['code']]
if invalid_numbers:
raise exceptions.UserError(_('Following numbers are not correctly encoded: %s') % repr(invalid_numbers))
self.sanitized_numbers = ','.join(sanitized_numbers)
else:
self.sanitized_numbers = False
def action_send_sms(self):
self.ensure_one()
self.env['sms.api']._send_sms_batch([{
'res_id': 0,
'number': number,
'content': self.mailing_id.body_plaintext,
} for number in self.sanitized_numbers.split(',')])
return True
@@ -0,0 +1,30 @@
<?xml version="1.0"?>
<odoo>
<record id="mailing_sms_test_view_form" model="ir.ui.view">
<field name="name">mailing.sms.test.view.form</field>
<field name="model">mailing.sms.test</field>
<field name="arch" type="xml">
<form string="Send a Sample SMS">
<p class="text-muted">
Send a sample SMS for testing purpose to the numbers below.
</p>
<group>
<field name="numbers"/>
<field name="sanitized_numbers" invisible="0"/>
<field name="mailing_id" invisible="1"/>
</group>
<footer>
<button string="Send" name="action_send_sms" type="object" class="btn-primary"/>
<button string="Cancel" class="btn btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="mailing_sms_test_action" model="ir.actions.act_window">
<field name="name">Test SMS Mailing</field>
<field name="res_model">mailing.sms.test</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import werkzeug.urls
from odoo import fields, models
class SMSComposer(models.TransientModel):
_inherit = 'sms.composer'
# mass mode with mass sms
mass_sms_allow_unsubscribe = fields.Boolean('Include opt-out link', default=True)
mailing_id = fields.Many2one('mailing.mailing', string='Mailing')
utm_campaign_id = fields.Many2one('utm.campaign', string='Campaign')
# ------------------------------------------------------------
# Mass mode specific
# ------------------------------------------------------------
def _get_unsubscribe_url(self, res_id, trace_code, number):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
return werkzeug.urls.url_join(
base_url,
'/sms/%s/%s' % (self.mailing_id.id, trace_code)
)
def _prepare_mass_sms_trace_values(self, record, sms_values):
trace_code = self.env['mailing.trace']._get_random_code()
trace_values = {
'model': self.res_model,
'res_id': record.id,
'trace_type': 'sms',
'mass_mailing_id': self.mailing_id.id,
'sms_number': sms_values['number'],
'sms_code': trace_code,
}
if sms_values['state'] == 'error':
if sms_values['error_code'] == 'sms_number_format':
trace_values['sent'] = fields.Datetime.now()
trace_values['bounced'] = fields.Datetime.now()
else:
trace_values['exception'] = fields.Datetime.now()
elif sms_values['state'] == 'canceled':
trace_values['ignored'] = fields.Datetime.now()
else:
if self.mass_sms_allow_unsubscribe:
sms_values['body'] = '%s %s' % ((sms_values['body'] or ''), self._get_unsubscribe_url(record.id, trace_code, sms_values['number']))
return trace_values
def _get_blacklist_record_ids(self, records, recipients_info):
""" Consider opt-outed contact as being blacklisted for that specific
mailing. """
res = super(SMSComposer, self)._get_blacklist_record_ids(records, recipients_info)
if self.mailing_id:
optout_res_ids = self.mailing_id._get_opt_out_list_sms()
res += optout_res_ids
return res
def _get_done_record_ids(self, records, recipients_info):
""" A/B testing could lead to records having been already mailed. """
res = super(SMSComposer, self)._get_done_record_ids(records, recipients_info)
if self.mailing_id:
seen_ids, seen_list = self.mailing_id._get_seen_list_sms()
res += seen_ids
return res
def _prepare_body_values(self, records):
all_bodies = super(SMSComposer, self)._prepare_body_values(records)
if self.mailing_id:
tracker_values = self.mailing_id._get_link_tracker_values()
for sms_id, body in all_bodies.items():
body = self.env['link.tracker'].sudo()._convert_links_text(body, tracker_values)
all_bodies[sms_id] = body
return all_bodies
def _prepare_mass_sms_values(self, records=None):
result = super(SMSComposer, self)._prepare_mass_sms_values(records=records)
if self.composition_mode == 'mass' and self.mailing_id:
records = records if records is not None else self._get_records()
# for record_id, sms_values in result.items():
for record in records:
sms_values = result[record.id]
trace_values = self._prepare_mass_sms_trace_values(record, sms_values)
sms_values.update({
'mailing_id': self.mailing_id.id,
'mailing_trace_ids': [(0, 0, trace_values)],
})
return result
def _action_send_sms_mass(self, records=None):
sms_all = super(SMSComposer, self)._action_send_sms_mass()
if self.mailing_id:
updated_bodies = sms_all._update_body_short_links()
for sms in sms_all:
sms.body = updated_bodies[sms.id]
return sms_all
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sms_composer_view_form" model="ir.ui.view">
<field name="name">sms.composer.views.inherit.sms</field>
<field name="model">sms.composer</field>
<field name="inherit_id" ref="sms.sms_composer_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='res_model']" position="after">
<field name="utm_campaign_id" groups="mass_mailing.group_mass_mailing_campaign"
invisible="1"/>
<field name="mailing_id" invisible="1"/>
</xpath>
</field>
</record>
</odoo>
+18 -5
View File
@@ -3,6 +3,7 @@
{
'name': 'Phone Numbers Validation',
'version': '2.0',
'summary': 'Validate and format phone numbers',
'sequence': '9999',
'category': 'Hidden',
@@ -11,12 +12,24 @@ Phone Numbers Validation
========================
This module adds the feature of validation and formatting phone numbers
according to a destination country. It also handles national and international
formatting.
according to a destination country.
This module applies this feature to Leads and Contacts.""",
It also adds phone blacklist management through a specific model storing
blacklisted phone numbers.
It adds two mixins :
* phone.validation.mixin: parsing / formatting helpers on records, to be
used for example in number fields onchange;
* mail.thread.phone: handle sanitation and blacklist of records numbers;
""",
'data': [
'views/res_company_view.xml'
'security/ir.model.access.csv',
'views/res_company_view.xml',
'views/phone_blacklist_views.xml',
],
'depends': [
'base',
'mail',
],
'depends': ['base'],
}
+3 -1
View File
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import res_company
from . import phone_blacklist
from . import phone_validation_mixin
from . import mail_thread_phone
from . import res_company
@@ -0,0 +1,103 @@
# -*- 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 PhoneMixin(models.AbstractModel):
""" Purpose of this mixin is to offer two services
* compute a sanitized phone number based on ´´_sms_get_number_fields´´.
It takes first sanitized value, trying each field returned by the
method (see ``MailThread._sms_get_number_fields()´´ for more details
about the usage of this method);
* compute blacklist state of records. It is based on phone.blacklist
model and give an easy-to-use field and API to manipulate blacklisted
records;
Main API methods
* ``_phone_set_blacklisted``: set recordset as blacklisted;
* ``_phone_reset_blacklisted``: reactivate recordset (even if not blacklisted
this method can be called safely);
"""
_name = 'mail.thread.phone'
_description = 'Phone Blacklist Mixin'
_inherit = ['mail.thread', 'phone.validation.mixin']
phone_sanitized = fields.Char(
string='Sanitized Number', compute="_compute_phone_sanitized", compute_sudo=True, store=True,
help="Field used to store sanitized phone number. Helps speeding up searches and comparisons.")
phone_blacklisted = fields.Boolean(
string='Phone Blacklisted', compute="_compute_phone_blacklisted", compute_sudo=True, store=False,
search="_search_phone_blacklisted", groups="base.group_user",
help="If the email address is on the blacklist, the contact won't receive mass mailing anymore, from any list")
@api.depends(lambda self: self._phone_get_number_fields())
def _compute_phone_sanitized(self):
self._assert_phone_field()
number_fields = self._phone_get_number_fields()
for record in self:
for fname in number_fields:
sanitized = record.phone_get_sanitized_number(number_fname=fname)
if sanitized:
break
record.phone_sanitized = sanitized
@api.depends('phone_sanitized')
def _compute_phone_blacklisted(self):
# TODO : Should remove the sudo as compute_sudo defined on methods.
# But if user doesn't have access to mail.blacklist, doen't work without sudo().
blacklist = set(self.env['phone.blacklist'].sudo().search([
('number', 'in', self.mapped('phone_sanitized'))]).mapped('number'))
for record in self:
record.phone_blacklisted = record.phone_sanitized in blacklist
@api.model
def _search_phone_blacklisted(self, operator, value):
# Assumes operator is '=' or '!=' and value is True or False
self._assert_phone_field()
if operator != '=':
if operator == '!=' and isinstance(value, bool):
value = not value
else:
raise NotImplementedError()
if value:
query = """
SELECT m.id
FROM phone_blacklist bl
JOIN %s m
ON m.phone_sanitized = bl.number AND bl.active
"""
else:
query = """
SELECT m.id
FROM %s m
LEFT JOIN phone_blacklist bl
ON m.phone_sanitized = bl.number AND bl.active
WHERE bl.id IS NULL
"""
self._cr.execute(query % self._table)
res = self._cr.fetchall()
if not res:
return [(0, '=', 1)]
return [('id', 'in', [r[0] for r in res])]
def _assert_phone_field(self):
if not hasattr(self, "_phone_get_number_fields"):
raise UserError(_('Invalid primary phone field on model %s') % self._name)
if not any(fname in self and self._fields[fname].type == 'char' for fname in self._phone_get_number_fields()):
raise UserError(_('Invalid primary phone field on model %s') % self._name)
def _phone_get_number_fields(self):
""" This method returns the fields to use to find the number to use to
send an SMS on a record. """
return []
def _phone_set_blacklisted(self):
return self.env['phone.blacklist'].sudo()._add([r.phone_sanitized for r in self])
def _phone_reset_blacklisted(self):
return self.env['phone.blacklist'].sudo()._remove([r.phone_sanitized for r in self])
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models, _
from odoo.addons.phone_validation.tools import phone_validation
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class PhoneBlackList(models.Model):
""" Blacklist of phone numbers. Used to avoid sending unwanted messages to people. """
_name = 'phone.blacklist'
_inherit = ['mail.thread']
_description = 'Phone Blacklist'
_rec_name = 'number'
number = fields.Char(string='Phone Number', required=True, index=True, tracking=True, help='Number should be E164 formatted')
active = fields.Boolean(default=True, tracking=True)
_sql_constraints = [
('unique_number', 'unique (number)', 'Number already exists')
]
@api.model_create_multi
def create(self, values):
# First of all, extract values to ensure emails are really unique (and don't modify values in place)
to_create = []
done = set()
for value in values:
number = value['number']
sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized']
if not sanitized:
raise UserError(_('Invalid number %s') % number)
if sanitized in done:
continue
done.add(sanitized)
to_create.append(dict(value, number=sanitized))
""" To avoid crash during import due to unique email, return the existing records if any """
sql = '''SELECT number, id FROM phone_blacklist WHERE number = ANY(%s)'''
numbers = [v['number'] for v in to_create]
self._cr.execute(sql, (numbers,))
bl_entries = dict(self._cr.fetchall())
to_create = [v for v in to_create if v['number'] not in bl_entries]
results = super(PhoneBlackList, self).create(to_create)
return self.env['phone.blacklist'].browse(bl_entries.values()) | results
def write(self, values):
if 'number' in values:
number = values['number']
sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized']
if not sanitized:
raise UserError(_('Invalid number %s') % number)
values['number'] = sanitized
return super(PhoneBlackList, self).write(values)
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
""" Override _search in order to grep search on sanitized number field """
if args:
new_args = []
for arg in args:
if isinstance(arg, (list, tuple)) and arg[0] == 'number' and isinstance(arg[2], str):
number = arg[2]
sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized']
if sanitized:
new_args.append([arg[0], arg[1], sanitized])
else:
new_args.append(arg)
else:
new_args.append(arg)
else:
new_args = args
return super(PhoneBlackList, self)._search(new_args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
def add(self, number):
sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized']
return self._add([sanitized])
def _add(self, numbers):
""" Add or re activate a phone blacklist entry.
:param numbers: list of sanitized numbers """
records = self.env["phone.blacklist"].with_context(active_test=False).search([('number', 'in', numbers)])
todo = [n for n in numbers if n not in records.mapped('number')]
if records:
records.write({'active': True})
if todo:
records += self.create([{'number': n} for n in todo])
return records
def remove(self, number):
sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized']
return self._remove([sanitized])
def _remove(self, numbers):
""" Add de-activated or de-activate a phone blacklist entry.
:param numbers: list of sanitized numbers """
records = self.env["phone.blacklist"].with_context(active_test=False).search([('number', 'in', numbers)])
todo = [n for n in numbers if n not in records.mapped('number')]
if records:
records.write({'active': False})
if todo:
records += self.create([{'number': n, 'active': False} for n in todo])
return records
@@ -9,6 +9,11 @@ class PhoneValidationMixin(models.AbstractModel):
_name = 'phone.validation.mixin'
_description = 'Phone Validation Mixin'
def _phone_get_country_field(self):
if 'country_id' in self:
return 'country_id'
return False
def _phone_get_country(self):
if 'country_id' in self and self.country_id:
return self.country_id
@@ -31,3 +36,17 @@ class PhoneValidationMixin(models.AbstractModel):
force_format='INTERNATIONAL' if always_international else 'NATIONAL',
raise_exception=False
)
def phone_get_sanitized_numbers(self, number_fname='mobile', force_format='E164'):
res = dict.fromkeys(self.ids, False)
country_fname = self._phone_get_country_field()
for record in self:
number = record[number_fname]
res[record.id] = phone_validation.phone_sanitize_numbers_w_record([number], record, record_country_fname=country_fname, force_format=force_format)[number]['sanitized']
return res
def phone_get_sanitized_number(self, number_fname='mobile', force_format='E164'):
self.ensure_one()
country_fname = self._phone_get_country_field()
number = self[number_fname]
return phone_validation.phone_sanitize_numbers_w_record([number], self, record_country_fname=country_fname, force_format=force_format)[number]['sanitized']
@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_phone_blacklist_all,access.phone.blacklist.all,model_phone_blacklist,,0,0,0,0
access_phone_blacklist_system,access.phone.blacklist.system,model_phone_blacklist,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_phone_blacklist_all access.phone.blacklist.all model_phone_blacklist 0 0 0 0
3 access_phone_blacklist_system access.phone.blacklist.system model_phone_blacklist base.group_system 1 1 1 1
@@ -17,7 +17,7 @@ try:
try:
phone_nbr = phonenumbers.parse(number, region=country_code, keep_raw_input=True)
except phonenumbers.phonenumberutil.NumberParseException as e:
raise UserError(_('Unable to parse %s:\n%s') % (number, e))
raise UserError(_('Unable to parse %s: %s') % (number, str(e)))
if not phonenumbers.is_possible_number(phone_nbr):
raise UserError(_('Impossible number %s: probably invalid number of digits') % number)
@@ -77,45 +77,41 @@ except ImportError:
def phone_sanitize_numbers(numbers, country_code, country_phone_code, force_format='E164'):
""" Given a list of numbers, return parsezd and sanitized information
:return dict: {number: {
'sanitized': sanitized and formated number or False (if cannot format)
'code': 'empty' (number was a void string), 'invalid' (error) or False (sanitize ok)
'msg': error message when 'invalid'
}}
"""
if not isinstance(numbers, (list)):
raise NotImplementedError()
result = dict.fromkeys(numbers, False)
for number in numbers:
if not number:
result[number] = {'sanitized': False, 'code': 'empty', 'msg': False}
continue
try:
stripped = number.strip()
sanitized = phone_format(
number, country_code, country_phone_code,
stripped, country_code, country_phone_code,
force_format=force_format, raise_exception=True)
except Exception as e:
result[number] = {'sanitized': False, 'code': 'invalid', 'msg': e}
result[number] = {'sanitized': False, 'code': 'invalid', 'msg': str(e)}
else:
result[number] = {'sanitized': sanitized, 'code': False, 'msg': False}
return result
def phone_sanitize_numbers_w_record(numbers, record, country=False, record_country_fname='country_id', force_format='E164'):
if not isinstance(numbers, (list)):
raise NotImplementedError()
if not country:
if record and hasattr(record, record_country_fname) and record[record_country_fname]:
if record and record_country_fname and hasattr(record, record_country_fname) and record[record_country_fname]:
country = record[record_country_fname]
elif record:
country = record.env.company.country_id
country_code = country.code if country else None
country_phone_code = country.phone_code if country else None
return phone_sanitize_numbers(numbers, country_code, country_phone_code, force_format=force_format)
def phone_sanitize_numbers_string_w_record(numbers_str, record, country=False, record_country_fname='country_id', force_format='E164'):
found_numbers = [number.strip() for number in numbers_str.split(',')]
return phone_sanitize_numbers_w_record(found_numbers, record, country=country, record_country_fname=record_country_fname, force_format=force_format)
def phone_get_sanitized_records_number(records, number_fname='mobile', country_fname='country_id', force_format='E164'):
res = dict.fromkeys(records.ids, False)
for record in records:
number = record[number_fname]
res[record.id] = phone_sanitize_numbers_w_record([number], records, record_country_fname=country_fname,force_format=force_format)[number]['sanitized']
return res
def phone_get_sanitized_record_number(record, number_fname='mobile', country_fname='country_id', force_format='E164'):
return phone_get_sanitized_records_number(record, number_fname, country_fname, force_format=force_format)[record.id]
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="phone_blacklist_view_tree" model="ir.ui.view">
<field name="name">phone.blacklist.view.tree</field>
<field name="model">phone.blacklist</field>
<field name="arch" type="xml">
<tree string="Phone Blacklist">
<field name="create_date" string="Blacklist Date"/>
<field name="number"/>
</tree>
</field>
</record>
<record id="phone_blacklist_view_form" model="ir.ui.view">
<field name="name">phone.blacklist.view.form</field>
<field name="model">phone.blacklist</field>
<field name="arch" type="xml">
<form string="Phone Blacklist" duplicate="false" edit="false">
<sheet>
<group>
<group>
<field name="number"/>
<field name="active"/>
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="message_ids" widget="mail_thread"/>
</div>
</form>
</field>
</record>
<record id="phone_blacklist_view_search" model="ir.ui.view">
<field name="name">phone.blacklist.view.search</field>
<field name="model">phone.blacklist</field>
<field name="arch" type="xml">
<search>
<field name="number"/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
</search>
</field>
</record>
<record id="phone_blacklist_action" model="ir.actions.act_window">
<field name="name">Phone Blacklist</field>
<field name="res_model">phone.blacklist</field>
<field name="view_id" ref="phone_blacklist_view_tree"/>
<field name="search_view_id" ref="phone_blacklist_view_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a phone number in the blacklist
</p><p>
Blacklisted phone numbers means that the recipient won't receive mass SMS anymore.
</p>
</field>
</record>
<!-- Technical Menu -->
<menuitem id="phone_menu_main"
name="Phone / SMS"
parent="base.menu_custom"
sequence="2"/>
<menuitem id="phone_blacklist_menu"
name="Phone Blacklist"
parent="phone_validation.phone_menu_main"
sequence="3"
action="phone_blacklist_action"/>
</odoo>
+2 -1
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'SMS gateway',
'version': '2.0',
'category': 'Tools',
'summary': 'SMS Text Messaging',
'description': """
@@ -32,5 +33,5 @@ The service is provided by the In App Purchase Odoo platform.
'static/src/xml/thread.xml',
],
'installable': True,
'auto_install': True,
'auto_install': False,
}
+2
View File
@@ -2,10 +2,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_actions
from . import ir_model
from . import mail_followers
from . import mail_message
from . import mail_notification
from . import mail_thread
from . import mail_thread_phone
from . import res_partner
from . import sms_api
from . import sms_sms
+39
View File
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class IrModel(models.Model):
_inherit = 'ir.model'
is_mail_thread_sms = fields.Boolean(
string="Mail Thread SMS", default=False,
store=False, compute='_compute_is_mail_thread_sms', search='_search_is_mail_thread_sms',
help="Whether this model supports messages and notifications through SMS",
)
@api.depends('is_mail_thread')
def _compute_is_mail_thread_sms(self):
for model in self:
if model.is_mail_thread:
ModelObject = self.env[model.model]
potential_fields = ModelObject._sms_get_number_fields() + ModelObject._sms_get_partner_fields()
if any(fname in ModelObject._fields for fname in potential_fields):
model.is_mail_thread_sms = True
continue
model.is_mail_thread_sms = False
def _search_is_mail_thread_sms(self, operator, value):
thread_models = self.search([('is_mail_thread', '=', True)])
valid_models = self.env['ir.model']
for model in thread_models:
ModelObject = self.env[model.model]
potential_fields = ModelObject._sms_get_number_fields() + ModelObject._sms_get_partner_fields()
if any(fname in ModelObject._fields for fname in potential_fields):
valid_models |= model
search_sms = (operator == '=' and value) or (operator == '!=' and not value)
if search_sms:
return [('id', 'in', valid_models.ids)]
return [('id', 'not in', valid_models.ids)]
+18 -8
View File
@@ -35,15 +35,25 @@ class MailThread(models.AbstractModel):
def _search_message_has_sms_error(self, operator, operand):
return ['&', ('message_ids.has_sms_error', operator, operand), ('message_ids.author_id', '=', self.env.user.partner_id.id)]
def _sms_get_partner_fields(self):
""" This method returns the fields to use to find the contact to link
whensending an SMS. Having partner is not necessary, having only phone
number fields is possible. However it gives more flexibility to
notifications management when having partners. """
fields = []
if hasattr(self, 'partner_id'):
fields.append('partner_id')
if hasattr(self, 'partner_ids'):
fields.append('partner_ids')
return fields
def _sms_get_default_partners(self):
""" This method will likely need to be overridden by inherited models.
:returns partners: recordset of res.partner
"""
partners = self.env['res.partner']
if hasattr(self, 'partner_id'):
partners |= self.mapped('partner_id')
if hasattr(self, 'partner_ids'):
partners |= self.mapped('partner_ids')
for fname in self._sms_get_partner_fields():
partners |= self.mapped(fname)
return partners
def _sms_get_number_fields(self):
@@ -78,7 +88,7 @@ class MailThread(models.AbstractModel):
valid_number = False
for fname in [f for f in tocheck_fields if f in record]:
valid_number = phone_validation.phone_get_sanitized_record_number(record, number_fname=fname)
valid_number = phone_validation.phone_sanitize_numbers_w_record([record[fname]], record)[record[fname]]['sanitized']
if valid_number:
break
@@ -92,7 +102,7 @@ class MailThread(models.AbstractModel):
for partner in all_partners:
partner_number = partner.mobile or partner.phone
if partner_number:
partner_number = phone_validation.phone_sanitize_numbers_string_w_record(partner_number, record)[partner_number]['sanitized']
partner_number = phone_validation.phone_sanitize_numbers_w_record([partner_number], record)[partner_number]['sanitized']
if partner_number:
break
@@ -127,7 +137,7 @@ class MailThread(models.AbstractModel):
create_vals = {
'mass_force_send': False,
'mass_keep_log': False,
'mass_keep_log': True,
}
if composer_values:
create_vals.update(composer_values)
@@ -233,7 +243,7 @@ class MailThread(models.AbstractModel):
if partner_ids:
for partner in self.env['res.partner'].sudo().browse(partner_ids):
number = sms_pid_to_number.get(partner.id) or partner.mobile or partner.phone
sanitize_res = phone_validation.phone_sanitize_numbers_string_w_record(number, partner)[number]
sanitize_res = phone_validation.phone_sanitize_numbers_w_record([number], partner)[number]
number = sanitize_res['sanitized'] or number
sms_create_vals.append(dict(
sms_base_vals,
+16
View File
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class PhoneMixin(models.AbstractModel):
_inherit = 'mail.thread.phone'
def _phone_get_number_fields(self):
""" Add fields coming from sms implementation. """
sms_fields = self._sms_get_number_fields()
res = super(PhoneMixin, self)._phone_get_number_fields()
for fname in (f for f in res if f not in sms_fields):
sms_fields.append(fname)
return sms_fields
+2 -1
View File
@@ -5,7 +5,8 @@ from odoo import models
class ResPartner(models.Model):
_inherit = 'res.partner'
_name = 'res.partner'
_inherit = ['res.partner', 'mail.thread.phone']
def _sms_get_default_partners(self):
""" Override of mail.thread method.
+18 -17
View File
@@ -15,6 +15,13 @@ class SmsSms(models.Model):
_rec_name = 'number'
_order = 'id DESC'
IAP_TO_SMS_STATE = {
'success': 'sent',
'insufficient_credit': 'sms_credit',
'wrong_number_format': 'sms_number_format',
'server_error': 'sms_server'
}
number = fields.Char('Number', required=True)
body = fields.Text()
partner_id = fields.Many2one('res.partner', 'Customer')
@@ -29,7 +36,10 @@ class SmsSms(models.Model):
('sms_number_missing', 'Missing Number'),
('sms_number_format', 'Wrong Number Format'),
('sms_credit', 'Insufficient Credit'),
('sms_server', 'Server Error')
('sms_server', 'Server Error'),
# mass mode specific codes
('sms_blacklist', 'Blacklisted'),
('sms_duplicate', 'Duplicate'),
])
def send(self, delete_all=False, auto_commit=False, raise_exception=False):
@@ -92,27 +102,24 @@ class SmsSms(models.Model):
_logger.info('Sent batch %s SMS: %s: failed with exception %s', len(self.ids), self.ids, e)
if raise_exception:
raise
self._postprocess_sent_sms([{'res_id': sms.id, 'state': 'server_error'} for sms in self], delete_all=delete_all)
self._postprocess_iap_sent_sms([{'res_id': sms.id, 'state': 'server_error'} for sms in self], delete_all=delete_all)
else:
_logger.info('Send batch %s SMS: %s: gave %s', len(self.ids), self.ids, iap_results)
self._postprocess_sent_sms(iap_results, delete_all=delete_all)
self._postprocess_iap_sent_sms(iap_results, delete_all=delete_all)
def _postprocess_sent_sms(self, iap_results, failure_reason=None, delete_all=False):
sms_to_notif_status = {
'success': False, 'insufficient_credit': 'sms_credit',
'wrong_number_format': 'sms_number_format', 'server_error': 'sms_server'}
def _postprocess_iap_sent_sms(self, iap_results, failure_reason=None, delete_all=False):
if delete_all:
todelete_sms_ids = [item['res_id'] for item in iap_results]
else:
todelete_sms_ids = [item['res_id'] for item in iap_results if item['state'] == 'success']
for state in sms_to_notif_status.keys():
for state in self.IAP_TO_SMS_STATE.keys():
sms_ids = [item['res_id'] for item in iap_results if item['state'] == state]
if sms_ids:
if not delete_all and state != 'success':
if state != 'success' and not delete_all:
self.env['sms.sms'].sudo().browse(sms_ids).write({
'state': 'error',
'error_code': sms_to_notif_status[state],
'error_code': self.IAP_TO_SMS_STATE[state],
})
notifications = self.env['mail.notification'].sudo().search([
('notification_type', '=', 'sms'),
@@ -122,15 +129,9 @@ class SmsSms(models.Model):
if notifications:
notifications.write({
'notification_status': 'sent' if state == 'success' else 'exception',
'failure_type': sms_to_notif_status[state],
'failure_type': self.IAP_TO_SMS_STATE[state] if state != 'success' else False,
'failure_reason': failure_reason if failure_reason else False,
})
if todelete_sms_ids:
self.browse(todelete_sms_ids).sudo().unlink()
def cancel(self):
self.write({
'state': 'canceled',
'error_code': False
})
+2 -2
View File
@@ -16,10 +16,10 @@ class SMSTemplate(models.Model):
res['model_id'] = self.env['ir.model']._get(res['model']).id
return res
name = fields.Char()
name = fields.Char('Name', translate=True)
model_id = fields.Many2one(
'ir.model', string='Applies to', required=True,
domain=['&', ('is_mail_thread', '=', True), ('transient', '=', False)],
domain=['&', ('is_mail_thread_sms', '=', True), ('transient', '=', False)],
help="The type of document this template can be used with")
model = fields.Char('Related Document Model', related='model_id.model', index=True, store=True, readonly=True)
body = fields.Char('Body', translate=True, required=True)
@@ -115,7 +115,7 @@ var SmsWidget = FieldText.extend({
* @private
*/
_renderSMSInfo: function () {
var string = _.str.sprintf(_t('%s chars, fits in %s SMS (%s) '), this.nbrChar, this.nbrSMS, this.encoding);
var string = _.str.sprintf(_t('%s characters, fits in %s SMS (%s) '), this.nbrChar, this.nbrSMS, this.encoding);
var $span = $('<span>', {
'class': 'text-muted o_sms_count',
});
@@ -129,7 +129,7 @@ var SmsWidget = FieldText.extend({
*/
_updateSMSInfo: function () {
this._compute();
var string = _.str.sprintf(_t('%s chars, fits in %s SMS (%s) '), this.nbrChar, this.nbrSMS, this.encoding);
var string = _.str.sprintf(_t('%s characters, fits in %s SMS (%s) '), this.nbrChar, this.nbrSMS, this.encoding);
this.$('.o_sms_count').text(string);
},
+16 -16
View File
@@ -33,40 +33,40 @@ QUnit.module('fields', {
arch: '<form><sheet><field name="message" widget="sms_widget"/></sheet></form>',
});
assert.containsOnce(form, '.o_sms_count', "Should have a sms counter");
assert.strictEqual(form.$('.o_sms_count').text(), '0 chars, fits in 0 SMS (GSM7) ',
'Should be "0 chars, fits in 0 SMS (GSM7) " by default');
assert.strictEqual(form.$('.o_sms_count').text(), '0 characters, fits in 0 SMS (GSM7) ',
'Should be "0 characters, fits in 0 SMS (GSM7) " by default');
// GSM-7
await testUtils.fields.editAndTrigger(form.$('.o_input'), "Hello from Odoo", 'input');
assert.strictEqual(form.$('.o_sms_count').text(), '15 chars, fits in 1 SMS (GSM7) ',
'Should be "15 chars, fits in 1 SMS (GSM7) " for "Hello from Odoo"');
assert.strictEqual(form.$('.o_sms_count').text(), '15 characters, fits in 1 SMS (GSM7) ',
'Should be "15 characters, fits in 1 SMS (GSM7) " for "Hello from Odoo"');
// GSM-7 with \n => this one count as 2 characters
form.$('.o_input').val("Hello from Odoo\n").trigger('input');
assert.strictEqual(form.$('.o_sms_count').text(), '17 chars, fits in 1 SMS (GSM7) ',
'Should be "17 chars, fits in 1 SMS (GSM7) " for "Hello from Odoo\\n"');
assert.strictEqual(form.$('.o_sms_count').text(), '17 characters, fits in 1 SMS (GSM7) ',
'Should be "17 characters, fits in 1 SMS (GSM7) " for "Hello from Odoo\\n"');
// Unicode => ê
form.$('.o_input').val("Hêllo from Odoo").trigger('input');
assert.strictEqual(form.$('.o_sms_count').text(), '15 chars, fits in 1 SMS (UNICODE) ',
'Should be "15 chars, fits in 1 SMS (UNICODE) " for "Hêllo from Odoo"');
assert.strictEqual(form.$('.o_sms_count').text(), '15 characters, fits in 1 SMS (UNICODE) ',
'Should be "15 characters, fits in 1 SMS (UNICODE) " for "Hêllo from Odoo"');
// GSM-7 with 160c
var text = Array(161).join('a');
await testUtils.fields.editAndTrigger(form.$('.o_input'), text, 'input');
assert.strictEqual(form.$('.o_sms_count').text(), '160 chars, fits in 1 SMS (GSM7) ',
'Should be "160 chars, fits in 1 SMS (GSM7) " for 160 x "a"');
assert.strictEqual(form.$('.o_sms_count').text(), '160 characters, fits in 1 SMS (GSM7) ',
'Should be "160 characters, fits in 1 SMS (GSM7) " for 160 x "a"');
// GSM-7 with 161c
text = Array(162).join('a');
await testUtils.fields.editAndTrigger(form.$('.o_input'), text, 'input');
assert.strictEqual(form.$('.o_sms_count').text(), '161 chars, fits in 2 SMS (GSM7) ',
'Should be "161 chars, fits in 2 SMS (GSM7) " for 161 x "a"');
assert.strictEqual(form.$('.o_sms_count').text(), '161 characters, fits in 2 SMS (GSM7) ',
'Should be "161 characters, fits in 2 SMS (GSM7) " for 161 x "a"');
// Unicode with 70c
text = Array(71).join('ê');
await testUtils.fields.editAndTrigger(form.$('.o_input'), text, 'input');
assert.strictEqual(form.$('.o_sms_count').text(), '70 chars, fits in 1 SMS (UNICODE) ',
'Should be "70 chars, fits in 1 SMS (UNICODE) " for 70 x "ê"');
assert.strictEqual(form.$('.o_sms_count').text(), '70 characters, fits in 1 SMS (UNICODE) ',
'Should be "70 characters, fits in 1 SMS (UNICODE) " for 70 x "ê"');
// Unicode with 71c
text = Array(72).join('ê');
await testUtils.fields.editAndTrigger(form.$('.o_input'), text, 'input');
assert.strictEqual(form.$('.o_sms_count').text(), '71 chars, fits in 2 SMS (UNICODE) ',
'Should be "71 chars, fits in 2 SMS (UNICODE) " for 71 x "ê"');
assert.strictEqual(form.$('.o_sms_count').text(), '71 characters, fits in 2 SMS (UNICODE) ',
'Should be "71 characters, fits in 2 SMS (UNICODE) " for 71 x "ê"');
form.destroy();
});
+11 -4
View File
@@ -74,7 +74,7 @@ class MockSMS(common.BaseCase):
""" Check canceled SMS. Search is done for a pair partner / number where
partner can be an empty recordset. """
if number is None and partner:
number = phone_validation.phone_get_sanitized_record_number(partner)
number = partner.phone_get_sanitized_number()
sms = self.env['sms.sms'].sudo().search([
('partner_id', '=', partner.id), ('number', '=', number),
('state', '=', 'canceled')
@@ -88,7 +88,7 @@ class MockSMS(common.BaseCase):
""" Check failed SMS. Search is done for a pair partner / number where
partner can be an empty recordset. """
if number is None and partner:
number = phone_validation.phone_get_sanitized_record_number(partner)
number = partner.phone_get_sanitized_number()
sms = self.env['sms.sms'].sudo().search([
('partner_id', '=', partner.id), ('number', '=', number),
('state', '=', 'error')
@@ -102,7 +102,7 @@ class MockSMS(common.BaseCase):
""" Check outgoing SMS. Search is done for a pair partner / number where
partner can be an empty recordset. """
if number is None and partner:
number = phone_validation.phone_get_sanitized_record_number(partner)
number = partner.phone_get_sanitized_number()
sms = self.env['sms.sms'].sudo().search([
('partner_id', '=', partner.id), ('number', '=', number),
('state', '=', 'outgoing')
@@ -139,7 +139,7 @@ class MockSMS(common.BaseCase):
number = recipient_info.get('number')
state = recipient_info.get('state', 'sent')
if number is None and partner:
number = phone_validation.phone_get_sanitized_record_number(partner)
number = partner.phone_get_sanitized_number()
notif = notifications.filtered(lambda n: n.res_partner_id == partner and n.sms_number == number and n.notification_status == state)
self.assertTrue(notif, 'SMS: not found notification for %s (number: %s, state: %s)' % (partner, number, state))
@@ -161,3 +161,10 @@ class MockSMS(common.BaseCase):
if messages is not None:
for message in messages:
self.assertEqual(content, tools.html2plaintext(message.body).rstrip('\n'))
def assertSMSLogged(self, records, body):
for record in records:
message = record.message_ids[-1]
self.assertEqual(message.subtype_id, self.env.ref('mail.mt_note'))
self.assertEqual(message.message_type, 'sms')
self.assertEqual(tools.html2plaintext(message.body).rstrip('\n'), body)
+4
View File
@@ -8,6 +8,9 @@
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="priority">10</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='mobile']" position="after">
<field name="phone_sanitized" groups="base.group_no_one"/>
</xpath>
<xpath expr="//field[@name='phone']" position="replace">
<label for="phone"/>
<div class="o_row">
@@ -45,6 +48,7 @@
target="new"
context="{
'default_composition_mode': 'comment',
'default_res_id': active_id,
}"
/>
+5 -3
View File
@@ -37,7 +37,7 @@
<field name="number"/>
<field name="partner_id"/>
<field name="state"/>
<field name="error_code"/>
</tree>
</field>
</record>
@@ -59,8 +59,10 @@
<field name="view_mode">tree,form</field>
</record>
<menuitem id="sms_menu_main" name="SMS" parent="base.menu_custom" sequence="2"/>
<menuitem id="sms_sms_menu" parent="sms_menu_main" action="sms_sms_action" sequence="1"/>
<menuitem id="sms_sms_menu"
parent="phone_validation.phone_menu_main"
action="sms_sms_action"
sequence="1"/>
</data>
</odoo>
+6 -2
View File
@@ -7,7 +7,7 @@
<form string="SMS Templates">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<label for="name" class="oe_edit_only" string="SMS Template"/>
<h1><field name="name" required="1"/></h1>
<group>
<field name="model_id" required="1" options="{'no_create': True}"/>
@@ -67,7 +67,11 @@
<field name="view_mode">tree,form</field>
</record>
<menuitem id="sms_template_menu" parent="sms_menu_main" action="sms_template_action"/>
<menuitem id="sms_template_menu"
name="SMS Templates"
parent="phone_validation.phone_menu_main"
sequence="2"
action="sms_template_action"/>
</data>
</odoo>
+116 -75
View File
@@ -12,7 +12,6 @@ from odoo.tools.safe_eval import safe_eval
class SendSMS(models.TransientModel):
_name = 'sms.composer'
_description = 'Send SMS Wizard'
_RECIPIENTS_DISPLAY_NBR = 3
@api.model
def default_get(self, fields):
@@ -24,12 +23,13 @@ class SendSMS(models.TransientModel):
result['res_model'] = result.get('res_model') or self.env.context.get('active_model')
result['composition_mode'] = result.get('composition_mode') or 'comment'
if result['composition_mode'] == 'comment' and not result.get('res_id'):
result['res_id'] = self.env.context.get('active_id')
if result['composition_mode'] != 'comment':
if result.get('use_active_domain') and not result.get('active_domain'):
result['active_domain'] = self.env.context['active_domain']
elif not result.get('res_ids'):
if not result.get('active_domain'):
result['active_domain'] = repr(self.env.context.get('active_domain', []))
if not result.get('res_id'):
if not result.get('res_ids'):
result['res_id'] = self.env.context.get('active_id')
if not result.get('res_ids'):
if not result.get('res_id'):
result['res_ids'] = repr(self.env.context.get('active_ids'))
if result['res_model']:
@@ -50,14 +50,22 @@ class SendSMS(models.TransientModel):
res_model = fields.Char('Document Model Name')
res_id = fields.Integer('Document ID')
res_ids = fields.Char('Document IDs')
res_ids_count = fields.Integer(
'Visible records count', compute='_compute_recipients_count',
help='UX field computing the number of recipients in mass mode without active domain')
use_active_domain = fields.Boolean('Use active domain')
active_domain = fields.Text('Active domain', readonly=True)
active_domain_count = fields.Integer(
'Active records count', compute='_compute_recipients_count',
help='UX field computing the number of recipients in mass mode based on given active domain')
# options for comment and mass mode
mass_keep_log = fields.Boolean('Keep a note on document')
mass_force_send = fields.Boolean('Send directly')
mass_keep_log = fields.Boolean('Keep a note on document', default=True)
mass_force_send = fields.Boolean('Send directly', default=False)
mass_use_blacklist = fields.Boolean('Use blacklist', default=True)
# recipients
recipient_description = fields.Text('Recipients (Partners)', compute='_compute_description')
recipient_invalid = fields.Text('Invalid recipients', compute='_compute_description')
recipient_description = fields.Text('Recipients (Partners)', compute='_compute_recipients')
recipient_count = fields.Integer('# Valid recipients', compute='_compute_recipients')
recipient_invalid_count = fields.Integer('# Invalid recipients', compute='_compute_recipients')
number_field_name = fields.Char(string='Field holding number')
partner_ids = fields.Many2many('res.partner')
numbers = fields.Char('Recipients (Numbers)')
@@ -66,55 +74,45 @@ class SendSMS(models.TransientModel):
template_id = fields.Many2one('sms.template', string='Use Template', domain="[('model', '=', res_model)]")
body = fields.Text('Message', required=True)
@api.depends('res_model', 'res_ids', 'active_domain')
def _compute_recipients_count(self):
self.res_ids_count = len(literal_eval(self.res_ids)) if self.res_ids else 0
self.active_domain_count = self.env[self.res_model].search_count(safe_eval(self.active_domain or '[]'))
@api.depends('partner_ids', 'res_model', 'res_id', 'res_ids', 'use_active_domain', 'composition_mode', 'number_field_name', 'sanitized_numbers')
def _compute_description(self):
def _compute_recipients(self):
self.recipient_description = False
self.recipient_count = 0
self.recipient_invalid_count = 0
if self.partner_ids:
description = ''
description = ','.join('%s - %s' % (partner.display_name, partner.mobile or partner.phone or _('Missing number')) for partner in self.partner_ids[:self._RECIPIENTS_DISPLAY_NBR])
if len(self.partner_ids) > self._RECIPIENTS_DISPLAY_NBR:
description += _(' (and %s more)') % (len(self.partner_ids) - self._RECIPIENTS_DISPLAY_NBR)
self.recipient_description = description
self.recipient_invalid = False
if len(self.partner_ids) == 1:
self.recipient_description = '%s (%s)' % (self.partner_ids[0].display_name, self.partner_ids[0].mobile or self.partner_ids[0].phone or _('Missing number'))
self.recipient_count = len(self.partner_ids)
elif self.composition_mode in ('comment', 'mass') and self.res_model:
records = None
if self.composition_mode == 'comment' and self.res_id:
records = self.env[self.res_model].browse(self.res_id)
elif self.composition_mode == 'mass' and self.res_ids:
records = self.env[self.res_model].browse(literal_eval(self.res_ids))
records = self._get_records()
if records and issubclass(type(records), self.pool['mail.thread']):
res = records._sms_get_recipients_info(force_field=self.number_field_name)
valid_ids = [rid for rid, rvalues in res.items() if rvalues['sanitized']]
invalid_ids = [rid for rid, rvalues in res.items() if not rvalues['sanitized']]
self.recipient_description = ', '.join('%s (%s)' % (
res[record.id]['partner'].name or record.display_name,
res[record.id]['sanitized'])
for record in records if record.id in valid_ids[:self._RECIPIENTS_DISPLAY_NBR]
) or False
if len(valid_ids) > self._RECIPIENTS_DISPLAY_NBR:
self.recipient_description += _(', and %s more') % (len(valid_ids) - self._RECIPIENTS_DISPLAY_NBR)
self.recipient_invalid = ', '.join('%s (%s)' % (
res[record.id]['partner'].display_name or record.display_name,
res[record.id]['number'])
for record in records if record.id in invalid_ids[:self._RECIPIENTS_DISPLAY_NBR]
) or False
if len(invalid_ids) > self._RECIPIENTS_DISPLAY_NBR:
self.recipient_description += _(', and %s more') % (len(invalid_ids) - self._RECIPIENTS_DISPLAY_NBR)
self.recipient_count = len(valid_ids)
self.recipient_invalid_count = len(invalid_ids)
if len(records) == 1:
self.recipient_description = '%s (%s)' % (
res[records.id]['partner'].name or records.display_name,
res[records.id]['sanitized']
)
else:
self.recipient_description = ''
self.recipient_invalid = '' if self.sanitized_numbers or (self.composition_mode == 'mass' and self.use_active_domain) else _('No record found')
else:
self.recipient_description = False
self.recipient_invalid_count = 0 if (self.sanitized_numbers or (self.composition_mode == 'mass' and self.use_active_domain)) else 1
@api.depends('numbers', 'res_model', 'res_id')
def _compute_sanitized_numbers(self):
if self.numbers:
record = self._get_records() if self.res_model and self.res_id else self.env.user
sanitize_res = phone_validation.phone_sanitize_numbers_string_w_record(self.numbers, record)
numbers = [number.strip() for number in self.numbers.split(',')]
sanitize_res = phone_validation.phone_sanitize_numbers_w_record(numbers, record)
sanitized_numbers = [info['sanitized'] for info in sanitize_res.values() if info['sanitized']]
invalid_numbers = [number for number, info in sanitize_res.items() if info['code']]
if invalid_numbers:
@@ -125,7 +123,7 @@ class SendSMS(models.TransientModel):
@api.onchange('composition_mode', 'res_model', 'res_id', 'template_id')
def _onchange_template_id(self):
if self.template_id and self.composition_mode == 'comment':
if self.template_id and self.composition_mode == 'comment' and self.res_id:
self.body = self.template_id._render_template(self.template_id.body, self.res_model, [self.res_id])[self.res_id]
elif self.template_id:
self.body = self.template_id.body
@@ -135,8 +133,8 @@ class SendSMS(models.TransientModel):
# ------------------------------------------------------------
def action_send_sms(self):
if self.composition_mode in ('numbers', 'comment') and self.recipient_invalid:
raise UserError(_('Invalid recipients: %s') % self.recipient_invalid)
if self.composition_mode in ('numbers', 'comment') and self.recipient_invalid_count:
raise UserError(_('%s invalid recipients') % self.recipient_invalid_count)
self._action_send_sms()
return False
@@ -148,8 +146,6 @@ class SendSMS(models.TransientModel):
if records is not None and issubclass(type(records), self.pool['mail.thread']):
return self._action_send_sms_comment(records)
return self._action_send_sms_numbers()
elif self.mass_keep_log and records is not None and issubclass(type(records), self.pool['mail.thread']):
return self._action_send_sms_mass_w_log(records)
else:
return self._action_send_sms_mass(records)
@@ -163,11 +159,11 @@ class SendSMS(models.TransientModel):
def _action_send_sms_comment(self, records=None):
records = records if records is not None else self._get_records()
subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.note')
subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')
messages = self.env['mail.message']
for record in records:
messages |= records._message_sms(
messages |= record._message_sms(
self.body, subtype_id=subtype_id,
partner_ids=self.partner_ids.ids or False,
number_field=self.number_field_name,
@@ -177,36 +173,48 @@ class SendSMS(models.TransientModel):
def _action_send_sms_mass(self, records=None):
records = records if records is not None else self._get_records()
record_values = self._prepare_mass_sms_values(records)
sms_create_vals = [record_values[record.id] for record in records]
sms_record_values = self._prepare_mass_sms_values(records)
sms_create_vals = [sms_record_values[record.id] for record in records]
sms = self.env['sms.sms'].sudo().create(sms_create_vals)
if sms and self.mass_keep_log and records and issubclass(type(records), self.pool['mail.thread']):
log_values = self._prepare_mass_log_values(sms_record_values)
records._message_log_batch(**log_values)
if sms and self.mass_force_send:
sms.send(auto_commit=False, raise_exception=False)
return sms
def _action_send_sms_mass_w_log(self, records=None):
records = records if records is not None else self._get_records()
all_bodies = self._prepare_body_values(records=records)
subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')
messages = self.env['mail.message']
for record in records:
messages |= record._message_sms(all_bodies[record.id], subtype_id=subtype_id, partner_ids=False, sms_numbers=None, put_in_queue=not self.mass_force_send)
return messages
# ------------------------------------------------------------
# Mass mode specific
# ------------------------------------------------------------
def _prepare_recipient_values(self, records=None):
records = records if records is not None else self._get_records()
def _get_blacklist_record_ids(self, records, recipients_info):
""" Get a list of blacklisted records. Those will be directly canceled
with the right error code. """
if self.mass_use_blacklist:
bl_numbers = self.env['phone.blacklist'].sudo().search([]).mapped('number')
return [r.id for r in records if recipients_info[r.id]['sanitized'] in bl_numbers]
return []
def _get_done_record_ids(self, records, recipients_info):
""" Get a list of already-done records. Order of record set is used to
spot duplicates so pay attention to it if necessary. """
done_ids, done = [], []
for record in records:
sanitized = recipients_info[record.id]['sanitized']
if sanitized in done:
done_ids.append(record.id)
else:
done.append(sanitized)
return done_ids
def _prepare_recipient_values(self, records):
recipients_info = records._sms_get_recipients_info(force_field=self.number_field_name)
return recipients_info
def _prepare_body_values(self, records=None):
records = records if records is not None else self._get_records()
def _prepare_body_values(self, records):
if self.template_id and self.body == self.template_id.body:
lang_to_rids = self.template_id._get_ids_per_lang(records.ids)
all_bodies = {}
@@ -219,20 +227,49 @@ class SendSMS(models.TransientModel):
def _prepare_mass_sms_values(self, records=None):
records = records if records is not None else self._get_records()
all_bodies = self._prepare_body_values(records=records)
all_recipients = self._prepare_recipient_values(records=records)
all_bodies = self._prepare_body_values(records)
all_recipients = self._prepare_recipient_values(records)
blacklist_ids = self._get_blacklist_record_ids(records, all_recipients)
done_ids = self._get_done_record_ids(records, all_recipients)
result = {}
for record in records:
recipients = all_recipients[record.id]
sanitized = recipients['sanitized']
if sanitized and record.id in blacklist_ids:
state = 'canceled'
error_code = 'sms_blacklist'
elif sanitized and record.id in done_ids:
state = 'canceled'
error_code = 'sms_duplicate'
elif not sanitized:
state = 'error'
error_code = 'sms_number_format' if recipients['number'] else 'sms_number_missing'
else:
state = 'outgoing'
error_code = ''
result[record.id] = {
'body': all_bodies[record.id],
'partner_id': recipients['partner'].id,
'number': recipients['sanitized'] or recipients['number'],
'state': 'outgoing' if recipients['sanitized'] else 'error',
'number': sanitized if sanitized else recipients['number'],
'state': state,
'error_code': error_code,
}
return result
def _prepare_log_body_values(self, sms_records_values):
result = {}
for record_id, sms_values in sms_records_values.items():
result[record_id] = sms_values['body']
return result
def _prepare_mass_log_values(self, sms_records_values):
return {
'bodies': self._prepare_log_body_values(sms_records_values),
'message_type': 'sms',
}
# ------------------------------------------------------------
# Tools
# ------------------------------------------------------------
@@ -240,9 +277,12 @@ class SendSMS(models.TransientModel):
def _get_composer_values(self, composition_mode, res_model, res_id, body, template_id):
result = {}
if composition_mode == 'comment':
if not body and template_id:
if not body and template_id and res_id:
template = self.env['sms.template'].browse(template_id)
result['body'] = template._render_template(template.body, res_model, [res_id])[res_id]
elif template_id:
template = self.env['sms.template'].browse(template_id)
result['body'] = template.body
else:
if not body and template_id:
template = self.env['sms.template'].browse(template_id)
@@ -252,8 +292,9 @@ class SendSMS(models.TransientModel):
def _get_records(self):
if not self.res_model:
return None
if self.use_active_domain: # TDE FIXME: clear active_domain (False,[], ..)
records = self.env[self.res_model].search(safe_eval(self.active_domain))
if self.use_active_domain:
active_domain = safe_eval(self.active_domain or '[]')
records = self.env[self.res_model].search(active_domain)
elif self.res_id:
records = self.env[self.res_model].browse(self.res_id)
else:
+13 -36
View File
@@ -18,51 +18,28 @@
<field name="numbers" invisible="1"/>
<field name="sanitized_numbers" invisible="1"/>
<!-- Recipient warning -->
<div colspan="2" class="alert alert-danger" role="alert"
attrs="{'invisible': [('recipient_invalid', '=', False)]}">
<strong>Some recipients are invalid</strong><br />
<field name="recipient_invalid"/>
</div>
<!-- Mass mode information (res_ids versus active domain) -->
<div colspan="2" class="oe_form_box_info bg-info oe_text_center mb-3"
<div colspan="2" class="alert alert-info text-center mb-3" role="alert"
attrs="{'invisible': [('composition_mode', '=', 'comment')]}">
<p attrs="{'invisible': [('use_active_domain', '=', False)]}">
<strong>All records matching your current search filter will receive a SMS,
not only the ids selected in the list view.</strong><br />
The SMS will be sent for all the records selected in the list.<br />
Confirming this wizard will probably take a few minutes blocking your browser.
</p>
<p attrs="{'invisible': [('use_active_domain', '=', True)]}">
<strong>Only records checked in list view will be used.</strong><br />
The SMS will be sent for all the records selected in the list.
</p>
<p class="mt8">
<span attrs="{'invisible': [('use_active_domain', '=', True)]}">
If you want to send it for all the records matching your search criterion, check this box :
<p>
<span attrs="{'invisible': [('use_active_domain', '=', 'True')]}">
<field class="oe_inline font-weight-bold" name="res_ids_count"/> visible records selected.
</span>
<span attrs="{'invisible': [('use_active_domain', '=', False)]}">
If you want to use only selected records please uncheck this selection box :
</span>
<field class="oe_inline" name="use_active_domain"/>
Check <field class="oe_inline ml-2" name="use_active_domain"/> to send to all
<field class="oe_inline font-weight-bold ml-2" name="active_domain_count"/> records instead. <br/>
<field class="oe_inline font-weight-bold" name="recipient_count"/> recipients are valid
and <field class="oe_inline font-weight-bold" name="recipient_invalid_count"/> are invalid.
</p>
</div>
<label for="partner_ids" string="Recipients"/>
<div>
<span attrs="{'invisible': ['|', ('composition_mode', '!=', 'mass'), ('use_active_domain', '=', False)]}">
<strong>Mass SMS</strong> on the current search filter
</span>
<field name="recipient_description" readonly="1" attrs="{
'invisible': [('use_active_domain', '=', True)]}"
/>
</div>
<field name="template_id" options="{'no_create': True}" attrs="{'invisible': [('composition_mode', '!=', 'mass')]}"/>
<field name="recipient_description" string="Recipients"
readonly="1"
attrs="{'invisible': [('composition_mode', '!=', 'comment')]}"/>
<field name="body" widget="sms_widget"/>
</group>
<group>
<group>
<field name="template_id" options="{'no_create': True}" attrs="{'invisible': [('composition_mode', '!=', 'mass')]}"/>
<field name="mass_keep_log" attrs="{'invisible': [('composition_mode', '!=', 'mass')]}"/>
<field name="mass_keep_log" invisible="1"/>
</group>
</group>
</sheet>
+2 -1
View File
@@ -15,7 +15,8 @@ real applications. """,
'mail',
'mail_bot',
# 'snailmail',
# 'mass_mailing',
'mass_mailing',
'mass_mailing_sms',
'phone_validation',
'sms',
],
@@ -19,8 +19,30 @@ class MailTestSMS(models.Model):
mobile_nbr = fields.Char()
customer_id = fields.Many2one('res.partner', 'Customer')
def _sms_get_default_partners(self):
return self.mapped('customer_id')
def _sms_get_partner_fields(self):
return ['customer_id']
def _sms_get_number_fields(self):
return ['phone_nbr', 'mobile_nbr']
class MailTestSMSBL(models.Model):
""" A model inheriting from mail.thread with some fields used for SMS
gateway, like a partner, a specific mobile phone, ... """
_description = 'Chatter Model for SMS Gateway'
_name = 'mail.test.sms.bl'
_inherit = ['mail.thread.phone']
_order = 'name asc, id asc'
name = fields.Char()
subject = fields.Char()
email_from = fields.Char()
phone_nbr = fields.Char()
mobile_nbr = fields.Char()
customer_id = fields.Many2one('res.partner', 'Customer')
def _sms_get_partner_fields(self):
return ['customer_id']
def _sms_get_number_fields(self):
return ['phone_nbr', 'mobile_nbr']
@@ -1,5 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mail_test_sms_all,mail.test.sms.all,model_mail_test_sms,,0,0,0,0
access_mail_test_sms_user,mail.test.sms.user,model_mail_test_sms,base.group_user,1,1,1,1
access_mail_test_sms_bl_all,mail.test.sms.bl.all,model_mail_test_sms_bl,,0,0,0,0
access_mail_test_sms_bl_user,mail.test.sms.bl.user,model_mail_test_sms_bl,base.group_user,1,1,1,1
access_mail_test_sms_partner_all,mail.test.sms.partner.all,model_mail_test_sms_partner,,0,0,0,0
access_mail_test_sms_partner_user,mail.test.sms.partner.user,model_mail_test_sms_partner,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mail_test_sms_all mail.test.sms.all model_mail_test_sms 0 0 0 0
3 access_mail_test_sms_user mail.test.sms.user model_mail_test_sms base.group_user 1 1 1 1
4 access_mail_test_sms_bl_all mail.test.sms.bl.all model_mail_test_sms_bl 0 0 0 0
5 access_mail_test_sms_bl_user mail.test.sms.bl.user model_mail_test_sms_bl base.group_user 1 1 1 1
6 access_mail_test_sms_partner_all mail.test.sms.partner.all model_mail_test_sms_partner 0 0 0 0
7 access_mail_test_sms_partner_user mail.test.sms.partner.user model_mail_test_sms_partner base.group_user 1 1 1 1
+2
View File
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
from . import common
from . import test_phone_blacklist
from . import test_mass_sms
from . import test_sms_composer
from . import test_sms_management
from . import test_sms_performance
+50
View File
@@ -2,6 +2,7 @@
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.test_mail.tests import common as test_mail_common
from odoo.addons.test_mail.tests.common import mail_new_test_user
class BaseFunctionalTest(test_mail_common.BaseFunctionalTest):
@@ -24,3 +25,52 @@ class BaseFunctionalTest(test_mail_common.BaseFunctionalTest):
# some numbers for mass testing
cls.mass_numbers = ['04561%s2%s3%s' % (x, x, x) for x in range(0, 10)]
cls.mass_numbers_san = [phone_validation.phone_format(number, 'BE', '32', force_format='E164') for number in cls.mass_numbers]
@classmethod
def _create_sms_template(cls, model, body=False):
return cls.env['sms.template'].create({
'name': 'Test Template',
'model_id': cls.env['ir.model']._get(model).id,
'body': body if body else 'Dear ${object.display_name} this is an SMS.'
})
@classmethod
def _create_records_for_batch(cls, model, count):
records = cls.env['mail.test.sms']
partners = cls.env['res.partner']
country_id = cls.env.ref('base.be').id,
for x in range(count):
partners += cls.env['res.partner'].with_context(**cls._test_context).create({
'name': 'Partner_%s' % (x),
'email': '_test_partner_%s@example.com' % (x),
'country_id': country_id,
'mobile': '047500%02d%02d' % (x, x)
})
records += cls.env[model].with_context(**cls._test_context).create({
'name': 'Test_%s' % (x),
'customer_id': partners[x].id,
})
cls.records = cls._reset_mail_context(records)
cls.partners = partners
class TestRecipients(test_mail_common.TestRecipients):
@classmethod
def setUpClass(cls):
super(TestRecipients, cls).setUpClass()
cls.partner_numbers = [
phone_validation.phone_format(partner.mobile, partner.country_id.code, partner.country_id.phone_code, force_format='E164')
for partner in (cls.partner_1 | cls.partner_2)
]
class MassSMSBaseFunctionalTest(BaseFunctionalTest):
@classmethod
def setUpClass(cls):
super(MassSMSBaseFunctionalTest, cls).setUpClass()
cls.user_marketing = mail_new_test_user(
cls.env, login='marketing',
groups='base.group_user,mass_mailing.group_mass_mailing_user',
name='Martial Marketing', signature='--\nMartial')
@@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.mass_mailing_sms.tests import common as mass_mailing_sms_common
from odoo.addons.test_mail.tests import common as test_mail_common
from odoo.addons.test_mail_full.tests import common as test_mail_full_common
from odoo.tools import mute_logger
from odoo.tests import tagged
@tagged('mass_mailing')
class TestMassSMS(test_mail_full_common.MassSMSBaseFunctionalTest, mass_mailing_sms_common.MockSMS):
@classmethod
def setUpClass(cls):
super(TestMassSMS, cls).setUpClass()
cls._test_body = 'Mass SMS in your face'
records = cls.env['mail.test.sms']
partners = cls.env['res.partner']
country_be_id = cls.env.ref('base.be').id,
country_us_id = cls.env.ref('base.us').id,
for x in range(10):
partners += cls.env['res.partner'].with_context(**cls._test_context).create({
'name': 'Partner_%s' % (x),
'email': '_test_partner_%s@example.com' % (x),
'country_id': country_be_id,
'mobile': '045600%s%s99' % (x, x)
})
records += cls.env['mail.test.sms'].with_context(**cls._test_context).create({
'name': 'MassSMSTest_%s' % (x),
'customer_id': partners[x].id,
'phone_nbr': '045600%s%s44' % (x, x)
})
cls.records = cls._reset_mail_context(records)
cls.records_numbers = [phone_validation.phone_format(r.phone_nbr, 'BE', '32', force_format='E164') for r in cls.records]
cls.partners = partners
cls.sms_template = cls.env['sms.template'].create({
'name': 'Test Template',
'model_id': cls.env['ir.model']._get('mail.test.sms').id,
'body': 'Dear ${object.display_name} this is a mass SMS.',
})
cls.partner_numbers = [
phone_validation.phone_format(partner.mobile, partner.country_id.code, partner.country_id.phone_code, force_format='E164')
for partner in partners
]
cls.mailing = cls.env['mailing.mailing'].with_user(cls.user_marketing).create({
'name': 'Xmas Spam',
'subject': 'Xmas Spam',
'mailing_model_id': cls.env['ir.model']._get('mail.test.sms').id,
'mailing_type': 'sms',
'mailing_domain': '%s' % repr([('name', 'ilike', 'MassSMSTest')]),
'sms_template_id': cls.sms_template.id,
'sms_allow_unsubscribe': False,
})
def test_mass_sms_internals(self):
with self.sudo('marketing'):
mailing = self.env['mailing.mailing'].create({
'name': 'Xmas Spam',
'subject': 'Xmas Spam',
'mailing_model_id': self.env['ir.model']._get('mail.test.sms').id,
'mailing_type': 'sms',
'mailing_domain': '%s' % repr([('name', 'ilike', 'MassSMSTest')]),
'sms_template_id': self.sms_template.id,
'sms_allow_unsubscribe': False,
})
self.assertEqual(mailing.mailing_model_real, 'mail.test.sms')
self.assertEqual(mailing.medium_id, self.env.ref('mass_mailing_sms.utm_medium_sms'))
self.assertEqual(mailing.body_plaintext, self.sms_template.body)
remaining_res_ids = mailing._get_remaining_recipients()
self.assertEqual(set(remaining_res_ids), set(self.records.ids))
with self.mockSMSGateway():
mailing.action_send_sms()
self.assertSMSStatistics(
[{'partner': record.customer_id, 'number': self.records_numbers[i], 'content': 'Dear %s this is a mass SMS.' % record.display_name} for i, record in enumerate(self.records)],
mailing, self.records, check_sms=True
)
def test_mass_sms_internals_errors(self):
# same customer, specific different number on record -> should be valid
new_record_1 = self.env['mail.test.sms'].create({
'name': 'MassSMSTest_nr1',
'customer_id': self.partners[0].id,
'phone_nbr': '0456999999',
})
# new customer, number already on record -> should be ignored
country_be_id = self.env.ref('base.be').id,
nr2_partner = self.env['res.partner'].create({
'name': 'Partner_nr2',
'country_id': country_be_id,
'mobile': '0456449999',
})
new_record_2 = self.env['mail.test.sms'].create({
'name': 'MassSMSTest_nr2',
'customer_id': nr2_partner.id,
'phone_nbr': self.records[0].phone_nbr,
})
records_numbers = self.records_numbers + ['+32456999999']
with self.sudo('marketing'):
with self.mockSMSGateway():
self.mailing.action_send_sms()
self.assertSMSStatistics(
[{'partner': record.customer_id, 'number': records_numbers[i], 'content': 'Dear %s this is a mass SMS.' % record.display_name} for i, record in enumerate(self.records | new_record_1)],
self.mailing, self.records | new_record_1, check_sms=True
)
self.assertSMSStatistics(
[{'partner': new_record_2.customer_id, 'number': self.records_numbers[0], 'content': 'Dear %s this is a mass SMS.' % new_record_2.display_name, 'state': 'ignored', 'failure_type': 'sms_duplicate'}],
self.mailing, new_record_2, check_sms=True
)
def test_mass_sms_internals_done_ids(self):
with self.sudo('marketing'):
with self.mockSMSGateway():
self.mailing.action_send_sms(res_ids=self.records[:5].ids)
traces = self.env['mailing.trace'].search([('mass_mailing_id', 'in', self.mailing.ids)])
self.assertEqual(len(traces), 5)
# new traces generated
self.assertSMSStatistics(
[{'partner': record.customer_id, 'number': self.records_numbers[i], 'content': 'Dear %s this is a mass SMS.' % record.display_name} for i, record in enumerate(self.records[:5])],
self.mailing, self.records[:5], check_sms=True
)
with self.sudo('marketing'):
with self.mockSMSGateway():
self.mailing.action_send_sms(res_ids=self.records.ids)
# delete old traces (for testing purpose: ease check by deleting old ones)
traces.unlink()
# new failed traces generated for duplicates
self.assertSMSStatistics(
[{'partner': record.customer_id, 'number': self.records_numbers[i], 'content': 'Dear %s this is a mass SMS.' % record.display_name, 'state': 'ignored', 'failure_type': 'sms_duplicate'} for i, record in enumerate(self.records[:5])],
self.mailing, self.records[:5], check_sms=True
)
# new traces generated
self.assertSMSStatistics(
[{'partner': record.customer_id, 'number': self.records_numbers[i+5], 'content': 'Dear %s this is a mass SMS.' % record.display_name} for i, record in enumerate(self.records[5:])],
self.mailing, self.records[5:], check_sms=True
)
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sms.tests import common as sms_common
from odoo.addons.test_mail_full.tests import common as test_mail_full_common
class TestPhoneBlacklist(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_full_common.TestRecipients):
""" TODO """
@classmethod
def setUpClass(cls):
super(TestPhoneBlacklist, cls).setUpClass()
cls._test_body = 'VOID CONTENT'
cls.test_record = cls.env['mail.test.sms.bl'].with_context(**cls._test_context).create({
'name': 'Test',
'customer_id': cls.partner_1.id,
'mobile_nbr': cls.test_numbers[0],
'phone_nbr': cls.test_numbers[1],
})
cls.test_record = cls._reset_mail_context(cls.test_record)
def test_phone_blacklist_internals(self):
with self.sudo('employee'):
test_record = self.env['mail.test.sms.bl'].browse(self.test_record.id)
self.assertEqual(test_record.phone_sanitized, self.test_numbers_san[1])
self.assertFalse(test_record.phone_blacklisted)
bl_record = self.env['phone.blacklist'].sudo().create([{'number': self.test_numbers_san[1]}])
test_record.invalidate_cache()
self.assertTrue(test_record.phone_blacklisted)
self.env['phone.blacklist'].sudo().remove(self.test_numbers_san[1])
self.assertFalse(bl_record.active)
test_record.invalidate_cache()
self.assertFalse(test_record.phone_blacklisted)
self.env['phone.blacklist'].sudo().add(self.test_numbers_san[1])
self.assertTrue(bl_record.active)
test_record.invalidate_cache()
self.assertTrue(test_record.phone_blacklisted)
bl_record_2 = self.env['phone.blacklist'].sudo().create([{'number': self.test_numbers_san[1]}])
self.assertEqual(bl_record, bl_record_2)
rec = self.env['mail.test.sms.bl'].search([('phone_blacklisted', '=', True)])
self.assertEqual(rec, test_record)
bl_record.unlink()
rec = self.env['mail.test.sms.bl'].search([('phone_blacklisted', '=', True)])
self.assertEqual(rec, self.env['mail.test.sms.bl'])
def test_phone_sanitize_api(self):
with self.sudo('employee'):
test_record = self.env['mail.test.sms.bl'].browse(self.test_record.id)
self.assertFalse(test_record.phone_blacklisted)
test_record._phone_set_blacklisted()
test_record.invalidate_cache()
self.assertTrue(test_record.phone_blacklisted)
test_record._phone_reset_blacklisted()
test_record.invalidate_cache()
self.assertFalse(test_record.phone_blacklisted)
def test_phone_sanitize_internals(self):
with self.sudo('employee'):
test_record = self.env['mail.test.sms.bl'].browse(self.test_record.id)
self.assertEqual(test_record.phone_nbr, self.test_numbers[1])
self.assertEqual(test_record.phone_sanitized, self.test_numbers_san[1])
test_record.write({'phone_nbr': 'incorrect'})
self.assertEqual(test_record.phone_nbr, 'incorrect')
self.assertEqual(test_record.phone_sanitized, self.test_numbers_san[0])
test_record.write({'mobile_nbr': 'incorrect'})
self.assertEqual(test_record.mobile_nbr, 'incorrect')
self.assertEqual(test_record.phone_sanitized, False)
test_record.write({'phone_nbr': self.test_numbers[1]})
self.assertEqual(test_record.phone_nbr, self.test_numbers[1])
self.assertEqual(test_record.phone_sanitized, self.test_numbers_san[1])
+179 -82
View File
@@ -1,13 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.sms.tests import common as sms_common
from odoo.addons.test_mail.tests import common as test_mail_common
from odoo.addons.test_mail_full.tests import common as test_mail_full_common
class TestSMSComposerComment(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
class TestSMSComposerComment(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_full_common.TestRecipients):
""" TODO LIST
* add test for default_res_model / default_res_id and stuff like that;
@@ -21,11 +19,6 @@ class TestSMSComposerComment(test_mail_full_common.BaseFunctionalTest, sms_commo
super(TestSMSComposerComment, cls).setUpClass()
cls._test_body = 'VOID CONTENT'
cls.partner_numbers = [
phone_validation.phone_format(partner.mobile, partner.country_id.code, partner.country_id.phone_code, force_format='E164')
for partner in (cls.partner_1 | cls.partner_2)
]
cls.test_record = cls.env['mail.test.sms'].with_context(**cls._test_context).create({
'name': 'Test',
'customer_id': cls.partner_1.id,
@@ -139,59 +132,92 @@ class TestSMSComposerComment(test_mail_full_common.BaseFunctionalTest, sms_commo
composer._action_send_sms()
self.assertSMSSent(self.random_numbers_san, self._test_body)
# def test_composer_partners_sanitize(self):
# partner_incorrect = self.env['res.partner'].create({
# 'name': 'Jean-Claude Incorrect',
# 'email': 'jean.claude@example.com',
# 'mobile': 'coincoin',
# })
# partners = self.partner_1 | self.partner_2 | partner_incorrect
# with self.sudo('employee'):
# composer = self.env['sms.composer'].with_context(
# active_model='res.partner',
# active_domain=[('id', 'in', partners.ids)]
# ).create({
# 'body': self._test_body,
# })
# with self.mockSMSGateway():
# composer.action_send_sms()
# self.assertSMSSent((self.partner_1 | self.partner_2).mapped('mobile'), test_body)
class TestSMSComposerBatch(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS):
@classmethod
def setUpClass(cls):
super(TestSMSComposerBatch, cls).setUpClass()
cls._test_body = 'Zizisse an SMS.'
cls._create_records_for_batch('mail.test.sms', 3)
cls.sms_template = cls._create_sms_template('mail.test.sms')
def test_composer_batch_active_domain(self):
with self.sudo('employee'):
composer = self.env['sms.composer'].with_context(
default_composition_mode='comment',
default_res_model='mail.test.sms',
default_use_active_domain=True,
active_domain=[('id', 'in', self.records.ids)],
).create({
'body': self._test_body,
})
with self.mockSMSGateway():
messages = composer._action_send_sms()
for record in self.records:
self.assertSMSNotification([{'partner': r.customer_id} for r in self.records], 'Zizisse an SMS.', messages)
def test_composer_batch_active_ids(self):
with self.sudo('employee'):
composer = self.env['sms.composer'].with_context(
default_composition_mode='comment',
default_res_model='mail.test.sms',
active_ids=self.records.ids
).create({
'body': self._test_body,
})
with self.mockSMSGateway():
messages = composer._action_send_sms()
for record in self.records:
self.assertSMSNotification([{'partner': r.customer_id} for r in self.records], 'Zizisse an SMS.', messages)
def test_composer_batch_domain(self):
with self.sudo('employee'):
composer = self.env['sms.composer'].with_context(
default_composition_mode='comment',
default_res_model='mail.test.sms',
default_use_active_domain=True,
default_active_domain=repr([('id', 'in', self.records.ids)]),
).create({
'body': self._test_body,
})
with self.mockSMSGateway():
messages = composer._action_send_sms()
for record in self.records:
self.assertSMSNotification([{'partner': r.customer_id} for r in self.records], 'Zizisse an SMS.', messages)
def test_composer_batch_res_ids(self):
with self.sudo('employee'):
composer = self.env['sms.composer'].with_context(
default_composition_mode='comment',
default_res_model='mail.test.sms',
default_res_ids=repr(self.records.ids),
).create({
'body': self._test_body,
})
with self.mockSMSGateway():
messages = composer._action_send_sms()
for record in self.records:
self.assertSMSNotification([{'partner': r.customer_id} for r in self.records], 'Zizisse an SMS.', messages)
class TestSMSComposerMass(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
""" TODO LIST
* add test for mass with log note
"""
class TestSMSComposerMass(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS):
@classmethod
def setUpClass(cls):
super(TestSMSComposerMass, cls).setUpClass()
cls._test_body = 'Zizisse an SMS.'
records = cls.env['mail.test.sms']
partners = cls.env['res.partner']
country_id = cls.env.ref('base.be').id,
for x in range(3):
partners += cls.env['res.partner'].with_context(**cls._test_context).create({
'name': 'Partner_%s' % (x),
'email': '_test_partner_%s@example.com' % (x),
'country_id': country_id,
'mobile': '047500%s%s99' % (x, x)
})
records += cls.env['mail.test.sms'].with_context(**cls._test_context).create({
'name': 'Test_%s' % (x),
'customer_id': partners[x].id,
})
cls.records = cls._reset_mail_context(records)
cls.partners = partners
cls.sms_template = cls.env['sms.template'].create({
'name': 'Test Template',
'model_id': cls.env['ir.model']._get('mail.test.sms').id,
'body': 'Dear ${object.display_name} this is an SMS.',
})
cls._create_records_for_batch('mail.test.sms', 3)
cls.sms_template = cls._create_sms_template('mail.test.sms')
def test_composer_mass_active_domain(self):
with self.sudo('employee'):
@@ -199,9 +225,10 @@ class TestSMSComposerMass(test_mail_full_common.BaseFunctionalTest, sms_common.M
default_composition_mode='mass',
default_res_model='mail.test.sms',
default_use_active_domain=True,
active_domain=repr([('id', 'in', self.records.ids)]),
active_domain=[('id', 'in', self.records.ids)],
).create({
'body': self._test_body,
'mass_keep_log': False,
})
with self.mockSMSGateway():
@@ -216,9 +243,10 @@ class TestSMSComposerMass(test_mail_full_common.BaseFunctionalTest, sms_common.M
default_composition_mode='mass',
default_res_model='mail.test.sms',
default_use_active_domain=True,
active_domain=repr([('id', 'in', self.records.ids)]),
active_domain=[('id', 'in', self.records.ids)],
default_template_id=self.sms_template.id,
).create({
'mass_keep_log': False,
})
with self.mockSMSGateway():
@@ -235,6 +263,7 @@ class TestSMSComposerMass(test_mail_full_common.BaseFunctionalTest, sms_common.M
active_ids=self.records.ids,
).create({
'body': self._test_body,
'mass_keep_log': False,
})
with self.mockSMSGateway():
@@ -243,6 +272,84 @@ class TestSMSComposerMass(test_mail_full_common.BaseFunctionalTest, sms_common.M
for partner in self.partners:
self.assertSMSOutgoing(partner, None, self._test_body)
def test_composer_mass_active_ids_w_blacklist(self):
self.env['phone.blacklist'].create([{
'number': p.phone_sanitized,
'active': True,
} for p in self.partners[:5]])
with self.sudo('employee'):
composer = self.env['sms.composer'].with_context(
default_composition_mode='mass',
default_res_model='mail.test.sms',
active_ids=self.records.ids,
).create({
'body': self._test_body,
'mass_keep_log': False,
'mass_use_blacklist': True,
})
with self.mockSMSGateway():
composer.action_send_sms()
for partner in self.partners[5:]:
self.assertSMSOutgoing(partner, partner.phone_sanitized, content=self._test_body)
for partner in self.partners[:5]:
self.assertSMSCanceled(partner, partner.phone_sanitized, 'sms_blacklist', content=self._test_body)
def test_composer_mass_active_ids_wo_blacklist(self):
self.env['phone.blacklist'].create([{
'number': p.phone_sanitized,
'active': True,
} for p in self.partners[:5]])
with self.sudo('employee'):
composer = self.env['sms.composer'].with_context(
default_composition_mode='mass',
default_res_model='mail.test.sms',
active_ids=self.records.ids,
).create({
'body': self._test_body,
'mass_keep_log': False,
'mass_use_blacklist': False,
})
with self.mockSMSGateway():
composer.action_send_sms()
for partner in self.partners:
self.assertSMSOutgoing(partner, partner.phone_sanitized, content=self._test_body)
def test_composer_mass_active_ids_w_blacklist_and_done(self):
self.env['phone.blacklist'].create([{
'number': p.phone_sanitized,
'active': True,
} for p in self.partners[:5]])
for p in self.partners[8:]:
p.mobile = self.partners[8].mobile
self.assertEqual(p.phone_sanitized, self.partners[8].phone_sanitized)
with self.sudo('employee'):
composer = self.env['sms.composer'].with_context(
default_composition_mode='mass',
default_res_model='mail.test.sms',
active_ids=self.records.ids,
).create({
'body': self._test_body,
'mass_keep_log': False,
'mass_use_blacklist': True,
})
with self.mockSMSGateway():
composer.action_send_sms()
for partner in self.partners[8:]:
self.assertSMSOutgoing(partner, partner.phone_sanitized, content=self._test_body)
for partner in self.partners[5:8]:
self.assertSMSCanceled(partner, partner.phone_sanitized, 'sms_duplicate', content=self._test_body)
for partner in self.partners[:5]:
self.assertSMSCanceled(partner, partner.phone_sanitized, 'sms_blacklist', content=self._test_body)
def test_composer_mass_active_ids_w_template(self):
with self.sudo('employee'):
composer = self.env['sms.composer'].with_context(
@@ -250,7 +357,9 @@ class TestSMSComposerMass(test_mail_full_common.BaseFunctionalTest, sms_common.M
default_res_model='mail.test.sms',
active_ids=self.records.ids,
default_template_id=self.sms_template.id,
).create({})
).create({
'mass_keep_log': False,
})
with self.mockSMSGateway():
composer.action_send_sms()
@@ -281,7 +390,9 @@ class TestSMSComposerMass(test_mail_full_common.BaseFunctionalTest, sms_common.M
default_res_model='mail.test.sms',
active_ids=self.records.ids,
default_template_id=self.sms_template.id,
).create({})
).create({
'mass_keep_log': False,
})
with self.mockSMSGateway():
composer.action_send_sms()
@@ -292,34 +403,20 @@ class TestSMSComposerMass(test_mail_full_common.BaseFunctionalTest, sms_common.M
else:
self.assertSMSOutgoing(record.customer_id, None, 'Dear %s this is an SMS.' % record.display_name)
def test_message_schedule_sms(self):
def test_composer_mass_active_ids_w_template_and_log(self):
with self.sudo('employee'):
composer = self.env['sms.composer'].with_context(
default_composition_mode='mass',
default_res_model='mail.test.sms',
active_ids=self.records.ids,
default_template_id=self.sms_template.id,
).create({
'mass_keep_log': True,
})
with self.mockSMSGateway():
self.env['mail.test.sms'].browse(self.records.ids)._message_sms_schedule_mass(body=self._test_body)
for record in self.records:
self.assertSMSOutgoing(record.customer_id, None, self._test_body)
def test_message_schedule_sms_w_log(self):
with self.sudo('employee'):
with self.mockSMSGateway():
self.env['mail.test.sms'].browse(self.records.ids)._message_sms_schedule_mass(body=self._test_body, mass_keep_log=True)
for record in self.records:
self.assertSMSNotification([{'partner': record.customer_id, 'state': 'ready'}], self._test_body)
def test_message_schedule_sms_w_template(self):
with self.sudo('employee'):
with self.mockSMSGateway():
self.env['mail.test.sms'].browse(self.records.ids)._message_sms_schedule_mass(template=self.sms_template)
composer.action_send_sms()
for record in self.records:
self.assertSMSOutgoing(record.customer_id, None, 'Dear %s this is an SMS.' % record.display_name)
def test_message_schedule_sms_w_template_and_log(self):
with self.sudo('employee'):
with self.mockSMSGateway():
self.env['mail.test.sms'].browse(self.records.ids)._message_sms_schedule_mass(template=self.sms_template, mass_keep_log=True)
for record in self.records:
self.assertSMSNotification([{'partner': record.customer_id, 'state': 'ready'}], 'Dear %s this is an SMS.' % record.display_name)
self.assertSMSLogged(record, 'Dear %s this is an SMS.' % record.display_name)
@@ -2,11 +2,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sms.tests import common as sms_common
from odoo.addons.test_mail.tests import common as test_mail_common
from odoo.addons.test_mail_full.tests import common as test_mail_full_common
class TestSMSWizards(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
class TestSMSWizards(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_full_common.TestRecipients):
@classmethod
def setUpClass(cls):
@@ -113,7 +113,7 @@ class TestSMSMassPerformance(BaseMailPerformance, sms_common.MockSMS):
'name': 'Partner_%s' % (x),
'email': '_test_partner_%s@example.com' % (x),
'country_id': be_country_id,
'mobile': '047500%s%s99' % (x, x)
'mobile': '047500%02d%02d' % (x, x)
})
records += self.env['mail.test.sms'].with_context(**self._quick_create_ctx).create({
'name': 'Test_%s' % (x),
@@ -136,14 +136,28 @@ class TestSMSMassPerformance(BaseMailPerformance, sms_common.MockSMS):
default_composition_mode='mass',
default_res_model='mail.test.sms',
default_use_active_domain=True,
active_domain=repr([('id', 'in', self.records.ids)]),
active_domain=[('id', 'in', self.records.ids)],
).create({
'body': self._test_body,
'mass_keep_log': False,
})
with self.mockSMSGateway(), self.assertQueryCount(employee=108):
composer.action_send_sms()
# TDE FIXME
# for record in self.records:
# self.assertSMSOutgoing(record.customer_id, None, self._test_body)
@mute_logger('odoo.addons.sms.models.sms_sms')
@users('employee')
@warmup
def test_composer_mass_active_domain_w_log(self):
composer = self.env['sms.composer'].with_context(
default_composition_mode='mass',
default_res_model='mail.test.sms',
default_use_active_domain=True,
active_domain=[('id', 'in', self.records.ids)],
).create({
'body': self._test_body,
'mass_keep_log': True,
})
with self.mockSMSGateway(), self.assertQueryCount(employee=159):
composer.action_send_sms()
+49 -11
View File
@@ -1,13 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.sms.tests import common as sms_common
from odoo.addons.test_mail.tests import common as test_mail_common
from odoo.addons.test_mail_full.tests import common as test_mail_full_common
class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_full_common.TestRecipients):
""" TODO
* add tests for new mail.message and mail.thread fields;
@@ -18,11 +16,6 @@ class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS,
super(TestSMSPost, cls).setUpClass()
cls._test_body = 'VOID CONTENT'
cls.partner_numbers = [
phone_validation.phone_format(partner.mobile, partner.country_id.code, partner.country_id.phone_code, force_format='E164')
for partner in (cls.partner_1 | cls.partner_2)
]
cls.test_record = cls.env['mail.test.sms'].with_context(**cls._test_context).create({
'name': 'Test',
'customer_id': cls.partner_1.id,
@@ -227,7 +220,7 @@ class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS,
self.assertSMSNotification([{'partner': self.partner_1, 'number': self.test_numbers_san[1]}], 'Dear %s this is an SMS.' % self.test_record.display_name, messages)
class TestSMSPostException(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
class TestSMSPostException(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_full_common.TestRecipients):
@classmethod
def setUpClass(cls):
@@ -304,7 +297,7 @@ class TestSMSPostException(test_mail_full_common.BaseFunctionalTest, sms_common.
], self._test_body, messages)
def test_message_sms_crash_credit_single(self):
with self.sudo('employee'), self.mockSMSGateway(nbr_t_error={phone_validation.phone_get_sanitized_record_number(self.partner_2): 'credit'}):
with self.sudo('employee'), self.mockSMSGateway(nbr_t_error={self.partner_2.phone_get_sanitized_number(): 'credit'}):
test_record = self.env['mail.test.sms'].browse(self.test_record.id)
messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2 | self.partner_3).ids)
@@ -336,7 +329,7 @@ class TestSMSPostException(test_mail_full_common.BaseFunctionalTest, sms_common.
], self._test_body, messages)
def test_message_sms_crash_wrong_number_single(self):
with self.sudo('employee'), self.mockSMSGateway(nbr_t_error={phone_validation.phone_get_sanitized_record_number(self.partner_2): 'wrong_number_format'}):
with self.sudo('employee'), self.mockSMSGateway(nbr_t_error={self.partner_2.phone_get_sanitized_number(): 'wrong_number_format'}):
test_record = self.env['mail.test.sms'].browse(self.test_record.id)
messages = test_record._message_sms(self._test_body, partner_ids=(self.partner_1 | self.partner_2 | self.partner_3).ids)
@@ -345,3 +338,48 @@ class TestSMSPostException(test_mail_full_common.BaseFunctionalTest, sms_common.
{'partner': self.partner_2, 'state': 'exception', 'failure_type': 'sms_number_format'},
{'partner': self.partner_3, 'state': 'sent'},
], self._test_body, messages)
class TestSMSApi(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS):
@classmethod
def setUpClass(cls):
super(TestSMSApi, cls).setUpClass()
cls._test_body = 'Zizisse an SMS.'
cls._create_records_for_batch('mail.test.sms', 3)
cls.sms_template = cls._create_sms_template('mail.test.sms')
def test_message_schedule_sms(self):
with self.sudo('employee'):
with self.mockSMSGateway():
self.env['mail.test.sms'].browse(self.records.ids)._message_sms_schedule_mass(body=self._test_body, mass_keep_log=False)
for record in self.records:
self.assertSMSOutgoing(record.customer_id, None, self._test_body)
def test_message_schedule_sms_w_log(self):
with self.sudo('employee'):
with self.mockSMSGateway():
self.env['mail.test.sms'].browse(self.records.ids)._message_sms_schedule_mass(body=self._test_body, mass_keep_log=True)
for record in self.records:
self.assertSMSOutgoing(record.customer_id, None, self._test_body)
self.assertSMSLogged(record, self._test_body)
def test_message_schedule_sms_w_template(self):
with self.sudo('employee'):
with self.mockSMSGateway():
self.env['mail.test.sms'].browse(self.records.ids)._message_sms_schedule_mass(template=self.sms_template, mass_keep_log=False)
for record in self.records:
self.assertSMSOutgoing(record.customer_id, None, 'Dear %s this is an SMS.' % record.display_name)
def test_message_schedule_sms_w_template_and_log(self):
with self.sudo('employee'):
with self.mockSMSGateway():
self.env['mail.test.sms'].browse(self.records.ids)._message_sms_schedule_mass(template=self.sms_template, mass_keep_log=True)
for record in self.records:
self.assertSMSOutgoing(record.customer_id, None, 'Dear %s this is an SMS.' % record.display_name)
self.assertSMSLogged(record, 'Dear %s this is an SMS.' % record.display_name)
@@ -2,15 +2,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sms.tests import common as sms_common
from odoo.addons.test_mail.tests import common as test_mail_common
from odoo.addons.test_mail_full.tests import common as test_mail_full_common
class TestSmsTemplate(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.TestRecipients):
class TestServerAction(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_full_common.TestRecipients):
@classmethod
def setUpClass(cls):
super(TestSmsTemplate, cls).setUpClass()
super(TestServerAction, cls).setUpClass()
cls.test_record = cls.env['mail.test.sms'].with_context(**cls._test_context).create({
'name': 'Test',
'customer_id': cls.partner_1.id,
@@ -21,16 +20,10 @@ class TestSmsTemplate(test_mail_full_common.BaseFunctionalTest, sms_common.MockS
'phone_nbr': cls.test_numbers[0],
})
model_id = cls.env['ir.model']._get('mail.test.sms').id
cls.sms_template = cls.env['sms.template'].create({
'name': 'Test Template',
'model_id': model_id,
'body': 'Dear ${object.display_name} this is an SMS.'
})
cls.sms_template = cls._create_sms_template('mail.test.sms')
cls.action = cls.env['ir.actions.server'].create({
'name': 'Test SMS Action',
'model_id': model_id,
'model_id': cls.env['ir.model']._get('mail.test.sms').id,
'state': 'sms',
'sms_template_id': cls.sms_template.id,
})
@@ -67,5 +60,8 @@ class TestSmsTemplate(test_mail_full_common.BaseFunctionalTest, sms_common.MockS
with self.sudo('employee'), self.mockSMSGateway():
self.action.with_user(self.env.user).with_context(**context).run()
self.assertSMSNotification([{'partner': self.test_record.customer_id, 'state': 'ready'}], 'Dear %s this is an SMS.' % self.test_record.display_name)
self.assertSMSNotification([{'partner': self.env['res.partner'], 'number': self.test_numbers_san[0], 'state': 'ready'}], 'Dear %s this is an SMS.' % self.test_record_2.display_name)
self.assertSMSOutgoing(self.test_record.customer_id, None, 'Dear %s this is an SMS.' % self.test_record.display_name)
self.assertSMSLogged(self.test_record, 'Dear %s this is an SMS.' % self.test_record.display_name)
self.assertSMSOutgoing(self.env['res.partner'], self.test_numbers_san[0], 'Dear %s this is an SMS.' % self.test_record_2.display_name)
self.assertSMSLogged(self.test_record_2, 'Dear %s this is an SMS.' % self.test_record_2.display_name)
+108 -2
View File
@@ -1,17 +1,61 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import werkzeug
from unittest.mock import patch
from unittest.mock import DEFAULT
from odoo import exceptions
from odoo.addons.sms.models.sms_sms import SmsSms as SmsSms
from odoo.addons.sms.tests import common as sms_common
from odoo.addons.test_mail.tests import common as test_mail_common
from odoo.addons.test_mail_full.tests import common as test_mail_full_common
from odoo.tests import common
class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
class LinkTrackerMock(common.BaseCase):
def setUp(self):
super(LinkTrackerMock, self).setUp()
def _compute_favicon():
# 1px to avoid real request
return 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='
def _get_title_from_url(u):
return "Test_TITLE"
self.env['ir.config_parameter'].sudo().set_param('web.base.url', 'https://test.odoo.com')
link_tracker_favicon_patch = patch('odoo.addons.link_tracker.models.link_tracker.LinkTracker._compute_favicon', wraps=_compute_favicon)
link_tracker_title_patch = patch('odoo.addons.link_tracker.models.link_tracker.LinkTracker._get_title_from_url', wraps=_get_title_from_url)
link_tracker_favicon_patch.start()
link_tracker_title_patch.start()
self.addCleanup(link_tracker_favicon_patch.stop)
self.addCleanup(link_tracker_title_patch.stop)
self.utm_c = self.env.ref('utm.utm_campaign_fall_drive')
self.utm_m = self.env.ref('mass_mailing_sms.utm_medium_sms')
self.tracker_values = {
'campaign_id': self.utm_c.id,
'medium_id': self.utm_m.id,
}
def assertLinkTracker(self, url, url_params):
links = self.env['link.tracker'].sudo().search([('url', '=', url)])
self.assertEqual(len(links), 1)
# check UTMS are correctly set on redirect URL
original_url = werkzeug.urls.url_parse(url)
redirect_url = werkzeug.urls.url_parse(links.redirected_url)
redirect_params = redirect_url.decode_query().to_dict(flat=True)
self.assertEqual(redirect_url.scheme, original_url.scheme)
self.assertEqual(redirect_url.decode_netloc(), original_url.decode_netloc())
self.assertEqual(redirect_url.path, original_url.path)
self.assertEqual(redirect_params, url_params)
class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, LinkTrackerMock):
@classmethod
def setUpClass(cls):
@@ -25,6 +69,68 @@ class TestSMSPost(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS,
'body': cls._test_body,
})
def test_body_link_shorten(self):
link = 'http://www.example.com'
self.env['link.tracker'].search([('url', '=', link)]).unlink()
new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values)
self.assertNotIn(link, new_body)
self.assertLinkTracker(link, {'utm_campaign': self.utm_c.name, 'utm_medium': self.utm_m.name})
link = self.env['link.tracker'].search([('url', '=', link)])
self.assertIn(link.short_url, new_body)
link = 'https://test.odoo.com/my/super_page'
self.env['link.tracker'].search([('url', '=', link)]).unlink()
new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values)
self.assertNotIn(link, new_body)
self.assertLinkTracker(link, {'utm_campaign': self.utm_c.name, 'utm_medium': self.utm_m.name})
link = self.env['link.tracker'].search([('url', '=', link)])
self.assertIn(link.short_url, new_body)
def test_body_link_shorten_wshort(self):
link = 'https://test.odoo.com/r/RAOUL'
self.env['link.tracker'].search([('url', '=', link)]).unlink()
new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values)
self.assertIn(link, new_body)
self.assertFalse(self.env['link.tracker'].search([('url', '=', link)]))
def test_body_link_shorten_wunsubscribe(self):
link = 'https://test.odoo.com/sms/3/'
self.env['link.tracker'].search([('url', '=', link)]).unlink()
new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values)
self.assertIn(link, new_body)
self.assertFalse(self.env['link.tracker'].search([('url', '=', link)]))
def test_sms_body_link_shorten_suffix(self):
mailing = self.env['mailing.mailing'].create({
'name': 'Minimal mailing',
'mailing_model_id': self.env['ir.model']._get('mail.test.sms').id,
'mailing_type': 'sms',
})
sms_0 = self.env['sms.sms'].create({
'body': 'Welcome to https://test.odoo.com',
'number': '12',
'mailing_id': mailing.id,
})
sms_1 = self.env['sms.sms'].create({
'body': 'Welcome to https://test.odoo.com/r/RAOUL',
'number': '12',
})
sms_2 = self.env['sms.sms'].create({
'body': 'Welcome to https://test.odoo.com/r/RAOUL',
'number': '12', 'mailing_id': mailing.id,
})
sms_3 = self.env['sms.sms'].create({
'body': 'Welcome to https://test.odoo.com/leodagan/r/RAOUL',
'number': '12', 'mailing_id': mailing.id,
})
res = (sms_0 | sms_1 | sms_2 | sms_3)._update_body_short_links()
self.assertEqual(res[sms_0.id], 'Welcome to https://test.odoo.com')
self.assertEqual(res[sms_1.id], 'Welcome to https://test.odoo.com/r/RAOUL')
self.assertEqual(res[sms_2.id], 'Welcome to https://test.odoo.com/r/RAOUL/s/%s' % sms_2.id)
self.assertEqual(res[sms_3.id], 'Welcome to https://test.odoo.com/leodagan/r/RAOUL')
def test_sms_send_batch_size(self):
self.count = 0
@@ -2,11 +2,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sms.tests import common as sms_common
from odoo.addons.test_mail.tests import common as test_mail_common
from odoo.addons.test_mail_full.tests import common as test_mail_full_common
class TestSmsTemplate(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_common.MockEmails, test_mail_common.TestRecipients):
class TestSmsTemplate(test_mail_full_common.BaseFunctionalTest, sms_common.MockSMS, test_mail_full_common.TestRecipients):
@classmethod
def setUpClass(cls):
@@ -19,11 +18,7 @@ class TestSmsTemplate(test_mail_full_common.BaseFunctionalTest, sms_common.MockS
cls.body_en = 'Dear ${object.display_name} this is an SMS.'
cls.body_fr = u"Hello ${object.display_name} ceci est en français."
cls.sms_template = cls.env['sms.template'].create({
'name': 'Test Template',
'model_id': cls.env['ir.model']._get('mail.test.sms').id,
'body': cls.body_en,
})
cls.sms_template = cls._create_sms_template('mail.test.sms', body=cls.body_en)
def test_sms_template_render(self):
rendered_body = self.sms_template._render_template(self.sms_template.body, self.sms_template.model, self.test_record.ids)
@@ -45,7 +45,7 @@ class TestAutoBlacklist(common.TransactionCase):
'mailing_domain': [('id', 'in',
[self.mailing_contact_1.id])],
'body_html': 'This is a bounced mail for auto blacklist demo'})
self.mass_mailing.put_in_queue()
self.mass_mailing.action_put_in_queue()
res_ids = self.mass_mailing._get_remaining_recipients()
composer_values = {
'body': self.mass_mailing.convert_links()[self.mass_mailing.id],

Some files were not shown because too many files have changed in this diff Show More