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 @@ +