diff --git a/debian/control b/debian/control index 21d742177..55b5ad7a2 100644 --- a/debian/control +++ b/debian/control @@ -24,6 +24,7 @@ Depends: python3-requests, python3-werkzeug, python3-tz, + python3-phonenumbers, wazo-auth-client-python3, wazo-provd-client-python3, wazo-bus-python3, diff --git a/integration_tests/suite/base/test_user_callerid.py b/integration_tests/suite/base/test_user_callerid.py index 5f72639fd..d56a5b09f 100644 --- a/integration_tests/suite/base/test_user_callerid.py +++ b/integration_tests/suite/base/test_user_callerid.py @@ -25,39 +25,31 @@ def test_list_when_no_incall(user): assert_that(response.total, equal_to(1)) -@fixtures.extension(exten='5555551234', context=INCALL_CONTEXT) -@fixtures.incall() @fixtures.extension(exten='5555556789', context=INCALL_CONTEXT) @fixtures.incall() @fixtures.user() -def test_list_with_associated_type(extension1, incall1, extension2, incall2, user): +def test_list_with_associated_type(extension, incall, user): destination = {'type': 'user', 'user_id': user['id']} - confd.incalls(incall1['id']).put(destination=destination).assert_updated() - confd.incalls(incall2['id']).put(destination=destination).assert_updated() + confd.incalls(incall['id']).put(destination=destination).assert_updated() - with a.incall_extension(incall1, extension1): - with a.incall_extension(incall2, extension2): - response = confd.users(user['uuid']).callerids.outgoing.get() + with a.incall_extension(incall, extension): + response = confd.users(user['uuid']).callerids.outgoing.get() expected = [ - {'type': 'main', 'number': '5555551234'}, - {'type': 'associated', 'number': '5555551234'}, {'type': 'associated', 'number': '5555556789'}, {'type': 'anonymous'}, ] assert_that(response.items, contains_inanyorder(*expected)) - assert_that(response.total, equal_to(4)) + assert_that(response.total, equal_to(2)) -@fixtures.extension(exten='5555551234', context=INCALL_CONTEXT) -@fixtures.incall(destination={'type': 'custom', 'command': 'Playback(Welcome)'}) +@fixtures.phone_number(main=True, number='5555551234') @fixtures.extension(exten='5555556789', context=INCALL_CONTEXT) @fixtures.incall(destination={'type': 'custom', 'command': 'Playback(IGNORED)'}) @fixtures.user() -def test_list_with_main_type(extension1, incall1, extension2, incall2, user): - with a.incall_extension(incall1, extension1): - with a.incall_extension(incall2, extension2): - response = confd.users(user['uuid']).callerids.outgoing.get() +def test_list_with_main_type(phone_number, extension, incall, user): + with a.incall_extension(incall, extension): + response = confd.users(user['uuid']).callerids.outgoing.get() # The first created is the main and other are ignored expected = [ @@ -68,26 +60,43 @@ def test_list_with_main_type(extension1, incall1, extension2, incall2, user): assert_that(response.total, equal_to(2)) -@fixtures.extension(exten='5555551234', context=INCALL_CONTEXT) -@fixtures.incall(destination={'type': 'custom', 'command': 'Playback(Welcome)'}) -@fixtures.extension(exten='5555556789', context=INCALL_CONTEXT) +@fixtures.phone_number(shared=True, number='5555551234') +@fixtures.user() +def test_list_with_shared(phone_number, user): + response = confd.users(user['uuid']).callerids.outgoing.get() + + # The first created is the main and other are ignored + expected = [ + {'type': 'shared', 'number': '5555551234'}, + {'type': 'anonymous'}, + ] + assert_that(response.items, contains_inanyorder(*expected)) + assert_that(response.total, equal_to(2)) + + +@fixtures.phone_number(main=True, number='5555551234') +@fixtures.phone_number(shared=True, number='5555551235') +@fixtures.phone_number(shared=True, number='5555551236') +@fixtures.extension(exten='5555551235', context=INCALL_CONTEXT) @fixtures.incall() @fixtures.user() -def test_list_with_all_type(main_extension, main_incall, extension, incall, user): +def test_list_with_all_type( + main_number, shared_number1, shared_number2, extension, incall, user +): destination = {'type': 'user', 'user_id': user['id']} confd.incalls(incall['id']).put(destination=destination).assert_updated() - with a.incall_extension(main_incall, main_extension): - with a.incall_extension(incall, extension): - response = confd.users(user['uuid']).callerids.outgoing.get() + with a.incall_extension(incall, extension): + response = confd.users(user['uuid']).callerids.outgoing.get() expected = [ {'type': 'main', 'number': '5555551234'}, - {'type': 'associated', 'number': '5555556789'}, + {'type': 'associated', 'number': '5555551235'}, + {'type': 'shared', 'number': '5555551236'}, {'type': 'anonymous'}, ] assert_that(response.items, contains_inanyorder(*expected)) - assert_that(response.total, equal_to(3)) + assert_that(response.total, equal_to(4)) @fixtures.user(wazo_tenant=MAIN_TENANT) diff --git a/requirements.txt b/requirements.txt index 45e3afb93..39317466b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,4 @@ sqlalchemy-utils==0.36.8 # from xivo-dao stevedore==4.0.2 unidecode==1.2.0 # from xivo-dao werkzeug==1.0.1 +phonenumbers==8.12.1 \ No newline at end of file diff --git a/wazo_confd/plugins/user_callerid/service.py b/wazo_confd/plugins/user_callerid/service.py index b1f16fda2..ad559cc82 100644 --- a/wazo_confd/plugins/user_callerid/service.py +++ b/wazo_confd/plugins/user_callerid/service.py @@ -1,27 +1,68 @@ # Copyright 2024 The Wazo Authors (see the AUTHORS file) # SPDX-License-Identifier: GPL-3.0-or-later +import phonenumbers +from dataclasses import dataclass + from xivo_dao.resources.user import dao as user_dao from xivo_dao.resources.incall import dao as incall_dao +from xivo_dao.resources.phone_number import dao as phone_number_dao + +from .types import CallerIDType class CallerIDAnonymous: type = 'anonymous' +@dataclass() +class CallerID: + type: CallerIDType + number: str + + +def same_phone_number(number1: str, number2: str) -> bool: + ''' + compare two strings semantically as phone numbers + ''' + result = phonenumbers.is_number_match(number1, number2) + return result in ( + phonenumbers.MatchType.EXACT_MATCH, + phonenumbers.MatchType.NSN_MATCH, + ) + + class UserCallerIDService: - def __init__(self, user_dao, incall_dao): + def __init__(self, user_dao, incall_dao, phone_number_dao): self.user_dao = user_dao self.incall_dao = incall_dao + self.phone_number_dao = phone_number_dao def search(self, user_id, tenant_uuid, parameters): callerids = [] - if main_callerid := self.incall_dao.find_main_callerid(tenant_uuid): - callerids.append(main_callerid) - callerids.extend(self.user_dao.list_outgoing_callerid_associated(user_id)) + if main_callerid := self.phone_number_dao.find_by( + main=True, tenant_uuids=[tenant_uuid] + ): + callerids.append(CallerID(type='main', number=main_callerid.number)) + + # consider "associated" caller ids from incalls + # as having precedence over shared phone numbers + callerids.extend( + callerid + for callerid in self.user_dao.list_outgoing_callerid_associated(user_id) + if not any(same_phone_number(callerid.number, c.number) for c in callerids) + ) + shared_callerids = self.phone_number_dao.find_all_by( + shared=True, main=False, tenant_uuids=[tenant_uuid] + ) + callerids.extend( + CallerID(type='shared', number=callerid.number) + for callerid in shared_callerids + if not any(same_phone_number(callerid.number, c.number) for c in callerids) + ) callerids.append(CallerIDAnonymous) return len(callerids), callerids def build_service(): - return UserCallerIDService(user_dao, incall_dao) + return UserCallerIDService(user_dao, incall_dao, phone_number_dao) diff --git a/wazo_confd/plugins/user_callerid/tests/__init__.py b/wazo_confd/plugins/user_callerid/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wazo_confd/plugins/user_callerid/tests/test_service.py b/wazo_confd/plugins/user_callerid/tests/test_service.py new file mode 100644 index 000000000..b529f55a0 --- /dev/null +++ b/wazo_confd/plugins/user_callerid/tests/test_service.py @@ -0,0 +1,25 @@ +import unittest + +from ..service import same_phone_number + + +class TestSamePhoneNumber(unittest.TestCase): + def test_exact(self): + numbers = ['1234567890', '11234567890', '+11234567890' '4567890', '911'] + for number in numbers: + self.assertTrue(same_phone_number(number, number), number) + + def test_actually_different(self): + number1 = '11234567890' + number2 = '11234567891' + self.assertFalse(same_phone_number(number1, number2), (number1, number2)) + + def test_different_country(self): + number1 = '11234567890' + number2 = '21234567890' + self.assertFalse(same_phone_number(number1, number2), (number1, number2)) + + def test_different_country_one_e164(self): + number1 = '+11234567890' + number2 = '21234567890' + self.assertFalse(same_phone_number(number1, number2), (number1, number2)) diff --git a/wazo_confd/plugins/user_callerid/types.py b/wazo_confd/plugins/user_callerid/types.py new file mode 100644 index 000000000..ea407b33d --- /dev/null +++ b/wazo_confd/plugins/user_callerid/types.py @@ -0,0 +1,3 @@ +from typing import Literal + +CallerIDType = Literal['main', 'associated', 'anonymous', 'shared']