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(
'{}',
url, count
)
return count
directories_count.short_description = _('Записей')
directories_count.admin_order_field = 'directory_count'
class DirectoryForm(TreeNodeForm):
"""Кастомная форма с дополнительными проверками"""
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()
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 # дропдаун-фильтрам нужна кнопка применения
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'),
'description': 'location_types — для должностей: в каких локациях доступна. '
'ad_group_dn, ad_ou_dn — для отделов.',
'classes': ('collapse',)
}),
(_('Дополнительные данные'), {
'fields': ('extra_data',),
'classes': ('collapse',)
}),
)
filter_horizontal = ['location_types']
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(
''
'{}{} {}',
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('Все типы')
return format_html_join(
', ',
'{}',
((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(
'{}',
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('все')
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')
fieldsets = (
(_('Основное'), {
'fields': ('connector_type', 'is_enabled', 'notes'),
}),
(_('Подключение'), {
'fields': ('host', 'port', 'use_ssl'),
}),
(_('Аутентификация'), {
'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. Nextcloud остаётся хранилищем файлов.'
),
}),
)
def connector_type_display(self, obj):
color = '#16a34a' if obj.is_enabled else '#dc2626'
return format_html(
'{}',
color, obj.get_connector_type_display()
)
connector_type_display.short_description = _('Коннектор')
connector_type_display.admin_order_field = 'connector_type'
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 = _('Виджеты')