telephony: автоформат +380, тип линии SIP/АТС и звонок через Asterisk AMI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 14:52:09 +03:00
parent cc62324b69
commit 5d06d4d3ce
42 changed files with 1158 additions and 57 deletions
+1
View File
@@ -109,6 +109,7 @@ INSTALLED_APPS = [
'apps.help', 'apps.help',
'apps.ad_connector', 'apps.ad_connector',
'apps.mail_connector', 'apps.mail_connector',
'apps.asterisk_connector',
'apps.mailparser', 'apps.mailparser',
'apps.mattermost_connector', 'apps.mattermost_connector',
'apps.portal_connector.apps.PortalConnectorConfig', 'apps.portal_connector.apps.PortalConnectorConfig',
+1
View File
@@ -61,6 +61,7 @@ urlpatterns = [
path('', include('apps.help.urls')), path('', include('apps.help.urls')),
path('', include('apps.nextcloud_connector.urls')), path('', include('apps.nextcloud_connector.urls')),
path('', include('apps.mail_connector.urls')), path('', include('apps.mail_connector.urls')),
path('', include('apps.asterisk_connector.urls')),
path('tinymce/', include('tinymce.urls')), path('tinymce/', include('tinymce.urls')),
path('', views.index_view, name='index'), path('', views.index_view, name='index'),
path('accounts/login/', views.login_view, name='login'), path('accounts/login/', views.login_view, name='login'),
View File
+154
View File
@@ -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]
+7
View File
@@ -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'
+106
View File
@@ -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
@@ -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)
+148
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path('telephony/call/', views.originate_call_view, name='telephony_call'),
]
+50
View File
@@ -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'<div role="status" style="background:var(--color-card); color:var(--color-foreground);'
f'border:1px solid {color}; border-radius:var(--radius); padding:0.75rem 1rem;'
f'box-shadow:0 8px 24px rgb(0 0 0/0.12); max-width:22rem; font-size:0.875rem;">'
f'{escape(text)}</div>'
)
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}). Возьмите свою трубку.')
+2 -2
View File
@@ -238,7 +238,7 @@ class EmployeeAdmin(ModelAdmin):
class Media: class Media:
css = {'all': ('users/css/employee_admin.css',)} css = {'all': ('users/css/employee_admin.css',)}
js = ('assets/js/admin_phone_format.js',) js = ('assets/js/phone-format.js',)
list_display = [ list_display = [
'photo_thumbnail', 'username', 'last_name', 'first_name', 'groups_display', 'photo_thumbnail', 'username', 'last_name', 'first_name', 'groups_display',
@@ -381,7 +381,7 @@ class EmployeeAdmin(ModelAdmin):
(_('Контакты и личное'), { (_('Контакты и личное'), {
'classes': ('tab',), 'classes': ('tab',),
'fields': ( 'fields': (
('phone', 'internal_number'), ('phone', 'internal_number', 'internal_line_type'),
('personal_phone', 'personal_email'), ('personal_phone', 'personal_email'),
('emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation'), ('emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation'),
('birth_date', 'inn_code'), ('birth_date', 'inn_code'),
@@ -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='Рабочий телефон'),
),
]
@@ -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='Тип линии'),
),
]
+18 -9
View File
@@ -5,7 +5,8 @@ from django.utils.translation import gettext_lazy as _
from core.models import BaseModel from core.models import BaseModel
from core.models.fields import EncryptedCharField from core.models.fields import EncryptedCharField
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager 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 from core.utils.pagination import PAGE_SIZE_CHOICES, DEFAULT_PAGE_SIZE
class EmployeeManager(BaseUserManager): class EmployeeManager(BaseUserManager):
@@ -59,23 +60,31 @@ class Employee(AbstractBaseUser, PermissionsMixin, BaseModel):
validators=[validate_ukrainian_tax_number] validators=[validate_ukrainian_tax_number]
) )
phone = models.CharField( phone = UkrainianPhoneField(
_('Рабочий телефон'), max_length=20, blank=True, _('Рабочий телефон'), blank=True,
validators=[validate_ukrainian_phone]
) )
class InternalLineType(models.TextChoices):
ANALOG = 'analog', _('Обычная телефония')
SIP = 'sip', _('SIP')
internal_number = models.CharField( internal_number = models.CharField(
_('Внутрішній номер'), max_length=10, blank=True, _('Внутрішній номер'), max_length=10, blank=True,
help_text=_('Короткий внутрішній (додатковий) номер АТС') help_text=_('Короткий внутрішній (додатковий) номер АТС')
) )
personal_phone = models.CharField( internal_line_type = models.CharField(
_('Личный телефон'), max_length=20, blank=True, _('Тип линии'), max_length=10,
validators=[validate_ukrainian_phone] choices=InternalLineType.choices,
default=InternalLineType.ANALOG,
help_text=_('Обычная АТС (аналог/DAHDI) или SIP-аппарат. Для офиса и склада.'),
)
personal_phone = UkrainianPhoneField(
_('Личный телефон'), blank=True,
) )
personal_email = models.EmailField(_('Личный email'), blank=True) personal_email = models.EmailField(_('Личный email'), blank=True)
emergency_contact_name = models.CharField(_('Контакт в экстренных случаях'), max_length=200, blank=True) emergency_contact_name = models.CharField(_('Контакт в экстренных случаях'), max_length=200, blank=True)
emergency_contact_phone = models.CharField(_('Телефон экстренного контакта'), max_length=20, blank=True, emergency_contact_phone = UkrainianPhoneField(_('Телефон экстренного контакта'), blank=True)
validators=[validate_ukrainian_phone])
emergency_contact_relation = models.CharField(_('Степень родства'), max_length=100, blank=True) emergency_contact_relation = models.CharField(_('Степень родства'), max_length=100, blank=True)
LOCATION_TYPES = [ LOCATION_TYPES = [
+2 -1
View File
@@ -22,7 +22,8 @@ class EmployeeDetailSerializer(EmployeeSerializer):
class Meta(EmployeeSerializer.Meta): class Meta(EmployeeSerializer.Meta):
fields = EmployeeSerializer.Meta.fields + [ 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', 'birth_date', 'inn_code',
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation', 'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
'mattermost_username', 'mattermost_sync_enabled', 'mattermost_username', 'mattermost_sync_enabled',
+3
View File
@@ -29,6 +29,9 @@ class VacancyAdmin(ModelAdmin):
@admin.register(HiringRequest) @admin.register(HiringRequest)
class HiringRequestAdmin(ModelAdmin): class HiringRequestAdmin(ModelAdmin):
class Media:
js = ('assets/js/phone-format.js',)
list_display = [ list_display = [
'last_name', 'first_name', 'status', 'employment_type', 'hire_date', 'trial_days', 'last_name', 'first_name', 'status', 'employment_type', 'hire_date', 'trial_days',
'requested_by', 'granted_ticket', 'created_at', 'requested_by', 'granted_ticket', 'created_at',
@@ -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='Телефон кандидата'),
),
]
+4 -5
View File
@@ -8,8 +8,8 @@ from django.utils.translation import gettext_lazy as _
from apps.employees.models.base import Employee from apps.employees.models.base import Employee
from core.models import BaseModel from core.models import BaseModel
from core.models.fields import EncryptedCharField from core.models.fields import EncryptedCharField, UkrainianPhoneField
from core.utils.validators import validate_ukrainian_phone, validate_ukrainian_tax_number from core.utils.validators import validate_ukrainian_tax_number
class InternAccount(BaseModel): class InternAccount(BaseModel):
@@ -78,9 +78,8 @@ class HiringRequest(BaseModel):
middle_name = models.CharField(_('Отчество'), max_length=150, blank=True) middle_name = models.CharField(_('Отчество'), max_length=150, blank=True)
candidate_email = models.EmailField(_('Личный email кандидата'), blank=True) candidate_email = models.EmailField(_('Личный email кандидата'), blank=True)
candidate_phone = models.CharField( candidate_phone = UkrainianPhoneField(
_('Телефон кандидата'), max_length=20, blank=True, _('Телефон кандидата'), blank=True,
validators=[validate_ukrainian_phone],
) )
inn_code = models.CharField( inn_code = models.CharField(
_('ИНН'), max_length=10, blank=True, _('ИНН'), max_length=10, blank=True,
+3
View File
@@ -422,6 +422,9 @@ class InternetContractPaymentInline(TabularInline):
@admin.register(InternetProvider) @admin.register(InternetProvider)
class InternetProviderAdmin(ModelAdmin): class InternetProviderAdmin(ModelAdmin):
class Media:
js = ('assets/js/phone-format.js',)
list_display = ['name', 'phone', 'support_phone', 'email', list_display = ['name', 'phone', 'support_phone', 'email',
'active_contracts_count', 'is_active'] 'active_contracts_count', 'is_active']
list_filter = ['is_active'] list_filter = ['is_active']
@@ -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='Телефон техподдержки'),
),
]
+3 -2
View File
@@ -4,14 +4,15 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from core.models.directories import directory_default from core.models.directories import directory_default
from core.models.fields import UkrainianPhoneField
class InternetProvider(models.Model): class InternetProvider(models.Model):
name = models.CharField('Название', max_length=200, unique=True) 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) email = models.EmailField('Email', blank=True)
website = models.URLField('Сайт', 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) support_email = models.EmailField('Email техподдержки', blank=True)
notes = models.TextField('Примечания', blank=True) notes = models.TextField('Примечания', blank=True)
is_active = models.BooleanField('Активен', default=True) is_active = models.BooleanField('Активен', default=True)
+1 -1
View File
@@ -232,7 +232,7 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
class Media: class Media:
css = {'all': ('locations/css/location_admin.css',)} 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 # List view
+2 -1
View File
@@ -5,6 +5,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import Directory from core.models import Directory
from core.utils.validators import phone_input_attrs
from .models import Location, Pharmacy from .models import Location, Pharmacy
@@ -50,7 +51,7 @@ class PortalPharmacyEditForm(forms.ModelForm):
'house_number': forms.TextInput(attrs={'class': 'input'}), 'house_number': forms.TextInput(attrs={'class': 'input'}),
'building': forms.TextInput(attrs={'class': 'input'}), 'building': forms.TextInput(attrs={'class': 'input'}),
'apartment': 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_number': forms.TextInput(attrs={'class': 'input'}),
'license_expiry_date': forms.DateInput(attrs={'class': 'input', 'type': 'date'}), 'license_expiry_date': forms.DateInput(attrs={'class': 'input', 'type': 'date'}),
'pharmacists_count': forms.NumberInput(attrs={'class': 'input', 'min': 1}), 'pharmacists_count': forms.NumberInput(attrs={'class': 'input', 'min': 1}),
@@ -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='Телефон'),
),
]
+3 -5
View File
@@ -2,7 +2,7 @@ from django.db import models
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ 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 core.models.fields import EncryptedCharField
from django.utils import timezone from django.utils import timezone
from .base import Location from .base import Location
@@ -140,8 +140,7 @@ class Pharmacy(Location):
choices=VAT_CATEGORY_CHOICES, blank=True, db_index=True, choices=VAT_CATEGORY_CHOICES, blank=True, db_index=True,
) )
phone = models.CharField(_('Телефон'), max_length=20, blank=True, phone = UkrainianPhoneField(_('Телефон'), blank=True)
validators=[validate_ukrainian_phone])
working_hours = models.CharField(_('Часы работы'), max_length=100, working_hours = models.CharField(_('Часы работы'), max_length=100,
default='08:00-20:00') default='08:00-20:00')
@@ -342,8 +341,7 @@ class PharmacyPhone(models.Model):
] ]
pharmacy = models.ForeignKey(Pharmacy, on_delete=models.CASCADE, pharmacy = models.ForeignKey(Pharmacy, on_delete=models.CASCADE,
related_name='phones', verbose_name='Аптека') related_name='phones', verbose_name='Аптека')
phone = models.CharField(_('Телефон'), max_length=20, phone = UkrainianPhoneField(_('Телефон'))
validators=[validate_ukrainian_phone])
phone_type = models.CharField(_('Тип'), max_length=10, choices=PHONE_TYPES, default='main') phone_type = models.CharField(_('Тип'), max_length=10, choices=PHONE_TYPES, default='main')
is_primary = models.BooleanField(_('Основной номер'), default=False) is_primary = models.BooleanField(_('Основной номер'), default=False)
is_active = models.BooleanField(_('Активен'), default=True) is_active = models.BooleanField(_('Активен'), default=True)
+2 -1
View File
@@ -431,7 +431,7 @@ class PharmacyCardVisibilityTests(TestCase):
'building': '', 'building': '',
'apartment': '', 'apartment': '',
'location': '46.4825,30.7233', 'location': '46.4825,30.7233',
'phone': '', 'phone': '050 111 22 33',
'license_number': '', 'license_number': '',
'license_expiry_date': '', 'license_expiry_date': '',
'head_pharmacist': '', 'head_pharmacist': '',
@@ -446,6 +446,7 @@ class PharmacyCardVisibilityTests(TestCase):
self.pharmacy.refresh_from_db() self.pharmacy.refresh_from_db()
self.assertEqual(self.pharmacy.working_hours, '09:00-21:00') self.assertEqual(self.pharmacy.working_hours, '09:00-21:00')
self.assertEqual(self.pharmacy.street, 'Нова') self.assertEqual(self.pharmacy.street, 'Нова')
self.assertEqual(self.pharmacy.phone, '+380501112233')
self.assertEqual(self.pharmacy.comments, 'Оновлено з порталу') self.assertEqual(self.pharmacy.comments, 'Оновлено з порталу')
self.assertContains(resp, 'Изменения сохранены') self.assertContains(resp, 'Изменения сохранены')
+23 -1
View File
@@ -338,6 +338,7 @@ class AccessSystemAdmin(ModelAdmin):
class ConnectorConfigAdmin(ModelAdmin): class ConnectorConfigAdmin(ModelAdmin):
list_display = ('connector_type_display', 'is_enabled', 'host', 'port', 'use_ssl', 'updated_at') list_display = ('connector_type_display', 'is_enabled', 'host', 'port', 'use_ssl', 'updated_at')
list_filter = ('is_enabled', 'use_ssl', 'connector_type') list_filter = ('is_enabled', 'use_ssl', 'connector_type')
actions = ('ping_asterisk_ami',)
fieldsets = ( fieldsets = (
(_('Основное'), { (_('Основное'), {
@@ -355,7 +356,10 @@ class ConnectorConfigAdmin(ModelAdmin):
'description': _( 'description': _(
'OnlyOffice: {"public_url": "https://docs.example", ' 'OnlyOffice: {"public_url": "https://docs.example", '
'"callback_base": "https://portal.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.short_description = _('Коннектор')
connector_type_display.admin_order_field = 'connector_type' 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): class DashboardDefaultsForm(forms.ModelForm):
"""Наборы виджетов дашборда по умолчанию — чекбоксами по типу локации.""" """Наборы виджетов дашборда по умолчанию — чекбоксами по типу локации."""
@@ -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='Дополнительные параметры'),
),
]
+13 -4
View File
@@ -13,6 +13,7 @@ class ConnectorConfig(BaseModel):
('mail', 'Почта (IMAP/SMTP)'), ('mail', 'Почта (IMAP/SMTP)'),
('nextcloud', 'Nextcloud'), ('nextcloud', 'Nextcloud'),
('onlyoffice', 'OnlyOffice'), ('onlyoffice', 'OnlyOffice'),
('asterisk', 'Asterisk AMI'),
] ]
connector_type = models.CharField( connector_type = models.CharField(
@@ -41,9 +42,9 @@ class ConnectorConfig(BaseModel):
_('Дополнительные параметры'), default=dict, blank=True, _('Дополнительные параметры'), default=dict, blank=True,
help_text=_( help_text=_(
'JSON. Nextcloud: verify_ssl, timeout. ' 'JSON. Nextcloud: verify_ssl, timeout. '
'OnlyOffice: public_url (URL Document Server), ' 'OnlyOffice: public_url, callback_base, verify_ssl. '
'callback_base (публичный URL портала для скачивания/сохранения файла), ' 'Asterisk AMI: originate_context (from-internal), '
'verify_ssl.' 'sip_channel (PJSIP/{exten}), analog_channel (DAHDI/{exten}), timeout.'
), ),
) )
@@ -62,7 +63,15 @@ class ConnectorConfig(BaseModel):
super().clean() super().clean()
if self.is_enabled and not self.host: if self.is_enabled and not self.host:
raise ValidationError({'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( raise ValidationError(
_('Для включённого коннектора необходимо указать токен или логин') _('Для включённого коннектора необходимо указать токен или логин')
) )
+32
View File
@@ -3,10 +3,42 @@ import logging
from django.db import models from django.db import models
from core.utils.crypto import encrypt_secret, decrypt_secret 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__) 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): class EncryptedCharField(models.CharField):
"""CharField, который хранит значение в БД в зашифрованном виде (Fernet). """CharField, который хранит значение в БД в зашифрованном виде (Fernet).
+52
View File
@@ -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'])
+52 -2
View File
@@ -5,10 +5,60 @@ from django.core.validators import EmailValidator
from django.utils.translation import gettext_lazy as _ 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: def validate_ukrainian_phone(value: str) -> None:
"""Валидация украинских номеров телефона""" """Валидация украинских номеров телефона"""
pattern = r'^\+380\d{9}$' if value in (None, ''):
if not re.match(pattern, value): return
canonical = normalize_ukrainian_phone(value)
if not UA_PHONE_CANONICAL_RE.match(canonical):
raise ValidationError( raise ValidationError(
_('Номер телефона должен быть в формате +380XXXXXXXXX'), _('Номер телефона должен быть в формате +380XXXXXXXXX'),
code='invalid_phone' code='invalid_phone'
+59 -11
View File
@@ -1,10 +1,27 @@
(function () { (function () {
'use strict'; 'use strict';
if (window.__acorePhoneFormatLoaded) {
return;
}
window.__acorePhoneFormatLoaded = true;
function isPhoneField(input) { function isPhoneField(input) {
if (!input || input.tagName !== 'INPUT' || input.type !== 'text') { if (!input || input.tagName !== 'INPUT') {
return false; 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 name = (input.name || '').toLowerCase();
var id = (input.id || '').toLowerCase(); var id = (input.id || '').toLowerCase();
@@ -54,7 +71,7 @@
return ''; return '';
} }
var local = uaDigits.slice(3); // 9 digits after country code var local = uaDigits.slice(3);
var p1 = local.slice(0, 2); var p1 = local.slice(0, 2);
var p2 = local.slice(2, 5); var p2 = local.slice(2, 5);
var p3 = local.slice(5, 7); var p3 = local.slice(5, 7);
@@ -85,12 +102,19 @@
if (!current) { if (!current) {
return; return;
} }
input.value = toPretty(current); var pretty = toPretty(current);
if (pretty === current) {
return;
}
input.value = pretty;
} }
function normalizePhoneInputs(scope) { function normalizePhoneInputs(scope) {
var root = scope || document; 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) { for (var i = 0; i < inputs.length; i += 1) {
if (!isPhoneField(inputs[i])) { if (!isPhoneField(inputs[i])) {
@@ -122,20 +146,44 @@
return; 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) { for (var i = 0; i < inputs.length; i += 1) {
if (!isPhoneField(inputs[i])) { if (!isPhoneField(inputs[i]) || !inputs[i].value) {
continue; continue;
} }
if (!inputs[i].value) {
continue;
}
inputs[i].value = toCanonical(inputs[i].value); inputs[i].value = toCanonical(inputs[i].value);
} }
}, true); }, 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 () { document.addEventListener('DOMContentLoaded', function () {
normalizePhoneInputs(document); normalizePhoneInputs(document);
}); });
+190
View File
@@ -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);
});
})();
+2
View File
@@ -31,6 +31,7 @@
<script>(function(){var t=localStorage.getItem('theme')||'light';if(t!=='light')document.documentElement.setAttribute('data-theme',t);})();</script> <script>(function(){var t=localStorage.getItem('theme')||'light';if(t!=='light')document.documentElement.setAttribute('data-theme',t);})();</script>
<script>window.ACORE_MAP_CONFIG={cartoApiKey:"{{ carto_basemaps_api_key|escapejs }}"};</script> <script>window.ACORE_MAP_CONFIG={cartoApiKey:"{{ carto_basemaps_api_key|escapejs }}"};</script>
<script src="{% url 'javascript-catalog' %}"></script> <script src="{% url 'javascript-catalog' %}"></script>
<script src="{% static 'assets/js/phone-format.js' %}"></script>
<script src="{% static 'assets/js/map-tiles.js' %}"></script> <script src="{% static 'assets/js/map-tiles.js' %}"></script>
<script src="{% static 'assets/js/pagination.js' %}"></script> <script src="{% static 'assets/js/pagination.js' %}"></script>
<script src="{% static 'assets/js/pharmacies.js' %}"></script> <script src="{% static 'assets/js/pharmacies.js' %}"></script>
@@ -52,6 +53,7 @@
<script src="{% static 'assets/js/documents.js' %}"></script> <script src="{% static 'assets/js/documents.js' %}"></script>
</head> </head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<div id="telephony-notice" style="position:fixed; right:1rem; bottom:5rem; z-index:80;"></div>
<script> <script>
function isMobile() { return window.innerWidth < 768; } function isMobile() { return window.innerWidth < 768; }
+3 -2
View File
@@ -1,5 +1,6 @@
{% load common_tags %} {% load common_tags %}
{% load i18n %} {% load i18n %}
{% load telephony %}
{% trans "адрес не указан" as no_addr_short %}{% trans "Должность не указана" as no_post %}{% trans "Адрес неизвестен" as no_addr %} {% trans "адрес не указан" as no_addr_short %}{% trans "Должность не указана" as no_post %}{% trans "Адрес неизвестен" as no_addr %}
<div class="page-content"> <div class="page-content">
@@ -371,7 +372,7 @@
{% for employee in dept.staff %} {% for employee in dept.staff %}
<tr class="tr-hover" style="border-bottom:1px solid var(--color-border); cursor:pointer;" <tr class="tr-hover" style="border-bottom:1px solid var(--color-border); cursor:pointer;"
hx-get="/employees/{{ employee.pk }}/" hx-target="#main-content" hx-push-url="/employees/{{ employee.pk }}/" hx-indicator="#loader"> hx-get="/employees/{{ employee.pk }}/" hx-target="#main-content" hx-push-url="/employees/{{ employee.pk }}/" hx-indicator="#loader">
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.internal_number|default:"—" }}</td> <td style="padding:0.625rem 1rem; color:var(--color-foreground);">{% internal_number_cell employee %}</td>
<td style="padding:0.625rem 1rem; color:var(--color-muted-foreground);">{{ employee.post.name|default:"—" }}</td> <td style="padding:0.625rem 1rem; color:var(--color-muted-foreground);">{{ employee.post.name|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-card-foreground); font-weight:500;">{{ employee.last_name }} {{ employee.first_name }} {{ employee.middle_name }}</td> <td style="padding:0.625rem 1rem; color:var(--color-card-foreground); font-weight:500;">{{ employee.last_name }} {{ employee.first_name }} {{ employee.middle_name }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.phone|default:"—" }}</td> <td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.phone|default:"—" }}</td>
@@ -386,7 +387,7 @@
{% for employee in office_no_department %} {% for employee in office_no_department %}
<tr class="tr-hover" style="border-bottom:1px solid var(--color-border); cursor:pointer;" <tr class="tr-hover" style="border-bottom:1px solid var(--color-border); cursor:pointer;"
hx-get="/employees/{{ employee.pk }}/" hx-target="#main-content" hx-push-url="/employees/{{ employee.pk }}/" hx-indicator="#loader"> hx-get="/employees/{{ employee.pk }}/" hx-target="#main-content" hx-push-url="/employees/{{ employee.pk }}/" hx-indicator="#loader">
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.internal_number|default:"—" }}</td> <td style="padding:0.625rem 1rem; color:var(--color-foreground);">{% internal_number_cell employee %}</td>
<td style="padding:0.625rem 1rem; color:var(--color-muted-foreground);">{{ employee.post.name|default:"—" }}</td> <td style="padding:0.625rem 1rem; color:var(--color-muted-foreground);">{{ employee.post.name|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-card-foreground); font-weight:500;">{{ employee.last_name }} {{ employee.first_name }} {{ employee.middle_name }}</td> <td style="padding:0.625rem 1rem; color:var(--color-card-foreground); font-weight:500;">{{ employee.last_name }} {{ employee.first_name }} {{ employee.middle_name }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.phone|default:"—" }}</td> <td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ employee.phone|default:"—" }}</td>
+6 -5
View File
@@ -1,5 +1,6 @@
{% load common_tags %} {% load common_tags %}
{% load i18n %} {% load i18n %}
{% load telephony %}
<div class="page-content" data-profile-active-tab="{{ profile_active_tab|default:'overview' }}"> <div class="page-content" data-profile-active-tab="{{ profile_active_tab|default:'overview' }}">
{% with tab=profile_active_tab|default:'overview' %} {% with tab=profile_active_tab|default:'overview' %}
@@ -74,11 +75,11 @@
<div style="display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:1rem; margin-bottom:1.5rem;"> <div style="display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:1rem; margin-bottom:1.5rem;">
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Рабочий телефон" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Рабочий телефон" %}</label>
<input type="text" name="phone" value="{{ employee.phone }}" class="input" style="width:100%;"> <input type="tel" name="phone" value="{{ employee.phone }}" class="input js-phone" inputmode="tel" autocomplete="tel" placeholder="+380 (__) ___-__-__" style="width:100%;">
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Личный телефон" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Личный телефон" %}</label>
<input type="text" name="personal_phone" value="{{ employee.personal_phone }}" class="input" style="width:100%;"> <input type="tel" name="personal_phone" value="{{ employee.personal_phone }}" class="input js-phone" inputmode="tel" autocomplete="tel" placeholder="+380 (__) ___-__-__" style="width:100%;">
</div> </div>
<div style="grid-column:1 / -1;"> <div style="grid-column:1 / -1;">
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Личный email" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Личный email" %}</label>
@@ -93,7 +94,7 @@
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Телефон" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Телефон" %}</label>
<input type="text" name="emergency_contact_phone" value="{{ employee.emergency_contact_phone }}" class="input" style="width:100%;"> <input type="tel" name="emergency_contact_phone" value="{{ employee.emergency_contact_phone }}" class="input js-phone" inputmode="tel" autocomplete="tel" placeholder="+380 (__) ___-__-__" style="width:100%;">
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Степень родства" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Степень родства" %}</label>
@@ -108,7 +109,7 @@
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.875rem;"> <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.875rem;">
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Рабочий email" %}</div><div>{{ employee.email|default:"—" }}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Рабочий email" %}</div><div>{{ employee.email|default:"—" }}</div></div>
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Рабочий телефон" %}</div><div>{{ employee.phone|default:"—" }}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Рабочий телефон" %}</div><div>{{ employee.phone|default:"—" }}</div></div>
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Внутренний номер" %}</div><div>{{ employee.internal_number|default:"—" }}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Внутренний номер" %}</div><div>{% internal_number_cell employee %}</div></div>
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Личный телефон" %}</div><div>{{ employee.personal_phone|default:"—" }}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Личный телефон" %}</div><div>{{ employee.personal_phone|default:"—" }}</div></div>
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Личный email" %}</div><div>{{ employee.personal_email|default:"—" }}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Личный email" %}</div><div>{{ employee.personal_email|default:"—" }}</div></div>
</div> </div>
@@ -133,7 +134,7 @@
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Организация" %}</div><div>{{ employee.user_organization.name|default:"—" }}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Организация" %}</div><div>{{ employee.user_organization.name|default:"—" }}</div></div>
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Отдел" %}</div><div>{{ employee.department.name|default:"—" }}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Отдел" %}</div><div>{{ employee.department.name|default:"—" }}</div></div>
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Должность" %}</div><div>{% if employee.post %}{{ employee.post.name }}{% else %}—{% endif %}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Должность" %}</div><div>{% if employee.post %}{{ employee.post.name }}{% else %}—{% endif %}</div></div>
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Внутренний номер" %}</div><div>{{ employee.internal_number|default:"—" }}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Внутренний номер" %}</div><div>{% internal_number_cell employee %}</div></div>
{% if can_view_pii %} {% if can_view_pii %}
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Оформление" %}</div><div>{{ employee.get_employment_type_display }}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Оформление" %}</div><div>{{ employee.get_employment_type_display }}</div></div>
<div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Дата приёма" %}</div><div>{{ employee.hire_date|date:"d.m.Y"|default:"—" }}{% if employee.hire_date %} <span style="color:var(--color-muted-foreground);">({{ employee.work_experience }} {% trans "л." %})</span>{% endif %}</div></div> <div><div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-bottom:0.125rem;">{% trans "Дата приёма" %}</div><div>{{ employee.hire_date|date:"d.m.Y"|default:"—" }}{% if employee.hire_date %} <span style="color:var(--color-muted-foreground);">({{ employee.work_experience }} {% trans "л." %})</span>{% endif %}</div></div>
+2 -2
View File
@@ -1298,7 +1298,7 @@
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Телефон" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Телефон" %}</label>
<input type="text" name="phone" class="input" style="width:100%;"> <input type="tel" name="phone" class="input js-phone" inputmode="tel" autocomplete="tel" placeholder="+380 (__) ___-__-__" style="width:100%;">
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">Email</label> <label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">Email</label>
@@ -1310,7 +1310,7 @@
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Телефон техподдержки" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Телефон техподдержки" %}</label>
<input type="text" name="support_phone" class="input" style="width:100%;"> <input type="tel" name="support_phone" class="input js-phone" inputmode="tel" autocomplete="tel" placeholder="+380 (__) ___-__-__" style="width:100%;">
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Email техподдержки" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Email техподдержки" %}</label>
@@ -1,5 +1,6 @@
{% load common_tags %} {% load common_tags %}
{% load i18n %} {% load i18n %}
{% load telephony %}
<aside style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05); padding:1.25rem; position:sticky; top:1rem;"> <aside style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05); padding:1.25rem; position:sticky; top:1rem;">
<div style="text-align:center; margin-bottom:1rem;"> <div style="text-align:center; margin-bottom:1rem;">
{% if is_own_profile %} {% if is_own_profile %}
@@ -68,9 +69,9 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if employee.internal_number %} {% if employee.internal_number %}
<div style="display:flex; justify-content:space-between; gap:0.5rem;"> <div style="display:flex; justify-content:space-between; gap:0.5rem; align-items:center;">
<span style="color:var(--color-muted-foreground);">{% trans "Внутр." %}</span> <span style="color:var(--color-muted-foreground);">{% trans "Внутр." %}</span>
<span style="text-align:right;">{{ employee.internal_number }}</span> <span style="text-align:right;">{% internal_number_cell employee %}</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
+1 -1
View File
@@ -74,7 +74,7 @@
</div> </div>
<div> <div>
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Телефон" %}</label> <label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Телефон" %}</label>
<input type="text" name="candidate_phone" placeholder="+380..." class="input" style="width:100%;"> <input type="tel" name="candidate_phone" class="input js-phone" inputmode="tel" autocomplete="tel" placeholder="+380 (__) ___-__-__" style="width:100%;">
</div> </div>
</div> </div>
</div> </div>
+27
View File
@@ -0,0 +1,27 @@
{% load i18n %}
{% if not employee.internal_number %}
<span style="color:var(--color-muted-foreground);">—</span>
{% else %}
<span style="display:inline-flex; align-items:center; gap:0.35rem; flex-wrap:wrap;">
{% if can_call %}
<button type="button"
class="btn btn-ghost"
style="padding:0.125rem 0.375rem; font-size:0.8125rem; font-weight:600;"
title="{% trans 'Позвонить на внутренний' %}"
hx-post="{% url 'telephony_call' %}"
hx-vals='{"employee_id": "{{ employee.pk }}"}'
hx-swap="none"
hx-indicator="#loader"
onclick="event.stopPropagation();">
{{ employee.internal_number }}
</button>
{% else %}
<span>{{ employee.internal_number }}</span>
{% endif %}
{% if employee.internal_line_type == 'sip' %}
<span class="badge badge-muted" title="SIP">SIP</span>
{% else %}
<span class="badge badge-muted" title="{% trans 'Обычная телефония' %}">{% trans "АТС" %}</span>
{% endif %}
</span>
{% endif %}