diff --git a/addons/calendar_sms/__manifest__.py b/addons/calendar_sms/__manifest__.py index 70156156a8a..f91a362bbe2 100644 --- a/addons/calendar_sms/__manifest__.py +++ b/addons/calendar_sms/__manifest__.py @@ -9,6 +9,7 @@ 'version': '1.0', 'depends': ['calendar', 'sms'], 'data': [ + 'data/sms_data.xml', 'views/calendar_views.xml', ], 'application': False, diff --git a/addons/calendar_sms/data/sms_data.xml b/addons/calendar_sms/data/sms_data.xml new file mode 100644 index 00000000000..8016fd429fe --- /dev/null +++ b/addons/calendar_sms/data/sms_data.xml @@ -0,0 +1,10 @@ + + + + + Calendar Event: Reminder + + Event reminder: ${object.name} on ${object.start_datetime or object.start_date} + + + diff --git a/addons/calendar_sms/models/calendar.py b/addons/calendar_sms/models/calendar.py index 65cd1a8cad5..9071a0181a3 100644 --- a/addons/calendar_sms/models/calendar.py +++ b/addons/calendar_sms/models/calendar.py @@ -11,7 +11,7 @@ _logger = logging.getLogger(__name__) class CalendarEvent(models.Model): _inherit = 'calendar.event' - def _get_default_sms_recipients(self): + def _sms_get_default_partners(self): """ Method overriden from mail.thread (defined in the sms module). SMS text messages will be sent to attendees that haven't declined the event(s). """ @@ -20,9 +20,12 @@ class CalendarEvent(models.Model): def _do_sms_reminder(self): """ Send an SMS text reminder to attendees that haven't declined the event """ for event in self: - sms_msg = _("Event reminder: %s on %s.") % (event.name, event.start_datetime or event.start_date) - note_msg = _('SMS text message reminder sent !') - event.message_post_send_sms(sms_msg, note_msg=note_msg) + event._message_sms_with_template( + template_xmlid='calendar_sms.sms_template_data_calendar_reminder', + template_fallback=_("Event reminder: %s on %s.") % (event.name, event.start_datetime or event.start_date), + partner_ids=self._sms_get_default_partners().ids, + put_in_queue=False + ) class CalendarAlarm(models.Model): diff --git a/addons/hr/models/hr_employee.py b/addons/hr/models/hr_employee.py index 887d23bd904..abdb09e996e 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -396,3 +396,9 @@ class HrEmployeePrivate(models.Model): @api.returns('mail.message', lambda value: value.id) 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_number_fields(self): + return ['mobile_phone'] diff --git a/addons/hr_presence/__manifest__.py b/addons/hr_presence/__manifest__.py index 8758156cddf..d0fdba48fff 100644 --- a/addons/hr_presence/__manifest__.py +++ b/addons/hr_presence/__manifest__.py @@ -21,6 +21,7 @@ Allows to contact directly the employee in case of unjustified absence. 'views/res_config_settings_views.xml', 'data/ir_actions_server.xml', 'views/hr_employee_views.xml', + 'data/sms_data.xml', 'data/mail_data.xml', ], 'demo': [], diff --git a/addons/hr_presence/data/sms_data.xml b/addons/hr_presence/data/sms_data.xml new file mode 100644 index 00000000000..8a334c772cc --- /dev/null +++ b/addons/hr_presence/data/sms_data.xml @@ -0,0 +1,12 @@ + + + + + Employee: Presence Reminder + + Exception made if there was a mistake of ours, it seems that you are not at your office and there is not request of leaves from you. +Please, take appropriate measures in order to carry out this work absence. +Do not hesitate to contact your manager or the human resource department. + + + diff --git a/addons/hr_presence/models/hr_employee.py b/addons/hr_presence/models/hr_employee.py index a40dfd256eb..2f0c55a9571 100644 --- a/addons/hr_presence/models/hr_employee.py +++ b/addons/hr_presence/models/hr_employee.py @@ -122,24 +122,33 @@ class ResCompany(models.Model): "context": {'default_employee_id': self.id}, } + # -------------------------------------------------- + # Messaging + # -------------------------------------------------- + def action_send_sms(self): self.ensure_one() if not self.env.user.has_group('hr.group_hr_manager'): raise UserError(_("You don't have the right to do this. Please contact an Administrator.")) if not self.mobile_phone: raise UserError(_("There is no professional phone for this employee.")) - body = _("""Exception made if there was a mistake of ours, it seems that you are not at your office and there is not request of leaves from you. + + context = dict(self.env.context) + context.update(default_res_model='hr.employee', default_res_id=self.id, default_composition_mode='comment', default_number_field_name='mobile_phone') + + template = self.env.ref('hr_presence.sms_template_presence', False) + if not template: + context['default_body'] = _("""Exception made if there was a mistake of ours, it seems that you are not at your office and there is not request of leaves from you. Please, take appropriate measures in order to carry out this work absence. Do not hesitate to contact your manager or the human resource department.""") + else: + context['default_template_id'] = template.id + return { "type": "ir.actions.act_window", "res_model": "sms.composer", "view_mode": 'form', - "context": { - 'active_id': self.id, - 'default_message': body, - 'default_recipients': self.mobile_phone, - }, + "context": context, "name": "Send SMS", "target": "new", } diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 7912a856ca4..cbc548825d9 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -503,15 +503,10 @@ class Channel(models.Model): notifications.append([(self._cr.dbname, 'res.partner', partner.id), channel_info]) return notifications - def _notify_thread(self, message, msg_vals=False, model_description=False, mail_auto_delete=True): + def _notify_thread(self, message, msg_vals=False, **kwargs): # When posting a message on a mail channel, manage moderation and postpone notify users if not msg_vals or msg_vals.get('moderation_status') != 'pending_moderation': - super(Channel, self)._notify_thread( - message, - msg_vals=msg_vals, - model_description=model_description, - mail_auto_delete=mail_auto_delete, - ) + super(Channel, self)._notify_thread(message, msg_vals=msg_vals, **kwargs) else: message._notify_pending_by_chat() diff --git a/addons/mail/models/mail_followers.py b/addons/mail/models/mail_followers.py index 1b5c94972c2..417b83e59f5 100644 --- a/addons/mail/models/mail_followers.py +++ b/addons/mail/models/mail_followers.py @@ -81,7 +81,7 @@ class Followers(models.Model): # Private tools methods to fetch followers data # -------------------------------------------------- - def _get_recipient_data(self, records, subtype_id, pids=None, cids=None): + def _get_recipient_data(self, records, message_type, subtype_id, pids=None, cids=None): """ Private method allowing to fetch recipients data based on a subtype. Purpose of this method is to fetch all data necessary to notify recipients in a single query. It fetches data from @@ -92,6 +92,7 @@ class Followers(models.Model): * channels if cids is given; :param records: fetch data from followers of records that follow subtype_id; + :param message_type: mail.message.message_type in order to allow custom behavior depending on it (SMS for example); :param subtype_id: mail.message.subtype to check against followers; :param pids: additional set of partner IDs from which to fetch recipient data; :param cids: additional set of channel IDs from which to fetch recipient data; diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index f97d0a52cde..f9069021307 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -154,24 +154,24 @@ class MailMail(models.Model): notif_mails_ids = [mail.id for mail in self if mail.notification] if notif_mails_ids: notifications = self.env['mail.notification'].search([ - ('is_email', '=', True), + ('notification_type', '=', 'email'), ('mail_id', 'in', notif_mails_ids), - ('email_status', 'not in', ('sent', 'canceled')) + ('notification_status', 'not in', ('sent', 'canceled')) ]) if notifications: - #find all notification linked to a failure + # find all notification linked to a failure failed = self.env['mail.notification'] if failure_type: failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids) failed.sudo().write({ - 'email_status': 'exception', + 'notification_status': 'exception', 'failure_type': failure_type, 'failure_reason': failure_reason, }) messages = notifications.mapped('mail_message_id').filtered(lambda m: m.is_thread_message()) - messages._notify_failure_update() # notify user that we have a failure + messages._notify_mail_failure_update() # notify user that we have a failure (notifications - failed).sudo().write({ - 'email_status': 'sent', + 'notification_status': 'sent', 'failure_type': '', 'failure_reason': '', }) @@ -335,14 +335,14 @@ class MailMail(models.Model): # update in case an email bounces while sending all emails related to current # mail record. notifs = self.env['mail.notification'].search([ - ('is_email', '=', True), + ('notification_type', '=', 'email'), ('mail_id', 'in', mail.ids), - ('email_status', 'not in', ('sent', 'canceled')) + ('notification_status', 'not in', ('sent', 'canceled')) ]) if notifs: notif_msg = _('Error without exception. Probably due do concurrent access update of notification records. Please see with an administrator.') notifs.sudo().write({ - 'email_status': 'exception', + 'notification_status': 'exception', 'failure_type': 'UNKNOWN', 'failure_reason': notif_msg, }) diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index e90249e9533..45b71613a85 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -5,6 +5,7 @@ import logging import re from binascii import Error as binascii_error +from collections import defaultdict from operator import itemgetter from email.utils import formataddr from openerp.http import request @@ -148,15 +149,15 @@ class Message(models.Model): def _compute_has_error(self): error_from_notification = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', self.ids), - ('email_status', 'in', ('bounce', 'exception'))]).mapped('mail_message_id') + ('notification_status', 'in', ('bounce', 'exception'))]).mapped('mail_message_id') for message in self: message.has_error = message in error_from_notification @api.multi def _search_has_error(self, operator, operand): if operator == '=' and operand: - return [('notification_ids.email_status', 'in', ('bounce', 'exception'))] - return ['!', ('notification_ids.email_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False" + return [('notification_ids.notification_status', 'in', ('bounce', 'exception'))] + return ['!', ('notification_ids.notification_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False" @api.depends('starred_partner_ids') def _get_starred(self): @@ -373,25 +374,22 @@ class Message(models.Model): partner_ids = [] if message.subtype_id: partner_ids = [partner_tree[partner.id] for partner in message.partner_ids - if partner.id in partner_tree] + if partner.id in partner_tree] else: partner_ids = [partner_tree[partner.id] for partner in message.partner_ids - if partner.id in partner_tree] + if partner.id in partner_tree] # we read customer_email_status before filtering inactive user because we don't want to miss a red enveloppe customer_email_status = ( - (all(n.email_status == 'sent' for n in message.notification_ids) and 'sent') or - (any(n.email_status == 'exception' for n in message.notification_ids) and 'exception') or - (any(n.email_status == 'bounce' for n in message.notification_ids) and 'bounce') or + (all(n.notification_status == 'sent' for n in message.notification_ids if n.notification_type == 'email') and 'sent') or + (any(n.notification_status == 'exception' for n in message.notification_ids if n.notification_type == 'email') and 'exception') or + (any(n.notification_status == 'bounce' for n in message.notification_ids if n.notification_type == 'email') and 'bounce') or 'ready' ) customer_email_data = [] - def filter_notification(notif): - return ( - (notif.email_status in ('bounce', 'exception', 'canceled') or notif.res_partner_id.partner_share) and - notif.res_partner_id.active - ) - for notification in message.notification_ids.filtered(filter_notification): - customer_email_data.append((partner_tree[notification.res_partner_id.id][0], partner_tree[notification.res_partner_id.id][1], notification.email_status)) + for notification in message.notification_ids.filtered( + lambda n: n.notification_type == 'email' and n.res_partner_id.active and + (n.notification_status in ('bounce', 'exception', 'canceled') or n.res_partner_id.partner_share)): + customer_email_data.append((partner_tree[notification.res_partner_id.id][0], partner_tree[notification.res_partner_id.id][1], notification.notification_status)) has_access_to_model = message.model and self.env[message.model].check_access_rights('read', raise_exception=False) if message.attachment_ids and message.res_id and issubclass(self.pool[message.model], self.pool['mail.thread']) and has_access_to_model: @@ -512,7 +510,7 @@ class Message(models.Model): # fetch notification status notif_dict = {} - notifs = self.env['mail.notification'].sudo().search([('mail_message_id', 'in', list(mid for mid in message_tree)), ('is_read', '=', False)]) + notifs = self.env['mail.notification'].sudo().search([('mail_message_id', 'in', list(mid for mid in message_tree)), ('res_partner_id', '!=', False), ('is_read', '=', False)]) for notif in notifs: mid = notif.mail_message_id.id if not notif_dict.get(mid): @@ -540,13 +538,45 @@ class Message(models.Model): 'moderation_status', ] + def _get_mail_failure_dict(self): + return { + 'message_id': self.id, + 'record_name': self.record_name, + 'model_name': self.env['ir.model']._get(self.model).display_name, + 'uuid': self.message_id, + 'res_id': self.res_id, + 'model': self.model, + 'last_message_date': self.date, + 'module_icon': '/mail/static/src/img/smiley/mailfailure.jpg', + } + @api.multi def _format_mail_failures(self): - """ - A shorter message to notify a failure update - """ + """ A shorter message to notify a failure update """ failures_infos = [] + + # prepare notifications computation in batch + all_notifications = self.env['mail.notification'].sudo().search([ + ('mail_message_id', 'in', self.ids) + ]) + msgid_to_notif = defaultdict(lambda: self.env['mail.notification'].sudo()) + for notif in all_notifications: + msgid_to_notif[notif.mail_message_id.id] += notif + # for each channel, build the information header and include the logged partner information + for message in self: + notifications = msgid_to_notif[message.id] + if not any(notification.notification_type == 'email' for notification in notifications): + continue + info = dict(message._get_mail_failure_dict(), + failure_type='mail', + notifications=dict((notif.res_partner_id.id, (notif.notification_status, notif.res_partner_id.name)) for notif in notifications)) + failures_infos.append(info) + return failures_infos + + @api.multi + def _notify_mail_failure_update(self): + messages = self.env['mail.message'] for message in self: # Check if user has access to the record before displaying a notification about it. # In case the user switches from one company to another, it might happen that he doesn't @@ -558,24 +588,10 @@ class Message(models.Model): record.check_access_rule('read') except AccessError: continue - info = { - 'message_id': message.id, - 'record_name': message.record_name, - 'model_name': self.env['ir.model']._get(message.model).display_name, - 'uuid': message.message_id, - 'res_id': message.res_id, - 'model': message.model, - 'last_message_date': message.date, - 'module_icon': '/mail/static/src/img/smiley/mailfailure.jpg', - 'notifications': dict((notif.res_partner_id.id, (notif.email_status, notif.res_partner_id.name)) for notif in message.notification_ids.sudo()) - } - failures_infos.append(info) - return failures_infos + else: + messages |= message - @api.multi - def _notify_failure_update(self): - authors = {} - for author, author_messages in groupby(self, itemgetter('author_id')): + for author, author_messages in groupby(messages, itemgetter('author_id')): self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', author.id), {'type': 'mail_failure', 'elements': self.env['mail.message'].concat(*author_messages)._format_mail_failures()} diff --git a/addons/mail/models/mail_notification.py b/addons/mail/models/mail_notification.py index 3397342a818..571023450d9 100644 --- a/addons/mail/models/mail_notification.py +++ b/addons/mail/models/mail_notification.py @@ -14,10 +14,12 @@ class Notification(models.Model): mail_message_id = fields.Many2one( 'mail.message', 'Message', index=True, ondelete='cascade', required=True) res_partner_id = fields.Many2one( - 'res.partner', 'Needaction Recipient', index=True, ondelete='cascade', required=True) + 'res.partner', 'Needaction Recipient', index=True, ondelete='cascade', required=False) is_read = fields.Boolean('Is Read', index=True) - is_email = fields.Boolean('Sent by Email', index=True) - email_status = fields.Selection([ + notification_type = fields.Selection([ + ('inbox', 'Inbox'), ('email', 'Email')], string='Notification Type', + default='inbox', index=True, required=True) + notification_status = fields.Selection([ ('ready', 'Ready to Send'), ('sent', 'Sent'), ('bounce', 'Bounced'), @@ -37,10 +39,17 @@ class Notification(models.Model): ], string='Failure type') failure_reason = fields.Text('Failure reason', copy=False) + _sql_constraints = [ + # email notification;: partner is required + ('notification_partner_required', + "CHECK(notification_type NOT IN ('email', 'inbox') OR res_partner_id IS NOT NULL)", + 'Customer is required for inbox / email notification'), + ] + def init(self): - self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('mail_notification_res_partner_id_is_read_email_status_mail_message_id',)) + self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('mail_notification_res_partner_id_is_read_notification_status_mail_message_id',)) if not self._cr.fetchone(): - self._cr.execute('CREATE INDEX mail_notification_res_partner_id_is_read_email_status_mail_message_id ON mail_message_res_partner_needaction_rel (res_partner_id, is_read, email_status, mail_message_id)') + self._cr.execute('CREATE INDEX mail_notification_res_partner_id_is_read_notification_status_mail_message_id ON mail_message_res_partner_needaction_rel (res_partner_id, is_read, notification_status, mail_message_id)') @api.multi def format_failure_reason(self): @@ -49,5 +58,3 @@ class Notification(models.Model): return dict(type(self).failure_type.selection).get(self.failure_type, _('No Error')) else: return _("Unknown error") + ": %s" % (self.failure_reason or '') - - diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index c1feb9ba820..432c0657b42 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -226,7 +226,7 @@ class MailThread(models.AbstractModel): if self.ids: self._cr.execute(""" SELECT msg.res_id, COUNT(msg.res_id) FROM mail_message msg RIGHT JOIN mail_message_res_partner_needaction_rel rel - ON rel.mail_message_id = msg.id AND rel.email_status in ('exception','bounce') + ON rel.mail_message_id = msg.id AND rel.notification_status in ('exception','bounce') WHERE msg.author_id = %s AND msg.model = %s AND msg.res_id in %s AND msg.message_type != 'user_notification' GROUP BY msg.res_id""", (self.env.user.partner_id.id, self._name, tuple(self.ids),)) @@ -915,7 +915,7 @@ class MailThread(models.AbstractModel): ('mail_message_id', '=', mail_message.id), ('res_partner_id', 'in', partners.ids)]) notifications.write({ - 'email_status': 'bounce' + 'notification_status': 'bounce' }) if bounced_model in self.env and hasattr(self.env[bounced_model], '_message_receive_bounce') and bounced_thread_id: @@ -1679,7 +1679,7 @@ class MailThread(models.AbstractModel): email_from=False, 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, model_description=False, mail_auto_delete=True, record_name=False, + add_sign=True, record_name=False, **kwargs): """ Post a new message in an existing thread, returning the new mail.message ID. @@ -1707,11 +1707,14 @@ class MailThread(models.AbstractModel): :return int: ID of newly created mail.message """ self.ensure_one() # should always be posted on a record, use message_notify if no record + # split message additional values from notify additional values + 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 self._name == 'mail.thread' or not self.id or message_type == 'user_notification': raise ValueError('message_post should only be call to post message on record. Use message_notify instead') - if 'model' in kwargs or 'res_id' in kwargs: + if 'model' in msg_kwargs or 'res_id' in msg_kwargs: raise ValueError("message_post doesn't support model and res_id parameters anymore. Please call message_post on record") self = self.with_lang() # add lang to context imediatly since it will be usefull in various flows latter. @@ -1746,7 +1749,7 @@ class MailThread(models.AbstractModel): # parent_message searched in sudo for performance, only used for id. # Note that with sudo we will match message with internal subtypes. parent_id = parent_message.id if parent_message else False - elif parent_id: + elif parent_id: old_parent_id = parent_id parent_message = MailMessage_sudo.search([('id', '=', parent_id), ('parent_id', '!=', False)], limit=1) # avoid loops when finding ancestors @@ -1757,7 +1760,8 @@ class MailThread(models.AbstractModel): processed_list.append(new_parent_id) parent_message = parent_message.parent_id parent_id = parent_message.id - values = dict(kwargs) + + values = dict(msg_kwargs) values.update({ 'author_id': author_id, 'model': self._name, @@ -1776,19 +1780,19 @@ class MailThread(models.AbstractModel): attachments = attachments or [] attachment_ids = attachment_ids or [] attachement_values = self._message_post_process_attachments(attachments, attachment_ids, values) - values.update(attachement_values) # attachement_ids, [body] + values.update(attachement_values) # attachement_ids, [body] - new_message= self._message_create(values) + new_message = self._message_create(values) # Set main attachment field if necessary self._message_set_main_attachment_id(values['attachment_ids']) if values['author_id'] and values['message_type'] != 'notification' and not self._context.get('mail_create_nosubscribe'): - #if self.env['res.partner'].browse(values['author_id']).active: # we dont want to add odoobot/inactive as a follower + # if self.env['res.partner'].browse(values['author_id']).active: # we dont want to add odoobot/inactive as a follower self._message_subscribe([values['author_id']]) self._message_post_after_hook(new_message, values) - self._notify_thread(new_message, values, model_description=model_description, mail_auto_delete=mail_auto_delete) + self._notify_thread(new_message, values, **notif_kwargs) return new_message def _message_set_main_attachment_id(self, attachment_ids): # todo move this out of mail.thread @@ -1863,14 +1867,15 @@ 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, model_description=False, - mail_auto_delete=True, **kwargs): + author_id=False, 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. """ - if self: self.ensure_one() + # split message additional values from notify additional values + 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) @@ -1914,9 +1919,9 @@ class MailThread(models.AbstractModel): 'reply_to': MailThread._notify_get_reply_to(default=email_from, records=None)[False], 'message_id': tools.generate_tracking_message_id('message-notify'), } - values.update(kwargs) + values.update(msg_kwargs) new_message = MailThread._message_create(values) - MailThread._notify_thread(new_message, values, model_description=model_description, mail_auto_delete=mail_auto_delete) + 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): @@ -1972,24 +1977,27 @@ class MailThread(models.AbstractModel): # ------------------------------------------------------ @api.multi - def _notify_thread(self, message, msg_vals=False, model_description=False, mail_auto_delete=True): + def _notify_thread(self, message, msg_vals=False, **kwargs): """ Main notification method. This method basically does two things + * call ``_notify_compute_recipients`` that computes recipients to notify based on message record or message creation values if given (to optimize performance if we already have data computed); - * performs the notification process; - Can be overridden to intercept and postpone notification mecanism (mail.channel moderation) - :param message: posted message; - :param msg_vals: dictionary of values used to create the message. If given - it is used instead of accessing ``self`` to lesen query count in some - simple cases where no notification is actually required; - :param force_send: tells whether to send notification emails within the - current transaction or to use the email queue; - :param model_description: optional data used in notification process (see - notification templates); - :param mail_auto_delete: delete notification emails once sent; - """ + * performs the notification process by calling the various notification + methods implemented; + This method cnn be overridden to intercept and postpone notification + mechanism like mail.channel moderation. + + :param message: mail.message record to notify; + :param msg_vals: dictionary of values used to create the message. If given + it is used instead of accessing ``self`` to lessen query count in some + simple cases where no notification is actually required; + + Kwargs allow to pass various parameters that are given to sub notification + methods. See those methods for more details about the additional parameters. + Parameters used for email-style notifications + """ msg_vals = msg_vals if msg_vals else {} rdata = self._notify_compute_recipients(message, msg_vals) if not rdata: @@ -1998,59 +2006,78 @@ class MailThread(models.AbstractModel): message_values = {} if rdata['channels']: message_values['channel_ids'] = [(6, 0, [r['id'] for r in rdata['channels']])] - if rdata['partners']: - message_values['needaction_partner_ids'] = [(6, 0, [r['id'] for r in rdata['partners'] if r['type'] != 'channel_email'])] - # change of behavior to check: since email_cids partner are added in _notify_compute_recipients, - # they will be added to needaction_partner_ids to. - # we may want to filter them (example with channel_email, a cleaner solution may be great) - # -> instead of using _notify_customize_recipients, we could add a flag on rdata - # (would work for needactions, not if we want to erase partner_ids, ids) - # (could also be interesting for, we could add partners with r['notif'] = 'ocn_client' and r['needaction']=False) - # then override a notify_recipients (as it was before) to effectively send ocn notifications. - # envelope will contain more needaction, those for the member of a email channel. - if message_values and self: - message_values.update(self._notify_customize_recipients(message, msg_vals)) - if message_values: - message.write(message_values) - inbox_pids = [r['id'] for r in rdata['partners'] if r['notif'] == 'inbox'] - partner_email_rdata = [r for r in rdata['partners'] if r['notif'] == 'email'] - channel_ids = [r['id'] for r in rdata['channels']] + self._notify_record_by_inbox(message, rdata, msg_vals=msg_vals, **kwargs) + self._notify_record_by_email(message, rdata, msg_vals=msg_vals, **kwargs) - notifications = [] + return rdata + + def _notify_record_by_inbox(self, message, recipients_data, msg_vals=False, **kwargs): + """ Notification method: inbox. Do two main things + + * create an inbox notification for users; + * create channel / message link (channel_ids field of mail.message); + * send bus notifications; + + TDE/XDO TODO: flag rdata directly, with for example r['notif'] = 'ocn_client' and r['needaction']=False + and correctly override notify_recipients + """ + channel_ids = [r['id'] for r in recipients_data['channels']] + if channel_ids: + message.write({'channel_ids': [(6, 0, channel_ids)]}) + + inbox_pids = [r['id'] for r in recipients_data['partners'] if r['notif'] == 'inbox'] + if inbox_pids: + notif_create_values = [{ + 'mail_message_id': message.id, + 'res_partner_id': pid, + 'notification_type': 'inbox', + } for pid in inbox_pids] + self.env['mail.notification'].sudo().create(notif_create_values) + + bus_notifications = [] if inbox_pids or channel_ids: - message_values = False + message_format_values = False if inbox_pids: - message_values = message.message_format()[0] + message_format_values = message.message_format()[0] for partner in self.env['res.partner'].browse(inbox_pids): - notifications.append([(self._cr.dbname, 'ir.needaction', partner), dict(message_values)]) + bus_notifications.append([(self._cr.dbname, 'ir.needaction', partner), dict(message_format_values)]) if channel_ids: - notifications += self.env['mail.channel'].sudo().browse(channel_ids)._channel_message_notifications(message, message_values) - if partner_email_rdata: - self._notify_record_by_email(message, partner_email_rdata, msg_vals=msg_vals, model_description=model_description, mail_auto_delete=mail_auto_delete) - if notifications: - self.env['bus.bus'].sudo().sendmany(notifications) - return True + bus_notifications += self.env['mail.channel'].sudo().browse(channel_ids)._channel_message_notifications(message, message_format_values) + if bus_notifications: + self.env['bus.bus'].sudo().sendmany(bus_notifications) - def _notify_record_by_email(self, message, partners_data, msg_vals=False, model_description=False, mail_auto_delete=True, send_after_commit=True): + def _notify_record_by_email(self, message, recipients_data, msg_vals=False, + model_description=False, mail_auto_delete=True, check_existing=False, + force_send=True, send_after_commit=True, + **kwargs): """ Method to send email linked to notified messages. + :param message: mail.message record to notify; - :param partners_data: partner to notify by email coming from _notify_compute_recipients - :param msg_vals: message creation values if available + :param recipients_data: see ``_notify_thread``; + :param msg_vals: see ``_notify_thread``; + + :param model_description: model description used in email notification process + (computed if not given); + :param mail_auto_delete: delete notification emails once sent; + :param check_existing: check for existing notifications to update based on + mailed recipient, otherwise create new notifications; + + :param force_send: send emails directly instead of using queue; :param send_after_commit: if force_send, tells whether to send emails after the transaction has been committed using a post-commit hook; - :param model_description: optional data used in notification process (see - notification templates); - :param mail_auto_delete: delete notification emails once sent; """ + partners_data = [r for r in recipients_data['partners'] if r['notif'] == 'email'] + if not partners_data: + return True + model = msg_vals.get('model') if msg_vals else message.model model_name = model_description or (self.with_lang().env['ir.model']._get(model).display_name if model else False) # one query for display name recipients_groups_data = self._notify_classify_recipients(partners_data, model_name) if not recipients_groups_data: return True - - force_send = self.env.context.get('mail_notify_force_send', True) + force_send = self.env.context.get('mail_notify_force_send', force_send) template_values = self._notify_prepare_template_context(message, msg_vals, model_description=model_description) # 10 queries @@ -2062,7 +2089,6 @@ class MailThread(models.AbstractModel): _logger.warning('QWeb template %s not found when sending notification emails. Sending without layouting.' % (template_xmlid)) base_template = False - mail_subject = message.subject or (message.record_name and 'Re: %s' % message.record_name) # in cache, no queries # prepare notification mail values base_mail_values = { @@ -2080,6 +2106,7 @@ class MailThread(models.AbstractModel): emails = self.env['mail.mail'].sudo() # loop on groups (customer, portal, user, ... + model specific like group_sale_salesman) + notif_create_values = [] recipients_max = 50 for recipients_group_data in recipients_groups_data: # generate notification email content @@ -2093,7 +2120,8 @@ class MailThread(models.AbstractModel): else: mail_body = message.body mail_body = self._replace_local_links(mail_body) - # send email + + # create email for recipients_ids_chunk in split_every(recipients_max, recipients_ids): recipient_values = self._notify_email_recipient_values(recipients_ids_chunk) email_to = recipient_values['email_to'] @@ -2110,22 +2138,32 @@ class MailThread(models.AbstractModel): email = Mail.create(create_values) if email and recipient_ids: - notifications = self.env['mail.notification'].sudo().search([ - ('mail_message_id', '=', email.mail_message_id.id), - ('res_partner_id', 'in', list(recipient_ids)) # not sure to check. - # TODO XDO what if recipient_ids are empty because of _notify_email_recipient_values - # should we use recipients_ids_chunk? - # should we unlink recipients_ids_chunk - recipient_ids ? - # should we avoid to create needation? by calling _notify_email_recipient_values at the same place _notify_customize_recipients does? (but no chubnk at this step) - ]) - notifications.write({ - 'is_email': True, + tocreate_recipient_ids = list(recipient_ids) + if check_existing: + existing_notifications = self.env['mail.notification'].sudo().search([ + ('mail_message_id', '=', message.id), + ('notification_type', '=', 'email'), + ('res_partner_id', 'in', tocreate_recipient_ids) + ]) + if existing_notifications: + tocreate_recipient_ids = [rid for rid in recipient_ids if rid not in existing_notifications.mapped('res_partner_id.id')] + existing_notifications.write({ + 'notification_status': 'ready', + 'mail_id': email.id, + }) + notif_create_values += [{ + 'mail_message_id': message.id, + 'res_partner_id': recipient_id, + 'notification_type': 'email', 'mail_id': email.id, - 'is_read': True, # handle by email discards Inbox notification - 'email_status': 'ready', - }) + 'is_read': True, # discard Inbox notification + 'notification_status': 'ready', + } for recipient_id in tocreate_recipient_ids] emails |= email + if notif_create_values: + self.env['mail.notification'].sudo().create(notif_create_values) + # NOTE: # 1. for more than 50 followers, use the queue system # 2. do not send emails immediately if the registry is not loaded, @@ -2158,7 +2196,7 @@ class MailThread(models.AbstractModel): author = message.env['res.partner'].browse(msg_vals.get('author_id')) if msg_vals else message.author_id model = msg_vals.get('model') if msg_vals else message.model add_sign = msg_vals.get('add_sign') if msg_vals else message.add_sign - subtype_id = msg_vals.get('subtype_id') if msg_vals else message.subtype_id.id + subtype_id = msg_vals.get('subtype_id') if msg_vals else message.subtype_id.id message_id = message.id record_name = msg_vals.get('record_name') if msg_vals else message.record_name author_user = user if user.partner_id == author else author.user_ids[0] if author and author.user_ids else False @@ -2222,13 +2260,14 @@ class MailThread(models.AbstractModel): # get values from msg_vals or from message if msg_vals doen't exists pids = msg_vals.get('partner_ids', []) if msg_vals else msg_sudo.partner_ids.ids cids = msg_vals.get('channel_ids', []) if msg_vals else msg_sudo.channel_ids.ids + message_type = msg_vals.get('message_type') if msg_vals else msg_sudo.message_type subtype_id = msg_vals.get('subtype_id') if msg_vals else msg_sudo.subtype_id.id # is it possible to have record but no subtype_id ? recipient_data = { 'partners': [], 'channels': [], } - res = self.env['mail.followers']._get_recipient_data(self, subtype_id, pids, cids) + res = self.env['mail.followers']._get_recipient_data(self, message_type, subtype_id, pids, cids) if not res: return recipient_data @@ -2243,11 +2282,11 @@ class MailThread(models.AbstractModel): if notif == 'inbox': recipient_data['partners'].append(dict(pdata, notif=notif, type='user')) elif not pshare and notif: # has an user and is not shared, is therefore user - recipient_data['partners'].append(dict(pdata, notif='email', type='user')) + recipient_data['partners'].append(dict(pdata, notif=notif, type='user')) elif pshare and notif: # has an user but is shared, is therefore portal - recipient_data['partners'].append(dict(pdata, notif='email', type='portal')) + recipient_data['partners'].append(dict(pdata, notif=notif, type='portal')) else: # has no user, is therefore customer - recipient_data['partners'].append(dict(pdata, notif='email', type='customer')) + recipient_data['partners'].append(dict(pdata, notif=notif if notif else 'email', type='customer')) elif cid: recipient_data['channels'].append({'id': cid, 'notif': notif, 'type': ctype}) @@ -2514,9 +2553,6 @@ class MailThread(models.AbstractModel): 'email_to': False, 'recipient_ids': recipient_ids, } - @api.multi - def _notify_customize_recipients(self, message, msg_vals): - return {} # ------------------------------------------------------ # Followers API diff --git a/addons/mail/views/mail_message_views.xml b/addons/mail/views/mail_message_views.xml index 70657edd08c..14c48dc4738 100644 --- a/addons/mail/views/mail_message_views.xml +++ b/addons/mail/views/mail_message_views.xml @@ -75,8 +75,8 @@ - - + + diff --git a/addons/mail/wizard/invite.py b/addons/mail/wizard/invite.py index 5a899e1a7c6..5c7889b6a76 100644 --- a/addons/mail/wizard/invite.py +++ b/addons/mail/wizard/invite.py @@ -72,13 +72,13 @@ class Invite(models.TransientModel): 'no_auto_thread': True, 'add_sign': True, }) - partners_data = [{ - 'id': pid, - 'share': True, - 'notif': 'email', - 'type': 'customer', + recipients_data = {'partners': [{ + 'id': pid, + 'share': True, + 'notif': 'email', + 'type': 'customer', 'groups': [] - } for pid in new_partners.ids] - document._notify_record_by_email(message, partners_data, send_after_commit=False) + } for pid in new_partners.ids]} + document._notify_record_by_email(message, recipients_data, send_after_commit=False) message.unlink() return {'type': 'ir.actions.act_window_close'} diff --git a/addons/mail/wizard/mail_resend_cancel.py b/addons/mail/wizard/mail_resend_cancel.py index 277ed8b96db..f3bb8307754 100644 --- a/addons/mail/wizard/mail_resend_cancel.py +++ b/addons/mail/wizard/mail_resend_cancel.py @@ -4,7 +4,7 @@ from odoo import _, api, fields, models -class MailCancelResend(models.TransientModel): +class MailResendCancel(models.TransientModel): _name = 'mail.resend.cancel' _description = 'Dismiss notification for resend by model' @@ -26,7 +26,7 @@ class MailCancelResend(models.TransientModel): FROM mail_message_res_partner_needaction_rel notif JOIN mail_message mes ON notif.mail_message_id = mes.id - WHERE notif.email_status IN ('bounce', 'exception') + WHERE notif.notification_status IN ('bounce', 'exception') AND mes.model = %s AND mes.author_id = %s """, (wizard.model, author_id)) @@ -34,6 +34,6 @@ class MailCancelResend(models.TransientModel): notif_ids = [row[0] for row in res] messages_ids = list(set([row[1] for row in res])) if notif_ids: - self.env["mail.notification"].browse(notif_ids).sudo().write({'email_status': 'canceled'}) - self.env["mail.message"].browse(messages_ids)._notify_failure_update() + self.env["mail.notification"].browse(notif_ids).sudo().write({'notification_status': 'canceled'}) + self.env["mail.message"].browse(messages_ids)._notify_mail_failure_update() return {'type': 'ir.actions.act_window_close'} diff --git a/addons/mail/wizard/mail_resend_message.py b/addons/mail/wizard/mail_resend_message.py index e5e4c069df1..9c72944961b 100644 --- a/addons/mail/wizard/mail_resend_message.py +++ b/addons/mail/wizard/mail_resend_message.py @@ -28,16 +28,14 @@ class MailResendMessage(models.TransientModel): message_id = self._context.get('mail_message_to_resend') if message_id: mail_message_id = self.env['mail.message'].browse(message_id) - notification_ids = mail_message_id.notification_ids.filtered(lambda notif: notif.email_status in ('exception', 'bounce')) - partner_ids = [(0, 0, - { - "partner_id": notif.res_partner_id.id, - "name": notif.res_partner_id.name, - "email": notif.res_partner_id.email, - "resend": True, - "message": notif.format_failure_reason(), - } - ) for notif in notification_ids] + notification_ids = mail_message_id.notification_ids.filtered(lambda notif: notif.notification_type == 'email' and notif.notification_status in ('exception', 'bounce')) + partner_ids = [(0, 0, { + "partner_id": notif.res_partner_id.id, + "name": notif.res_partner_id.name, + "email": notif.res_partner_id.email, + "resend": True, + "message": notif.format_failure_reason(), + }) for notif in notification_ids] has_user = any([notif.res_partner_id.user_ids for notif in notification_ids]) if has_user: partner_readonly = not self.env['res.users'].check_access_rights('write', raise_exception=False) @@ -59,14 +57,14 @@ class MailResendMessage(models.TransientModel): "If a partner disappeared from partner list, we cancel the notification" to_cancel = wizard.partner_ids.filtered(lambda p: not p.resend).mapped("partner_id") to_send = wizard.partner_ids.filtered(lambda p: p.resend).mapped("partner_id") - notif_to_cancel = wizard.notification_ids.filtered(lambda notif: notif.res_partner_id in to_cancel and notif.email_status in ('exception', 'bounce')) - notif_to_cancel.sudo().write({'email_status': 'canceled'}) + notif_to_cancel = wizard.notification_ids.filtered(lambda notif: notif.notification_type == 'email' and notif.res_partner_id in to_cancel and notif.notification_status in ('exception', 'bounce')) + notif_to_cancel.sudo().write({'notification_status': 'canceled'}) if to_send: message = wizard.mail_message_id record = self.env[message.model].browse(message.res_id) if message.is_thread_message() else self.env['mail.thread'] email_partners_data = [] - for pid, cid, active, pshare, ctype, notif, groups in self.env['mail.followers']._get_recipient_data(None, False, pids=to_send.ids): + for pid, cid, active, pshare, ctype, notif, groups in self.env['mail.followers']._get_recipient_data(None, 'comment', False, pids=to_send.ids): if pid and notif == 'email' or not notif: pdata = {'id': pid, 'share': pshare, 'active': active, 'notif': 'email', 'groups': groups or []} if not pshare and notif: # has an user and is not shared, is therefore user @@ -76,17 +74,17 @@ class MailResendMessage(models.TransientModel): else: # has no user, is therefore customer email_partners_data.append(dict(pdata, type='customer')) - record._notify_record_by_email(message, email_partners_data, send_after_commit=False) + record._notify_record_by_email(message, {'partners': email_partners_data}, check_existing=True, send_after_commit=False) - self.mail_message_id._notify_failure_update() + self.mail_message_id._notify_mail_failure_update() return {'type': 'ir.actions.act_window_close'} @api.multi def cancel_mail_action(self): for wizard in self: for notif in wizard.notification_ids: - notif.filtered(lambda notif: notif.email_status in ('exception', 'bounce')).sudo().write({'email_status': 'canceled'}) - wizard.mail_message_id._notify_failure_update() + notif.filtered(lambda notif: notif.notification_type == 'email' and notif.notification_status in ('exception', 'bounce')).sudo().write({'notification_status': 'canceled'}) + wizard.mail_message_id._notify_mail_failure_update() return {'type': 'ir.actions.act_window_close'} diff --git a/addons/phone_validation/tools/phone_validation.py b/addons/phone_validation/tools/phone_validation.py index 29828ffd739..e1df861ac8c 100644 --- a/addons/phone_validation/tools/phone_validation.py +++ b/addons/phone_validation/tools/phone_validation.py @@ -77,51 +77,43 @@ except ImportError: def phone_sanitize_numbers(numbers, country_code, country_phone_code, force_format='E164'): - valid, invalid, void_count = [], [], 0 + result = dict.fromkeys(numbers, False) for number in numbers: if not number: - void_count += 1 + result[number] = {'sanitized': False, 'code': 'empty', 'msg': False} continue try: sanitized = phone_format( number, country_code, country_phone_code, force_format=force_format, raise_exception=True) except Exception as e: - invalid.append(number) + result[number] = {'sanitized': False, 'code': 'invalid', 'msg': e} else: - valid.append(sanitized) - return valid, invalid, void_count + result[number] = {'sanitized': sanitized, 'code': False, 'msg': False} + return result -def phone_sanitize_numbers_w_record(numbers, country_code, country_phone_code, record, record_country_fname='country_id', force_format='E164'): - if not country_code or not country_phone_code: - country = False - if record and record_country_fname in record and record[record_country_fname]: +def phone_sanitize_numbers_w_record(numbers, record, country=False, record_country_fname='country_id', force_format='E164'): + if not country: + if record and hasattr(record, record_country_fname) and record[record_country_fname]: country = record[record_country_fname] elif record: country = record.env.company.country_id - if country: - country_code = country_code if country_code else country.code - country_phone_code = country_phone_code if country_phone_code else country.phone_code + 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, country_code, country_phone_code, record, record_country_fname='country_id', force_format='E164'): +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, country_code, country_phone_code, record, record_country_fname, force_format=force_format) + 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] - valid, invalid, void_count = phone_sanitize_numbers_w_record([number], None, None, records, country_fname,force_format=force_format) - if valid: - res[record.id] = valid[0] - elif void_count: - res[record.id] = False - else: - res[record.id] = False + res[record.id] = phone_sanitize_numbers_w_record([number], records, record_country_fname=country_fname,force_format=force_format)[number]['sanitized'] return res diff --git a/addons/sms/__manifest__.py b/addons/sms/__manifest__.py index 4ef5721be8f..ccfae7f9049 100644 --- a/addons/sms/__manifest__.py +++ b/addons/sms/__manifest__.py @@ -12,13 +12,23 @@ The service is provided by the In App Purchase Odoo platform. """, 'depends': ['base', 'iap', 'mail', 'phone_validation'], 'data': [ + 'data/ir_cron_data.xml', + 'wizard/sms_cancel_views.xml', 'wizard/sms_composer_views.xml', + 'wizard/sms_resend_views.xml', 'views/res_config_settings_views.xml', 'views/res_partner_views.xml', 'views/assets.xml', + 'views/sms_sms_views.xml', + 'views/sms_template_views.xml', + 'security/ir.model.access.csv', + ], + 'demo': [ + 'data/sms_demo.xml', + 'data/mail_demo.xml', ], 'qweb': [ - 'static/src/xml/sms_widget.xml', + 'static/src/xml/thread.xml', ], 'installable': True, 'auto_install': True, diff --git a/addons/sms/data/ir_cron_data.xml b/addons/sms/data/ir_cron_data.xml new file mode 100644 index 00000000000..e8a5d771dd5 --- /dev/null +++ b/addons/sms/data/ir_cron_data.xml @@ -0,0 +1,14 @@ + + + + SMS: SMS Queue Manager + + code + model._process_queue() + + 1 + hours + -1 + + + \ No newline at end of file diff --git a/addons/sms/data/mail_demo.xml b/addons/sms/data/mail_demo.xml new file mode 100644 index 00000000000..103a89fbd8c --- /dev/null +++ b/addons/sms/data/mail_demo.xml @@ -0,0 +1,62 @@ + + + + res.partner + +

