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
This commit is contained in:
admin i 2025-12-17 08:15:20 +03:00
parent b5fbeca09f
commit a280f6e7f7
2 changed files with 266 additions and 2 deletions

View File

@ -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):

View File

@ -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()