[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: Task1922163(4287481) Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
@@ -545,7 +545,7 @@ class Meeting(models.Model):
|
||||
"""
|
||||
|
||||
_name = 'calendar.event'
|
||||
_description = "Event"
|
||||
_description = "Calendar Event"
|
||||
_order = "id desc"
|
||||
_inherit = ["mail.thread"]
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
target="new"
|
||||
context="{
|
||||
'default_composition_mode': 'comment',
|
||||
'default_res_id': active_id,
|
||||
}"
|
||||
/>
|
||||
</odoo>
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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&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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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', '<', 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', '<', 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': ['&', ('sent', '=', 0), ('state', 'in', ('draft','test'))]}" >
|
||||
<field name="sent" string="Emails Sent" widget="statinfo"/>
|
||||
icon="fa-envelope-o" class="oe_stat_button"
|
||||
attrs="{'invisible': ['&', ('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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
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
|
||||
@@ -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': ['|', '&', ('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': ['|', '&', ('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>
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
@@ -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,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,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
|
||||
|
||||
@@ -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)]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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])
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user