From a280f6e7f7885e6f0eb553ce47da01a89b0befcc 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: Wed, 17 Dec 2025 08:15:20 +0300 Subject: [PATCH] feat: sync employee dismissal/activation with LDAP - Added LDAP synchronization in EmployeeActionViewSet._apply_effects() - Dismissal sets userAccountControl=514 (disabled) in LDAP - Other actions set userAccountControl=512 (enabled) - Graceful error handling: DB succeeds even if LDAP fails - Works correctly when LDAP is disabled - Added 5 comprehensive tests for LDAP sync behavior --- backend/api/v1/employees/views.py | 53 ++++- .../api/v1/employees/test_employee_actions.py | 215 ++++++++++++++++++ 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 6e5f61d0..5ab9e641 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -2731,19 +2731,68 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): # --- бизнес-эффекты после записи события --- def _apply_effects(self, action_obj: EmployeeAction): emp = action_obj.employee + 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() ) if emp.is_active: emp.is_active = False emp.save(update_fields=["is_active"]) + + # синхронизируем с LDAP (disable учётной записи) + if ldap_enabled: + try: + svc = DirectoryService() + # Проверяем, есть ли у пользователя ldap_dn + from employees.models import LdapSyncState + has_ldap_dn = LdapSyncState.objects.filter( + model='employee', + object_pk=str(emp.pk), + ldap_dn__isnull=False + ).exists() + + if has_ldap_dn: + # Обновляем is_active в LDAP (установит userAccountControl=514) + svc.update_user(emp, changes={"is_active": False}) + except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: + # Логируем ошибку, но не прерываем процесс + # БД-изменения уже применены + import logging + logger = logging.getLogger(__name__) + logger.error( + f"Failed to disable user in LDAP during dismissal: " + f"employee_id={emp.id}, error={e}" + ) else: - # любое иное событие делает сотрудника активным + # любое иное событие делает сотрудника активным в БД if not emp.is_active: emp.is_active = True emp.save(update_fields=["is_active"]) + + # синхронизируем с LDAP (enable учётной записи) + if ldap_enabled: + try: + svc = DirectoryService() + from employees.models import LdapSyncState + has_ldap_dn = LdapSyncState.objects.filter( + model='employee', + object_pk=str(emp.pk), + ldap_dn__isnull=False + ).exists() + + if has_ldap_dn: + # Обновляем is_active в LDAP (установит userAccountControl=512) + svc.update_user(emp, changes={"is_active": True}) + except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e: + import logging + logger = logging.getLogger(__name__) + logger.error( + f"Failed to enable user in LDAP during action: " + f"employee_id={emp.id}, action={action_obj.action}, error={e}" + ) @transaction.atomic def perform_create(self, serializer): diff --git a/backend/tests/api/v1/employees/test_employee_actions.py b/backend/tests/api/v1/employees/test_employee_actions.py index 55df0aed..4a3148a4 100644 --- a/backend/tests/api/v1/employees/test_employee_actions.py +++ b/backend/tests/api/v1/employees/test_employee_actions.py @@ -3,6 +3,7 @@ from django.urls import reverse from django.utils import timezone from django.contrib.auth.models import Permission from rest_framework import status +from unittest.mock import patch, Mock from employees.models import Employee, Department, EmployeeDepartment, EmployeeAction from employees.constants import ACTION_DISMISSED, ACTION_CHOICES @@ -213,3 +214,217 @@ def test_delete_requires_perm_or_staff(api_client): # grant -> 204 _grant(actor, "employees.delete_employeeaction") assert api_client.delete(url_det).status_code == 204 + + +@pytest.mark.django_db +@patch('api.v1.employees.views._is_ldap_enabled') +@patch('employees.ldap.directory_service.DirectoryService.update_user') +def test_dismissal_syncs_to_ldap_if_has_dn( + mock_update, mock_is_enabled, api_client +): + """Проверяет синхронизацию увольнения с LDAP при наличии ldap_dn.""" + from employees.models import LdapSyncState + + mock_is_enabled.return_value = True + + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user() + + # Создаём запись LdapSyncState (имитация сотрудника с LDAP) + 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 с is_active=False + mock_update.assert_called_once() + call_args = mock_update.call_args + assert call_args[0][0].id == emp.id # первый аргумент - emp + assert call_args[1]['changes'] == {'is_active': False} + + +@pytest.mark.django_db +@patch('api.v1.employees.views._is_ldap_enabled') +@patch('employees.ldap.directory_service.DirectoryService.update_user') +def test_dismissal_handles_ldap_error_gracefully( + mock_update, mock_is_enabled, api_client +): + """Проверяет graceful handling ошибки LDAP при увольнении.""" + from employees.ldap.errors import DirectoryLdapError + from employees.models import LdapSyncState + + mock_is_enabled.return_value = True + mock_update.side_effect = DirectoryLdapError("LDAP connection failed") + + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user() + + # Создаём запись LdapSyncState + 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", + ) + + # Операция должна успешно завершиться, несмотря на ошибку LDAP + assert resp.status_code == 201 + emp.refresh_from_db() + # БД-изменения должны быть применены + assert emp.is_active is False + + # LDAP был вызван (и упал) + mock_update.assert_called_once() + + +@pytest.mark.django_db +@patch('api.v1.employees.views._is_ldap_enabled') +@patch('employees.ldap.directory_service.DirectoryService.update_user') +def test_non_dismissal_syncs_activation_to_ldap( + mock_update, mock_is_enabled, api_client +): + """Проверяет активацию сотрудника в LDAP при не-увольнении.""" + from employees.models import LdapSyncState + + mock_is_enabled.return_value = True + + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user() + emp.is_active = False + emp.save(update_fields=["is_active"]) + + # Создаём запись LdapSyncState + 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": _any_non_dismissed(), + "date": timezone.now().isoformat(), + }, + format="json", + ) + + assert resp.status_code == 201 + emp.refresh_from_db() + assert emp.is_active is True + + # Проверяем, что вызвали update_user с is_active=True + 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': True} + + +@pytest.mark.django_db +@patch('api.v1.employees.views._is_ldap_enabled') +@patch('employees.ldap.directory_service.DirectoryService.update_user') +def test_dismissal_skips_ldap_if_no_dn( + mock_update, mock_is_enabled, api_client +): + """Проверяет, что LDAP не вызывается без ldap_dn.""" + mock_is_enabled.return_value = True + + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user() + # НЕ создаём LdapSyncState - сотрудник без 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 + + # LDAP НЕ должен быть вызван + mock_update.assert_not_called() + + +@pytest.mark.django_db +@patch('api.v1.employees.views._is_ldap_enabled') +@patch('employees.ldap.directory_service.DirectoryService.update_user') +def test_dismissal_works_when_ldap_disabled( + mock_update, mock_is_enabled, api_client +): + """Проверяет, что увольнение работает при отключенном LDAP.""" + from employees.models import LdapSyncState + + mock_is_enabled.return_value = False # LDAP отключен + + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user() + + # Даже если есть запись LdapSyncState + 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 + + # LDAP НЕ должен быть вызван, т.к. отключен + mock_update.assert_not_called()