Files

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 = _('Виджеты')