From 9ad7a88a08ceccc3665acd8972ac47614205bf0e Mon Sep 17 00:00:00 2001 From: Pierre Rousseau Date: Fri, 24 May 2019 10:12:44 +0000 Subject: [PATCH] [IMP] sms: add SMS template and its support in SMS composer Purpose of this commit is to provide users templates to use when sending SMS. It is inspired from what already exists for mail templates. This commit * adds templates for SMS * users can now create template for SMS Text messages similar to mail templates; * jinja syntax is supported for body like mail templates, which is why templates are linked to a given model; * refactor the SMS composer * templates are supported in composer like the message composer. This is supported only in mass mode to avoid bloating the interface in standard composition mode; * composer code is rewritten to support various use cases (comment, mass sms, numbers, ...) and better fits all use cases; * composer is extended to support notably mass SMS without posting messages; Templates will also be used in a near future in mass sms sending (mass mailing application improvement) and in marketing automation (for enterprise). Tests are updated and added to ensure feature works. Related to task 1922163 Linked to PR #33510 Co-Authored-By: Thibault Delavallee Co-Authored-By: Pierre Rousseau --- addons/sms/__manifest__.py | 1 + addons/sms/models/__init__.py | 1 + addons/sms/models/mail_thread.py | 40 +++ addons/sms/models/sms_template.py | 121 +++++++ addons/sms/security/ir.model.access.csv | 3 + addons/sms/tests/common.py | 8 +- addons/sms/views/res_partner_views.xml | 44 ++- addons/sms/views/sms_template_views.xml | 71 ++++ addons/sms/wizard/sms_composer.py | 279 +++++++++++++-- addons/sms/wizard/sms_composer_views.xml | 77 +++- addons/test_mail_full/tests/__init__.py | 1 + .../test_mail_full/tests/test_sms_composer.py | 337 +++++++++++++++--- .../tests/test_sms_performance.py | 61 ++++ addons/test_mail_full/tests/test_sms_post.py | 42 +++ .../test_mail_full/tests/test_sms_template.py | 76 ++++ 15 files changed, 1044 insertions(+), 118 deletions(-) create mode 100644 addons/sms/models/sms_template.py create mode 100644 addons/sms/views/sms_template_views.xml create mode 100644 addons/test_mail_full/tests/test_sms_template.py diff --git a/addons/sms/__manifest__.py b/addons/sms/__manifest__.py index dad1ac221da..0de865810e7 100644 --- a/addons/sms/__manifest__.py +++ b/addons/sms/__manifest__.py @@ -18,6 +18,7 @@ The service is provided by the In App Purchase Odoo platform. 'views/res_partner_views.xml', 'views/assets.xml', 'views/sms_sms_views.xml', + 'views/sms_template_views.xml', 'security/ir.model.access.csv', ], 'qweb': [ diff --git a/addons/sms/models/__init__.py b/addons/sms/models/__init__.py index b855b4b7955..4adcd67ab64 100644 --- a/addons/sms/models/__init__.py +++ b/addons/sms/models/__init__.py @@ -8,3 +8,4 @@ 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_thread.py b/addons/sms/models/mail_thread.py index fd47be27a5d..b088309cbfe 100644 --- a/addons/sms/models/mail_thread.py +++ b/addons/sms/models/mail_thread.py @@ -84,6 +84,46 @@ class MailThread(models.AbstractModel): 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: + composer_context['default_res_ids'] = self.ids + + 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 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 index 42c4d624aaa..c906bc097b7 100644 --- a/addons/sms/security/ir.model.access.csv +++ b/addons/sms/security/ir.model.access.csv @@ -1,3 +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/tests/common.py b/addons/sms/tests/common.py index 7c0c3b5d22a..f0f96c0e8d6 100644 --- a/addons/sms/tests/common.py +++ b/addons/sms/tests/common.py @@ -73,6 +73,8 @@ class MockSMS(common.BaseCase): def assertSMSCanceled(self, partner, number, error_code, content=None): """ 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) sms = self.env['sms.sms'].sudo().search([ ('partner_id', '=', partner.id), ('number', '=', number), ('state', '=', 'canceled') @@ -85,6 +87,8 @@ class MockSMS(common.BaseCase): def assertSMSFailed(self, partner, number, error_code, content=None): """ 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) sms = self.env['sms.sms'].sudo().search([ ('partner_id', '=', partner.id), ('number', '=', number), ('state', '=', 'error') @@ -97,11 +101,13 @@ class MockSMS(common.BaseCase): def assertSMSOutgoing(self, partner, number, content=None): """ 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) sms = self.env['sms.sms'].sudo().search([ ('partner_id', '=', partner.id), ('number', '=', number), ('state', '=', 'outgoing') ]) - self.assertTrue(sms, 'SMS: not found failed SMS for %s (number: %s, state)' % (partner, number)) + self.assertTrue(sms, 'SMS: not found failed SMS for %s (number: %s)' % (partner, number)) if content is not None: self.assertEqual(sms.body, content) diff --git a/addons/sms/views/res_partner_views.xml b/addons/sms/views/res_partner_views.xml index 1a483568178..e841fad4005 100644 --- a/addons/sms/views/res_partner_views.xml +++ b/addons/sms/views/res_partner_views.xml @@ -15,12 +15,11 @@