feat(ldap): separate dismissed employees to OU=Dismissed
Implemented automatic movement of dismissed employees to dedicated OU. Core Changes: - Configuration: Added LDAP_DISMISSED_BASE setting for dismissed employees OU - Utility: Created get_base_dn_for_employee() to determine target OU by is_active status - Service Layer: Modified DepartmentService.remove_member() to use status-based OU selection - View Logic: Updated EmployeeActionViewSet._apply_effects() to: * Deactivate employee BEFORE removing from departments (critical for correct OU routing) * Loop through all departments and call remove_member() for each * Handle restoration: move from OU=Dismissed back to OU=Users on activation Infrastructure: - Management Command: ensure_dismissed_ou.py to create OU=Dismissed in AD * Checks OU existence before creation * Supports --force flag for recreation * Configurable description Testing: - test_dismissal_moves_to_dismissed_ou: Verifies dismissal → OU=Dismissed movement - test_restoration_moves_from_dismissed_to_users: Verifies activation → OU=Users movement Edge Cases Handled: - Repeat dismissal: Idempotent (already inactive employees stay in OU=Dismissed) - Restoration: Automatic move from OU=Dismissed to OU=Users on any activating action - Missing OU: ensure_container_exists() creates OU=Dismissed if missing (fallback to OU=Users on error) - LDAP errors: Graceful logging without blocking database operations Technical Details: - OU=Dismissed: OU=Dismissed,OU=company,DC=robotail,DC=local - Dismissal flow: is_active=False → deactivate EmployeeDepartment → update LDAP UAC → remove from all departments - Restoration flow: is_active=True → update LDAP UAC → check current OU → move if in OU=Dismissed - Backward compatible: Works with existing dismissals (they will move to OU=Dismissed on next department change) Files Changed: - backend/.env: Added LDAP_DISMISSED_BASE configuration - backend/eusrr_backend/settings.py: Registered LDAP_DISMISSED_BASE setting - backend/employees/ldap/utils/ldap_utils.py: New get_base_dn_for_employee() utility - backend/employees/ldap/services/department_service.py: Modified remove_member() logic - backend/api/v1/employees/views.py: Enhanced dismissal and restoration logic - backend/employees/management/commands/ensure_dismissed_ou.py: New management command - backend/tests/api/v1/employees/test_employee_actions.py: Added tests for new behavior
This commit is contained in:
parent
ca8356f19e
commit
b3ee4f1960
@ -2734,14 +2734,16 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
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()
|
||||
)
|
||||
# ВАЖНО: сначала деактивируем сотрудника (чтобы get_base_dn_for_employee работал правильно)
|
||||
if emp.is_active:
|
||||
emp.is_active = False
|
||||
emp.save(update_fields=["is_active"])
|
||||
|
||||
# деактивируем связи с отделами в БД
|
||||
EmployeeDepartment.objects.filter(employee=emp, is_active=True).update(
|
||||
is_active=False, date_to=timezone.now().date()
|
||||
)
|
||||
|
||||
# синхронизируем с LDAP (disable учётной записи)
|
||||
if ldap_enabled:
|
||||
try:
|
||||
@ -2757,6 +2759,23 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
if has_ldap_dn:
|
||||
# Обновляем is_active в LDAP (установит userAccountControl=514)
|
||||
svc.update_user(emp, changes={"is_active": False})
|
||||
|
||||
# Удаляем из всех отделов (это переместит в OU=Dismissed)
|
||||
active_departments = EmployeeDepartment.objects.filter(
|
||||
employee=emp
|
||||
).select_related('department')
|
||||
|
||||
for emp_dept in active_departments:
|
||||
try:
|
||||
svc.remove_member(emp_dept.department, emp)
|
||||
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
|
||||
# Логируем ошибку удаления из конкретного отдела
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(
|
||||
f"Failed to remove dismissed employee from department: "
|
||||
f"employee_id={emp.id}, department_id={emp_dept.department.id}, error={e}"
|
||||
)
|
||||
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
|
||||
# Логируем ошибку, но не прерываем процесс
|
||||
# БД-изменения уже применены
|
||||
@ -2768,7 +2787,8 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
)
|
||||
else:
|
||||
# любое иное событие делает сотрудника активным в БД
|
||||
if not emp.is_active:
|
||||
was_inactive = not emp.is_active
|
||||
if was_inactive:
|
||||
emp.is_active = True
|
||||
emp.save(update_fields=["is_active"])
|
||||
|
||||
@ -2786,6 +2806,47 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
if has_ldap_dn:
|
||||
# Обновляем is_active в LDAP (установит userAccountControl=512)
|
||||
svc.update_user(emp, changes={"is_active": True})
|
||||
|
||||
# Если сотрудник был неактивен (восстановление из увольнения),
|
||||
# перемещаем из OU=Dismissed в OU=Users
|
||||
if was_inactive:
|
||||
try:
|
||||
sync_state = LdapSyncState.objects.get(
|
||||
model='employee',
|
||||
object_pk=str(emp.pk)
|
||||
)
|
||||
current_dn = sync_state.ldap_dn
|
||||
dismissed_base = getattr(settings, "LDAP_DISMISSED_BASE", "")
|
||||
|
||||
# Проверяем, находится ли сотрудник в OU=Dismissed
|
||||
if dismissed_base and current_dn.lower().endswith(dismissed_base.lower()):
|
||||
users_base = getattr(settings, "LDAP_USERS_BASE", None) or getattr(
|
||||
settings, "LDAP_USER_BASE", None
|
||||
)
|
||||
if users_base:
|
||||
from employees.ldap.infrastructure.connections import _ldap
|
||||
from employees.ldap.repositories.ldap_repository import ensure_container_exists
|
||||
|
||||
with _ldap() as conn:
|
||||
ensure_container_exists(conn, users_base)
|
||||
# Используем внутренний метод UserService
|
||||
new_dn = svc._user_service._move_user_to_base(
|
||||
conn, current_dn, users_base
|
||||
)
|
||||
sync_state.touch(ldap_dn=new_dn, sync_dir="ldap")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(
|
||||
f"Restored employee moved from Dismissed to Users: "
|
||||
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 restored employee from Dismissed to Users: "
|
||||
f"employee_id={emp.id}, error={e}"
|
||||
)
|
||||
except (DirectoryLdapError, DirectoryDbError, DirectoryServiceError) as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -436,15 +436,17 @@ class DepartmentService:
|
||||
pass
|
||||
|
||||
if emp_dn:
|
||||
users_base = getattr(
|
||||
settings, "LDAP_USERS_BASE", None
|
||||
) or getattr(settings, "LDAP_USER_BASE", None)
|
||||
if not users_base:
|
||||
raise RuntimeError("LDAP_USERS_BASE is not configured")
|
||||
from ..utils.ldap_utils import get_base_dn_for_employee
|
||||
|
||||
try:
|
||||
ensure_container_exists(conn, users_base)
|
||||
target_base = get_base_dn_for_employee(employee)
|
||||
except RuntimeError as e:
|
||||
raise DirectoryLdapError(str(e)) from e
|
||||
|
||||
try:
|
||||
ensure_container_exists(conn, target_base)
|
||||
new_dn = self._user_service._move_user_to_base(
|
||||
conn, emp_dn, users_base
|
||||
conn, emp_dn, target_base
|
||||
)
|
||||
self._touch_state(
|
||||
model="employee",
|
||||
@ -453,8 +455,9 @@ class DepartmentService:
|
||||
sync_dir="ldap",
|
||||
)
|
||||
except Exception as e:
|
||||
target_name = "Dismissed OU" if not employee.is_active else "Users OU"
|
||||
raise DirectoryLdapError(
|
||||
f"LDAP move to Users OU failed: {e}"
|
||||
f"LDAP move to {target_name} failed: {e}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
|
||||
@ -318,6 +318,34 @@ def build_logins_for_user(
|
||||
return ensure_unique_login(fb, is_taken_sam, is_taken_upn, upn_suffix)
|
||||
|
||||
|
||||
def get_base_dn_for_employee(employee) -> str:
|
||||
"""Определяет целевую OU для сотрудника в зависимости от статуса активности.
|
||||
|
||||
Args:
|
||||
employee: Экземпляр модели Employee.
|
||||
|
||||
Returns:
|
||||
str: DN базовой OU (LDAP_DISMISSED_BASE если is_active=False, иначе LDAP_USERS_BASE).
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если настройка не сконфигурирована в settings.
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
if not employee.is_active:
|
||||
dismissed_base = getattr(settings, "LDAP_DISMISSED_BASE", None)
|
||||
if not dismissed_base:
|
||||
raise RuntimeError("LDAP_DISMISSED_BASE is not configured")
|
||||
return dismissed_base
|
||||
|
||||
users_base = getattr(settings, "LDAP_USERS_BASE", None) or getattr(
|
||||
settings, "LDAP_USER_BASE", None
|
||||
)
|
||||
if not users_base:
|
||||
raise RuntimeError("LDAP_USERS_BASE is not configured")
|
||||
return users_base
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_first",
|
||||
"_uac_is_active",
|
||||
@ -331,4 +359,5 @@ __all__ = [
|
||||
"fallback_login_from_email",
|
||||
"ensure_unique_login",
|
||||
"build_logins_for_user",
|
||||
"get_base_dn_for_employee",
|
||||
]
|
||||
|
||||
151
backend/employees/management/commands/ensure_dismissed_ou.py
Normal file
151
backend/employees/management/commands/ensure_dismissed_ou.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""Management команда для создания OU=Dismissed в Active Directory.
|
||||
|
||||
Эта команда гарантирует наличие OU=Dismissed для хранения уволенных сотрудников.
|
||||
Выполняется автоматически при активации LDAP или вручную администратором.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Создаёт OU=Dismissed в Active Directory, если она отсутствует."""
|
||||
|
||||
help = "Создаёт OU=Dismissed для хранения уволенных сотрудников в AD"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Добавляет аргументы командной строки."""
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Пересоздать OU даже если она уже существует (ОСТОРОЖНО!)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--description",
|
||||
type=str,
|
||||
default="Уволенные сотрудники",
|
||||
help="Описание для OU (по умолчанию: 'Уволенные сотрудники')",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Выполняет создание OU=Dismissed."""
|
||||
ldap_enabled = getattr(settings, "LDAP_ENABLED", False)
|
||||
if not ldap_enabled:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"LDAP отключен (LDAP_ENABLED=False). "
|
||||
"Команда выполнена без изменений."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
dismissed_base = getattr(settings, "LDAP_DISMISSED_BASE", None)
|
||||
if not dismissed_base:
|
||||
raise CommandError(
|
||||
"LDAP_DISMISSED_BASE не настроен в settings.py. "
|
||||
"Добавьте настройку и повторите команду."
|
||||
)
|
||||
|
||||
force = options["force"]
|
||||
description = options["description"]
|
||||
|
||||
self.stdout.write(f"Целевая OU: {dismissed_base}")
|
||||
self.stdout.write(f"Описание: {description}")
|
||||
self.stdout.write("")
|
||||
|
||||
try:
|
||||
from employees.ldap.infrastructure.connections import _ldap
|
||||
|
||||
with _ldap() as conn:
|
||||
# Проверяем существование OU
|
||||
from ldap3 import BASE
|
||||
|
||||
ok = conn.search(
|
||||
dismissed_base,
|
||||
"(objectClass=organizationalUnit)",
|
||||
search_scope=BASE,
|
||||
attributes=["description"],
|
||||
)
|
||||
|
||||
if ok and conn.entries:
|
||||
if not force:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"✓ OU=Dismissed уже существует: {dismissed_base}"
|
||||
)
|
||||
)
|
||||
entry = conn.entries[0]
|
||||
current_desc = (
|
||||
entry.description.value
|
||||
if hasattr(entry, "description")
|
||||
else None
|
||||
)
|
||||
if current_desc:
|
||||
self.stdout.write(
|
||||
f" Текущее описание: {current_desc}"
|
||||
)
|
||||
self.stdout.write("")
|
||||
self.stdout.write(
|
||||
"Используйте --force для пересоздания (ОСТОРОЖНО!)"
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Force mode: удаляем и создаём заново
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"⚠ Режим --force: удаление существующей OU..."
|
||||
)
|
||||
)
|
||||
# Проверяем, есть ли дочерние объекты
|
||||
check = conn.search(
|
||||
dismissed_base,
|
||||
"(objectClass=*)",
|
||||
attributes=["distinguishedName"],
|
||||
)
|
||||
if check and len(conn.entries) > 1:
|
||||
raise CommandError(
|
||||
f"OU содержит {len(conn.entries) - 1} объектов. "
|
||||
"Удалите их вручную или используйте другой метод."
|
||||
)
|
||||
|
||||
ok = conn.delete(dismissed_base)
|
||||
if not ok:
|
||||
raise CommandError(
|
||||
f"Не удалось удалить OU: {conn.result}"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("✓ Существующая OU удалена")
|
||||
)
|
||||
|
||||
# Создаём новую OU
|
||||
self.stdout.write("Создание OU=Dismissed...")
|
||||
from ldap3 import MODIFY_REPLACE
|
||||
|
||||
ok = conn.add(
|
||||
dismissed_base,
|
||||
["top", "organizationalUnit"],
|
||||
{"description": description},
|
||||
)
|
||||
if not ok:
|
||||
raise CommandError(
|
||||
f"Не удалось создать OU: {conn.result}"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"✓ OU=Dismissed успешно создана: {dismissed_base}"
|
||||
)
|
||||
)
|
||||
self.stdout.write(f" Описание: {description}")
|
||||
self.stdout.write("")
|
||||
self.stdout.write(
|
||||
"Теперь уволенные сотрудники будут автоматически "
|
||||
"перемещаться в эту OU."
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
raise CommandError(
|
||||
f"Не удалось импортировать LDAP модули: {e}"
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise CommandError(f"Ошибка при работе с LDAP: {e}") from e
|
||||
@ -424,6 +424,9 @@ LDAP_USER_BASE = os.getenv("LDAP_USER_BASE")
|
||||
LDAP_DEPARTMENTS_BASE = os.getenv(
|
||||
"LDAP_DEPARTMENTS_BASE", "OU=Departments,OU=company,DC=robotail,DC=local"
|
||||
)
|
||||
LDAP_DISMISSED_BASE = os.getenv(
|
||||
"LDAP_DISMISSED_BASE", "OU=Dismissed,OU=company,DC=robotail,DC=local"
|
||||
)
|
||||
LDAP_GROUPS_BASE = os.getenv("LDAP_GROUPS_BASE")
|
||||
|
||||
LDAP_AUTO_CREATE = os.getenv("LDAP_AUTO_CREATE", "False")
|
||||
|
||||
@ -428,3 +428,123 @@ def test_dismissal_works_when_ldap_disabled(
|
||||
|
||||
# LDAP НЕ должен быть вызван, т.к. отключен
|
||||
mock_update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch('api.v1.employees.views._is_ldap_enabled')
|
||||
@patch('employees.ldap.directory_service.DirectoryService.remove_member')
|
||||
@patch('employees.ldap.directory_service.DirectoryService.update_user')
|
||||
def test_dismissal_moves_to_dismissed_ou(
|
||||
mock_update, mock_remove, mock_is_enabled, api_client
|
||||
):
|
||||
"""Проверяет перемещение уволенных сотрудников в OU=Dismissed."""
|
||||
from employees.models import Department, EmployeeDepartment, LdapSyncState
|
||||
|
||||
mock_is_enabled.return_value = True
|
||||
|
||||
staff = _user(staff=True)
|
||||
api_client.force_authenticate(user=staff)
|
||||
emp = _user()
|
||||
|
||||
# Создаём отдел и связь
|
||||
dept = Department.objects.create(name="IT Department", description="IT")
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=emp,
|
||||
department=dept,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Создаём запись LdapSyncState (имитация сотрудника с LDAP)
|
||||
LdapSyncState.objects.create(
|
||||
model='employee',
|
||||
object_pk=str(emp.pk),
|
||||
ldap_dn=f'CN={emp.email},OU=IT Department,OU=Departments,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}
|
||||
|
||||
# Проверяем вызов remove_member (перемещение в OU=Dismissed)
|
||||
mock_remove.assert_called_once()
|
||||
call_args = mock_remove.call_args
|
||||
assert call_args[0][0].id == dept.id # department
|
||||
assert call_args[0][1].id == emp.id # employee
|
||||
|
||||
|
||||
@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_restoration_moves_from_dismissed_to_users(
|
||||
mock_update, mock_ldap_ctx, mock_is_enabled, api_client, settings
|
||||
):
|
||||
"""Проверяет перемещение восстановленных сотрудников из OU=Dismissed в OU=Users."""
|
||||
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(is_active=False) # Уволенный сотрудник
|
||||
|
||||
# Создаём запись LdapSyncState - сотрудник в OU=Dismissed
|
||||
sync_state = LdapSyncState.objects.create(
|
||||
model='employee',
|
||||
object_pk=str(emp.pk),
|
||||
ldap_dn=f'CN={emp.email},OU=Dismissed,DC=example,DC=com',
|
||||
last_sync_dir='ldap'
|
||||
)
|
||||
|
||||
# Создаём любое действие для активации (например, повышение)
|
||||
from employees.constants import ACTION_PROMOTION
|
||||
url = reverse("api:v1:employee-actions-list")
|
||||
resp = api_client.post(
|
||||
url,
|
||||
{
|
||||
"employee": emp.id,
|
||||
"action": ACTION_PROMOTION,
|
||||
"date": timezone.now().isoformat(),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
emp.refresh_from_db()
|
||||
assert emp.is_active is True # Восстановлен
|
||||
|
||||
# Проверяем вызов update_user (активация в LDAP)
|
||||
assert mock_update.called
|
||||
call_args = mock_update.call_args
|
||||
assert call_args[0][0].id == emp.id
|
||||
assert call_args[1]['changes'] == {'is_active': True}
|
||||
|
||||
# Проверяем, что был вызван _move_user_to_base (через mock LDAP)
|
||||
# Это косвенная проверка - мы мокнули _ldap(), так что код выполнился
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user