diff --git a/addons/phone_validation/__manifest__.py b/addons/phone_validation/__manifest__.py index 2ebc99f3ead..9afac809ea8 100644 --- a/addons/phone_validation/__manifest__.py +++ b/addons/phone_validation/__manifest__.py @@ -3,6 +3,7 @@ { 'name': 'Phone Numbers Validation', + 'version': '2.0', 'summary': 'Validate and format phone numbers', 'sequence': '9999', 'category': 'Hidden', @@ -11,12 +12,24 @@ Phone Numbers Validation ======================== This module adds the feature of validation and formatting phone numbers -according to a destination country. It also handles national and international -formatting. +according to a destination country. -This module applies this feature to Leads and Contacts.""", +It also adds phone blacklist management through a specific model storing +blacklisted phone numbers. + +It adds two mixins : + + * phone.validation.mixin: parsing / formatting helpers on records, to be + used for example in number fields onchange; + * mail.thread.phone: handle sanitation and blacklist of records numbers; +""", 'data': [ - 'views/res_company_view.xml' + 'security/ir.model.access.csv', + 'views/res_company_view.xml', + 'views/phone_blacklist_views.xml', + ], + 'depends': [ + 'base', + 'mail', ], - 'depends': ['base'], } diff --git a/addons/phone_validation/models/__init__.py b/addons/phone_validation/models/__init__.py index 85ea0597483..dfe42e557e3 100644 --- a/addons/phone_validation/models/__init__.py +++ b/addons/phone_validation/models/__init__.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import res_company +from . import phone_blacklist from . import phone_validation_mixin +from . import mail_thread_phone +from . import res_company diff --git a/addons/phone_validation/models/mail_thread_phone.py b/addons/phone_validation/models/mail_thread_phone.py new file mode 100644 index 00000000000..3e166985f8e --- /dev/null +++ b/addons/phone_validation/models/mail_thread_phone.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class PhoneMixin(models.AbstractModel): + """ Purpose of this mixin is to offer two services + + * compute a sanitized phone number based on ´´_sms_get_number_fields´´. + It takes first sanitized value, trying each field returned by the + method (see ``MailThread._sms_get_number_fields()´´ for more details + about the usage of this method); + * compute blacklist state of records. It is based on phone.blacklist + model and give an easy-to-use field and API to manipulate blacklisted + records; + + Main API methods + + * ``_phone_set_blacklisted``: set recordset as blacklisted; + * ``_phone_reset_blacklisted``: reactivate recordset (even if not blacklisted + this method can be called safely); + """ + _name = 'mail.thread.phone' + _description = 'Phone Blacklist Mixin' + _inherit = ['mail.thread', 'phone.validation.mixin'] + + phone_sanitized = fields.Char( + string='Sanitized Number', compute="_compute_phone_sanitized", compute_sudo=True, store=True, + help="Field used to store sanitized phone number. Helps speeding up searches and comparisons.") + phone_blacklisted = fields.Boolean( + string='Phone Blacklisted', compute="_compute_phone_blacklisted", compute_sudo=True, store=False, + search="_search_phone_blacklisted", groups="base.group_user", + help="If the email address is on the blacklist, the contact won't receive mass mailing anymore, from any list") + + @api.depends(lambda self: self._phone_get_number_fields()) + def _compute_phone_sanitized(self): + self._assert_phone_field() + number_fields = self._phone_get_number_fields() + for record in self: + for fname in number_fields: + sanitized = record.phone_get_sanitized_number(number_fname=fname) + if sanitized: + break + record.phone_sanitized = sanitized + + @api.depends('phone_sanitized') + def _compute_phone_blacklisted(self): + # TODO : Should remove the sudo as compute_sudo defined on methods. + # But if user doesn't have access to mail.blacklist, doen't work without sudo(). + blacklist = set(self.env['phone.blacklist'].sudo().search([ + ('number', 'in', self.mapped('phone_sanitized'))]).mapped('number')) + for record in self: + record.phone_blacklisted = record.phone_sanitized in blacklist + + @api.model + def _search_phone_blacklisted(self, operator, value): + # Assumes operator is '=' or '!=' and value is True or False + self._assert_phone_field() + if operator != '=': + if operator == '!=' and isinstance(value, bool): + value = not value + else: + raise NotImplementedError() + + if value: + query = """ + SELECT m.id + FROM phone_blacklist bl + JOIN %s m + ON m.phone_sanitized = bl.number AND bl.active + """ + else: + query = """ + SELECT m.id + FROM %s m + LEFT JOIN phone_blacklist bl + ON m.phone_sanitized = bl.number AND bl.active + WHERE bl.id IS NULL + """ + self._cr.execute(query % self._table) + res = self._cr.fetchall() + if not res: + return [(0, '=', 1)] + return [('id', 'in', [r[0] for r in res])] + + def _assert_phone_field(self): + if not hasattr(self, "_phone_get_number_fields"): + raise UserError(_('Invalid primary phone field on model %s') % self._name) + if not any(fname in self and self._fields[fname].type == 'char' for fname in self._phone_get_number_fields()): + raise UserError(_('Invalid primary phone field on model %s') % self._name) + + def _phone_get_number_fields(self): + """ This method returns the fields to use to find the number to use to + send an SMS on a record. """ + return [] + + def _phone_set_blacklisted(self): + return self.env['phone.blacklist'].sudo()._add([r.phone_sanitized for r in self]) + + def _phone_reset_blacklisted(self): + return self.env['phone.blacklist'].sudo()._remove([r.phone_sanitized for r in self]) diff --git a/addons/phone_validation/models/phone_blacklist.py b/addons/phone_validation/models/phone_blacklist.py new file mode 100644 index 00000000000..2a6e90b6cb5 --- /dev/null +++ b/addons/phone_validation/models/phone_blacklist.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import api, fields, models, _ +from odoo.addons.phone_validation.tools import phone_validation +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class PhoneBlackList(models.Model): + """ Blacklist of phone numbers. Used to avoid sending unwanted messages to people. """ + _name = 'phone.blacklist' + _inherit = ['mail.thread'] + _description = 'Phone Blacklist' + _rec_name = 'number' + + number = fields.Char(string='Phone Number', required=True, index=True, tracking=True, help='Number should be E164 formatted') + active = fields.Boolean(default=True, tracking=True) + + _sql_constraints = [ + ('unique_number', 'unique (number)', 'Number already exists') + ] + + @api.model_create_multi + def create(self, values): + # First of all, extract values to ensure emails are really unique (and don't modify values in place) + to_create = [] + done = set() + for value in values: + number = value['number'] + sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized'] + if not sanitized: + raise UserError(_('Invalid number %s') % number) + if sanitized in done: + continue + done.add(sanitized) + to_create.append(dict(value, number=sanitized)) + + """ To avoid crash during import due to unique email, return the existing records if any """ + sql = '''SELECT number, id FROM phone_blacklist WHERE number = ANY(%s)''' + numbers = [v['number'] for v in to_create] + self._cr.execute(sql, (numbers,)) + bl_entries = dict(self._cr.fetchall()) + to_create = [v for v in to_create if v['number'] not in bl_entries] + + results = super(PhoneBlackList, self).create(to_create) + return self.env['phone.blacklist'].browse(bl_entries.values()) | results + + def write(self, values): + if 'number' in values: + number = values['number'] + sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized'] + if not sanitized: + raise UserError(_('Invalid number %s') % number) + values['number'] = sanitized + return super(PhoneBlackList, self).write(values) + + def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): + """ Override _search in order to grep search on sanitized number field """ + if args: + new_args = [] + for arg in args: + if isinstance(arg, (list, tuple)) and arg[0] == 'number' and isinstance(arg[2], str): + number = arg[2] + sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized'] + if sanitized: + new_args.append([arg[0], arg[1], sanitized]) + else: + new_args.append(arg) + else: + new_args.append(arg) + else: + new_args = args + return super(PhoneBlackList, self)._search(new_args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) + + def add(self, number): + sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized'] + return self._add([sanitized]) + + def _add(self, numbers): + """ Add or re activate a phone blacklist entry. + + :param numbers: list of sanitized numbers """ + records = self.env["phone.blacklist"].with_context(active_test=False).search([('number', 'in', numbers)]) + todo = [n for n in numbers if n not in records.mapped('number')] + if records: + records.write({'active': True}) + if todo: + records += self.create([{'number': n} for n in todo]) + return records + + def remove(self, number): + sanitized = phone_validation.phone_sanitize_numbers_w_record([number], self.env.user)[number]['sanitized'] + return self._remove([sanitized]) + + def _remove(self, numbers): + """ Add de-activated or de-activate a phone blacklist entry. + + :param numbers: list of sanitized numbers """ + records = self.env["phone.blacklist"].with_context(active_test=False).search([('number', 'in', numbers)]) + todo = [n for n in numbers if n not in records.mapped('number')] + if records: + records.write({'active': False}) + if todo: + records += self.create([{'number': n, 'active': False} for n in todo]) + return records diff --git a/addons/phone_validation/models/phone_validation_mixin.py b/addons/phone_validation/models/phone_validation_mixin.py index f062586c7c3..0e7ab1c2aaa 100644 --- a/addons/phone_validation/models/phone_validation_mixin.py +++ b/addons/phone_validation/models/phone_validation_mixin.py @@ -9,6 +9,11 @@ class PhoneValidationMixin(models.AbstractModel): _name = 'phone.validation.mixin' _description = 'Phone Validation Mixin' + def _phone_get_country_field(self): + if 'country_id' in self: + return 'country_id' + return False + def _phone_get_country(self): if 'country_id' in self and self.country_id: return self.country_id @@ -31,3 +36,17 @@ class PhoneValidationMixin(models.AbstractModel): force_format='INTERNATIONAL' if always_international else 'NATIONAL', raise_exception=False ) + + def phone_get_sanitized_numbers(self, number_fname='mobile', force_format='E164'): + res = dict.fromkeys(self.ids, False) + country_fname = self._phone_get_country_field() + for record in self: + number = record[number_fname] + res[record.id] = phone_validation.phone_sanitize_numbers_w_record([number], record, record_country_fname=country_fname, force_format=force_format)[number]['sanitized'] + return res + + def phone_get_sanitized_number(self, number_fname='mobile', force_format='E164'): + self.ensure_one() + country_fname = self._phone_get_country_field() + number = self[number_fname] + return phone_validation.phone_sanitize_numbers_w_record([number], self, record_country_fname=country_fname, force_format=force_format)[number]['sanitized'] diff --git a/addons/phone_validation/security/ir.model.access.csv b/addons/phone_validation/security/ir.model.access.csv new file mode 100644 index 00000000000..53a27a4e6b2 --- /dev/null +++ b/addons/phone_validation/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_phone_blacklist_all,access.phone.blacklist.all,model_phone_blacklist,,0,0,0,0 +access_phone_blacklist_system,access.phone.blacklist.system,model_phone_blacklist,base.group_system,1,1,1,1 diff --git a/addons/phone_validation/tools/phone_validation.py b/addons/phone_validation/tools/phone_validation.py index e1df861ac8c..91ee9ba6b37 100644 --- a/addons/phone_validation/tools/phone_validation.py +++ b/addons/phone_validation/tools/phone_validation.py @@ -17,7 +17,7 @@ try: try: phone_nbr = phonenumbers.parse(number, region=country_code, keep_raw_input=True) except phonenumbers.phonenumberutil.NumberParseException as e: - raise UserError(_('Unable to parse %s:\n%s') % (number, e)) + raise UserError(_('Unable to parse %s: %s') % (number, str(e))) if not phonenumbers.is_possible_number(phone_nbr): raise UserError(_('Impossible number %s: probably invalid number of digits') % number) @@ -77,45 +77,41 @@ except ImportError: def phone_sanitize_numbers(numbers, country_code, country_phone_code, force_format='E164'): + """ Given a list of numbers, return parsezd and sanitized information + + :return dict: {number: { + 'sanitized': sanitized and formated number or False (if cannot format) + 'code': 'empty' (number was a void string), 'invalid' (error) or False (sanitize ok) + 'msg': error message when 'invalid' + }} + """ + if not isinstance(numbers, (list)): + raise NotImplementedError() result = dict.fromkeys(numbers, False) for number in numbers: if not number: result[number] = {'sanitized': False, 'code': 'empty', 'msg': False} continue try: + stripped = number.strip() sanitized = phone_format( - number, country_code, country_phone_code, + stripped, country_code, country_phone_code, force_format=force_format, raise_exception=True) except Exception as e: - result[number] = {'sanitized': False, 'code': 'invalid', 'msg': e} + result[number] = {'sanitized': False, 'code': 'invalid', 'msg': str(e)} else: result[number] = {'sanitized': sanitized, 'code': False, 'msg': False} return result def phone_sanitize_numbers_w_record(numbers, record, country=False, record_country_fname='country_id', force_format='E164'): + if not isinstance(numbers, (list)): + raise NotImplementedError() if not country: - if record and hasattr(record, record_country_fname) and record[record_country_fname]: + if record and record_country_fname and hasattr(record, record_country_fname) and record[record_country_fname]: country = record[record_country_fname] elif record: country = record.env.company.country_id country_code = country.code if country else None country_phone_code = country.phone_code if country else None return phone_sanitize_numbers(numbers, country_code, country_phone_code, force_format=force_format) - - -def phone_sanitize_numbers_string_w_record(numbers_str, record, country=False, record_country_fname='country_id', force_format='E164'): - found_numbers = [number.strip() for number in numbers_str.split(',')] - return phone_sanitize_numbers_w_record(found_numbers, record, country=country, record_country_fname=record_country_fname, force_format=force_format) - - -def phone_get_sanitized_records_number(records, number_fname='mobile', country_fname='country_id', force_format='E164'): - res = dict.fromkeys(records.ids, False) - for record in records: - number = record[number_fname] - res[record.id] = phone_sanitize_numbers_w_record([number], records, record_country_fname=country_fname,force_format=force_format)[number]['sanitized'] - return res - - -def phone_get_sanitized_record_number(record, number_fname='mobile', country_fname='country_id', force_format='E164'): - return phone_get_sanitized_records_number(record, number_fname, country_fname, force_format=force_format)[record.id] diff --git a/addons/phone_validation/views/phone_blacklist_views.xml b/addons/phone_validation/views/phone_blacklist_views.xml new file mode 100644 index 00000000000..4d76576cefa --- /dev/null +++ b/addons/phone_validation/views/phone_blacklist_views.xml @@ -0,0 +1,72 @@ + + + + phone.blacklist.view.tree + phone.blacklist + + + + + + + + + + phone.blacklist.view.form + phone.blacklist + +
+ + + + + + + + +
+ + +
+
+
+
+ + + phone.blacklist.view.search + phone.blacklist + + + + + + + + + + Phone Blacklist + phone.blacklist + + + +

