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.ad_connector',
'apps.mail_connector',
'apps.asterisk_connector',
'apps.mailparser',
'apps.mattermost_connector',
'apps.portal_connector.apps.PortalConnectorConfig',
+1
View File
@@ -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'),
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:
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='Тип линии'),
),
]
+18 -9
View File
@@ -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 = [
+2 -1
View File
@@ -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',
+3
View File
@@ -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='Телефон кандидата'),
),
]
+4 -5
View File
@@ -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,
+3
View File
@@ -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='Телефон техподдержки'),
),
]
+3 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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='Телефон'),
),
]
+3 -5
View File
@@ -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)
+2 -1
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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(
_('Для включённого коннектора необходимо указать токен или логин')
)
+32
View File
@@ -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).
+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 _
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'
+59 -11
View File
@@ -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);
});
+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>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; }
+3 -2
View File
@@ -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>
+6 -5
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+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 %}