diff --git a/acore/settings/base.py b/acore/settings/base.py
index 9144c6c..61be2d9 100644
--- a/acore/settings/base.py
+++ b/acore/settings/base.py
@@ -109,6 +109,7 @@ INSTALLED_APPS = [
'apps.help',
'apps.ad_connector',
'apps.mail_connector',
+ 'apps.asterisk_connector',
'apps.mailparser',
'apps.mattermost_connector',
'apps.portal_connector.apps.PortalConnectorConfig',
diff --git a/acore/urls.py b/acore/urls.py
index 2b0fb96..1e82424 100644
--- a/acore/urls.py
+++ b/acore/urls.py
@@ -61,6 +61,7 @@ urlpatterns = [
path('', include('apps.help.urls')),
path('', include('apps.nextcloud_connector.urls')),
path('', include('apps.mail_connector.urls')),
+ path('', include('apps.asterisk_connector.urls')),
path('tinymce/', include('tinymce.urls')),
path('', views.index_view, name='index'),
path('accounts/login/', views.login_view, name='login'),
diff --git a/apps/asterisk_connector/__init__.py b/apps/asterisk_connector/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/asterisk_connector/ami.py b/apps/asterisk_connector/ami.py
new file mode 100644
index 0000000..0ea3880
--- /dev/null
+++ b/apps/asterisk_connector/ami.py
@@ -0,0 +1,154 @@
+"""Минимальный клиент Asterisk Manager Interface без сторонних пакетов."""
+from __future__ import annotations
+
+import logging
+import socket
+import ssl
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_PORT = 5038
+MAX_PACKET = 65536
+
+
+class AmiError(Exception):
+ """Ошибка протокола или соединения AMI."""
+
+
+def _encode_action(action: str, fields: dict) -> bytes:
+ lines = [f'Action: {action}']
+ for key, value in fields.items():
+ if value is None or value == '':
+ continue
+ lines.append(f'{key}: {value}')
+ lines.append('')
+ lines.append('')
+ return '\r\n'.join(lines).encode('utf-8')
+
+
+def parse_ami_packet(text: str) -> dict:
+ packet = {}
+ for line in text.split('\r\n'):
+ if not line or ':' not in line:
+ continue
+ key, value = line.split(':', 1)
+ packet[key.strip()] = value.strip()
+ return packet
+
+
+class AmiClient:
+ def __init__(
+ self,
+ host: str,
+ port: int,
+ username: str,
+ secret: str,
+ *,
+ timeout: float = 8,
+ use_ssl: bool = False,
+ ):
+ self.host = host
+ self.port = int(port or DEFAULT_PORT)
+ self.username = username
+ self.secret = secret
+ self.timeout = timeout
+ self.use_ssl = use_ssl
+ self._sock = None
+
+ def connect(self):
+ raw = socket.create_connection((self.host, self.port), timeout=self.timeout)
+ if self.use_ssl:
+ ctx = ssl.create_default_context()
+ raw = ctx.wrap_socket(raw, server_hostname=self.host)
+ self._sock = raw
+ banner = self._read_until(b'\r\n')
+ if b'Asterisk' not in banner and b'Call Manager' not in banner:
+ logger.warning('Unexpected AMI banner: %s', banner[:120])
+
+ def close(self):
+ if self._sock is None:
+ return
+ try:
+ self._sock.close()
+ except OSError:
+ pass
+ self._sock = None
+
+ def __enter__(self):
+ self.connect()
+ self.login()
+ return self
+
+ def __exit__(self, *exc):
+ try:
+ if self._sock is not None:
+ self.send_action('Logoff')
+ except AmiError:
+ pass
+ self.close()
+
+ def login(self) -> dict:
+ reply = self.send_action('Login', Username=self.username, Secret=self.secret)
+ if reply.get('Response') != 'Success':
+ raise AmiError(reply.get('Message') or 'AMI login failed')
+ return reply
+
+ def ping(self) -> dict:
+ reply = self.send_action('Ping')
+ if reply.get('Response') != 'Success':
+ raise AmiError(reply.get('Message') or 'AMI ping failed')
+ return reply
+
+ def originate(
+ self,
+ *,
+ channel: str,
+ exten: str,
+ context: str,
+ caller_id: str = '',
+ timeout_ms: int = 30000,
+ async_orig: bool = True,
+ ) -> dict:
+ reply = self.send_action(
+ 'Originate',
+ Channel=channel,
+ Context=context,
+ Exten=exten,
+ Priority='1',
+ CallerID=caller_id,
+ Timeout=str(timeout_ms),
+ Async='true' if async_orig else 'false',
+ )
+ if reply.get('Response') != 'Success':
+ raise AmiError(reply.get('Message') or 'AMI originate failed')
+ return reply
+
+ def extension_state(self, exten: str, context: str) -> dict:
+ return self.send_action('ExtensionState', Exten=exten, Context=context)
+
+ def send_action(self, action: str, **fields) -> dict:
+ if self._sock is None:
+ raise AmiError('AMI is not connected')
+ self._sock.sendall(_encode_action(action, fields))
+ packet = self._read_packet()
+ if not packet:
+ raise AmiError('Empty AMI response')
+ return parse_ami_packet(packet.decode('utf-8', errors='replace'))
+
+ def _read_until(self, marker: bytes) -> bytes:
+ buf = b''
+ while marker not in buf:
+ chunk = self._sock.recv(4096)
+ if not chunk:
+ break
+ buf += chunk
+ if len(buf) > MAX_PACKET:
+ raise AmiError('AMI packet too large')
+ return buf
+
+ def _read_packet(self) -> bytes:
+ buf = self._read_until(b'\r\n\r\n')
+ idx = buf.find(b'\r\n\r\n')
+ if idx == -1:
+ return buf
+ return buf[:idx]
diff --git a/apps/asterisk_connector/apps.py b/apps/asterisk_connector/apps.py
new file mode 100644
index 0000000..6bacd80
--- /dev/null
+++ b/apps/asterisk_connector/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class AsteriskConnectorConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'apps.asterisk_connector'
+ verbose_name = 'Asterisk AMI'
diff --git a/apps/asterisk_connector/services.py b/apps/asterisk_connector/services.py
new file mode 100644
index 0000000..fb5096c
--- /dev/null
+++ b/apps/asterisk_connector/services.py
@@ -0,0 +1,106 @@
+"""Бизнес-логика AMI: канал по типу линии, originate, ping."""
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING
+
+from core.models import ConnectorConfig
+
+from .ami import AmiClient, AmiError, DEFAULT_PORT
+
+if TYPE_CHECKING:
+ from apps.employees.models import Employee
+
+EXTEN_RE = re.compile(r'^\d{2,10}$')
+CONNECTOR = 'asterisk'
+
+DEFAULT_SIP_CHANNEL = 'PJSIP/{exten}'
+DEFAULT_ANALOG_CHANNEL = 'DAHDI/{exten}'
+DEFAULT_CONTEXT = 'from-internal'
+
+
+class TelephonyError(Exception):
+ """Ошибка звонка с понятным текстом для пользователя."""
+
+
+def get_ami_config() -> ConnectorConfig | None:
+ return ConnectorConfig.get_for(CONNECTOR)
+
+
+def ami_is_enabled() -> bool:
+ return get_ami_config() is not None
+
+
+def sanitize_exten(raw: str) -> str:
+ digits = re.sub(r'\D', '', raw or '')
+ if not EXTEN_RE.fullmatch(digits):
+ raise TelephonyError('Некорректный внутренний номер.')
+ return digits
+
+
+def channel_for_employee(employee, extra: dict | None = None) -> str:
+ extra = extra or {}
+ exten = sanitize_exten(employee.internal_number)
+ line = getattr(employee, 'internal_line_type', '') or 'analog'
+ if line == 'sip':
+ template = (extra.get('sip_channel') or DEFAULT_SIP_CHANNEL)
+ else:
+ template = (extra.get('analog_channel') or DEFAULT_ANALOG_CHANNEL)
+ return str(template).replace('{exten}', exten)
+
+
+def _client_from_config(cfg: ConnectorConfig) -> AmiClient:
+ extra = cfg.extra_config or {}
+ timeout = extra.get('timeout', 8)
+ try:
+ timeout = float(timeout)
+ except (TypeError, ValueError):
+ timeout = 8
+ return AmiClient(
+ host=cfg.host,
+ port=cfg.port or DEFAULT_PORT,
+ username=cfg.username,
+ secret=cfg.password,
+ timeout=timeout,
+ use_ssl=bool(cfg.use_ssl),
+ )
+
+
+def ping_ami(cfg: ConnectorConfig | None = None) -> dict:
+ cfg = cfg or get_ami_config()
+ if cfg is None:
+ raise TelephonyError('Коннектор Asterisk AMI выключен.')
+ if not cfg.host or not cfg.username or not cfg.password:
+ raise TelephonyError('В AMI не заданы хост, логин или секрет.')
+ with _client_from_config(cfg) as client:
+ return client.ping()
+
+
+def originate_between(caller: Employee, callee: Employee) -> dict:
+ if caller.pk == callee.pk:
+ raise TelephonyError('Нельзя позвонить самому себе.')
+ if not caller.internal_number:
+ raise TelephonyError('У вас не указан внутренний номер.')
+ if not callee.internal_number:
+ raise TelephonyError('У сотрудника нет внутреннего номера.')
+
+ cfg = get_ami_config()
+ if cfg is None:
+ raise TelephonyError('Коннектор Asterisk AMI выключен.')
+
+ extra = cfg.extra_config or {}
+ context = extra.get('originate_context') or DEFAULT_CONTEXT
+ channel = channel_for_employee(caller, extra)
+ dest = sanitize_exten(callee.internal_number)
+ 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,
+ )
+ except AmiError as exc:
+ raise TelephonyError(str(exc)) from exc
diff --git a/apps/asterisk_connector/templatetags/__init__.py b/apps/asterisk_connector/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/asterisk_connector/templatetags/telephony.py b/apps/asterisk_connector/templatetags/telephony.py
new file mode 100644
index 0000000..e0a0def
--- /dev/null
+++ b/apps/asterisk_connector/templatetags/telephony.py
@@ -0,0 +1,39 @@
+from django import template
+
+from apps.asterisk_connector.services import ami_is_enabled
+
+register = template.Library()
+
+
+def _ami_on_request(request) -> bool:
+ cached = getattr(request, '_ami_enabled', None)
+ if cached is None:
+ cached = ami_is_enabled()
+ request._ami_enabled = cached
+ return cached
+
+
+@register.inclusion_tag('partials/internal-number.html', takes_context=True)
+def internal_number_cell(context, employee, **kwargs):
+ request = context.get('request')
+ user = getattr(request, 'user', None) if request else None
+ ami_on = bool(request and user and user.is_authenticated and _ami_on_request(request))
+ can_call = bool(
+ ami_on
+ and getattr(user, 'internal_number', '')
+ and getattr(employee, 'internal_number', '')
+ and employee.pk != user.pk
+ )
+ return {
+ 'employee': employee,
+ 'can_call': can_call,
+ 'compact': kwargs.get('compact', False),
+ }
+
+
+@register.simple_tag(takes_context=True)
+def ami_enabled(context):
+ request = context.get('request')
+ if not request:
+ return False
+ return _ami_on_request(request)
diff --git a/apps/asterisk_connector/tests.py b/apps/asterisk_connector/tests.py
new file mode 100644
index 0000000..9725663
--- /dev/null
+++ b/apps/asterisk_connector/tests.py
@@ -0,0 +1,148 @@
+from datetime import date
+from unittest import mock
+
+from django.test import Client, SimpleTestCase, TestCase
+
+from apps.asterisk_connector.ami import AmiClient, AmiError, parse_ami_packet
+from apps.asterisk_connector.services import (
+ TelephonyError,
+ channel_for_employee,
+ originate_between,
+ sanitize_exten,
+)
+from apps.employees.models import Employee
+from core.models import ConnectorConfig
+from core.test_utils import login_2fa
+
+
+def make_employee(**overrides):
+ n = Employee.objects.count() + 1
+ defaults = {
+ 'username': f'ami.user{n}',
+ 'email': f'ami.user{n}@company.local',
+ 'first_name': 'Тест',
+ 'last_name': 'Ами',
+ 'hire_date': date(2026, 1, 1),
+ 'user_location_type': 'pharmacy',
+ 'mattermost_sync_enabled': False,
+ 'ad_sync_enabled': False,
+ 'is_staff': False,
+ }
+ defaults.update(overrides)
+ return Employee.objects.create(**defaults)
+
+
+class AmiProtocolTests(SimpleTestCase):
+ def test_parse_packet(self):
+ packet = parse_ami_packet('Response: Success\r\nMessage: Authentication accepted\r\n')
+ self.assertEqual(packet['Response'], 'Success')
+
+ def test_send_login_and_originate(self):
+ packets = [
+ b'Asterisk Call Manager/2.10\r\n',
+ b'Response: Success\r\nMessage: Authentication accepted\r\n\r\n',
+ b'Response: Success\r\nMessage: Originate successfully queued\r\n\r\n',
+ b'Response: Goodbye\r\n\r\n',
+ ]
+
+ class FakeSock:
+ def __init__(self):
+ self.sent = []
+ self._packets = list(packets)
+
+ def recv(self, _n):
+ return self._packets.pop(0) if self._packets else b''
+
+ def sendall(self, data):
+ self.sent.append(data)
+
+ def close(self):
+ pass
+
+ sock = FakeSock()
+ with mock.patch('apps.asterisk_connector.ami.socket.create_connection', return_value=sock):
+ with AmiClient('127.0.0.1', 5038, 'acore', 'secret') as client:
+ reply = client.originate(
+ channel='PJSIP/101',
+ exten='202',
+ context='from-internal',
+ caller_id='Test <101>',
+ )
+ self.assertEqual(reply['Response'], 'Success')
+ self.assertTrue(any(b'Action: Login' in chunk for chunk in sock.sent))
+ self.assertTrue(any(b'Channel: PJSIP/101' in chunk for chunk in sock.sent))
+
+
+class ChannelMappingTests(TestCase):
+ def test_sip_uses_pjsip_template(self):
+ emp = make_employee(internal_number='101', internal_line_type='sip')
+ self.assertEqual(channel_for_employee(emp), 'PJSIP/101')
+
+ def test_analog_uses_dahdi(self):
+ emp = make_employee(internal_number='12', internal_line_type='analog')
+ self.assertEqual(channel_for_employee(emp), 'DAHDI/12')
+
+ def test_custom_template(self):
+ emp = make_employee(internal_number='55', internal_line_type='sip')
+ self.assertEqual(
+ channel_for_employee(emp, {'sip_channel': 'SIP/{exten}'}),
+ 'SIP/55',
+ )
+
+ def test_bad_exten(self):
+ with self.assertRaises(TelephonyError):
+ sanitize_exten('ab')
+
+
+class OriginateViewTests(TestCase):
+ def setUp(self):
+ self.caller = make_employee(
+ internal_number='101', internal_line_type='sip', username='ami.caller',
+ email='ami.caller@company.local',
+ )
+ self.callee = make_employee(
+ internal_number='202', internal_line_type='analog', username='ami.callee',
+ email='ami.callee@company.local',
+ )
+ ConnectorConfig.objects.create(
+ connector_type='asterisk',
+ is_enabled=True,
+ host='pbx.local',
+ port=5038,
+ username='acore',
+ password='secret',
+ extra_config={'originate_context': 'from-internal'},
+ )
+ self.client = Client()
+ login_2fa(self.client, self.caller)
+
+ def test_originate_posts_to_ami(self):
+ fake = mock.MagicMock()
+ fake.__enter__.return_value = fake
+ fake.__exit__.return_value = False
+ fake.originate.return_value = {'Response': 'Success'}
+ with mock.patch(
+ 'apps.asterisk_connector.services._client_from_config',
+ return_value=fake,
+ ):
+ response = self.client.post('/telephony/call/', {'employee_id': str(self.callee.pk)})
+ self.assertEqual(response.status_code, 200)
+ 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['exten'], '202')
+
+ def test_disabled_without_internal(self):
+ bare = make_employee(username='ami.bare', email='ami.bare@company.local')
+ login_2fa(self.client, bare)
+ response = self.client.post('/telephony/call/', {'employee_id': str(self.callee.pk)})
+ self.assertEqual(response.status_code, 400)
+
+ def test_login_failure_surfaces_message(self):
+ with mock.patch(
+ 'apps.asterisk_connector.services._client_from_config',
+ side_effect=AmiError('Authentication failed'),
+ ):
+ with self.assertRaises(TelephonyError):
+ originate_between(self.caller, self.callee)
diff --git a/apps/asterisk_connector/urls.py b/apps/asterisk_connector/urls.py
new file mode 100644
index 0000000..e2ca1c9
--- /dev/null
+++ b/apps/asterisk_connector/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+ path('telephony/call/', views.originate_call_view, name='telephony_call'),
+]
diff --git a/apps/asterisk_connector/views.py b/apps/asterisk_connector/views.py
new file mode 100644
index 0000000..5b83ec2
--- /dev/null
+++ b/apps/asterisk_connector/views.py
@@ -0,0 +1,50 @@
+import time
+
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.utils.html import escape
+from django.views.decorators.http import require_POST
+
+from apps.employees.models import Employee
+
+from .services import TelephonyError, originate_between
+
+CALL_COOLDOWN_SEC = 3
+
+
+def _notice(ok: bool, text: str, status: int = 200) -> HttpResponse:
+ color = 'var(--color-primary)' if ok else 'var(--color-destructive)'
+ html = (
+ f'
'
+ f'{escape(text)}
'
+ )
+ response = HttpResponse(html, status=status)
+ response['HX-Retarget'] = '#telephony-notice'
+ response['HX-Reswap'] = 'innerHTML'
+ return response
+
+
+@login_required
+@require_POST
+def originate_call_view(request):
+ raw_id = (request.POST.get('employee_id') or '').strip()
+ if not raw_id.isdigit():
+ return _notice(False, 'Не указан сотрудник.', status=400)
+
+ now = time.time()
+ last = request.session.get('ami_last_call_at') or 0
+ if now - float(last) < CALL_COOLDOWN_SEC:
+ return _notice(False, 'Подождите секунду перед следующим звонком.', status=429)
+ request.session['ami_last_call_at'] = now
+
+ callee = get_object_or_404(Employee, pk=int(raw_id), is_active=True)
+ try:
+ originate_between(request.user, callee)
+ except TelephonyError as exc:
+ return _notice(False, str(exc), status=400)
+
+ label = callee.internal_number
+ return _notice(True, f'Звонок на {callee.short_name} ({label}). Возьмите свою трубку.')
diff --git a/apps/employees/admin.py b/apps/employees/admin.py
index e80f702..9ed2272 100644
--- a/apps/employees/admin.py
+++ b/apps/employees/admin.py
@@ -238,7 +238,7 @@ class EmployeeAdmin(ModelAdmin):
class Media:
css = {'all': ('users/css/employee_admin.css',)}
- js = ('assets/js/admin_phone_format.js',)
+ js = ('assets/js/phone-format.js',)
list_display = [
'photo_thumbnail', 'username', 'last_name', 'first_name', 'groups_display',
@@ -381,7 +381,7 @@ class EmployeeAdmin(ModelAdmin):
(_('Контакты и личное'), {
'classes': ('tab',),
'fields': (
- ('phone', 'internal_number'),
+ ('phone', 'internal_number', 'internal_line_type'),
('personal_phone', 'personal_email'),
('emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation'),
('birth_date', 'inn_code'),
diff --git a/apps/employees/migrations/0025_ukrainian_phone_field.py b/apps/employees/migrations/0025_ukrainian_phone_field.py
new file mode 100644
index 0000000..2e66ba2
--- /dev/null
+++ b/apps/employees/migrations/0025_ukrainian_phone_field.py
@@ -0,0 +1,29 @@
+# Generated by Django 6.0.3 on 2026-10-13 11:08
+
+import core.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('employees', '0024_employee_employment_type'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='employee',
+ name='emergency_contact_phone',
+ field=core.models.fields.UkrainianPhoneField(blank=True, max_length=20, verbose_name='Телефон экстренного контакта'),
+ ),
+ migrations.AlterField(
+ model_name='employee',
+ name='personal_phone',
+ field=core.models.fields.UkrainianPhoneField(blank=True, max_length=20, verbose_name='Личный телефон'),
+ ),
+ migrations.AlterField(
+ model_name='employee',
+ name='phone',
+ field=core.models.fields.UkrainianPhoneField(blank=True, max_length=20, verbose_name='Рабочий телефон'),
+ ),
+ ]
diff --git a/apps/employees/migrations/0026_internal_line_type_and_asterisk_ami.py b/apps/employees/migrations/0026_internal_line_type_and_asterisk_ami.py
new file mode 100644
index 0000000..735543e
--- /dev/null
+++ b/apps/employees/migrations/0026_internal_line_type_and_asterisk_ami.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.3 on 2026-10-13 11:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('employees', '0025_ukrainian_phone_field'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='employee',
+ name='internal_line_type',
+ field=models.CharField(choices=[('analog', 'Обычная телефония'), ('sip', 'SIP')], default='analog', help_text='Обычная АТС (аналог/DAHDI) или SIP-аппарат. Для офиса и склада.', max_length=10, verbose_name='Тип линии'),
+ ),
+ ]
diff --git a/apps/employees/models/base.py b/apps/employees/models/base.py
index 9b9fbdc..50c75f9 100644
--- a/apps/employees/models/base.py
+++ b/apps/employees/models/base.py
@@ -5,7 +5,8 @@ from django.utils.translation import gettext_lazy as _
from core.models import BaseModel
from core.models.fields import EncryptedCharField
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
-from core.utils.validators import validate_ukrainian_phone, validate_birth_date, validate_ukrainian_tax_number
+from core.models.fields import UkrainianPhoneField
+from core.utils.validators import validate_birth_date, validate_ukrainian_tax_number
from core.utils.pagination import PAGE_SIZE_CHOICES, DEFAULT_PAGE_SIZE
class EmployeeManager(BaseUserManager):
@@ -59,23 +60,31 @@ class Employee(AbstractBaseUser, PermissionsMixin, BaseModel):
validators=[validate_ukrainian_tax_number]
)
- phone = models.CharField(
- _('Рабочий телефон'), max_length=20, blank=True,
- validators=[validate_ukrainian_phone]
+ phone = UkrainianPhoneField(
+ _('Рабочий телефон'), blank=True,
)
+
+ class InternalLineType(models.TextChoices):
+ ANALOG = 'analog', _('Обычная телефония')
+ SIP = 'sip', _('SIP')
+
internal_number = models.CharField(
_('Внутрішній номер'), max_length=10, blank=True,
help_text=_('Короткий внутрішній (додатковий) номер АТС')
)
- personal_phone = models.CharField(
- _('Личный телефон'), max_length=20, blank=True,
- validators=[validate_ukrainian_phone]
+ internal_line_type = models.CharField(
+ _('Тип линии'), max_length=10,
+ choices=InternalLineType.choices,
+ default=InternalLineType.ANALOG,
+ help_text=_('Обычная АТС (аналог/DAHDI) или SIP-аппарат. Для офиса и склада.'),
+ )
+ personal_phone = UkrainianPhoneField(
+ _('Личный телефон'), blank=True,
)
personal_email = models.EmailField(_('Личный email'), blank=True)
emergency_contact_name = models.CharField(_('Контакт в экстренных случаях'), max_length=200, blank=True)
- emergency_contact_phone = models.CharField(_('Телефон экстренного контакта'), max_length=20, blank=True,
- validators=[validate_ukrainian_phone])
+ emergency_contact_phone = UkrainianPhoneField(_('Телефон экстренного контакта'), blank=True)
emergency_contact_relation = models.CharField(_('Степень родства'), max_length=100, blank=True)
LOCATION_TYPES = [
diff --git a/apps/employees/serializers.py b/apps/employees/serializers.py
index 643eda6..80376bf 100644
--- a/apps/employees/serializers.py
+++ b/apps/employees/serializers.py
@@ -22,7 +22,8 @@ class EmployeeDetailSerializer(EmployeeSerializer):
class Meta(EmployeeSerializer.Meta):
fields = EmployeeSerializer.Meta.fields + [
- 'phone', 'internal_number', 'personal_phone', 'personal_email',
+ 'phone', 'internal_number', 'internal_line_type',
+ 'personal_phone', 'personal_email',
'birth_date', 'inn_code',
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
'mattermost_username', 'mattermost_sync_enabled',
diff --git a/apps/hiring/admin.py b/apps/hiring/admin.py
index bfa425c..c690b8d 100644
--- a/apps/hiring/admin.py
+++ b/apps/hiring/admin.py
@@ -29,6 +29,9 @@ class VacancyAdmin(ModelAdmin):
@admin.register(HiringRequest)
class HiringRequestAdmin(ModelAdmin):
+ class Media:
+ js = ('assets/js/phone-format.js',)
+
list_display = [
'last_name', 'first_name', 'status', 'employment_type', 'hire_date', 'trial_days',
'requested_by', 'granted_ticket', 'created_at',
diff --git a/apps/hiring/migrations/0011_ukrainian_phone_field.py b/apps/hiring/migrations/0011_ukrainian_phone_field.py
new file mode 100644
index 0000000..b87edfd
--- /dev/null
+++ b/apps/hiring/migrations/0011_ukrainian_phone_field.py
@@ -0,0 +1,19 @@
+# Generated by Django 6.0.3 on 2026-10-13 11:08
+
+import core.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('hiring', '0010_hiringrequest_employment_type'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='hiringrequest',
+ name='candidate_phone',
+ field=core.models.fields.UkrainianPhoneField(blank=True, max_length=20, verbose_name='Телефон кандидата'),
+ ),
+ ]
diff --git a/apps/hiring/models.py b/apps/hiring/models.py
index 200bce1..c047bf2 100644
--- a/apps/hiring/models.py
+++ b/apps/hiring/models.py
@@ -8,8 +8,8 @@ from django.utils.translation import gettext_lazy as _
from apps.employees.models.base import Employee
from core.models import BaseModel
-from core.models.fields import EncryptedCharField
-from core.utils.validators import validate_ukrainian_phone, validate_ukrainian_tax_number
+from core.models.fields import EncryptedCharField, UkrainianPhoneField
+from core.utils.validators import validate_ukrainian_tax_number
class InternAccount(BaseModel):
@@ -78,9 +78,8 @@ class HiringRequest(BaseModel):
middle_name = models.CharField(_('Отчество'), max_length=150, blank=True)
candidate_email = models.EmailField(_('Личный email кандидата'), blank=True)
- candidate_phone = models.CharField(
- _('Телефон кандидата'), max_length=20, blank=True,
- validators=[validate_ukrainian_phone],
+ candidate_phone = UkrainianPhoneField(
+ _('Телефон кандидата'), blank=True,
)
inn_code = models.CharField(
_('ИНН'), max_length=10, blank=True,
diff --git a/apps/it_manager/admin.py b/apps/it_manager/admin.py
index 9ef8c24..1b911db 100644
--- a/apps/it_manager/admin.py
+++ b/apps/it_manager/admin.py
@@ -422,6 +422,9 @@ class InternetContractPaymentInline(TabularInline):
@admin.register(InternetProvider)
class InternetProviderAdmin(ModelAdmin):
+ class Media:
+ js = ('assets/js/phone-format.js',)
+
list_display = ['name', 'phone', 'support_phone', 'email',
'active_contracts_count', 'is_active']
list_filter = ['is_active']
diff --git a/apps/it_manager/migrations/0024_ukrainian_phone_field.py b/apps/it_manager/migrations/0024_ukrainian_phone_field.py
new file mode 100644
index 0000000..4532e97
--- /dev/null
+++ b/apps/it_manager/migrations/0024_ukrainian_phone_field.py
@@ -0,0 +1,24 @@
+# Generated by Django 6.0.3 on 2026-10-13 11:08
+
+import core.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('it_manager', '0023_equipment_label_template'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='internetprovider',
+ name='phone',
+ field=core.models.fields.UkrainianPhoneField(blank=True, max_length=50, verbose_name='Телефон'),
+ ),
+ migrations.AlterField(
+ model_name='internetprovider',
+ name='support_phone',
+ field=core.models.fields.UkrainianPhoneField(blank=True, max_length=50, verbose_name='Телефон техподдержки'),
+ ),
+ ]
diff --git a/apps/it_manager/models/providers.py b/apps/it_manager/models/providers.py
index 5477e4b..0da5fc9 100644
--- a/apps/it_manager/models/providers.py
+++ b/apps/it_manager/models/providers.py
@@ -4,14 +4,15 @@ from django.db import models
from django.utils import timezone
from core.models.directories import directory_default
+from core.models.fields import UkrainianPhoneField
class InternetProvider(models.Model):
name = models.CharField('Название', max_length=200, unique=True)
- phone = models.CharField('Телефон', max_length=50, blank=True)
+ phone = UkrainianPhoneField('Телефон', max_length=50, blank=True)
email = models.EmailField('Email', blank=True)
website = models.URLField('Сайт', blank=True)
- support_phone = models.CharField('Телефон техподдержки', max_length=50, blank=True)
+ support_phone = UkrainianPhoneField('Телефон техподдержки', max_length=50, blank=True)
support_email = models.EmailField('Email техподдержки', blank=True)
notes = models.TextField('Примечания', blank=True)
is_active = models.BooleanField('Активен', default=True)
diff --git a/apps/locations/admin.py b/apps/locations/admin.py
index 3780374..44f85fa 100644
--- a/apps/locations/admin.py
+++ b/apps/locations/admin.py
@@ -232,7 +232,7 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
class Media:
css = {'all': ('locations/css/location_admin.css',)}
- js = ('assets/js/admin_phone_format.js', 'assets/js/admin_leaflet_resize_fix.js')
+ js = ('assets/js/phone-format.js', 'assets/js/admin_leaflet_resize_fix.js')
# ------------------------------------------------------------------
# List view
diff --git a/apps/locations/forms.py b/apps/locations/forms.py
index a44b6cd..8761749 100644
--- a/apps/locations/forms.py
+++ b/apps/locations/forms.py
@@ -5,6 +5,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import Directory
+from core.utils.validators import phone_input_attrs
from .models import Location, Pharmacy
@@ -50,7 +51,7 @@ class PortalPharmacyEditForm(forms.ModelForm):
'house_number': forms.TextInput(attrs={'class': 'input'}),
'building': forms.TextInput(attrs={'class': 'input'}),
'apartment': forms.TextInput(attrs={'class': 'input'}),
- 'phone': forms.TextInput(attrs={'class': 'input', 'placeholder': '+380...'}),
+ 'phone': forms.TextInput(attrs=phone_input_attrs()),
'license_number': forms.TextInput(attrs={'class': 'input'}),
'license_expiry_date': forms.DateInput(attrs={'class': 'input', 'type': 'date'}),
'pharmacists_count': forms.NumberInput(attrs={'class': 'input', 'min': 1}),
diff --git a/apps/locations/migrations/0016_ukrainian_phone_field.py b/apps/locations/migrations/0016_ukrainian_phone_field.py
new file mode 100644
index 0000000..223d16f
--- /dev/null
+++ b/apps/locations/migrations/0016_ukrainian_phone_field.py
@@ -0,0 +1,24 @@
+# Generated by Django 6.0.3 on 2026-10-13 11:08
+
+import core.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('locations', '0015_location_vendor_name'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='pharmacy',
+ name='phone',
+ field=core.models.fields.UkrainianPhoneField(blank=True, max_length=20, verbose_name='Телефон'),
+ ),
+ migrations.AlterField(
+ model_name='pharmacyphone',
+ name='phone',
+ field=core.models.fields.UkrainianPhoneField(max_length=20, verbose_name='Телефон'),
+ ),
+ ]
diff --git a/apps/locations/models/pharmacies.py b/apps/locations/models/pharmacies.py
index 112f3aa..b749534 100644
--- a/apps/locations/models/pharmacies.py
+++ b/apps/locations/models/pharmacies.py
@@ -2,7 +2,7 @@ from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
-from core.utils.validators import validate_ukrainian_phone
+from core.models.fields import UkrainianPhoneField
from core.models.fields import EncryptedCharField
from django.utils import timezone
from .base import Location
@@ -140,8 +140,7 @@ class Pharmacy(Location):
choices=VAT_CATEGORY_CHOICES, blank=True, db_index=True,
)
- phone = models.CharField(_('Телефон'), max_length=20, blank=True,
- validators=[validate_ukrainian_phone])
+ phone = UkrainianPhoneField(_('Телефон'), blank=True)
working_hours = models.CharField(_('Часы работы'), max_length=100,
default='08:00-20:00')
@@ -342,8 +341,7 @@ class PharmacyPhone(models.Model):
]
pharmacy = models.ForeignKey(Pharmacy, on_delete=models.CASCADE,
related_name='phones', verbose_name='Аптека')
- phone = models.CharField(_('Телефон'), max_length=20,
- validators=[validate_ukrainian_phone])
+ phone = UkrainianPhoneField(_('Телефон'))
phone_type = models.CharField(_('Тип'), max_length=10, choices=PHONE_TYPES, default='main')
is_primary = models.BooleanField(_('Основной номер'), default=False)
is_active = models.BooleanField(_('Активен'), default=True)
diff --git a/apps/locations/tests.py b/apps/locations/tests.py
index 5c9f94b..cc5fe60 100644
--- a/apps/locations/tests.py
+++ b/apps/locations/tests.py
@@ -431,7 +431,7 @@ class PharmacyCardVisibilityTests(TestCase):
'building': '',
'apartment': '',
'location': '46.4825,30.7233',
- 'phone': '',
+ 'phone': '050 111 22 33',
'license_number': '',
'license_expiry_date': '',
'head_pharmacist': '',
@@ -446,6 +446,7 @@ class PharmacyCardVisibilityTests(TestCase):
self.pharmacy.refresh_from_db()
self.assertEqual(self.pharmacy.working_hours, '09:00-21:00')
self.assertEqual(self.pharmacy.street, 'Нова')
+ self.assertEqual(self.pharmacy.phone, '+380501112233')
self.assertEqual(self.pharmacy.comments, 'Оновлено з порталу')
self.assertContains(resp, 'Изменения сохранены')
diff --git a/core/admin.py b/core/admin.py
index ba43290..cec2d62 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -338,6 +338,7 @@ class AccessSystemAdmin(ModelAdmin):
class ConnectorConfigAdmin(ModelAdmin):
list_display = ('connector_type_display', 'is_enabled', 'host', 'port', 'use_ssl', 'updated_at')
list_filter = ('is_enabled', 'use_ssl', 'connector_type')
+ actions = ('ping_asterisk_ami',)
fieldsets = (
(_('Основное'), {
@@ -355,7 +356,10 @@ class ConnectorConfigAdmin(ModelAdmin):
'description': _(
'OnlyOffice: {"public_url": "https://docs.example", '
'"callback_base": "https://portal.example"} — '
- 'token = JWT_SECRET Document Server. Nextcloud остаётся хранилищем файлов.'
+ 'token = JWT_SECRET Document Server. '
+ 'Asterisk AMI: {"originate_context": "from-internal", '
+ '"sip_channel": "PJSIP/{exten}", "analog_channel": "DAHDI/{exten}"}. '
+ 'Логин/пароль = AMI user/secret, порт по умолчанию 5038.'
),
}),
)
@@ -369,6 +373,24 @@ class ConnectorConfigAdmin(ModelAdmin):
connector_type_display.short_description = _('Коннектор')
connector_type_display.admin_order_field = 'connector_type'
+ @admin.action(description=_('Проверить AMI (Ping)'))
+ def ping_asterisk_ami(self, request, queryset):
+ from apps.asterisk_connector.ami import AmiError
+ from apps.asterisk_connector.services import TelephonyError, ping_ami
+ for cfg in queryset:
+ if cfg.connector_type != 'asterisk':
+ self.message_user(
+ request,
+ _('%s: это не коннектор Asterisk AMI') % cfg,
+ messages.WARNING,
+ )
+ continue
+ try:
+ ping_ami(cfg)
+ self.message_user(request, _('%s: AMI отвечает') % cfg, messages.SUCCESS)
+ except (AmiError, TelephonyError) as exc:
+ self.message_user(request, f'{cfg}: {exc}', messages.ERROR)
+
class DashboardDefaultsForm(forms.ModelForm):
"""Наборы виджетов дашборда по умолчанию — чекбоксами по типу локации."""
diff --git a/core/migrations/0025_internal_line_type_and_asterisk_ami.py b/core/migrations/0025_internal_line_type_and_asterisk_ami.py
new file mode 100644
index 0000000..c06e0a0
--- /dev/null
+++ b/core/migrations/0025_internal_line_type_and_asterisk_ami.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.3 on 2026-10-13 11:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0024_seed_pvms_visit_permissions'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='connectorconfig',
+ name='connector_type',
+ field=models.CharField(choices=[('mattermost', 'Mattermost'), ('ad', 'Active Directory'), ('carbonio', 'Carbonio CE'), ('mail', 'Почта (IMAP/SMTP)'), ('nextcloud', 'Nextcloud'), ('onlyoffice', 'OnlyOffice'), ('asterisk', 'Asterisk AMI')], max_length=20, unique=True, verbose_name='Тип коннектора'),
+ ),
+ migrations.AlterField(
+ model_name='connectorconfig',
+ name='extra_config',
+ field=models.JSONField(blank=True, default=dict, help_text='JSON. Nextcloud: verify_ssl, timeout. OnlyOffice: public_url, callback_base, verify_ssl. Asterisk AMI: originate_context (from-internal), sip_channel (PJSIP/{exten}), analog_channel (DAHDI/{exten}), timeout.', verbose_name='Дополнительные параметры'),
+ ),
+ ]
diff --git a/core/models/connector_config.py b/core/models/connector_config.py
index 662d8a8..5d54144 100644
--- a/core/models/connector_config.py
+++ b/core/models/connector_config.py
@@ -13,6 +13,7 @@ class ConnectorConfig(BaseModel):
('mail', 'Почта (IMAP/SMTP)'),
('nextcloud', 'Nextcloud'),
('onlyoffice', 'OnlyOffice'),
+ ('asterisk', 'Asterisk AMI'),
]
connector_type = models.CharField(
@@ -41,9 +42,9 @@ class ConnectorConfig(BaseModel):
_('Дополнительные параметры'), default=dict, blank=True,
help_text=_(
'JSON. Nextcloud: verify_ssl, timeout. '
- 'OnlyOffice: public_url (URL Document Server), '
- 'callback_base (публичный URL портала для скачивания/сохранения файла), '
- 'verify_ssl.'
+ 'OnlyOffice: public_url, callback_base, verify_ssl. '
+ 'Asterisk AMI: originate_context (from-internal), '
+ 'sip_channel (PJSIP/{exten}), analog_channel (DAHDI/{exten}), timeout.'
),
)
@@ -62,7 +63,15 @@ class ConnectorConfig(BaseModel):
super().clean()
if self.is_enabled and not self.host:
raise ValidationError({'host': _('Хост обязателен для включённого коннектора')})
- if self.is_enabled and not self.token and not self.username:
+ if self.connector_type == 'asterisk' and self.is_enabled:
+ errors = {}
+ if not self.username:
+ errors['username'] = _('Для AMI нужен логин менеджера')
+ if not self.password:
+ errors['password'] = _('Для AMI нужен секрет (password)')
+ if errors:
+ raise ValidationError(errors)
+ elif self.is_enabled and not self.token and not self.username:
raise ValidationError(
_('Для включённого коннектора необходимо указать токен или логин')
)
diff --git a/core/models/fields.py b/core/models/fields.py
index 608d29e..48c616d 100644
--- a/core/models/fields.py
+++ b/core/models/fields.py
@@ -3,10 +3,42 @@ import logging
from django.db import models
from core.utils.crypto import encrypt_secret, decrypt_secret
+from core.utils.validators import (
+ normalize_ukrainian_phone,
+ phone_input_attrs,
+ validate_ukrainian_phone,
+)
logger = logging.getLogger(__name__)
+class UkrainianPhoneField(models.CharField):
+ """Украинский телефон: в БД всегда +380XXXXXXXXX, в форме — маска."""
+
+ default_validators = [validate_ukrainian_phone]
+
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault('max_length', 20)
+ super().__init__(*args, **kwargs)
+
+ def to_python(self, value):
+ value = super().to_python(value)
+ if value in (None, ''):
+ return value
+ return normalize_ukrainian_phone(value)
+
+ def get_prep_value(self, value):
+ value = super().get_prep_value(value)
+ if value in (None, ''):
+ return value
+ return normalize_ukrainian_phone(value)
+
+ def formfield(self, **kwargs):
+ from django import forms
+ kwargs.setdefault('widget', forms.TextInput(attrs=phone_input_attrs()))
+ return super().formfield(**kwargs)
+
+
class EncryptedCharField(models.CharField):
"""CharField, который хранит значение в БД в зашифрованном виде (Fernet).
diff --git a/core/tests_phone.py b/core/tests_phone.py
new file mode 100644
index 0000000..47eebd4
--- /dev/null
+++ b/core/tests_phone.py
@@ -0,0 +1,52 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+
+from core.tests_portal_access import make_employee
+from core.utils.validators import normalize_ukrainian_phone, validate_ukrainian_phone
+
+
+class UkrainianPhoneNormalizeTests(TestCase):
+ def test_variants_become_canonical(self):
+ expected = '+380501112233'
+ for raw in (
+ '+380501112233',
+ '0501112233',
+ '050 111 22 33',
+ '+38 (050) 111-22-33',
+ '380501112233',
+ '80501112233',
+ '501112233',
+ ):
+ self.assertEqual(normalize_ukrainian_phone(raw), expected, raw)
+
+ def test_empty_stays_empty(self):
+ self.assertEqual(normalize_ukrainian_phone(''), '')
+ self.assertEqual(normalize_ukrainian_phone(None), '')
+ validate_ukrainian_phone('')
+
+ def test_garbage_rejected(self):
+ with self.assertRaises(ValidationError):
+ validate_ukrainian_phone('немає')
+ with self.assertRaises(ValidationError):
+ validate_ukrainian_phone('12345')
+
+ def test_employee_save_normalizes(self):
+ employee = make_employee(phone='050 111 22 33')
+ employee.refresh_from_db()
+ self.assertEqual(employee.phone, '+380501112233')
+
+ def test_full_clean_accepts_pretty(self):
+ employee = make_employee(user_location_type='office')
+ employee.set_unusable_password()
+ employee.personal_phone = '+38 (067) 123-45-67'
+ employee.clean_fields()
+ employee.save(update_fields=['personal_phone'])
+ employee.refresh_from_db()
+ self.assertEqual(employee.personal_phone, '+380671234567')
+
+ def test_full_clean_rejects_short(self):
+ employee = make_employee(user_location_type='office')
+ employee.set_unusable_password()
+ employee.phone = '12345'
+ with self.assertRaises(ValidationError):
+ employee.clean_fields(exclude=['password'])
diff --git a/core/utils/validators.py b/core/utils/validators.py
index 5902499..ca5b163 100644
--- a/core/utils/validators.py
+++ b/core/utils/validators.py
@@ -5,10 +5,60 @@ from django.core.validators import EmailValidator
from django.utils.translation import gettext_lazy as _
+PHONE_PLACEHOLDER = '+380 (__) ___-__-__'
+UA_PHONE_CANONICAL_RE = re.compile(r'^\+380\d{9}$')
+
+
+def phone_input_attrs(**extra):
+ """Единые атрибуты полей телефона в портале и админке."""
+ attrs = {
+ 'class': 'input js-phone',
+ 'placeholder': PHONE_PLACEHOLDER,
+ 'inputmode': 'tel',
+ 'autocomplete': 'tel',
+ }
+ extra_class = extra.pop('class', None)
+ attrs.update(extra)
+ if extra_class:
+ attrs['class'] = extra_class
+ return attrs
+
+
+def normalize_ukrainian_phone(value) -> str:
+ """Приводит украинский номер к канону +380XXXXXXXXX.
+
+ Понимает 050…, 380…, +38 (050) …, 9 цифр без кода. Если это не номер —
+ возвращает исходную строку (валидатор уже покажет ошибку).
+ """
+ if value is None:
+ return ''
+ text = str(value).strip()
+ if not text:
+ return ''
+ digits = re.sub(r'\D', '', text)
+ if not digits:
+ return text
+ if digits.startswith('380'):
+ ua = digits[:12]
+ elif digits.startswith('80'):
+ ua = ('3' + digits)[:12]
+ elif digits.startswith('0'):
+ ua = ('38' + digits)[:12]
+ elif len(digits) <= 9:
+ ua = ('380' + digits)[:12]
+ else:
+ return text
+ if len(ua) != 12 or not ua.startswith('380'):
+ return text
+ return f'+{ua}'
+
+
def validate_ukrainian_phone(value: str) -> None:
"""Валидация украинских номеров телефона"""
- pattern = r'^\+380\d{9}$'
- if not re.match(pattern, value):
+ if value in (None, ''):
+ return
+ canonical = normalize_ukrainian_phone(value)
+ if not UA_PHONE_CANONICAL_RE.match(canonical):
raise ValidationError(
_('Номер телефона должен быть в формате +380XXXXXXXXX'),
code='invalid_phone'
diff --git a/static/assets/js/admin_phone_format.js b/static/assets/js/admin_phone_format.js
index 12cec46..d7580d4 100644
--- a/static/assets/js/admin_phone_format.js
+++ b/static/assets/js/admin_phone_format.js
@@ -1,10 +1,27 @@
(function () {
'use strict';
+ if (window.__acorePhoneFormatLoaded) {
+ return;
+ }
+ window.__acorePhoneFormatLoaded = true;
function isPhoneField(input) {
- if (!input || input.tagName !== 'INPUT' || input.type !== 'text') {
+ if (!input || input.tagName !== 'INPUT') {
return false;
}
+ if (input.type && input.type !== 'text' && input.type !== 'tel') {
+ return false;
+ }
+
+ if ((input.className || '').split(/\s+/).indexOf('js-phone') !== -1) {
+ return true;
+ }
+ if ((input.getAttribute('inputmode') || '') === 'tel') {
+ return true;
+ }
+ if ((input.getAttribute('autocomplete') || '') === 'tel') {
+ return true;
+ }
var name = (input.name || '').toLowerCase();
var id = (input.id || '').toLowerCase();
@@ -54,7 +71,7 @@
return '';
}
- var local = uaDigits.slice(3); // 9 digits after country code
+ var local = uaDigits.slice(3);
var p1 = local.slice(0, 2);
var p2 = local.slice(2, 5);
var p3 = local.slice(5, 7);
@@ -85,12 +102,19 @@
if (!current) {
return;
}
- input.value = toPretty(current);
+ var pretty = toPretty(current);
+ if (pretty === current) {
+ return;
+ }
+ input.value = pretty;
}
function normalizePhoneInputs(scope) {
var root = scope || document;
- var inputs = root.querySelectorAll('input[type="text"]');
+ if (!root.querySelectorAll) {
+ return;
+ }
+ var inputs = root.querySelectorAll('input[type="text"], input[type="tel"], input.js-phone');
for (var i = 0; i < inputs.length; i += 1) {
if (!isPhoneField(inputs[i])) {
@@ -122,20 +146,44 @@
return;
}
- var inputs = form.querySelectorAll('input[type="text"]');
+ var inputs = form.querySelectorAll('input[type="text"], input[type="tel"], input.js-phone');
for (var i = 0; i < inputs.length; i += 1) {
- if (!isPhoneField(inputs[i])) {
+ if (!isPhoneField(inputs[i]) || !inputs[i].value) {
continue;
}
-
- if (!inputs[i].value) {
- continue;
- }
-
inputs[i].value = toCanonical(inputs[i].value);
}
}, true);
+ document.addEventListener('htmx:configRequest', function (event) {
+ var params = event.detail && event.detail.parameters;
+ if (!params) {
+ return;
+ }
+ var source = event.detail.elt;
+ var form = source && source.closest ? source.closest('form') : null;
+ if (!form) {
+ return;
+ }
+ var inputs = form.querySelectorAll('input[type="text"], input[type="tel"], input.js-phone');
+ for (var i = 0; i < inputs.length; i += 1) {
+ if (!isPhoneField(inputs[i]) || !inputs[i].name || !inputs[i].value) {
+ continue;
+ }
+ var canonical = toCanonical(inputs[i].value);
+ if (typeof params.set === 'function') {
+ params.set(inputs[i].name, canonical);
+ } else {
+ params[inputs[i].name] = canonical;
+ }
+ }
+ });
+
+ document.addEventListener('htmx:afterSwap', function (event) {
+ var target = event.detail && event.detail.target;
+ normalizePhoneInputs(target || document);
+ });
+
document.addEventListener('DOMContentLoaded', function () {
normalizePhoneInputs(document);
});
diff --git a/static/assets/js/phone-format.js b/static/assets/js/phone-format.js
new file mode 100644
index 0000000..d7580d4
--- /dev/null
+++ b/static/assets/js/phone-format.js
@@ -0,0 +1,190 @@
+(function () {
+ 'use strict';
+ if (window.__acorePhoneFormatLoaded) {
+ return;
+ }
+ window.__acorePhoneFormatLoaded = true;
+
+ function isPhoneField(input) {
+ if (!input || input.tagName !== 'INPUT') {
+ return false;
+ }
+ if (input.type && input.type !== 'text' && input.type !== 'tel') {
+ return false;
+ }
+
+ if ((input.className || '').split(/\s+/).indexOf('js-phone') !== -1) {
+ return true;
+ }
+ if ((input.getAttribute('inputmode') || '') === 'tel') {
+ return true;
+ }
+ if ((input.getAttribute('autocomplete') || '') === 'tel') {
+ return true;
+ }
+
+ var name = (input.name || '').toLowerCase();
+ var id = (input.id || '').toLowerCase();
+ return name.indexOf('phone') !== -1 || id.indexOf('phone') !== -1;
+ }
+
+ function onlyDigits(value) {
+ return (value || '').replace(/\D/g, '');
+ }
+
+ function toUaDigits(value) {
+ var digits = onlyDigits(value);
+ if (!digits) {
+ return '';
+ }
+
+ if (digits.indexOf('380') === 0) {
+ return digits.slice(0, 12);
+ }
+
+ if (digits.indexOf('80') === 0) {
+ return ('3' + digits).slice(0, 12);
+ }
+
+ if (digits.charAt(0) === '0') {
+ return ('38' + digits).slice(0, 12);
+ }
+
+ if (digits.length <= 9) {
+ return ('380' + digits).slice(0, 12);
+ }
+
+ return digits.slice(0, 12);
+ }
+
+ function toCanonical(value) {
+ var uaDigits = toUaDigits(value);
+ if (!uaDigits) {
+ return '';
+ }
+ return '+' + uaDigits;
+ }
+
+ function toPretty(value) {
+ var uaDigits = toUaDigits(value);
+ if (!uaDigits) {
+ return '';
+ }
+
+ var local = uaDigits.slice(3);
+ var p1 = local.slice(0, 2);
+ var p2 = local.slice(2, 5);
+ var p3 = local.slice(5, 7);
+ var p4 = local.slice(7, 9);
+
+ var result = '+380';
+ if (p1) {
+ result += ' (' + p1;
+ }
+ if (p1.length === 2) {
+ result += ')';
+ }
+ if (p2) {
+ result += ' ' + p2;
+ }
+ if (p3) {
+ result += '-' + p3;
+ }
+ if (p4) {
+ result += '-' + p4;
+ }
+
+ return result;
+ }
+
+ function formatField(input) {
+ var current = input.value;
+ if (!current) {
+ return;
+ }
+ var pretty = toPretty(current);
+ if (pretty === current) {
+ return;
+ }
+ input.value = pretty;
+ }
+
+ function normalizePhoneInputs(scope) {
+ var root = scope || document;
+ if (!root.querySelectorAll) {
+ return;
+ }
+ var inputs = root.querySelectorAll('input[type="text"], input[type="tel"], input.js-phone');
+
+ for (var i = 0; i < inputs.length; i += 1) {
+ if (!isPhoneField(inputs[i])) {
+ continue;
+ }
+ formatField(inputs[i]);
+ }
+ }
+
+ document.addEventListener('input', function (event) {
+ var input = event.target;
+ if (!isPhoneField(input)) {
+ return;
+ }
+ formatField(input);
+ });
+
+ document.addEventListener('blur', function (event) {
+ var input = event.target;
+ if (!isPhoneField(input)) {
+ return;
+ }
+ formatField(input);
+ }, true);
+
+ document.addEventListener('submit', function (event) {
+ var form = event.target;
+ if (!form || form.tagName !== 'FORM') {
+ return;
+ }
+
+ var inputs = form.querySelectorAll('input[type="text"], input[type="tel"], input.js-phone');
+ for (var i = 0; i < inputs.length; i += 1) {
+ if (!isPhoneField(inputs[i]) || !inputs[i].value) {
+ continue;
+ }
+ inputs[i].value = toCanonical(inputs[i].value);
+ }
+ }, true);
+
+ document.addEventListener('htmx:configRequest', function (event) {
+ var params = event.detail && event.detail.parameters;
+ if (!params) {
+ return;
+ }
+ var source = event.detail.elt;
+ var form = source && source.closest ? source.closest('form') : null;
+ if (!form) {
+ return;
+ }
+ var inputs = form.querySelectorAll('input[type="text"], input[type="tel"], input.js-phone');
+ for (var i = 0; i < inputs.length; i += 1) {
+ if (!isPhoneField(inputs[i]) || !inputs[i].name || !inputs[i].value) {
+ continue;
+ }
+ var canonical = toCanonical(inputs[i].value);
+ if (typeof params.set === 'function') {
+ params.set(inputs[i].name, canonical);
+ } else {
+ params[inputs[i].name] = canonical;
+ }
+ }
+ });
+
+ document.addEventListener('htmx:afterSwap', function (event) {
+ var target = event.detail && event.detail.target;
+ normalizePhoneInputs(target || document);
+ });
+
+ document.addEventListener('DOMContentLoaded', function () {
+ normalizePhoneInputs(document);
+ });
+})();
diff --git a/templates/index.html b/templates/index.html
index 0de79da..2d07de2 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -31,6 +31,7 @@
+
@@ -52,6 +53,7 @@
+