+ Add a phone number in the blacklist +

+ Blacklisted phone numbers means that the recipient won't receive mass SMS anymore. +

+
+
+ + + + + + +
diff --git a/addons/sms/__manifest__.py b/addons/sms/__manifest__.py index 9be1a884b13..a522fc65bd4 100644 --- a/addons/sms/__manifest__.py +++ b/addons/sms/__manifest__.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'SMS gateway', + 'version': '2.0', 'category': 'Tools', 'summary': 'SMS Text Messaging', 'description': """ @@ -32,5 +33,5 @@ The service is provided by the In App Purchase Odoo platform. 'static/src/xml/thread.xml', ], 'installable': True, - 'auto_install': True, + 'auto_install': False, } diff --git a/addons/sms/models/__init__.py b/addons/sms/models/__init__.py index aab4ae27445..8ae76d2a518 100644 --- a/addons/sms/models/__init__.py +++ b/addons/sms/models/__init__.py @@ -6,6 +6,7 @@ from . import mail_followers from . import mail_message from . import mail_notification from . import mail_thread +from . import mail_thread_phone from . import res_partner from . import sms_api from . import sms_sms diff --git a/addons/sms/models/mail_thread.py b/addons/sms/models/mail_thread.py index 9deee25877f..5df73183266 100644 --- a/addons/sms/models/mail_thread.py +++ b/addons/sms/models/mail_thread.py @@ -78,7 +78,7 @@ class MailThread(models.AbstractModel): valid_number = False for fname in [f for f in tocheck_fields if f in record]: - valid_number = phone_validation.phone_get_sanitized_record_number(record, number_fname=fname) + valid_number = phone_validation.phone_sanitize_numbers_w_record([record[fname]], record)[record[fname]]['sanitized'] if valid_number: break @@ -92,7 +92,7 @@ class MailThread(models.AbstractModel): for partner in all_partners: partner_number = partner.mobile or partner.phone if partner_number: - partner_number = phone_validation.phone_sanitize_numbers_string_w_record(partner_number, record)[partner_number]['sanitized'] + partner_number = phone_validation.phone_sanitize_numbers_w_record([partner_number], record)[partner_number]['sanitized'] if partner_number: break @@ -233,7 +233,7 @@ class MailThread(models.AbstractModel): if partner_ids: for partner in self.env['res.partner'].sudo().browse(partner_ids): number = sms_pid_to_number.get(partner.id) or partner.mobile or partner.phone - sanitize_res = phone_validation.phone_sanitize_numbers_string_w_record(number, partner)[number] + sanitize_res = phone_validation.phone_sanitize_numbers_w_record([number], partner)[number] number = sanitize_res['sanitized'] or number sms_create_vals.append(dict( sms_base_vals, diff --git a/addons/sms/models/mail_thread_phone.py b/addons/sms/models/mail_thread_phone.py new file mode 100644 index 00000000000..b886a0f15c7 --- /dev/null +++ b/addons/sms/models/mail_thread_phone.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class PhoneMixin(models.AbstractModel): + _inherit = 'mail.thread.phone' + + def _phone_get_number_fields(self): + """ Add fields coming from sms implementation. """ + sms_fields = self._sms_get_number_fields() + res = super(PhoneMixin, self)._phone_get_number_fields() + for fname in (f for f in res if f not in sms_fields): + sms_fields.append(fname) + return sms_fields diff --git a/addons/sms/models/res_partner.py b/addons/sms/models/res_partner.py index 03431d0a68a..987791da643 100644 --- a/addons/sms/models/res_partner.py +++ b/addons/sms/models/res_partner.py @@ -5,7 +5,8 @@ from odoo import models class ResPartner(models.Model): - _inherit = 'res.partner' + _name = 'res.partner' + _inherit = ['res.partner', 'mail.thread.phone'] def _sms_get_default_partners(self): """ Override of mail.thread method. diff --git a/addons/sms/tests/common.py b/addons/sms/tests/common.py index 7535eaa25c4..a0ef0a20436 100644 --- a/addons/sms/tests/common.py +++ b/addons/sms/tests/common.py @@ -74,7 +74,7 @@ class MockSMS(common.BaseCase): """ Check canceled SMS. Search is done for a pair partner / number where partner can be an empty recordset. """ if number is None and partner: - number = phone_validation.phone_get_sanitized_record_number(partner) + number = partner.phone_get_sanitized_number() sms = self.env['sms.sms'].sudo().search([ ('partner_id', '=', partner.id), ('number', '=', number), ('state', '=', 'canceled') @@ -88,7 +88,7 @@ class MockSMS(common.BaseCase): """ Check failed SMS. Search is done for a pair partner / number where partner can be an empty recordset. """ if number is None and partner: - number = phone_validation.phone_get_sanitized_record_number(partner) + number = partner.phone_get_sanitized_number() sms = self.env['sms.sms'].sudo().search([ ('partner_id', '=', partner.id), ('number', '=', number), ('state', '=', 'error') @@ -102,7 +102,7 @@ class MockSMS(common.BaseCase): """ Check outgoing SMS. Search is done for a pair partner / number where partner can be an empty recordset. """ if number is None and partner: - number = phone_validation.phone_get_sanitized_record_number(partner) + number = partner.phone_get_sanitized_number() sms = self.env['sms.sms'].sudo().search([ ('partner_id', '=', partner.id), ('number', '=', number), ('state', '=', 'outgoing') @@ -139,7 +139,7 @@ class MockSMS(common.BaseCase): number = recipient_info.get('number') state = recipient_info.get('state', 'sent') if number is None and partner: - number = phone_validation.phone_get_sanitized_record_number(partner) + number = partner.phone_get_sanitized_number() notif = notifications.filtered(lambda n: n.res_partner_id == partner and n.sms_number == number and n.notification_status == state) self.assertTrue(notif, 'SMS: not found notification for %s (number: %s, state: %s)' % (partner, number, state)) diff --git a/addons/sms/views/res_partner_views.xml b/addons/sms/views/res_partner_views.xml index f7e1b5da19a..bf779c2cad5 100644 --- a/addons/sms/views/res_partner_views.xml +++ b/addons/sms/views/res_partner_views.xml @@ -8,6 +8,9 @@ 10 + + +