telephony: автоформат +380, тип линии SIP/АТС и звонок через Asterisk AMI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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]
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('telephony/call/', views.originate_call_view, name='telephony_call'),
|
||||
]
|
||||
@@ -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}). Возьмите свою трубку.')
|
||||
@@ -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'),
|
||||
|
||||
@@ -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='Тип линии'),
|
||||
),
|
||||
]
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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='Телефон кандидата'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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='Телефон техподдержки'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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='Телефон'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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, 'Изменения сохранены')
|
||||
|
||||
|
||||
+23
-1
@@ -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):
|
||||
"""Наборы виджетов дашборда по умолчанию — чекбоксами по типу локации."""
|
||||
|
||||
@@ -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,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(
|
||||
_('Для включённого коннектора необходимо указать токен или логин')
|
||||
)
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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'])
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -31,6 +31,7 @@
|
||||
<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 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/pagination.js' %}"></script>
|
||||
<script src="{% static 'assets/js/pharmacies.js' %}"></script>
|
||||
@@ -52,6 +53,7 @@
|
||||
<script src="{% static 'assets/js/documents.js' %}"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
<div id="telephony-notice" style="position:fixed; right:1rem; bottom:5rem; z-index:80;"></div>
|
||||
|
||||
<script>
|
||||
function isMobile() { return window.innerWidth < 768; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load common_tags %}
|
||||
{% load i18n %}
|
||||
{% load telephony %}
|
||||
{% trans "адрес не указан" as no_addr_short %}{% trans "Должность не указана" as no_post %}{% trans "Адрес неизвестен" as no_addr %}
|
||||
<div class="page-content">
|
||||
|
||||
@@ -371,7 +372,7 @@
|
||||
{% for employee in dept.staff %}
|
||||
<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">
|
||||
<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-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>
|
||||
@@ -386,7 +387,7 @@
|
||||
{% for employee in office_no_department %}
|
||||
<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">
|
||||
<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-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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load common_tags %}
|
||||
{% load i18n %}
|
||||
{% load telephony %}
|
||||
<div class="page-content" data-profile-active-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>
|
||||
<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>
|
||||
<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 style="grid-column:1 / -1;">
|
||||
<label style="display:block; font-size:0.8125rem; font-weight:500; margin-bottom:0.375rem;">{% trans "Личный email" %}</label>
|
||||
@@ -93,7 +94,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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><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.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 "Личный email" %}</div><div>{{ employee.personal_email|default:"—" }}</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.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>{{ 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 %}
|
||||
<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>
|
||||
|
||||
@@ -1298,7 +1298,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 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;">
|
||||
<div style="text-align:center; margin-bottom:1rem;">
|
||||
{% if is_own_profile %}
|
||||
@@ -68,9 +69,9 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% 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="text-align:right;">{{ employee.internal_number }}</span>
|
||||
<span style="text-align:right;">{% internal_number_cell employee %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user