fix(ldap): handle dismissal of employees without departments

Проблема: При увольнении сотрудника, не состоящего ни в одном отделе,
он оставался в текущей 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 независимо
от наличия отделов.
This commit is contained in:
admin i 2025-12-18 12:32:09 +03:00
parent c7f43d0c5b
commit e973cc6075
2 changed files with 99 additions and 0 deletions

View File

@ -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:
# Логируем ошибку, но не прерываем процесс
# БД-изменения уже применены

View File

@ -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"