telephony: Originate через Local и запасной набор 380 при отсутствии 050.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Бизнес-логика AMI: канал по типу линии, originate, ping."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -17,11 +18,12 @@ EXTEN_RE = re.compile(r'^\d{2,10}$')
|
||||
OUTBOUND_RE = re.compile(r'^\d{10,15}$')
|
||||
CONNECTOR = 'asterisk'
|
||||
|
||||
DEFAULT_SIP_CHANNEL = 'PJSIP/{exten}'
|
||||
DEFAULT_ANALOG_CHANNEL = 'DAHDI/{exten}'
|
||||
DEFAULT_CHANNEL = 'Local/{exten}@{context}'
|
||||
DEFAULT_CONTEXT = 'from-internal'
|
||||
DEFAULT_OUTBOUND = '0{nsn}'
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LINE_LABELS = {
|
||||
'idle': 'свободна',
|
||||
'busy': 'разговор',
|
||||
@@ -95,15 +97,40 @@ def classify_extension_status(reply: dict) -> str:
|
||||
return 'unavailable'
|
||||
|
||||
|
||||
def outbound_alt_exten(dest: str) -> str | None:
|
||||
"""0XXXXXXXXX ↔ 380XXXXXXXXX, если первый вариант не совпал с маршрутом АТС."""
|
||||
if dest.startswith('0') and len(dest) == 10:
|
||||
return f'38{dest}'
|
||||
if dest.startswith('380') and len(dest) == 12:
|
||||
return f'0{dest[3:]}'
|
||||
return None
|
||||
|
||||
|
||||
def _missing_extension(exc: BaseException) -> bool:
|
||||
return 'extension does not exist' in str(exc).lower()
|
||||
|
||||
|
||||
def _originate_error(exc: BaseException, dest: str, context: str) -> str:
|
||||
if _missing_extension(exc):
|
||||
return (
|
||||
f'Номер {dest} нет в диалплане ({context}). '
|
||||
'Внутренний должен быть создан на АТС. '
|
||||
'Городской: исходящий маршрут под 0XXXXXXXXX или 380… '
|
||||
'(в коннекторе outbound_exten: "0{nsn}" или "{e164}").'
|
||||
)
|
||||
return str(exc)
|
||||
|
||||
|
||||
def channel_for_employee(employee, extra: dict | None = None) -> str:
|
||||
extra = extra or {}
|
||||
exten = sanitize_exten(employee.internal_number)
|
||||
context = extra.get('originate_context') or DEFAULT_CONTEXT
|
||||
line = getattr(employee, 'internal_line_type', '') or 'analog'
|
||||
if line == 'sip':
|
||||
template = (extra.get('sip_channel') or DEFAULT_SIP_CHANNEL)
|
||||
template = extra.get('sip_channel') or extra.get('channel') or DEFAULT_CHANNEL
|
||||
else:
|
||||
template = (extra.get('analog_channel') or DEFAULT_ANALOG_CHANNEL)
|
||||
return str(template).replace('{exten}', exten)
|
||||
template = extra.get('analog_channel') or extra.get('channel') or DEFAULT_CHANNEL
|
||||
return str(template).replace('{exten}', exten).replace('{context}', context)
|
||||
|
||||
|
||||
def _ami_secret(cfg: ConnectorConfig) -> str:
|
||||
@@ -158,14 +185,30 @@ def _originate(caller: Employee, dest: str) -> dict:
|
||||
caller_id = f'{caller.short_name} <{sanitize_exten(caller.internal_number)}>'
|
||||
try:
|
||||
with _client_from_config(cfg) as client:
|
||||
return client.originate(
|
||||
channel=channel,
|
||||
exten=dest,
|
||||
context=context,
|
||||
caller_id=caller_id,
|
||||
)
|
||||
try:
|
||||
return client.originate(
|
||||
channel=channel,
|
||||
exten=dest,
|
||||
context=context,
|
||||
caller_id=caller_id,
|
||||
)
|
||||
except AmiError as exc:
|
||||
alt = outbound_alt_exten(dest)
|
||||
if alt and _missing_extension(exc):
|
||||
logger.warning('AMI dest %s missing in %s, retry %s', dest, context, alt)
|
||||
try:
|
||||
return client.originate(
|
||||
channel=channel,
|
||||
exten=alt,
|
||||
context=context,
|
||||
caller_id=caller_id,
|
||||
)
|
||||
except AmiError as retry_exc:
|
||||
exc = retry_exc
|
||||
dest = alt
|
||||
raise TelephonyError(_originate_error(exc, dest, context)) from exc
|
||||
except AmiError as exc:
|
||||
raise TelephonyError(str(exc)) from exc
|
||||
raise TelephonyError(_originate_error(exc, dest, context)) from exc
|
||||
|
||||
|
||||
def originate_between(caller: Employee, callee: Employee) -> dict:
|
||||
|
||||
@@ -10,6 +10,8 @@ from apps.asterisk_connector.services import (
|
||||
channel_for_employee,
|
||||
classify_extension_status,
|
||||
originate_between,
|
||||
originate_employee_dest,
|
||||
outbound_alt_exten,
|
||||
outbound_exten,
|
||||
sanitize_exten,
|
||||
)
|
||||
@@ -128,13 +130,13 @@ class AmiProtocolTests(SimpleTestCase):
|
||||
|
||||
|
||||
class ChannelMappingTests(TestCase):
|
||||
def test_sip_uses_pjsip_template(self):
|
||||
def test_sip_uses_local_channel(self):
|
||||
emp = make_employee(internal_number='101', internal_line_type='sip')
|
||||
self.assertEqual(channel_for_employee(emp), 'PJSIP/101')
|
||||
self.assertEqual(channel_for_employee(emp), 'Local/101@from-internal')
|
||||
|
||||
def test_analog_uses_dahdi(self):
|
||||
def test_analog_uses_local_channel(self):
|
||||
emp = make_employee(internal_number='12', internal_line_type='analog')
|
||||
self.assertEqual(channel_for_employee(emp), 'DAHDI/12')
|
||||
self.assertEqual(channel_for_employee(emp), 'Local/12@from-internal')
|
||||
|
||||
def test_custom_template(self):
|
||||
emp = make_employee(internal_number='55', internal_line_type='sip')
|
||||
@@ -150,6 +152,8 @@ class ChannelMappingTests(TestCase):
|
||||
def test_outbound_national_default(self):
|
||||
self.assertEqual(outbound_exten('+380501112233'), '0501112233')
|
||||
self.assertEqual(outbound_exten('050 111 22 33', {'outbound_exten': '{e164}'}), '380501112233')
|
||||
self.assertEqual(outbound_alt_exten('0501112233'), '380501112233')
|
||||
self.assertEqual(outbound_alt_exten('380501112233'), '0501112233')
|
||||
|
||||
def test_classify_extension_status(self):
|
||||
self.assertEqual(classify_extension_status({'Status': '0'}), 'idle')
|
||||
@@ -199,7 +203,7 @@ class OriginateViewTests(TestCase):
|
||||
self.assertIn('HX-Retarget', response)
|
||||
fake.originate.assert_called_once()
|
||||
kwargs = fake.originate.call_args.kwargs
|
||||
self.assertEqual(kwargs['channel'], 'PJSIP/101')
|
||||
self.assertEqual(kwargs['channel'], 'Local/101@from-internal')
|
||||
self.assertEqual(kwargs['exten'], '202')
|
||||
|
||||
def test_originate_mobile_uses_national_digits(self):
|
||||
@@ -218,6 +222,23 @@ class OriginateViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(fake.originate.call_args.kwargs['exten'], '0501112233')
|
||||
|
||||
def test_outbound_retries_e164_if_missing(self):
|
||||
fake = mock.MagicMock()
|
||||
fake.__enter__.return_value = fake
|
||||
fake.__exit__.return_value = False
|
||||
fake.originate.side_effect = [
|
||||
AmiError('Extension does not exist'),
|
||||
{'Response': 'Success'},
|
||||
]
|
||||
with mock.patch(
|
||||
'apps.asterisk_connector.services._client_from_config',
|
||||
return_value=fake,
|
||||
):
|
||||
originate_employee_dest(self.caller, self.callee, 'phone')
|
||||
self.assertEqual(fake.originate.call_count, 2)
|
||||
self.assertEqual(fake.originate.call_args_list[0].kwargs['exten'], '0501112233')
|
||||
self.assertEqual(fake.originate.call_args_list[1].kwargs['exten'], '380501112233')
|
||||
|
||||
def test_personal_phone_hidden_from_stranger(self):
|
||||
self.callee.personal_phone = '+380671112233'
|
||||
self.callee.save(update_fields=['personal_phone'])
|
||||
|
||||
+1
-1
@@ -362,8 +362,8 @@ class ConnectorConfigAdmin(ModelAdmin):
|
||||
'"callback_base": "https://portal.example"} — '
|
||||
'token = JWT_SECRET Document Server. '
|
||||
'Asterisk AMI: {"originate_context": "from-internal", '
|
||||
'"sip_channel": "PJSIP/{exten}", "analog_channel": "DAHDI/{exten}", '
|
||||
'"outbound_exten": "0{nsn}"}. '
|
||||
'Канал по умолчанию Local/{exten}@from-internal (SIP и аналог). '
|
||||
'Исходящие: 0{nsn} → 050…, {e164} → 380… . '
|
||||
'Логин/пароль = AMI user/secret, порт по умолчанию 5038.'
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user