From bfd67241c434f481ddc2bd004e099cece15deab5 Mon Sep 17 00:00:00 2001 From: lokses Date: Thu, 2 Jul 2026 16:48:38 +0300 Subject: [PATCH] AD office-only scope, mapping page, auto-rebuild of directory tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- acore/urls.py | 1 + apps/ad_connector/ad_service.py | 23 ++- .../management/commands/sync_users_to_ad.py | 6 +- apps/ad_connector/services/sync_service.py | 20 ++ apps/ad_connector/signals.py | 43 +++- apps/ad_connector/urls.py | 7 + apps/ad_connector/views.py | 124 +++++++++++- core/admin.py | 19 +- core/apps.py | 25 +++ core/management/__init__.py | 0 core/management/commands/__init__.py | 0 .../commands/rebuild_directory_tree.py | 13 ++ templates/index.html | 15 ++ templates/pages/ad_mapping.html | 188 ++++++++++++++++++ 14 files changed, 470 insertions(+), 14 deletions(-) create mode 100644 apps/ad_connector/urls.py create mode 100644 core/management/__init__.py create mode 100644 core/management/commands/__init__.py create mode 100644 core/management/commands/rebuild_directory_tree.py create mode 100644 templates/pages/ad_mapping.html diff --git a/acore/urls.py b/acore/urls.py index b2d14ea..9d0fb34 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/attendance/', include('apps.visit_manager.api.urls')), path('', include('apps.employees.urls')), + path('', include('apps.ad_connector.urls')), path('', views.index_view, name='index'), path('accounts/login/', views.login_view, name='login'), path('login/', views.login_view, name='login'), diff --git a/apps/ad_connector/ad_service.py b/apps/ad_connector/ad_service.py index 86201c5..aa82f01 100644 --- a/apps/ad_connector/ad_service.py +++ b/apps/ad_connector/ad_service.py @@ -86,9 +86,15 @@ class ADService: return None return self._entry_to_dict(self.conn.entries[0]) - @staticmethod - def _entry_to_dict(entry) -> Dict: + @classmethod + def _entry_to_dict(cls, entry) -> Dict: data = {attr: entry[attr].value for attr in entry.entry_attributes} + # Сервер может вернуть имена атрибутов в другом регистре — нормализуем + lower_map = {k.lower(): k for k in data} + for attr in cls.USER_ATTRIBUTES: + if attr not in data and attr.lower() in lower_map: + data[attr] = data[lower_map[attr.lower()]] + data.setdefault('cn', data.get(lower_map.get('cn', ''), None)) data['dn'] = entry.entry_dn return data @@ -115,6 +121,19 @@ class ADService: f'(&(objectClass=user)(mail={escape_filter_chars(email)}))' ) + def get_all_users(self) -> List[Dict]: + """Список всех учётных записей пользователей в каталоге""" + try: + self.conn.search( + self.base_dn, + '(&(objectClass=user)(!(objectClass=computer)))', + attributes=self.USER_ATTRIBUTES, + ) + except LDAPException as e: + logger.error(f"AD: ошибка получения списка пользователей — {type(e).__name__}: {e}") + raise + return [self._entry_to_dict(entry) for entry in self.conn.entries] + # --- Создание и обновление --- def create_user(self, dn: str, attributes: Dict) -> None: diff --git a/apps/ad_connector/management/commands/sync_users_to_ad.py b/apps/ad_connector/management/commands/sync_users_to_ad.py index ce05291..ba887b0 100644 --- a/apps/ad_connector/management/commands/sync_users_to_ad.py +++ b/apps/ad_connector/management/commands/sync_users_to_ad.py @@ -12,7 +12,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--user-id', type=int, help='ID конкретного сотрудника') parser.add_argument('--all', action='store_true', - help='Все активные сотрудники с ad_sync_enabled=True') + help='Все активные офисные сотрудники с ad_sync_enabled=True') def handle(self, *args, **options): if options['user_id']: @@ -20,7 +20,9 @@ class Command(BaseCommand): if not employees.exists(): raise CommandError(f"Сотрудник с ID {options['user_id']} не найден") elif options['all']: - employees = Employee.objects.filter(is_active=True, ad_sync_enabled=True) + employees = Employee.objects.filter( + is_active=True, ad_sync_enabled=True, user_location_type='office', + ) else: raise CommandError('Укажите --user-id или --all') diff --git a/apps/ad_connector/services/sync_service.py b/apps/ad_connector/services/sync_service.py index 5cbffab..3e264c4 100644 --- a/apps/ad_connector/services/sync_service.py +++ b/apps/ad_connector/services/sync_service.py @@ -16,6 +16,11 @@ logger = logging.getLogger(__name__) SAM_MAX_LENGTH = 20 +def is_office_employee(employee: Employee) -> bool: + """AD-учётки заводятся только офисным сотрудникам""" + return employee.user_location_type == 'office' + + def generate_random_password(length: int = 16) -> str: """Генерация пароля, удовлетворяющего дефолтной политике сложности AD""" alphabet = string.ascii_letters + string.digits @@ -83,6 +88,16 @@ def sync_employee_to_ad(employee: Employee, password: Optional[str] = None) -> b employee: Объект сотрудника password: Пароль (если известен, используется при создании; иначе генерируется) """ + if not is_office_employee(employee): + logger.info(f"AD: {employee.email} не относится к офису — синхронизация пропущена") + Employee.objects.filter(pk=employee.pk).update( + ad_sync_status='disabled', + ad_sync_error='AD-синхронизация доступна только офисным сотрудникам ' + '(тип локации: офис).', + ) + employee.ad_sync_status = 'disabled' + return False + try: service = ADService() except ADConfigError as e: @@ -124,6 +139,11 @@ def sync_employee_to_ad(employee: Employee, password: Optional[str] = None) -> b service.update_user(dn, attributes) if password: service.set_password(dn, password) + + # Учётка могла быть отключена (блокировка, перевод из офиса) — включаем обратно + if employee.is_active and service.is_disabled(ad_user): + service.enable_user(dn) + logger.info(f"AD: учётная запись {dn} включена повторно") _mark_synced(employee, dn) logger.info(f"AD: обновлён пользователь {dn} для {employee.email}") return True diff --git a/apps/ad_connector/signals.py b/apps/ad_connector/signals.py index e147ffd..cab1f38 100644 --- a/apps/ad_connector/signals.py +++ b/apps/ad_connector/signals.py @@ -17,26 +17,35 @@ AD_SYNCED_FIELDS = ( ) +def _is_office(instance) -> bool: + return instance.user_location_type == 'office' + + @receiver(pre_save, sender=Employee) def cache_employee_ad_state(sender, instance, **kwargs): """Кешируем состояние до сохранения чтобы в post_save можно было сравнить.""" if not instance.pk: instance._ad_pre_save_is_active = None + instance._ad_pre_save_location_type = None instance._ad_pre_save_fields = None return try: - old = Employee.objects.values('is_active', *AD_SYNCED_FIELDS).get(pk=instance.pk) + old = Employee.objects.values( + 'is_active', 'user_location_type', *AD_SYNCED_FIELDS + ).get(pk=instance.pk) instance._ad_pre_save_is_active = old['is_active'] + instance._ad_pre_save_location_type = old['user_location_type'] instance._ad_pre_save_fields = {f: old[f] for f in AD_SYNCED_FIELDS} except Employee.DoesNotExist: instance._ad_pre_save_is_active = None + instance._ad_pre_save_location_type = None instance._ad_pre_save_fields = None @receiver(post_save, sender=Employee) def sync_new_employee_to_ad(sender, instance, created, **kwargs): - """Создаём учётную запись AD при создании нового сотрудника.""" - if not created or not instance.ad_sync_enabled: + """Создаём учётную запись AD при создании нового офисного сотрудника.""" + if not created or not instance.ad_sync_enabled or not _is_office(instance): return # Если пароль задавался в этой же транзакции (set_password), используем его, @@ -58,21 +67,38 @@ def sync_new_employee_to_ad(sender, instance, created, **kwargs): @receiver(post_save, sender=Employee) def update_employee_in_ad(sender, instance, created, **kwargs): - """Обновляем атрибуты в AD при изменении данных сотрудника.""" + """Обновляем атрибуты в AD при изменении данных офисного сотрудника.""" if created or not instance.ad_sync_enabled or not instance.ad_user_dn: return + if not _is_office(instance): + return # перевод из офиса обрабатывает handle_location_change + old_fields = getattr(instance, '_ad_pre_save_fields', None) if old_fields is None: return changed = [f for f in AD_SYNCED_FIELDS if old_fields[f] != getattr(instance, f)] - if not changed: + old_location = getattr(instance, '_ad_pre_save_location_type', None) + if not changed and old_location == instance.user_location_type: return from apps.ad_connector.tasks import sync_employee_to_ad_task sync_employee_to_ad_task.delay(instance.pk) - logger.info(f"Поставлена задача обновления AD-аккаунта для {instance.email} (изменены: {', '.join(changed)})") + logger.info(f"Поставлена задача обновления AD-аккаунта для {instance.email}") + + +@receiver(post_save, sender=Employee) +def handle_location_change(sender, instance, created, **kwargs): + """Отключаем учётную запись AD при переводе сотрудника из офиса.""" + if created or not instance.ad_user_dn: + return + + old_location = getattr(instance, '_ad_pre_save_location_type', None) + if old_location == 'office' and not _is_office(instance): + from apps.ad_connector.tasks import deactivate_employee_in_ad_task + deactivate_employee_in_ad_task.delay(instance.pk) + logger.info(f"Сотрудник {instance.email} переведён из офиса — AD-аккаунт будет отключён") @receiver(post_save, sender=Employee) @@ -80,6 +106,8 @@ def sync_employee_password_to_ad(sender, instance, created, **kwargs): """Синхронизируем пароль в AD при его смене в панели.""" if created or not instance.ad_sync_enabled or not instance.ad_user_dn: return + if not _is_office(instance): + return raw_password = getattr(instance, '_password', None) if not raw_password: @@ -104,7 +132,8 @@ def toggle_employee_in_ad(sender, instance, created, **kwargs): from apps.ad_connector.tasks import deactivate_employee_in_ad_task deactivate_employee_in_ad_task.delay(instance.pk) logger.info(f"Поставлена задача отключения AD-аккаунта для {instance.email}") - elif old_active is False and instance.is_active and instance.ad_sync_enabled: + elif (old_active is False and instance.is_active + and instance.ad_sync_enabled and _is_office(instance)): from apps.ad_connector.tasks import activate_employee_in_ad_task activate_employee_in_ad_task.delay(instance.pk) logger.info(f"Поставлена задача включения AD-аккаунта для {instance.email}") diff --git a/apps/ad_connector/urls.py b/apps/ad_connector/urls.py new file mode 100644 index 0000000..05eb9c1 --- /dev/null +++ b/apps/ad_connector/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('ad-mapping/', views.ad_mapping_view, name='ad_mapping'), +] diff --git a/apps/ad_connector/views.py b/apps/ad_connector/views.py index 91ea44a..a21656b 100644 --- a/apps/ad_connector/views.py +++ b/apps/ad_connector/views.py @@ -1,3 +1,123 @@ -from django.shortcuts import render +import logging -# Create your views here. +from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404, render + +from apps.ad_connector.ad_service import ADConfigError, ADService + +Employee = get_user_model() +logger = logging.getLogger(__name__) + + +def is_htmx_request(request): + return request.headers.get('HX-Request') == 'true' + + +def _handle_action(request): + """Обработка действий сопоставления: связать / отвязать / создать""" + action = request.POST.get('action') + employee = get_object_or_404( + Employee, pk=request.POST.get('employee_id'), user_location_type='office', + ) + + if action == 'link': + dn = request.POST.get('dn', '').strip() + if dn: + Employee.objects.filter(pk=employee.pk).update( + ad_user_dn=dn, ad_sync_status='not_synced', ad_sync_error='', + ) + # Выравниваем атрибуты привязанной учётной записи + from apps.ad_connector.tasks import sync_employee_to_ad_task + sync_employee_to_ad_task.delay(employee.pk) + logger.info(f"AD-сопоставление: {employee.email} связан с {dn}") + + elif action == 'unlink': + Employee.objects.filter(pk=employee.pk).update( + ad_user_dn='', ad_sync_status='not_synced', ad_sync_error='', + ) + logger.info(f"AD-сопоставление: {employee.email} отвязан от AD") + + elif action == 'create': + from apps.ad_connector.tasks import sync_employee_to_ad_task + sync_employee_to_ad_task.delay(employee.pk) + logger.info(f"AD-сопоставление: поставлена задача создания учётки для {employee.email}") + + +def _find_suggestion(employee, ad_users, linked_dns): + """Подбор кандидата в AD: по email, затем по логину, затем по ФИО""" + full_name = employee.get_full_name() + for ad_user in ad_users: + if ad_user['dn'] in linked_dns: + continue + if employee.email and (ad_user.get('mail') or '').lower() == employee.email.lower(): + return ad_user, 'email' + for ad_user in ad_users: + if ad_user['dn'] in linked_dns: + continue + if (ad_user.get('sAMAccountName') or '').lower() == (employee.username or '').lower(): + return ad_user, 'логін' + for ad_user in ad_users: + if ad_user['dn'] in linked_dns: + continue + display = ad_user.get('displayName') or ad_user.get('cn') or '' + if full_name and display.lower() == full_name.lower(): + return ad_user, 'ПІБ' + return None, None + + +@staff_member_required +def ad_mapping_view(request): + """Страница сопоставления офисных сотрудников с учётными записями AD""" + if request.method == 'POST': + _handle_action(request) + + employees = list( + Employee.objects.filter(user_location_type='office', is_active=True) + .select_related('post', 'department') + .order_by('last_name', 'first_name') + ) + + ad_users, ad_error = [], '' + try: + service = ADService() + try: + ad_users = service.get_all_users() + for ad_user in ad_users: + ad_user['is_disabled'] = service.is_disabled(ad_user) + finally: + service.unbind() + except ADConfigError as e: + ad_error = str(e) + except Exception as e: + ad_error = f'Помилка з’єднання з Active Directory: {e}' + logger.error(f"AD-сопоставление: {ad_error}") + + ad_by_dn = {u['dn']: u for u in ad_users} + linked_dns = {e.ad_user_dn for e in employees if e.ad_user_dn} + + linked_count = 0 + for employee in employees: + employee.ad_account = ad_by_dn.get(employee.ad_user_dn) if employee.ad_user_dn else None + employee.dn_missing = bool(employee.ad_user_dn) and not employee.ad_account and not ad_error + employee.suggestion, employee.suggestion_reason = (None, None) + if employee.ad_user_dn: + linked_count += 1 + elif ad_users: + employee.suggestion, employee.suggestion_reason = _find_suggestion( + employee, ad_users, linked_dns, + ) + + unmatched_ad_users = [u for u in ad_users if u['dn'] not in linked_dns] + + context = { + 'employees': employees, + 'ad_error': ad_error, + 'ad_total': len(ad_users), + 'linked_count': linked_count, + 'unlinked_count': len(employees) - linked_count, + 'unmatched_ad_users': unmatched_ad_users, + } + if is_htmx_request(request): + return render(request, 'pages/ad_mapping.html', context) + return render(request, 'index.html', {'page_template': 'pages/ad_mapping.html', **context}) diff --git a/core/admin.py b/core/admin.py index 287921f..d3d51f5 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,9 +1,11 @@ -from django.contrib import admin +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 @@ -132,6 +134,7 @@ class DirectoryAdmin(ModelAdmin, ImportExportModelAdmin): list_display_links = ['name_with_hierarchy'] list_filter = [DirectoryTypeFilter, ActiveFilter] ordering = ('_path',) + actions_list = ['rebuild_tree_action'] fieldsets = ( (_('Основная информация'), { @@ -156,6 +159,20 @@ class DirectoryAdmin(ModelAdmin, ImportExportModelAdmin): 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 '' diff --git a/core/apps.py b/core/apps.py index 26f78a8..687fb48 100644 --- a/core/apps.py +++ b/core/apps.py @@ -1,5 +1,30 @@ +import logging + from django.apps import AppConfig +from django.db.models.signals import post_migrate + +logger = logging.getLogger(__name__) + + +def rebuild_directory_tree(sender, **kwargs): + """ + Перестройка дерева справочников после миграций. + + Data-миграции работают с historical-моделями и создают записи Directory + в обход treenode-логики — _path/_depth остаются пустыми и дерево в админке + ломается. Пересчитываем после каждого migrate. + """ + try: + from core.models import Directory + Directory.update_tree() + logger.info('Дерево справочников перестроено после migrate') + except Exception as e: + # Никогда не роняем migrate из-за перестройки дерева + logger.error(f'Не удалось перестроить дерево справочников: {e}') class CoreConfig(AppConfig): name = 'core' + + def ready(self): + post_migrate.connect(rebuild_directory_tree, sender=self) diff --git a/core/management/__init__.py b/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/__init__.py b/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/rebuild_directory_tree.py b/core/management/commands/rebuild_directory_tree.py new file mode 100644 index 0000000..43fef41 --- /dev/null +++ b/core/management/commands/rebuild_directory_tree.py @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand + +from core.models import Directory + + +class Command(BaseCommand): + help = 'Перестроить дерево справочников (пересчёт _path/_depth из parent)' + + def handle(self, *args, **options): + Directory.update_tree() + self.stdout.write(self.style.SUCCESS( + f'Дерево справочников перестроено ({Directory.objects.count()} записей)' + )) diff --git a/templates/index.html b/templates/index.html index 1d08b38..5193a87 100644 --- a/templates/index.html +++ b/templates/index.html @@ -177,6 +177,21 @@ Документи + {% if request.user.is_staff %} + + + + + + Active Directory + + {% endif %} + + +
+
+

Зіставлення з Active Directory

+

Зв'язок офісних співробітників з обліковими записами домену

+
+
+ + + + Оновити + +
+ + {% if ad_error %} +
+ + + +
{{ ad_error }}
+
+ {% endif %} + + +
+
+
Офісних співробітників
+
{{ employees|length }}
+
+
+
Зв'язано з AD
+
{{ linked_count }}
+
+
+
Без облікового запису
+
{{ unlinked_count }}
+
+
+
Записів у домені
+
{{ ad_total }}
+
+
+ + +
+
+

Співробітники офісу

+

Зіставлення з обліковими записами Active Directory

+
+
+ + + + + + + + + + + {% for employee in employees %} + + + + + + + {% empty %} + + + + {% endfor %} + +
СпівробітникСтатусОбліковий запис ADДії
+
{{ employee.get_full_name }}
+
+ {{ employee.post.name|default:"—" }} · {{ employee.email }} +
+
+ {% if employee.dn_missing %} + Не знайдено в AD + {% elif employee.ad_user_dn %} + {% if employee.ad_sync_status == 'synced' %} + Зв'язано + {% elif employee.ad_sync_status == 'error' %} + Помилка + {% else %} + Очікує синхронізації + {% endif %} + {% elif employee.suggestion %} + Знайдено збіг ({{ employee.suggestion_reason }}) + {% else %} + Не зв'язано + {% endif %} + + {% if employee.ad_user_dn %} + {% if employee.ad_account %} +
{{ employee.ad_account.sAMAccountName }}
+
{{ employee.ad_user_dn }}
+ {% else %} +
{{ employee.ad_user_dn }}
+ {% endif %} + {% elif not ad_error %} +
+ {% csrf_token %} + + + + +
+ {% else %} + — + {% endif %} +
+ {% if employee.ad_user_dn %} +
+ {% csrf_token %} + + + +
+ {% elif not ad_error %} +
+ {% csrf_token %} + + + +
+ {% endif %} +
Активних офісних співробітників не знайдено
+
+
+ + + {% if unmatched_ad_users %} +
+
+

Облікові записи AD без співробітника

+

{{ unmatched_ad_users|length }} записів у домені не зв'язані з жодним співробітником

+
+
+ + + + + + + + + + + {% for ad_user in unmatched_ad_users %} + + + + + + + {% endfor %} + +
ЛогінІм'яEmailСтан
{{ ad_user.sAMAccountName|default:"—" }}{{ ad_user.displayName|default:ad_user.cn|default:"—" }}{{ ad_user.mail|default:"—" }} + {% if ad_user.is_disabled %} + Відключений + {% else %} + Активний + {% endif %} +
+
+
+ {% endif %} + +