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:
parent
b5fbeca09f
commit
a280f6e7f7
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user