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