6a9fbdf6a7
Co-authored-by: Cursor <cursoragent@cursor.com>
466 lines
20 KiB
Python
466 lines
20 KiB
Python
from django.contrib import admin, messages
|
|
from django.contrib.admin import SimpleListFilter
|
|
from django.shortcuts import redirect
|
|
from django.utils.html import format_html, format_html_join
|
|
from django.utils.safestring import mark_safe
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.urls import reverse
|
|
from unfold.admin import ModelAdmin
|
|
from unfold.decorators import action
|
|
from unfold.contrib.filters.admin import DropdownFilter
|
|
from treenode.forms import TreeNodeForm
|
|
from django.forms import TextInput, Textarea
|
|
from django.db.models import Count
|
|
from django import forms
|
|
from core.models import (
|
|
DirectoryType, Directory, ConnectorConfig, AccessSystem,
|
|
DashboardDefaults, DashboardLayout,
|
|
)
|
|
from import_export.admin import ImportExportModelAdmin
|
|
from unfold.contrib.import_export.forms import ExportForm, ImportForm
|
|
|
|
# Register your models here.
|
|
class DirectoryTypeFilter(DropdownFilter):
|
|
title = _('Тип справочника')
|
|
parameter_name = 'directory_type'
|
|
|
|
def lookups(self, request, model_admin):
|
|
return DirectoryType.objects.filter(is_active=True).values_list('id', 'name')
|
|
|
|
def queryset(self, request, queryset):
|
|
if self.value():
|
|
return queryset.filter(directory_type_id=self.value())
|
|
return queryset
|
|
|
|
class ActiveFilter(DropdownFilter):
|
|
title = _('Статус')
|
|
parameter_name = 'is_active'
|
|
|
|
def lookups(self, request, model_admin):
|
|
return (
|
|
('1', _('Активные')),
|
|
('0', _('Неактивные')),
|
|
)
|
|
|
|
def queryset(self, request, queryset):
|
|
if self.value() == '1':
|
|
return queryset.filter(is_active=True)
|
|
elif self.value() == '0':
|
|
return queryset.filter(is_active=False)
|
|
return queryset
|
|
|
|
class HasChildrenFilter(SimpleListFilter):
|
|
title = _('Наличие потомков')
|
|
parameter_name = 'has_children'
|
|
|
|
def lookups(self, request, model_admin):
|
|
return (
|
|
('yes', _('Есть потомки')),
|
|
('no', _('Нет потомков')),
|
|
)
|
|
|
|
def queryset(self, request, queryset):
|
|
if self.value() == 'yes':
|
|
# Фильтруем по наличию детей
|
|
return queryset.filter(tn_children_count__gt=0) if hasattr(queryset.model, 'tn_children_count') else queryset
|
|
elif self.value() == 'no':
|
|
return queryset.filter(tn_children_count=0) if hasattr(queryset.model, 'tn_children_count') else queryset
|
|
return queryset
|
|
|
|
|
|
@admin.register(DirectoryType)
|
|
class DirectoryTypeAdmin(ModelAdmin, ImportExportModelAdmin):
|
|
import_form_class = ImportForm
|
|
export_form_class = ExportForm
|
|
list_display = [
|
|
'name', 'code', 'hierarchy_type', 'is_active',
|
|
'directories_count', 'created_at'
|
|
]
|
|
|
|
def directories_count(self, obj):
|
|
"""Количество записей в справочнике"""
|
|
count = obj.directory_set.count()
|
|
if count > 0:
|
|
url = reverse('admin:core_directory_changelist') + f'?directory_type={obj.id}'
|
|
return format_html(
|
|
'<a href="{}" title="Перейти к записям">{}</a>',
|
|
url, count
|
|
)
|
|
return count
|
|
directories_count.short_description = _('Записей')
|
|
directories_count.admin_order_field = 'directory_count'
|
|
|
|
|
|
class DirectoryForm(TreeNodeForm):
|
|
"""Кастомная форма с дополнительными проверками"""
|
|
|
|
create_ou_in_ad = forms.BooleanField(
|
|
label=_('Создать OU в AD автоматически'), required=False,
|
|
help_text=_(
|
|
'Если включено и поле «OU в Active Directory» ниже не задано — при сохранении '
|
|
'будет создана новая OU с названием справочника (вложенная в OU родителя, '
|
|
'если он привязан; иначе — в users_ou коннектора).'
|
|
),
|
|
)
|
|
|
|
class Meta:
|
|
model = Directory
|
|
fields = '__all__'
|
|
widgets = {
|
|
'code': TextInput(attrs={'size': '30'}),
|
|
'name': TextInput(attrs={'size': '50'}),
|
|
'description': Textarea(attrs={'rows': 3, 'cols': 60}),
|
|
'extra_data': Textarea(attrs={'rows': 4, 'cols': 60, 'class': 'vLargeTextField'}),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Фильтруем допустимых родителей по правилам иерархии
|
|
if 'parent' in self.fields and self.instance.directory_type_id:
|
|
self.fields['parent'].queryset = self._get_allowed_parents()
|
|
|
|
if 'ad_ou_dn' in self.fields:
|
|
try:
|
|
from apps.ad_connector.ad_service import ADService
|
|
service = ADService()
|
|
try:
|
|
ous = service.list_ous()
|
|
root = service.users_ou
|
|
finally:
|
|
service.unbind()
|
|
choices = [('', '— не задано (users_ou коннектора) —')]
|
|
if root:
|
|
choices.append((root, f'{root} (корень)'))
|
|
choices += [(ou['dn'], ou['dn']) for ou in ous if ou['dn'] != root]
|
|
self.fields['ad_ou_dn'].widget = forms.Select(choices=choices)
|
|
self.fields['ad_ou_dn'].required = False
|
|
except Exception:
|
|
pass
|
|
manage_url = reverse('admin:ad_connector_ou_manage')
|
|
self.fields['ad_ou_dn'].help_text = format_html(
|
|
'{} <a href="{}" target="_blank">Управлять деревом OU в AD →</a>',
|
|
self.fields['ad_ou_dn'].help_text or '',
|
|
manage_url,
|
|
)
|
|
|
|
def _get_allowed_parents(self):
|
|
"""Получить разрешенных родителей для текущего типа"""
|
|
current_type = self.instance.directory_type
|
|
|
|
# Для корневых элементов можем выбирать из всех типов
|
|
if not self.instance.parent:
|
|
return Directory.objects.filter(directory_type=current_type).order_by('name')
|
|
|
|
# Иначе фильтруем по правилам иерархии
|
|
allowed_parents = Directory.objects.none()
|
|
|
|
for dir_type in DirectoryType.objects.filter(is_active=True):
|
|
if dir_type.can_have_child_type(current_type):
|
|
allowed_parents = allowed_parents | Directory.objects.filter(directory_type=dir_type)
|
|
|
|
return allowed_parents.exclude(pk=self.instance.pk).order_by('name')
|
|
|
|
|
|
|
|
@admin.register(Directory)
|
|
class DirectoryAdmin(ModelAdmin, ImportExportModelAdmin):
|
|
"""Справочники с иерархией (отступы по уровню вложенности)."""
|
|
import_form_class = ImportForm
|
|
export_form_class = ExportForm
|
|
form = DirectoryForm
|
|
list_display = ['name_with_hierarchy', 'directory_type', 'code', 'is_active', 'updated_at']
|
|
list_display_links = ['name_with_hierarchy']
|
|
list_filter = [DirectoryTypeFilter, ActiveFilter]
|
|
list_filter_sheet = False # фильтры сверху горизонтально, а не в боковой панели
|
|
list_filter_submit = True # дропдаун-фильтрам нужна кнопка применения
|
|
search_fields = ['name', 'code', 'description']
|
|
ordering = ('_path', 'name')
|
|
actions_list = ['rebuild_tree_action']
|
|
|
|
fieldsets = (
|
|
(_('Основная информация'), {
|
|
'fields': ('directory_type', 'parent', 'code', 'name', 'description', 'is_active')
|
|
}),
|
|
(_('Должности и локации'), {
|
|
'fields': ('location_types', 'ad_group_dn', 'ad_ou_dn', 'create_ou_in_ad'),
|
|
'description': 'location_types — для должностей: в каких локациях доступна. '
|
|
'ad_group_dn, ad_ou_dn — для отделов.',
|
|
'classes': ('collapse',)
|
|
}),
|
|
(_('Дополнительные данные'), {
|
|
'fields': ('extra_data',),
|
|
'classes': ('collapse',)
|
|
}),
|
|
)
|
|
filter_horizontal = ['location_types']
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
old = None
|
|
if change:
|
|
old = Directory.objects.filter(pk=obj.pk).values('parent_id', 'ad_ou_dn').first()
|
|
|
|
if form.cleaned_data.get('create_ou_in_ad') and not obj.ad_ou_dn:
|
|
parent_dn = obj.parent.ad_ou_dn if obj.parent_id and obj.parent.ad_ou_dn else None
|
|
try:
|
|
from apps.ad_connector.ad_service import ADService
|
|
service = ADService()
|
|
try:
|
|
obj.ad_ou_dn = service.create_ou(obj.name, parent_dn)
|
|
finally:
|
|
service.unbind()
|
|
except Exception as exc:
|
|
messages.error(request, _('Не удалось создать OU в AD: %s') % exc)
|
|
|
|
super().save_model(request, obj, form, change)
|
|
|
|
if old and old['ad_ou_dn'] and obj.ad_ou_dn == old['ad_ou_dn'] and old['parent_id'] != obj.parent_id:
|
|
from apps.ad_connector.ad_service import ADService
|
|
from apps.ad_connector.services.sync_service import relocate_ou_references
|
|
try:
|
|
service = ADService()
|
|
try:
|
|
desired_parent = (
|
|
obj.parent.ad_ou_dn
|
|
if obj.parent_id and obj.parent.ad_ou_dn
|
|
else service.users_ou
|
|
)
|
|
current_parent = old['ad_ou_dn'].split(',', 1)[1]
|
|
if desired_parent and desired_parent != current_parent:
|
|
new_dn = service.move_ou(old['ad_ou_dn'], desired_parent)
|
|
relocate_ou_references(old['ad_ou_dn'], new_dn)
|
|
messages.success(request, _('OU перенесена в AD: %s') % new_dn)
|
|
finally:
|
|
service.unbind()
|
|
except Exception as exc:
|
|
messages.error(request, _('Не удалось перенести OU в AD: %s') % exc)
|
|
|
|
def get_queryset(self, request):
|
|
return super().get_queryset(request).select_related(
|
|
'parent', 'directory_type'
|
|
).annotate(
|
|
tn_children_count=Count('children', distinct=True)
|
|
).order_by('_path', 'name')
|
|
|
|
@action(description=_('🔄 Перестроить дерево'), url_path='rebuild-tree',
|
|
permissions=['change'])
|
|
def rebuild_tree_action(self, request):
|
|
"""Ручная перестройка дерева (пересчёт _path/_depth из parent)"""
|
|
Directory.update_tree()
|
|
messages.success(request, _('Дерево справочников перестроено'))
|
|
return redirect(reverse('admin:core_directory_changelist'))
|
|
|
|
def process_result(self, result, request):
|
|
"""Импорт пишет записи в обход treenode — перестраиваем дерево после него"""
|
|
if not result.has_errors():
|
|
Directory.update_tree()
|
|
return super().process_result(result, request)
|
|
|
|
def name_with_hierarchy(self, obj):
|
|
"""Имя с отступом по уровню вложенности. Глубина из _path (точки)."""
|
|
path = getattr(obj, '_path', '') or ''
|
|
depth = path.count('.') # root=0, дочерний=1, внук=2...
|
|
indent_px = depth * 24
|
|
url = reverse('admin:core_directory_change', args=[obj.pk])
|
|
has_children = getattr(obj, 'tn_children_count', 0) > 0
|
|
icon = '📁' if has_children else '📄'
|
|
branch = '└ ' if depth else ''
|
|
return format_html(
|
|
'<span style="display:inline-block;margin-left:{}px;white-space:nowrap;">'
|
|
'{}<a href="{}">{} {}</a></span>',
|
|
indent_px, branch, url, icon, obj.name
|
|
)
|
|
name_with_hierarchy.short_description = _('Название')
|
|
name_with_hierarchy.admin_order_field = '_path'
|
|
|
|
def location_types_display(self, obj):
|
|
"""Отображение типов локаций для должностей"""
|
|
if obj.directory_type.code != 'post':
|
|
return '-'
|
|
|
|
location_types = obj.location_types.all()
|
|
if not location_types.exists():
|
|
return mark_safe('<span style="color: #999;">Все типы</span>')
|
|
|
|
return format_html_join(
|
|
', ',
|
|
'<span style="background-color: #e3f2fd; padding: 2px 6px; border-radius: 3px; font-size: 11px;">{}</span>',
|
|
((lt.name,) for lt in location_types),
|
|
)
|
|
location_types_display.short_description = _('Типы локаций')
|
|
|
|
def children_count_display(self, obj):
|
|
"""Отображение количества потомков"""
|
|
count = obj.get_children_count()
|
|
if count > 0:
|
|
return format_html(
|
|
'<span style="color: #0066cc; font-weight: bold;">{}</span>',
|
|
count
|
|
)
|
|
return count
|
|
children_count_display.short_description = 'Потомков'
|
|
|
|
|
|
@admin.register(AccessSystem)
|
|
class AccessSystemAdmin(ModelAdmin):
|
|
"""Отдельный раздел для списка систем/баз доступа (1С, почта, CRM…),
|
|
которые выбираются чекбоксами в заявке на найм."""
|
|
list_display = ['name', 'code', 'location_types_display', 'is_active', 'updated_at']
|
|
list_filter = [ActiveFilter]
|
|
list_filter_sheet = False
|
|
list_filter_submit = True
|
|
search_fields = ['name', 'code']
|
|
ordering = ['name']
|
|
fields = ['name', 'code', 'description', 'location_types', 'is_active']
|
|
filter_horizontal = ['location_types']
|
|
|
|
def get_queryset(self, request):
|
|
return (
|
|
super().get_queryset(request)
|
|
.filter(directory_type__code='access_system')
|
|
.prefetch_related('location_types')
|
|
)
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if not obj.directory_type_id:
|
|
obj.directory_type = DirectoryType.objects.get(code='access_system')
|
|
super().save_model(request, obj, form, change)
|
|
|
|
def location_types_display(self, obj):
|
|
types = list(obj.location_types.all())
|
|
if not types:
|
|
return mark_safe('<span style="color:#999;">все</span>')
|
|
return ', '.join(t.name for t in types)
|
|
location_types_display.short_description = _('Типы локаций')
|
|
|
|
|
|
@admin.register(ConnectorConfig)
|
|
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 = (
|
|
(_('Основное'), {
|
|
'fields': ('connector_type', 'is_enabled', 'notes'),
|
|
}),
|
|
(_('Подключение'), {
|
|
'fields': ('host', 'port', 'use_ssl'),
|
|
'description': _(
|
|
'Asterisk AMI: порт 5038, «Использовать SSL» выключить '
|
|
'(это обычный TCP, не HTTPS). TLS AMI — только если АТС слушает AMI over TLS.'
|
|
),
|
|
}),
|
|
(_('Аутентификация'), {
|
|
'fields': ('username', 'password', 'token'),
|
|
'description': _('Укажите токен или логин/пароль — в зависимости от типа коннектора'),
|
|
}),
|
|
(_('Дополнительные параметры'), {
|
|
'fields': ('extra_config',),
|
|
'description': _(
|
|
'OnlyOffice: {"public_url": "https://docs.example", '
|
|
'"callback_base": "https://portal.example"} — '
|
|
'token = JWT_SECRET Document Server. '
|
|
'Asterisk AMI / MikoPBX: канал Local/{exten}@internal-originate, '
|
|
'назначение all_peers. '
|
|
'{"caller_context": "internal-originate", "dest_context": "all_peers", '
|
|
'"outbound_exten": "0{nsn}"}. '
|
|
'Исходящие: 0{nsn} → 050…, {e164} → 380… . '
|
|
'Логин/пароль = AMI user/secret, порт по умолчанию 5038.'
|
|
),
|
|
}),
|
|
)
|
|
|
|
def connector_type_display(self, obj):
|
|
color = '#16a34a' if obj.is_enabled else '#dc2626'
|
|
return format_html(
|
|
'<span style="color:{}; font-weight:bold;">{}</span>',
|
|
color, obj.get_connector_type_display()
|
|
)
|
|
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)
|
|
except Exception as exc:
|
|
self.message_user(request, f'{cfg}: {exc}', messages.ERROR)
|
|
|
|
|
|
class DashboardDefaultsForm(forms.ModelForm):
|
|
"""Наборы виджетов дашборда по умолчанию — чекбоксами по типу локации."""
|
|
|
|
class Meta:
|
|
model = DashboardDefaults
|
|
fields = ('office', 'pharmacy', 'warehouse')
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
from core.dashboard import DASHBOARD_WIDGETS, valid_keys
|
|
choices = [(w.key, f'{w.label} — {w.description}') for w in DASHBOARD_WIDGETS]
|
|
for field in ('office', 'pharmacy', 'warehouse'):
|
|
self.fields[field] = forms.MultipleChoiceField(
|
|
label=self._meta.model._meta.get_field(field).verbose_name,
|
|
required=False, choices=choices, widget=forms.CheckboxSelectMultiple,
|
|
)
|
|
if self.instance and self.instance.pk:
|
|
self.initial[field] = valid_keys(getattr(self.instance, field) or [])
|
|
|
|
def clean(self):
|
|
from core.dashboard import DEFAULT_WIDGET_KEYS
|
|
cleaned = super().clean()
|
|
for field in ('office', 'pharmacy', 'warehouse'):
|
|
selected = set(cleaned.get(field) or [])
|
|
# сохраняем в порядке реестра виджетов
|
|
cleaned[field] = [k for k in DEFAULT_WIDGET_KEYS if k in selected]
|
|
return cleaned
|
|
|
|
|
|
@admin.register(DashboardDefaults)
|
|
class DashboardDefaultsAdmin(ModelAdmin):
|
|
form = DashboardDefaultsForm
|
|
fieldsets = (
|
|
(_('Виджеты дашборда по типу локации'), {
|
|
'fields': ('office', 'pharmacy', 'warehouse'),
|
|
'description': _(
|
|
'Набор виджетов, который видит сотрудник, ещё не настроивший '
|
|
'свой дашборд. Порядок — как в списке ниже; пользователь может '
|
|
'переопределить состав и порядок под себя.'
|
|
),
|
|
}),
|
|
)
|
|
|
|
def has_add_permission(self, request):
|
|
return not DashboardDefaults.objects.exists()
|
|
|
|
def has_delete_permission(self, request, obj=None):
|
|
return False
|
|
|
|
|
|
@admin.register(DashboardLayout)
|
|
class DashboardLayoutAdmin(ModelAdmin):
|
|
"""Персональные раскладки (read-only; удалить = сбросить к дефолту)."""
|
|
list_display = ('employee', 'widgets_display', 'updated_at')
|
|
search_fields = ('employee__username', 'employee__last_name', 'employee__first_name')
|
|
readonly_fields = ('employee', 'widgets', 'updated_at')
|
|
|
|
def has_add_permission(self, request):
|
|
return False
|
|
|
|
def widgets_display(self, obj):
|
|
return ', '.join(obj.widgets or []) or '—'
|
|
widgets_display.short_description = _('Виджеты') |