From b3ee4f1960d95b7e6e6d09952c65f853ff9fd970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Thu, 18 Dec 2025 11:56:51 +0300 Subject: [PATCH] feat(ldap): separate dismissed employees to OU=Dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented automatic movement of dismissed employees to dedicated OU. Core Changes: - Configuration: Added LDAP_DISMISSED_BASE setting for dismissed employees OU - Utility: Created get_base_dn_for_employee() to determine target OU by is_active status - Service Layer: Modified DepartmentService.remove_member() to use status-based OU selection - View Logic: Updated EmployeeActionViewSet._apply_effects() to: * Deactivate employee BEFORE removing from departments (critical for correct OU routing) * Loop through all departments and call remove_member() for each * Handle restoration: move from OU=Dismissed back to OU=Users on activation Infrastructure: - Management Command: ensure_dismissed_ou.py to create OU=Dismissed in AD * Checks OU existence before creation * Supports --force flag for recreation * Configurable description Testing: - test_dismissal_moves_to_dismissed_ou: Verifies dismissal → OU=Dismissed movement - test_restoration_moves_from_dismissed_to_users: Verifies activation → OU=Users movement Edge Cases Handled: - Repeat dismissal: Idempotent (already inactive employees stay in OU=Dismissed) - Restoration: Automatic move from OU=Dismissed to OU=Users on any activating action - Missing OU: ensure_container_exists() creates OU=Dismissed if missing (fallback to OU=Users on error) - LDAP errors: Graceful logging without blocking database operations Technical Details: - OU=Dismissed: OU=Dismissed,OU=company,DC=robotail,DC=local - Dismissal flow: is_active=False → deactivate EmployeeDepartment → update LDAP UAC → remove from all departments - Restoration flow: is_active=True → update LDAP UAC → check current OU → move if in OU=Dismissed - Backward compatible: Works with existing dismissals (they will move to OU=Dismissed on next department change) Files Changed: - backend/.env: Added LDAP_DISMISSED_BASE configuration - backend/eusrr_backend/settings.py: Registered LDAP_DISMISSED_BASE setting - backend/employees/ldap/utils/ldap_utils.py: New get_base_dn_for_employee() utility - backend/employees/ldap/services/department_service.py: Modified remove_member() logic - backend/api/v1/employees/views.py: Enhanced dismissal and restoration logic - backend/employees/management/commands/ensure_dismissed_ou.py: New management command - backend/tests/api/v1/employees/test_employee_actions.py: Added tests for new behavior --- backend/api/v1/employees/views.py | 71 +++++++- .../ldap/services/department_service.py | 19 ++- backend/employees/ldap/utils/ldap_utils.py | 29 ++++ .../commands/ensure_dismissed_ou.py | 151 ++++++++++++++++++ backend/eusrr_backend/settings.py | 3 + .../api/v1/employees/test_employee_actions.py | 120 ++++++++++++++ 6 files changed, 380 insertions(+), 13 deletions(-) create mode 100644 backend/employees/management/commands/ensure_dismissed_ou.py diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 5ab9e641..0da04250 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -2734,14 +2734,16 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): ldap_enabled = _is_ldap_enabled() if action_obj.action == ACTION_DISMISSED: - # деактивируем сотрудника и связи в БД - EmployeeDepartment.objects.filter(employee=emp, is_active=True).update( - is_active=False, date_to=timezone.now().date() - ) + # ВАЖНО: сначала деактивируем сотрудника (чтобы get_base_dn_for_employee работал правильно) if emp.is_active: emp.is_active = False emp.save(update_fields=["is_active"]) + # деактивируем связи с отделами в БД + EmployeeDepartment.objects.filter(employee=emp, is_active=True).update( + is_active=False, date_to=timezone.now().date() + ) + # синхронизируем с LDAP (disable учётной записи) if ldap_enabled: try: @@ -2757,6 +2759,23 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): if has_ldap_dn: # Обновляем is_active в LDAP (установит userAccountControl=514) svc.update_user(emp, changes={"is_active": False}) + + # Удаляем из всех отделов (это переместит в OU=Dismissed) + active_departments = EmployeeDepartment.objects.filter( + employee=emp + ).select_related('department') + + for emp_dept in active_departments: + try: + svc.remove_member(emp_dept.department, emp) + except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: + # Логируем ошибку удаления из конкретного отдела + import logging + logger = logging.getLogger(__name__) + logger.error( + f"Failed to remove dismissed employee from department: " + f"employee_id={emp.id}, department_id={emp_dept.department.id}, error={e}" + ) except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: # Логируем ошибку, но не прерываем процесс # БД-изменения уже применены @@ -2768,7 +2787,8 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): ) else: # любое иное событие делает сотрудника активным в БД - if not emp.is_active: + was_inactive = not emp.is_active + if was_inactive: emp.is_active = True emp.save(update_fields=["is_active"]) @@ -2786,6 +2806,47 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): if has_ldap_dn: # Обновляем is_active в LDAP (установит userAccountControl=512) svc.update_user(emp, changes={"is_active": True}) + + # Если сотрудник был неактивен (восстановление из увольнения), + # перемещаем из OU=Dismissed в OU=Users + if was_inactive: + try: + sync_state = LdapSyncState.objects.get( + model='employee', + object_pk=str(emp.pk) + ) + current_dn = sync_state.ldap_dn + dismissed_base = getattr(settings, "LDAP_DISMISSED_BASE", "") + + # Проверяем, находится ли сотрудник в OU=Dismissed + if dismissed_base and current_dn.lower().endswith(dismissed_base.lower()): + users_base = getattr(settings, "LDAP_USERS_BASE", None) or getattr( + settings, "LDAP_USER_BASE", None + ) + if users_base: + from employees.ldap.infrastructure.connections import _ldap + from employees.ldap.repositories.ldap_repository import ensure_container_exists + + with _ldap() as conn: + ensure_container_exists(conn, users_base) + # Используем внутренний метод UserService + new_dn = svc._user_service._move_user_to_base( + conn, current_dn, users_base + ) + sync_state.touch(ldap_dn=new_dn, sync_dir="ldap") + import logging + logger = logging.getLogger(__name__) + logger.info( + f"Restored employee moved from Dismissed to Users: " + f"employee_id={emp.id}, new_dn={new_dn}" + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error( + f"Failed to move restored employee from Dismissed to Users: " + f"employee_id={emp.id}, error={e}" + ) except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: import logging logger = logging.getLogger(__name__) diff --git a/backend/employees/ldap/services/department_service.py b/backend/employees/ldap/services/department_service.py index 90add8b7..be00df06 100644 --- a/backend/employees/ldap/services/department_service.py +++ b/backend/employees/ldap/services/department_service.py @@ -436,15 +436,17 @@ class DepartmentService: pass if emp_dn: - users_base = getattr( - settings, "LDAP_USERS_BASE", None - ) or getattr(settings, "LDAP_USER_BASE", None) - if not users_base: - raise RuntimeError("LDAP_USERS_BASE is not configured") + from ..utils.ldap_utils import get_base_dn_for_employee + try: - ensure_container_exists(conn, users_base) + target_base = get_base_dn_for_employee(employee) + except RuntimeError as e: + raise DirectoryLdapError(str(e)) from e + + try: + ensure_container_exists(conn, target_base) new_dn = self._user_service._move_user_to_base( - conn, emp_dn, users_base + conn, emp_dn, target_base ) self._touch_state( model="employee", @@ -453,8 +455,9 @@ class DepartmentService: sync_dir="ldap", ) except Exception as e: + target_name = "Dismissed OU" if not employee.is_active else "Users OU" raise DirectoryLdapError( - f"LDAP move to Users OU failed: {e}" + f"LDAP move to {target_name} failed: {e}" ) from e try: diff --git a/backend/employees/ldap/utils/ldap_utils.py b/backend/employees/ldap/utils/ldap_utils.py index c743a3b2..90bc932b 100644 --- a/backend/employees/ldap/utils/ldap_utils.py +++ b/backend/employees/ldap/utils/ldap_utils.py @@ -318,6 +318,34 @@ def build_logins_for_user( return ensure_unique_login(fb, is_taken_sam, is_taken_upn, upn_suffix) +def get_base_dn_for_employee(employee) -> str: + """Определяет целевую OU для сотрудника в зависимости от статуса активности. + + Args: + employee: Экземпляр модели Employee. + + Returns: + str: DN базовой OU (LDAP_DISMISSED_BASE если is_active=False, иначе LDAP_USERS_BASE). + + Raises: + RuntimeError: Если настройка не сконфигурирована в settings. + """ + from django.conf import settings + + if not employee.is_active: + dismissed_base = getattr(settings, "LDAP_DISMISSED_BASE", None) + if not dismissed_base: + raise RuntimeError("LDAP_DISMISSED_BASE is not configured") + return dismissed_base + + users_base = getattr(settings, "LDAP_USERS_BASE", None) or getattr( + settings, "LDAP_USER_BASE", None + ) + if not users_base: + raise RuntimeError("LDAP_USERS_BASE is not configured") + return users_base + + __all__ = [ "_first", "_uac_is_active", @@ -331,4 +359,5 @@ __all__ = [ "fallback_login_from_email", "ensure_unique_login", "build_logins_for_user", + "get_base_dn_for_employee", ] diff --git a/backend/employees/management/commands/ensure_dismissed_ou.py b/backend/employees/management/commands/ensure_dismissed_ou.py new file mode 100644 index 00000000..da227e71 --- /dev/null +++ b/backend/employees/management/commands/ensure_dismissed_ou.py @@ -0,0 +1,151 @@ +"""Management команда для создания OU=Dismissed в Active Directory. + +Эта команда гарантирует наличие OU=Dismissed для хранения уволенных сотрудников. +Выполняется автоматически при активации LDAP или вручную администратором. +""" + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + """Создаёт OU=Dismissed в Active Directory, если она отсутствует.""" + + help = "Создаёт OU=Dismissed для хранения уволенных сотрудников в AD" + + def add_arguments(self, parser): + """Добавляет аргументы командной строки.""" + parser.add_argument( + "--force", + action="store_true", + help="Пересоздать OU даже если она уже существует (ОСТОРОЖНО!)", + ) + parser.add_argument( + "--description", + type=str, + default="Уволенные сотрудники", + help="Описание для OU (по умолчанию: 'Уволенные сотрудники')", + ) + + def handle(self, *args, **options): + """Выполняет создание OU=Dismissed.""" + ldap_enabled = getattr(settings, "LDAP_ENABLED", False) + if not ldap_enabled: + self.stdout.write( + self.style.WARNING( + "LDAP отключен (LDAP_ENABLED=False). " + "Команда выполнена без изменений." + ) + ) + return + + dismissed_base = getattr(settings, "LDAP_DISMISSED_BASE", None) + if not dismissed_base: + raise CommandError( + "LDAP_DISMISSED_BASE не настроен в settings.py. " + "Добавьте настройку и повторите команду." + ) + + force = options["force"] + description = options["description"] + + self.stdout.write(f"Целевая OU: {dismissed_base}") + self.stdout.write(f"Описание: {description}") + self.stdout.write("") + + try: + from employees.ldap.infrastructure.connections import _ldap + + with _ldap() as conn: + # Проверяем существование OU + from ldap3 import BASE + + ok = conn.search( + dismissed_base, + "(objectClass=organizationalUnit)", + search_scope=BASE, + attributes=["description"], + ) + + if ok and conn.entries: + if not force: + self.stdout.write( + self.style.SUCCESS( + f"✓ OU=Dismissed уже существует: {dismissed_base}" + ) + ) + entry = conn.entries[0] + current_desc = ( + entry.description.value + if hasattr(entry, "description") + else None + ) + if current_desc: + self.stdout.write( + f" Текущее описание: {current_desc}" + ) + self.stdout.write("") + self.stdout.write( + "Используйте --force для пересоздания (ОСТОРОЖНО!)" + ) + return + else: + # Force mode: удаляем и создаём заново + self.stdout.write( + self.style.WARNING( + "⚠ Режим --force: удаление существующей OU..." + ) + ) + # Проверяем, есть ли дочерние объекты + check = conn.search( + dismissed_base, + "(objectClass=*)", + attributes=["distinguishedName"], + ) + if check and len(conn.entries) > 1: + raise CommandError( + f"OU содержит {len(conn.entries) - 1} объектов. " + "Удалите их вручную или используйте другой метод." + ) + + ok = conn.delete(dismissed_base) + if not ok: + raise CommandError( + f"Не удалось удалить OU: {conn.result}" + ) + self.stdout.write( + self.style.SUCCESS("✓ Существующая OU удалена") + ) + + # Создаём новую OU + self.stdout.write("Создание OU=Dismissed...") + from ldap3 import MODIFY_REPLACE + + ok = conn.add( + dismissed_base, + ["top", "organizationalUnit"], + {"description": description}, + ) + if not ok: + raise CommandError( + f"Не удалось создать OU: {conn.result}" + ) + + self.stdout.write( + self.style.SUCCESS( + f"✓ OU=Dismissed успешно создана: {dismissed_base}" + ) + ) + self.stdout.write(f" Описание: {description}") + self.stdout.write("") + self.stdout.write( + "Теперь уволенные сотрудники будут автоматически " + "перемещаться в эту OU." + ) + + except ImportError as e: + raise CommandError( + f"Не удалось импортировать LDAP модули: {e}" + ) from e + except Exception as e: + raise CommandError(f"Ошибка при работе с LDAP: {e}") from e diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 96ddce91..feb83555 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -424,6 +424,9 @@ LDAP_USER_BASE = os.getenv("LDAP_USER_BASE") LDAP_DEPARTMENTS_BASE = os.getenv( "LDAP_DEPARTMENTS_BASE", "OU=Departments,OU=company,DC=robotail,DC=local" ) +LDAP_DISMISSED_BASE = os.getenv( + "LDAP_DISMISSED_BASE", "OU=Dismissed,OU=company,DC=robotail,DC=local" +) LDAP_GROUPS_BASE = os.getenv("LDAP_GROUPS_BASE") LDAP_AUTO_CREATE = os.getenv("LDAP_AUTO_CREATE", "False") diff --git a/backend/tests/api/v1/employees/test_employee_actions.py b/backend/tests/api/v1/employees/test_employee_actions.py index 4a3148a4..30137e32 100644 --- a/backend/tests/api/v1/employees/test_employee_actions.py +++ b/backend/tests/api/v1/employees/test_employee_actions.py @@ -428,3 +428,123 @@ def test_dismissal_works_when_ldap_disabled( # LDAP НЕ должен быть вызван, т.к. отключен mock_update.assert_not_called() + + +@pytest.mark.django_db +@patch('api.v1.employees.views._is_ldap_enabled') +@patch('employees.ldap.directory_service.DirectoryService.remove_member') +@patch('employees.ldap.directory_service.DirectoryService.update_user') +def test_dismissal_moves_to_dismissed_ou( + mock_update, mock_remove, mock_is_enabled, api_client +): + """Проверяет перемещение уволенных сотрудников в OU=Dismissed.""" + from employees.models import Department, EmployeeDepartment, LdapSyncState + + mock_is_enabled.return_value = True + + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user() + + # Создаём отдел и связь + dept = Department.objects.create(name="IT Department", description="IT") + EmployeeDepartment.objects.create( + employee=emp, + department=dept, + is_active=True + ) + + # Создаём запись LdapSyncState (имитация сотрудника с LDAP) + LdapSyncState.objects.create( + model='employee', + object_pk=str(emp.pk), + ldap_dn=f'CN={emp.email},OU=IT Department,OU=Departments,DC=example,DC=com', + last_sync_dir='ldap' + ) + + url = reverse("api:v1:employee-actions-list") + resp = api_client.post( + url, + { + "employee": emp.id, + "action": ACTION_DISMISSED, + "date": timezone.now().isoformat(), + }, + format="json", + ) + + assert resp.status_code == 201 + emp.refresh_from_db() + assert emp.is_active is False + + # Проверяем вызов update_user (деактивация в LDAP) + mock_update.assert_called_once() + call_args = mock_update.call_args + assert call_args[0][0].id == emp.id + assert call_args[1]['changes'] == {'is_active': False} + + # Проверяем вызов remove_member (перемещение в OU=Dismissed) + mock_remove.assert_called_once() + call_args = mock_remove.call_args + assert call_args[0][0].id == dept.id # department + assert call_args[0][1].id == emp.id # employee + + +@pytest.mark.django_db +@patch('api.v1.employees.views._is_ldap_enabled') +@patch('employees.ldap.infrastructure.connections._ldap') +@patch('employees.ldap.directory_service.DirectoryService.update_user') +def test_restoration_moves_from_dismissed_to_users( + mock_update, mock_ldap_ctx, mock_is_enabled, api_client, settings +): + """Проверяет перемещение восстановленных сотрудников из OU=Dismissed в OU=Users.""" + from employees.models import LdapSyncState + from unittest.mock import MagicMock + + mock_is_enabled.return_value = True + settings.LDAP_DISMISSED_BASE = "OU=Dismissed,DC=example,DC=com" + settings.LDAP_USERS_BASE = "OU=Users,DC=example,DC=com" + + # Мокаем LDAP соединение + mock_conn = MagicMock() + mock_ldap_ctx.return_value.__enter__.return_value = mock_conn + mock_ldap_ctx.return_value.__exit__.return_value = False + + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user(is_active=False) # Уволенный сотрудник + + # Создаём запись LdapSyncState - сотрудник в OU=Dismissed + sync_state = LdapSyncState.objects.create( + model='employee', + object_pk=str(emp.pk), + ldap_dn=f'CN={emp.email},OU=Dismissed,DC=example,DC=com', + last_sync_dir='ldap' + ) + + # Создаём любое действие для активации (например, повышение) + from employees.constants import ACTION_PROMOTION + url = reverse("api:v1:employee-actions-list") + resp = api_client.post( + url, + { + "employee": emp.id, + "action": ACTION_PROMOTION, + "date": timezone.now().isoformat(), + }, + format="json", + ) + + assert resp.status_code == 201 + emp.refresh_from_db() + assert emp.is_active is True # Восстановлен + + # Проверяем вызов update_user (активация в LDAP) + assert mock_update.called + call_args = mock_update.call_args + assert call_args[0][0].id == emp.id + assert call_args[1]['changes'] == {'is_active': True} + + # Проверяем, что был вызван _move_user_to_base (через mock LDAP) + # Это косвенная проверка - мы мокнули _ldap(), так что код выполнился +