telephony: Originate через Local и запасной набор 380 при отсутствии 050.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 15:46:52 +03:00
parent 9117140d6d
commit 8b6924ffd1
3 changed files with 82 additions and 18 deletions
+55 -12
View File
@@ -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:
+26 -5
View File
@@ -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
View File
@@ -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.'
),