From e973cc6075c5faeb98df6cdf4cd253c9096564ca 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 12:32:09 +0300 Subject: [PATCH] fix(ldap): handle dismissal of employees without departments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: При увольнении сотрудника, не состоящего ни в одном отделе, он оставался в текущей OU (например, OU=Users) вместо перемещения в OU=Dismissed. Причина: Цикл по active_departments был пустым, remove_member() не вызывался, перемещение в OU=Dismissed не происходило. Решение: - Добавлена проверка departments_processed - Если сотрудник не состоял ни в одном отделе, выполняется явное перемещение - Используется get_base_dn_for_employee() для определения целевой OU - Прямой вызов _move_user_to_base() для перемещения в OU=Dismissed Тест: test_dismissal_without_department_moves_to_dismissed - Проверяет увольнение сотрудника без отделов - Убеждается, что LDAP соединение создано для перемещения - Подтверждает деактивацию в LDAP (userAccountControl=514) Edge case покрыт: Теперь все уволенные попадают в OU=Dismissed независимо от наличия отделов. --- backend/api/v1/employees/views.py | 41 +++++++++++++ .../api/v1/employees/test_employee_actions.py | 58 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 0da04250..0adc0f59 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -2765,9 +2765,11 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): employee=emp ).select_related('department') + departments_processed = False for emp_dept in active_departments: try: svc.remove_member(emp_dept.department, emp) + departments_processed = True except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: # Логируем ошибку удаления из конкретного отдела import logging @@ -2776,6 +2778,45 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): f"Failed to remove dismissed employee from department: " f"employee_id={emp.id}, department_id={emp_dept.department.id}, error={e}" ) + + # Если сотрудник не состоял ни в одном отделе, вручную перемещаем в OU=Dismissed + if not departments_processed: + try: + from employees.ldap.utils.ldap_utils import get_base_dn_for_employee + from employees.ldap.infrastructure.connections import _ldap + from employees.ldap.repositories.ldap_repository import ensure_container_exists + from employees.models import LdapSyncState + + sync_state = LdapSyncState.objects.filter( + model='employee', + object_pk=str(emp.pk) + ).first() + + if sync_state and sync_state.ldap_dn: + target_base = get_base_dn_for_employee(emp) + current_dn = sync_state.ldap_dn + + # Проверяем, нужно ли перемещение + if not current_dn.lower().endswith(target_base.lower()): + with _ldap() as conn: + ensure_container_exists(conn, target_base) + new_dn = svc._user_service._move_user_to_base( + conn, current_dn, target_base + ) + sync_state.touch(ldap_dn=new_dn, sync_dir="ldap") + import logging + logger = logging.getLogger(__name__) + logger.info( + f"Dismissed employee without department moved to OU=Dismissed: " + 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 dismissed employee without department to OU=Dismissed: " + f"employee_id={emp.id}, error={e}" + ) except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: # Логируем ошибку, но не прерываем процесс # БД-изменения уже применены diff --git a/backend/tests/api/v1/employees/test_employee_actions.py b/backend/tests/api/v1/employees/test_employee_actions.py index 30137e32..187a0122 100644 --- a/backend/tests/api/v1/employees/test_employee_actions.py +++ b/backend/tests/api/v1/employees/test_employee_actions.py @@ -548,3 +548,61 @@ def test_restoration_moves_from_dismissed_to_users( # Проверяем, что был вызван _move_user_to_base (через mock LDAP) # Это косвенная проверка - мы мокнули _ldap(), так что код выполнился + +@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_dismissal_without_department_moves_to_dismissed( + mock_update, mock_ldap_ctx, mock_is_enabled, api_client, settings +): + """Проверяет перемещение в OU=Dismissed при увольнении сотрудника без отдела.""" + 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() + + # Сотрудник НЕ состоит ни в одном отделе (нет EmployeeDepartment записей) + # Создаём запись LdapSyncState - сотрудник в OU=Users + sync_state = LdapSyncState.objects.create( + model='employee', + object_pk=str(emp.pk), + ldap_dn=f'CN={emp.email},OU=Users,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} + + # Проверяем, что LDAP соединение было создано (для перемещения в OU=Dismissed) + assert mock_ldap_ctx.called, "LDAP connection should be established for moving to OU=Dismissed" +