Hello! This is an example of incoming email.

+ email + + + +
+ + res.partner + +

Hello! This is an example of user comment.

+ comment + + + +
+ + + + email + exception + SMTP + + + + res.partner + +

Hello! This is an example of SMS.

+ sms + + + +
+ + res.partner + +

Hello! This is an example of another SMS with notifications.

+ sms + + + +
+ + + + sms + exception + sms_credit + + + + + sms + exception + sms_credit + + +
diff --git a/addons/sms/data/sms_demo.xml b/addons/sms/data/sms_demo.xml new file mode 100644 index 00000000000..b3d252c1e02 --- /dev/null +++ b/addons/sms/data/sms_demo.xml @@ -0,0 +1,8 @@ + + + + Customer: automated SMS + + Dear ${object.display_name} this is an automated SMS. + + diff --git a/addons/sms/models/__init__.py b/addons/sms/models/__init__.py index 7a75a7a5f6d..4adcd67ab64 100644 --- a/addons/sms/models/__init__.py +++ b/addons/sms/models/__init__.py @@ -1,6 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import mail_followers +from . import mail_message +from . import mail_notification from . import mail_thread from . import res_partner from . import sms_api +from . import sms_sms +from . import sms_template diff --git a/addons/sms/models/mail_followers.py b/addons/sms/models/mail_followers.py new file mode 100644 index 00000000000..76be79b677b --- /dev/null +++ b/addons/sms/models/mail_followers.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class Followers(models.Model): + _inherit = ['mail.followers'] + + def _get_recipient_data(self, records, message_type, subtype_id, pids=None, cids=None): + if message_type == 'sms': + if pids is None: + sms_pids = records._sms_get_default_partners().ids + else: + sms_pids = pids + res = super(Followers, self)._get_recipient_data(records, message_type, subtype_id, pids=pids, cids=cids) + new_res = [] + for pid, cid, pactive, pshare, ctype, notif, groups in res: + if pid and pid in sms_pids: + notif = 'sms' + new_res.append((pid, cid, pactive, pshare, ctype, notif, groups)) + return new_res + else: + return super(Followers, self)._get_recipient_data(records, message_type, subtype_id, pids=pids, cids=cids) diff --git a/addons/sms/models/mail_message.py b/addons/sms/models/mail_message.py new file mode 100644 index 00000000000..92fda5e8a83 --- /dev/null +++ b/addons/sms/models/mail_message.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict +from operator import itemgetter + +from odoo import api, exceptions, fields, models +from odoo.tools import groupby + + +class MailMessage(models.Model): + """ Override MailMessage class in order to add a new type: SMS messages. + Those messages comes with their own notification method, using SMS + gateway. """ + _inherit = 'mail.message' + + message_type = fields.Selection(selection_add=[('sms', 'SMS')]) + has_sms_error = fields.Boolean( + 'Has SMS error', compute='_compute_has_sms_error', search='_search_has_sms_error', + help='Has error') + + @api.multi + def _compute_has_sms_error(self): + sms_error_from_notification = self.env['mail.notification'].sudo().search([ + ('notification_type', '=', 'sms'), + ('mail_message_id', 'in', self.ids), + ('notification_status', '=', 'exception')]).mapped('mail_message_id') + for message in self: + message.has_error = message in sms_error_from_notification + + @api.multi + def _search_has_sms_error(self, operator, operand): + if operator == '=' and operand: + return ['&', ('notification_ids.notification_status', '=', 'exception'), ('notification_ids.notification_type', '=', True)] + raise NotImplementedError() + + @api.multi + def _format_mail_failures(self): + """ A shorter message to notify a SMS delivery failure update + + TDE FIXME: should be cleaned + """ + res = super(MailMessage, self)._format_mail_failures() + + # prepare notifications computation in batch + all_notifications = self.env['mail.notification'].sudo().search([ + ('mail_message_id', 'in', self.ids) + ]) + msgid_to_notif = defaultdict(lambda: self.env['mail.notification'].sudo()) + for notif in all_notifications: + msgid_to_notif[notif.mail_message_id.id] += notif + + for message in self: + notifications = msgid_to_notif[message.id] + if not any(notification.notification_type == 'sms' for notification in notifications): + continue + info = dict(message._get_mail_failure_dict(), + failure_type='sms', + notifications=dict((notif.res_partner_id.id, (notif.notification_status, notif.res_partner_id.name)) for notif in notifications if notif.notification_type == 'sms'), + module_icon='/sms/static/img/sms_failure.png' + ) + res.append(info) + return res + + @api.multi + def _notify_sms_update(self): + """ Send bus notifications to update status of notifications in chatter. + Purpose is to send the updated status per author. + + TDE FIXME: author_id strategy seems curious, check with JS """ + messages = self.env['mail.message'] + for message in self: + # YTI FIXME: check allowed_company_ids if necessary + if message.model and message.res_id: + record = self.env[message.model].browse(message.res_id) + try: + record.check_access_rights('read') + record.check_access_rule('read') + except exceptions.AccessError: + continue + else: + messages |= message + + """ Notify channels after update of SMS status """ + updates = [[ + (self._cr.dbname, 'res.partner', author.id), + {'type': 'sms_update', 'elements': self.env['mail.message'].concat(*author_messages)._format_mail_failures()} + ] for author, author_messages in groupby(messages, itemgetter('author_id'))] + self.env['bus.bus'].sendmany(updates) + + @api.multi + def message_format(self): + """ Override in order to retrieves data about SMS (recipient name and + SMS status) + + TDE FIXME: clean the overall message_format thingy + """ + message_values = super(MailMessage, self).message_format() + all_sms_notifications = self.env['mail.notification'].sudo().search([ + ('mail_message_id', 'in', [r['id'] for r in message_values]), + ('notification_type', '=', 'sms') + ]) + msgid_to_notif = defaultdict(lambda: self.env['mail.notification'].sudo()) + for notif in all_sms_notifications: + msgid_to_notif[notif.mail_message_id.id] += notif + + for message in message_values: + customer_sms_data = [(notif.id, notif.res_partner_id.display_name or notif.sms_number, notif.notification_status) for notif in msgid_to_notif.get(message['id'], [])] + message['sms_ids'] = customer_sms_data + return message_values diff --git a/addons/sms/models/mail_notification.py b/addons/sms/models/mail_notification.py new file mode 100644 index 00000000000..c45981ea0b0 --- /dev/null +++ b/addons/sms/models/mail_notification.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models +from odoo.tools.translate import _ + + +class Notification(models.Model): + _inherit = 'mail.notification' + + notification_type = fields.Selection(selection_add=[('sms', 'SMS')]) + sms_id = fields.Many2one('sms.sms', string='SMS', index=True, ondelete='set null') + sms_number = fields.Char('SMS Number') + 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')] + ) diff --git a/addons/sms/models/mail_thread.py b/addons/sms/models/mail_thread.py index a439487e4e9..a432d29a2d3 100644 --- a/addons/sms/models/mail_thread.py +++ b/addons/sms/models/mail_thread.py @@ -3,9 +3,9 @@ import logging -from odoo import models, _ - -from odoo.addons.iap.models.iap import InsufficientCreditError +from odoo import api, models, fields +from odoo.addons.phone_validation.tools import phone_validation +from odoo.tools import html2plaintext _logger = logging.getLogger(__name__) @@ -13,7 +13,30 @@ _logger = logging.getLogger(__name__) class MailThread(models.AbstractModel): _inherit = 'mail.thread' - def _get_default_sms_recipients(self): + message_has_sms_error = fields.Boolean( + 'SMS Delivery error', compute='_compute_message_has_sms_error', search='_search_message_has_sms_error', + help="If checked, some messages have a delivery error.") + + @api.multi + def _compute_message_has_sms_error(self): + res = {} + if self.ids: + self._cr.execute(""" SELECT msg.res_id, COUNT(msg.res_id) FROM mail_message msg + RIGHT JOIN mail_message_res_partner_needaction_rel rel + ON rel.mail_message_id = msg.id AND rel.notification_type = 'sms' AND rel.notification_status in ('exception') + WHERE msg.author_id = %s AND msg.model = %s AND msg.res_id in %s AND msg.message_type != 'user_notification' + GROUP BY msg.res_id""", + (self.env.user.partner_id.id, self._name, tuple(self.ids),)) + res.update(self._cr.fetchall()) + + for record in self: + record.message_has_sms_error = bool(res.get(record._origin.id, 0)) + + @api.model + 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_default_partners(self): """ This method will likely need to be overriden by inherited models. :returns partners: recordset of res.partner """ @@ -24,36 +47,250 @@ class MailThread(models.AbstractModel): partners |= self.mapped('partner_ids') return partners - def message_post_send_sms(self, sms_message, numbers=None, partners=None, note_msg=None, log_error=False): - """ Send an SMS text message and post an internal note in the chatter if successfull - :param sms_message: plaintext message to send by sms - :param partners: the numbers to send to, if none are given it will take those - from partners or _get_default_sms_recipients - :param partners: the recipients partners, if none are given it will take those - from _get_default_sms_recipients, this argument - is ignored if numbers is defined - :param note_msg: message to log in the chatter, if none is given a default one - containing the sms_message is logged + def _sms_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 ['mobile'] + + def _sms_get_recipients_info(self, force_field=False): + """" Get SMS recipient information on current record set. This method + checks for numbers and sanitation in order to centralize computation. + + Example of use cases + + * click on a field -> number is actually forced from field, find customer + linked to record, force its number to field or fallback on customer fields; + * contact -> find numbers from all possible phone fields on record, find + customer, force its number to found field number or fallback on customer fields; + + :return dict: record.id: { + 'partner': a res.partner recordset that is the customer (void or singleton); + 'sanitized': sanitized number to use (coming from record's field or partner's mobile + or phone). Set to False is number impossible to parse and format; + 'number': original number before sanitation; + } for each record in self """ - if not numbers: - if not partners: - partners = self._get_default_sms_recipients() + result = dict.fromkeys(self.ids, False) + number_fields = self._sms_get_number_fields() + for record in self: + tocheck_fields = [force_field] if force_field else number_fields + all_numbers = [record[fname] for fname in tocheck_fields if fname in record] + all_partners = record._sms_get_default_partners() - # Collect numbers, we will consider the message to be sent if at least one number can be found - numbers = list(set([i.mobile for i in partners if i.mobile])) + 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) + if valid_number: + break - if numbers: - try: - self.env['sms.api']._send_sms(numbers, sms_message) - mail_message = note_msg or _('SMS message sent: %s') % sms_message + if valid_number: + result[record.id] = { + 'partner': all_partners[0] if all_partners else self.env['res.partner'], + 'sanitized': valid_number, 'number': valid_number, + } + elif all_partners: + partner_number, partner = False, self.env['res.partner'] + 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'] + if partner_number: + break - except InsufficientCreditError as e: - if not log_error: - raise e - mail_message = _('Insufficient credit, unable to send SMS message: %s') % sms_message + if partner_number: + result[record.id] = {'partner': partner, 'sanitized': partner_number, 'number': partner_number} + else: + result[record.id] = {'partner': partner, 'sanitized': False, 'number': partner.mobile or partner.phone} + elif all_numbers: + result[record.id] = {'partner': self.env['res.partner'], 'sanitized': False, 'number': all_numbers[0]} + else: + result[record.id] = {'partner': self.env['res.partner'], 'sanitized': False, 'number': False} + return result + + def _message_sms_schedule_mass(self, body='', template=False, active_domain=None): + """ Shortcut method to schedule a mass sms sending on a recordset. + + :param template: an optional sms.template record; + :param active_domain: bypass self.ids and apply composer on active_domain + instead; + """ + composer_context = { + 'default_res_model': self._name, + 'default_composition_mode': 'mass', + 'default_template_id': template.id if template else False, + 'default_body': body if body and not template else False, + } + if active_domain is not None: + composer_context['default_use_active_domain'] = True + composer_context['default_active_domain'] = repr(active_domain) else: - mail_message = _('No mobile number defined, unable to send SMS message: %s') % sms_message + composer_context['default_res_ids'] = self.ids - for thread in self: - thread.message_post(body=mail_message) - return False + composer = self.env['sms.composer'].with_context(**composer_context).create({}) + return composer._action_send_sms() + + def _message_sms_with_template(self, template=False, template_xmlid=False, template_fallback='', partner_ids=False, **kwargs): + """ Shortcut method to perform a _message_sms with an sms.template. + + :param template: a valid sms.template record; + :param template_xmlid: XML ID of an sms.template (if no template given); + :param template_fallback: plaintext (jinja-enabled) in case template + and template xml id are falsy (for example due to deleted data); + """ + self.ensure_one() + if not template and template_xmlid: + template = self.env.ref(template_xmlid, raise_if_not_found=False) + if template: + template_w_lang = template._get_context_lang_per_id(self.ids)[self.id] + body = template._render_template(template_w_lang.body, self._name, self.ids)[self.id] + else: + body = self.env['sms.template']._render_template(template_fallback, self._name, self.ids)[self.id] + return self._message_sms(body, partner_ids=partner_ids, **kwargs) + + def _message_sms(self, body, subtype_id=False, partner_ids=False, number_field=False, + sms_numbers=None, sms_pid_to_number=None, **kwargs): + """ Main method to post a message on a record using SMS-based notification + method. + + :param body: content of SMS; + :param subtype_id: mail.message.subtype used in mail.message associated + to the sms notification process; + :param partner_ids: if set is a record set of partners to notify; + :param number_field: if set is a name of field to use on current record + to compute a number to notify; + :param sms_numbers: see ``_notify_record_by_sms``; + :param sms_pid_to_number: see ``_notify_record_by_sms``; + """ + self.ensure_one() + sms_pid_to_number = sms_pid_to_number if sms_pid_to_number is not None else {} + + if number_field or (partner_ids is False and sms_numbers is None): + info = self._sms_get_recipients_info(force_field=number_field)[self.id] + info_partner_ids = info['partner'].ids if info['partner'] else False + info_number = info['sanitized'] if info['sanitized'] else info['number'] + if info_partner_ids and info_number: + sms_pid_to_number[info_partner_ids[0]] = info_number + if info_partner_ids: + partner_ids = info_partner_ids + (partner_ids or []) + if info_number and not info_partner_ids: + sms_numbers = [info_number] + (sms_numbers or []) + + if subtype_id is False: + subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment') + + return self.message_post( + body=body, partner_ids=partner_ids or [], # TDE FIXME: temp fix otherwise crash mail_thread.py + message_type='sms', subtype_id=subtype_id, + sms_numbers=sms_numbers, sms_pid_to_number=sms_pid_to_number, + **kwargs + ) + + @api.multi + def _notify_thread(self, message, msg_vals=False, **kwargs): + recipients_data = super(MailThread, self)._notify_thread(message, msg_vals=msg_vals, **kwargs) + self._notify_record_by_sms(message, recipients_data, msg_vals=msg_vals, **kwargs) + return recipients_data + + @api.multi + def _notify_record_by_sms(self, message, recipients_data, msg_vals=False, + sms_numbers=None, sms_pid_to_number=None, + check_existing=False, put_in_queue=False, **kwargs): + """ Notification method: by SMS. + + :param message: mail.message record to notify; + :param recipients_data: see ``_notify_thread``; + :param msg_vals: see ``_notify_thread``; + + :param sms_numbers: additional numbers to notify in addition to partners + and classic recipients; + :param pid_to_number: force a number to notify for a given partner ID + instead of taking its mobile / phone number; + :param check_existing: check for existing notifications to update based on + mailed recipient, otherwise create new notifications; + :param put_in_queue: use cron to send queued SMS instead of sending them + directly; + """ + sms_pid_to_number = sms_pid_to_number if sms_pid_to_number is not None else {} + sms_numbers = sms_numbers if sms_numbers is not None else [] + sms_create_vals = [] + sms_all = self.env['sms.sms'].sudo() + + # pre-compute SMS data + body = msg_vals['body'] if msg_vals and msg_vals.get('body') else message.body + sms_base_vals = { + 'body': html2plaintext(body).rstrip('\n'), + 'mail_message_id': message.id, + 'state': 'outgoing', + } + + # notify from computed recipients_data (followers, specific recipients) + partners_data = [r for r in recipients_data['partners'] if r['notif'] == 'sms'] + partner_ids = [r['id'] for r in partners_data] + 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] + number = sanitize_res['sanitized'] or number + sms_create_vals.append(dict( + sms_base_vals, + partner_id=partner.id, + number=number + )) + + # notify from additional numbers + if sms_numbers: + sanitized = phone_validation.phone_sanitize_numbers_w_record(sms_numbers, self) + tocreate_numbers = [ + value['sanitized'] or original + for original, value in sanitized.items() + if value['code'] != 'empty' + ] + sms_create_vals += [dict(sms_base_vals, partner_id=False, number=n) for n in tocreate_numbers] + + # create sms and notification + existing_pids, existing_numbers = [], [] + if sms_create_vals: + sms_all |= self.env['sms.sms'].sudo().create(sms_create_vals) + + if check_existing: + existing = self.env['mail.notification'].sudo().search([ + '|', ('res_partner_id', 'in', partner_ids), + '&', ('res_partner_id', '=', False), ('sms_number', 'in', sms_numbers), + ('notification_type', '=', 'sms'), + ('mail_message_id', '=', message.id) + ]) + for n in existing: + if n.res_partner_id.id in partner_ids and n.mail_message_id == message: + existing_pids.append(n.res_partner_id.id) + if not n.res_partner_id and n.sms_number in sms_numbers and n.mail_message_id == message: + existing_numbers.append(n.sms_number) + + notif_create_values = [{ + 'mail_message_id': message.id, + 'res_partner_id': sms.partner_id.id, + 'sms_number': sms.number, + 'notification_type': 'sms', + 'sms_id': sms.id, + 'is_read': True, # discard Inbox notification + 'notification_status': 'ready', + } for sms in sms_all if (sms.partner_id and sms.partner_id.id not in existing_pids) or (not sms.partner_id and sms.number not in existing_numbers)] + if notif_create_values: + self.env['mail.notification'].sudo().create(notif_create_values) + + if existing_pids or existing_numbers: + for sms in sms_all: + notif = next((n for n in existing if + (n.res_partner_id.id in existing_pids and n.res_partner_id.id == sms.partner_id.id) or + (not n.res_partner_id and n.sms_number in existing_numbers and n.sms_number == sms.number)), False) + if notif: + notif.write({ + 'notification_type': 'sms', + 'notification_status': 'ready', + 'sms_id': sms.id, + 'sms_number': sms.number, + }) + + if sms_all and not put_in_queue: + sms_all.send(auto_commit=False, raise_exception=False) + + return True diff --git a/addons/sms/models/res_partner.py b/addons/sms/models/res_partner.py index 6d85ef2fe06..6cc6734cc5c 100644 --- a/addons/sms/models/res_partner.py +++ b/addons/sms/models/res_partner.py @@ -7,7 +7,7 @@ from odoo import models class ResPartner(models.Model): _inherit = 'res.partner' - def _get_default_sms_recipients(self): + def _sms_get_default_partners(self): """ Override of mail.thread method. SMS recipients on partners are the partners themselves. """ diff --git a/addons/sms/models/sms_sms.py b/addons/sms/models/sms_sms.py new file mode 100644 index 00000000000..699f4efa5f5 --- /dev/null +++ b/addons/sms/models/sms_sms.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import threading + +from odoo import api, fields, models, tools + +_logger = logging.getLogger(__name__) + + +class SmsSms(models.Model): + _name = 'sms.sms' + _description = 'Outgoing SMS' + _rec_name = 'number' + + number = fields.Char('Number', required=True) + body = fields.Text() + partner_id = fields.Many2one('res.partner', 'Customer') + mail_message_id = fields.Many2one('mail.message', index=True) + state = fields.Selection([ + ('outgoing', 'In Queue'), + ('sent', 'Sent'), + ('error', 'Error'), + ('canceled', 'Canceled') + ], 'SMS Status', readonly=True, copy=False, default='outgoing', required=True) + error_code = fields.Selection([ + ('sms_number_missing', 'Missing Number'), + ('sms_number_format', 'Wrong Number Format'), + ('sms_credit', 'Insufficient Credit'), + ('sms_server', 'Server Error') + ]) + + @api.multi + def send(self, delete_all=False, auto_commit=False, raise_exception=False): + """ Main API method to send SMS. + + :param delete_all: delete all SMS (sent or not); otherwise delete only + sent SMS; + :param auto_commit: commit after each batch of SMS; + :param raise_exception: raise if there is an issue contacting IAP; + """ + for batch_ids in self._split_batch(): + self.browse(batch_ids)._send(delete_all=delete_all, raise_exception=raise_exception) + if auto_commit is True: + self._cr.commit() + + @api.model + def _process_queue(self, ids=None): + """ Send immediately queued messages, committing after each message is sent. + This is not transactional and should not be called during another transaction! + + :param list ids: optional list of emails ids to send. If passed no search + is performed, and these ids are used instead. + """ + domain = [('state', '=', 'outgoing')] + + filtered_ids = self.search(domain, limit=10000).ids # TDE note: arbitrary limit we might have to update + if ids: + ids = list(set(filtered_ids) & set(ids)) + else: + ids = filtered_ids + ids.sort() + + res = None + try: + # auto-commit except in testing mode + auto_commit = not getattr(threading.currentThread(), 'testing', False) + res = self.browse(ids).send(delete_all=False, auto_commit=auto_commit, raise_exception=False) + except Exception: + _logger.exception("Failed processing SMS queue") + return res + + def _split_batch(self): + batch_size = int(self.env['ir.config_parameter'].sudo().get_param('sms.session.batch.size', 10)) + for sms_batch in tools.split_every(batch_size, self.ids): + yield sms_batch + + @api.multi + def _send(self, delete_all=False, raise_exception=False): + """ This method tries to send SMS after checking the number (presence and + formatting). """ + iap_data = [{ + 'res_id': record.id, + 'number': record.number, + 'content': record.body, + } for record in self] + + try: + iap_results = self.env['sms.api']._send_sms_batch(iap_data) + except Exception as e: + _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) + 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) + + def _postprocess_sent_sms(self, iap_results, failure_reason=None, delete_all=False): + sms_to_notif_status = { + 'success': False, 'insufficient_credit': 'sms_credit', + 'wrong_format_number': 'sms_number_format', 'server_error': 'sms_server'} + 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(): + sms_ids = [item['res_id'] for item in iap_results if item['state'] == state] + if sms_ids: + if not delete_all and state != 'success': + self.env['sms.sms'].sudo().browse(sms_ids).write({ + 'state': 'error', + 'error_code': sms_to_notif_status[state], + }) + notifications = self.env['mail.notification'].sudo().search([ + ('notification_type', '=', 'sms'), + ('sms_id', 'in', sms_ids), + ('notification_status', 'not in', ('sent', 'canceled'))] + ) + if notifications: + notifications.write({ + 'notification_status': 'sent' if state == 'success' else 'exception', + 'failure_type': sms_to_notif_status[state], + 'failure_reason': failure_reason if failure_reason else False, + }) + + if todelete_sms_ids: + self.browse(todelete_sms_ids).sudo().unlink() + + @api.multi + def cancel(self): + self.write({ + 'state': 'canceled', + 'error_code': False + }) diff --git a/addons/sms/models/sms_template.py b/addons/sms/models/sms_template.py new file mode 100644 index 00000000000..6835bac7eb6 --- /dev/null +++ b/addons/sms/models/sms_template.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class SMSTemplate(models.Model): + "Templates for sending SMS" + _name = "sms.template" + _description = 'SMS Templates' + + @api.model + def default_get(self, fields): + res = super(SMSTemplate, self).default_get(fields) + if not fields or 'model_id' in fields and not res.get('model_id') and res.get('model'): + res['model_id'] = self.env['ir.model']._get(res['model']).id + return res + + name = fields.Char() + model_id = fields.Many2one( + 'ir.model', string='Applies to', required=True, + domain=['&', ('is_mail_thread', '=', 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) + lang = fields.Char('Language', placeholder="${object.partner_id.lang}") + # Fake fields used to implement the placeholder assistant + model_object_field = fields.Many2one('ir.model.fields', string="Field", store=False, + help="Select target field from the related document model.\n" + "If it is a relationship field you will be able to select " + "a target field at the destination of the relationship.") + sub_object = fields.Many2one('ir.model', 'Sub-model', readonly=True, store=False, + help="When a relationship field is selected as first field, " + "this field shows the document model the relationship goes to.") + sub_model_object_field = fields.Many2one('ir.model.fields', 'Sub-field', store=False, + help="When a relationship field is selected as first field, " + "this field lets you select the target field within the " + "destination document model (sub-model).") + null_value = fields.Char('Default Value', store=False, help="Optional value to use if the target field is empty") + copyvalue = fields.Char('Placeholder Expression', store=False, + help="Final placeholder expression, to be copy-pasted in the desired template field.") + + @api.onchange('model_object_field', 'sub_model_object_field', 'null_value') + def _onchange_dynamic_placeholder(self): + """ Generate the dynamic placeholder """ + if self.model_object_field: + if self.model_object_field.ttype in ['many2one', 'one2many', 'many2many']: + model = self.env['ir.model']._get(self.model_object_field.relation) + if model: + self.sub_object = model.id + sub_field_name = self.sub_model_object_field.name + self.copyvalue = self._build_expression(self.model_object_field.name, + sub_field_name, self.null_value or False) + else: + self.sub_object = False + self.sub_model_object_field = False + self.copyvalue = self._build_expression(self.model_object_field.name, False, self.null_value or False) + else: + self.sub_object = False + self.copyvalue = False + self.sub_model_object_field = False + self.null_value = False + + @api.model + def _build_expression(self, field_name, sub_field_name, null_value): + """Returns a placeholder expression for use in a template field, + based on the values provided in the placeholder assistant. + + :param field_name: main field name + :param sub_field_name: sub field name (M2O) + :param null_value: default value if the target value is empty + :return: final placeholder expression """ + expression = '' + if field_name: + expression = "${object." + field_name + if sub_field_name: + expression += "." + sub_field_name + if null_value: + expression += " or '''%s'''" % null_value + expression += "}" + return expression + + @api.multi + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + default = dict(default or {}, + name=_("%s (copy)") % self.name) + return super(SMSTemplate, self).copy(default=default) + + @api.multi + def _get_context_lang_per_id(self, res_ids): + self.ensure_one() + if res_ids is None: + return {None: self} + + if self.env.context.get('template_preview_lang'): + lang = self.env.context.get('template_preview_lang') + results = dict((res_id, self.with_context(lang=lang)) for res_id in res_ids) + else: + rendered_langs = self._render_template(self.lang, self.model, res_ids) + results = dict( + (res_id, self.with_context(lang=lang) if lang else self) + for res_id, lang in rendered_langs.items()) + + return results + + @api.multi + def _get_ids_per_lang(self, res_ids): + self.ensure_one() + + rids_to_tpl = self._get_context_lang_per_id(res_ids) + tpl_to_rids = {} + for res_id, template in rids_to_tpl.items(): + tpl_to_rids.setdefault(template._context.get('lang', self.env.user.lang), []).append(res_id) + + return tpl_to_rids + + @api.model + def _render_template(self, template_txt, model, res_ids): + """ Render the jinja template """ + return self.env['mail.template']._render_template(template_txt, model, res_ids) diff --git a/addons/sms/security/ir.model.access.csv b/addons/sms/security/ir.model.access.csv new file mode 100644 index 00000000000..c906bc097b7 --- /dev/null +++ b/addons/sms/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_sms_sms_all,access.sms.sms.all,model_sms_sms,,0,0,0,0 +access_sms_sms_system,access.sms.sms.system,model_sms_sms,base.group_system,1,1,1,1 +access_sms_template_all,access.sms.template.all,model_sms_template,,0,0,0,0 +access_sms_template_user,access.sms.template.user,model_sms_template,base.group_user,1,0,0,0 +access_sms_template_system,access.sms.template.system,model_sms_template,base.group_system,1,1,1,0 diff --git a/addons/sms/static/img/sms_failure.png b/addons/sms/static/img/sms_failure.png new file mode 100644 index 00000000000..6c40062f92c Binary files /dev/null and b/addons/sms/static/img/sms_failure.png differ diff --git a/addons/sms/static/src/js/fields_phone_widget.js b/addons/sms/static/src/js/fields_phone_widget.js new file mode 100644 index 00000000000..f02d9b8625a --- /dev/null +++ b/addons/sms/static/src/js/fields_phone_widget.js @@ -0,0 +1,70 @@ +odoo.define('sms.fields', function (require) { +"use strict"; + +var basic_fields = require('web.basic_fields'); +var core = require('web.core'); +var session = require('web.session'); + +var _t = core._t; + +/** + * Override of FieldPhone to use add a button calling SMS composer if option activated + */ + +var Phone = basic_fields.FieldPhone; +Phone.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Open SMS composer wizard + * + * @private + */ + _onClickSMS: function (ev) { + ev.preventDefault(); + + var context = session.user_context; + context = _.extend({}, context, { + default_res_model: this.model, + default_res_id: parseInt(this.res_id), + default_number_field_name: this.name, + default_composition_mode: 'comment', + }); + + return this.do_action({ + title: _t('Send SMS Text Message'), + type: 'ir.actions.act_window', + res_model: 'sms.composer', + target: 'new', + views: [[false, 'form']], + context: context, + }); + }, + + /** + * Add a button to call the composer wizard + * + * @override + * @private + */ + _renderReadonly: function () { + var def = this._super.apply(this, arguments); + if (this.nodeOptions.enable_sms) { + var $composerButton = $('', { + title: _t('Send SMS Text Message'), + href: '', + class: 'btn-sm btn-link mb4 fa fa-comment o_field_phone_sms', + }); + $composerButton.on('click', this._onClickSMS.bind(this)); + this.$el = $('
').append(this.$el).append($composerButton); + } + + return def; + }, +}); + +return Phone; + +}); diff --git a/addons/sms/static/src/js/sms_widget.js b/addons/sms/static/src/js/fields_sms_widget.js similarity index 63% rename from addons/sms/static/src/js/sms_widget.js rename to addons/sms/static/src/js/fields_sms_widget.js index cbb0dda05b9..e9dbf8e7cc7 100644 --- a/addons/sms/static/src/js/sms_widget.js +++ b/addons/sms/static/src/js/fields_sms_widget.js @@ -4,21 +4,17 @@ odoo.define('sms.sms_widget', function (require) { var basicFields = require('web.basic_fields'); var core = require('web.core'); var fieldRegistry = require('web.field_registry'); -var dom = require('web.dom'); -var framework = require('web.framework'); - var FieldText = basicFields.FieldText; -var InputField = basicFields.InputField; -var QWeb = core.qweb; -var _t = core._t +var _t = core._t; /** * SmsWidget is a widget to display a textarea (the body) and a text representing * the number of SMS and the number of characters. This text is computed every * time the user changes the body. */ -var SmsWidget = InputField.extend({ +var SmsWidget = FieldText.extend({ + className: 'o_field_text', /** * @constructor */ @@ -26,12 +22,31 @@ var SmsWidget = InputField.extend({ this._super.apply(this, arguments); this.nbrChar = 0; this.nbrSMS = 0; - this.encoding = "GSM7"; - this.tagName = 'div'; + this.encoding = 'GSM7'; }, //-------------------------------------------------------------------------- - // Private + // Private: override widget + //-------------------------------------------------------------------------- + + /** + * @private + * @override + */ + _renderEdit: function () { + var def = this._super.apply(this, arguments); + + this._compute(); + var $sms_container = $('
'); + $sms_container.append(this._renderSMSInfo()); + $sms_container.append(this._renderIAPButton()); + this.$el = this.$el.add($sms_container); + + return def; + }, + + //-------------------------------------------------------------------------- + // Private: SMS //-------------------------------------------------------------------------- /** @@ -44,8 +59,8 @@ var SmsWidget = InputField.extend({ this.nbrChar = content.length; this.nbrChar += (content.match(/\n/g) || []).length; this.nbrSMS = this._countSMS(this.nbrChar, this.encoding); - this._renderSMS(); }, + /** * Count the number of SMS of the content * @private @@ -66,16 +81,7 @@ var SmsWidget = InputField.extend({ } return Math.ceil(this.nbrChar / 153); }, - /** - * @private - * @override - */ - _renderEdit: function () { - this.$el.empty(); - this._prepareInput($('