bfd67241c4
- ad_connector: restrict provisioning to office employees (user_location_type='office') in sync service, signals and command; disable AD account on transfer out of office, re-enable on return - ad_connector: /ad-mapping/ page (staff only) — match office employees to AD accounts with suggestions by email/login/full name, link/unlink/ create actions, list of unmatched domain accounts; sidebar nav item - core: auto-rebuild treenode Directory tree (_path/_depth) after import-export imports and post_migrate; "rebuild tree" button in DirectoryAdmin and rebuild_directory_tree management command Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
250 lines
10 KiB
Python
250 lines
10 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
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.urls import reverse
|
|
from unfold.admin import ModelAdmin
|
|
from unfold.decorators import action
|
|
from treenode.forms import TreeNodeForm
|
|
from django.forms import TextInput, Textarea
|
|
from django.db.models import Count
|
|
from core.models import DirectoryType, Directory, ConnectorConfig
|
|
from import_export.admin import ImportExportModelAdmin
|
|
from unfold.contrib.import_export.forms import ExportForm, ImportForm
|
|
|
|
# Register your models here.
|
|
class DirectoryTypeFilter(SimpleListFilter):
|
|
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(SimpleListFilter):
|
|
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):
|
|
"""Кастомная форма с дополнительными проверками"""
|
|
|
|
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)
|
|
|
|
# Иначе фильтруем по правилам иерархии
|
|
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)
|
|
|
|
|
|
|
|
@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]
|
|
ordering = ('_path',)
|
|
actions_list = ['rebuild_tree_action']
|
|
|
|
fieldsets = (
|
|
(_('Основная информация'), {
|
|
'fields': ('directory_type', 'parent', 'code', 'name', 'description', 'is_active')
|
|
}),
|
|
(_('Должности и локации'), {
|
|
'fields': ('location_types', 'ad_group_dn'),
|
|
'description': 'location_types — для должностей: в каких локациях доступна. ad_group_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')
|
|
|
|
@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 format_html('<span style="color: #999;">Все типы</span>')
|
|
|
|
types_html = ', '.join([
|
|
f'<span style="background-color: #e3f2fd; padding: 2px 6px; border-radius: 3px; font-size: 11px;">{lt.name}</span>'
|
|
for lt in location_types
|
|
])
|
|
return format_html(types_html)
|
|
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(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',),
|
|
'classes': ('collapse',),
|
|
}),
|
|
)
|
|
|
|
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' |