diff --git a/backend/api/v1/employees/views/departments.py b/backend/api/v1/employees/views/departments.py index 4f1a1454..42e90560 100644 --- a/backend/api/v1/employees/views/departments.py +++ b/backend/api/v1/employees/views/departments.py @@ -172,6 +172,36 @@ class DepartmentViewSet(viewsets.ModelViewSet): qs = qs.order_by("name") return qs + def _extract_department_ldap_changes( + self, instance: Department, data: Dict[str, Any] + ) -> Dict[str, Any]: + """Собирает diff значимых LDAP-полей до сохранения.""" + changes: Dict[str, Any] = {} + for field in ("name", "description"): + if field not in data: + continue + new_value = data.get(field) + if new_value != getattr(instance, field): + changes[field] = new_value + return changes + + def _mark_department_sync_attrs( + self, + instance: Department, + *, + changes: Dict[str, Any] | None = None, + sync_head: bool = False, + ) -> None: + """Передаёт post_save сигналу точный diff отдела.""" + if changes: + existing = getattr(instance, "_ldap_changes", {}) or {} + if not isinstance(existing, dict): + existing = {} + existing.update(changes) + instance._ldap_changes = existing + if sync_head: + instance._ldap_sync_head = True + # --- частичное изменение --- def _perform_set_head( @@ -223,10 +253,27 @@ class DepartmentViewSet(viewsets.ModelViewSet): instance.head_appointed_at = timezone.now() else: instance.head_appointed_at = None + self._mark_department_sync_attrs(instance, sync_head=True) instance.save(update_fields=["head", "head_appointed_at"]) return instance + def perform_update(self, serializer): + """Обновление отдела с фиксацией LDAP diff до save().""" + instance = serializer.instance + validated_data = serializer.validated_data + self._mark_department_sync_attrs( + instance, + changes=self._extract_department_ldap_changes( + instance, validated_data + ), + sync_head=( + "head" in validated_data + and validated_data.get("head") != instance.head + ), + ) + serializer.save() + def partial_update(self, request, *args, **kwargs) -> Response: """Частичный апдейт отдела.""" instance = self.get_object() @@ -268,14 +315,12 @@ class DepartmentViewSet(viewsets.ModelViewSet): instance = result # --- NAME / DESCRIPTION --- - changes: Dict[str, Any] = {} - for k in ("name", "description"): - if k in data: - changes[k] = data.get(k) + changes = self._extract_department_ldap_changes(instance, data) if changes: for k, v in changes.items(): setattr(instance, k, v) + self._mark_department_sync_attrs(instance, changes=changes) instance.save(update_fields=list(changes.keys())) return Response( diff --git a/backend/docs/guides/LDAP_REFACTORING_CHECKLIST.md b/backend/docs/guides/LDAP_REFACTORING_CHECKLIST.md index 5f3d70b7..47fcd20a 100644 --- a/backend/docs/guides/LDAP_REFACTORING_CHECKLIST.md +++ b/backend/docs/guides/LDAP_REFACTORING_CHECKLIST.md @@ -1,5 +1,22 @@ # Чеклист рефакторинга LDAP-сервисов +## ✅ Завершено + +### Обновление 10 апреля 2026: canonical Department sync + +- [x] `DepartmentService` получил sync-oriented API: + - `sync_department_state()` + - `sync_department_delete()` + - `sync_member_state()` +- [x] Signals и Celery retry для Department используют один и тот же service-path +- [x] Удалены вызовы устаревших private-методов retry-пути (`_move_user_to_department`, `_move_user_to_base_ou`) +- [x] `Department` diff теперь формируется до `save()` через `_ldap_changes` +- [x] Head sync теперь передаётся явно через `_ldap_sync_head` +- [x] Добавлены регрессионные тесты для: + - `department_member` retry (`is_active=True/False`) + - `department_save` retry + - API/view diff для `name`, `description`, `head` + ## ✅ Завершено (19 марта 2026) ### Фаза 1: Фундамент архитектуры @@ -44,9 +61,11 @@ - [x] Добавить логирование через _log_operation() - [x] Рефакторить _touch_state на использование BaseService - [ ] Использовать GroupServiceRefactored +- [x] Ввести единый Department sync contract для signals + retry +- [x] Перенести `department_save`, `department_delete`, `department_member` на canonical service-path **Статус:** ✅ Завершено (кроме миграции на GroupServiceRefactored) -**Дата:** 19 марта 2026 г. +**Дата:** 10 апреля 2026 г. #### PositionService - [x] Наследовать от BaseService @@ -83,7 +102,7 @@ - [ ] Создать unit-тесты для BaseService - [ ] Создать unit-тесты для подсервисов (Password, Login, Mapper) - [ ] Создать интеграционные тесты для GroupServiceRefactored -- [ ] Обновить существующие тесты +- [x] Обновить существующие тесты для Department retry/diff - [ ] Проверить покрытие тестами (цель: >80%) **Оценка:** 6-8 часов @@ -150,12 +169,12 @@ | UserLoginService | ✅ Готов | 100% | | UserMapperService | ✅ Готов | 100% | | GroupServiceRefactored | ✅ Готов | 100% | -| DepartmentService | ✅ Рефакторен | 90% | +| DepartmentService | ✅ Рефакторен | 95% | | PositionService | ✅ Рефакторен | 90% | | UserService | 🔄 Legacy | 0% | | SyncService | 🔄 Legacy | 0% | -**Общий прогресс:** 75% завершено (было 60%) +**Общий прогресс:** 80% завершено --- @@ -210,5 +229,5 @@ --- -**Последнее обновление:** 19 марта 2026 г. +**Последнее обновление:** 10 апреля 2026 г. **Статус проекта:** 🟢 Активный рефакторинг diff --git a/backend/docs/guides/LDAP_SERVICES_REFACTORING.md b/backend/docs/guides/LDAP_SERVICES_REFACTORING.md index 8fe6d2ab..98832b8a 100644 --- a/backend/docs/guides/LDAP_SERVICES_REFACTORING.md +++ b/backend/docs/guides/LDAP_SERVICES_REFACTORING.md @@ -1,9 +1,24 @@ # Рефакторинг LDAP-сервисов: Архитектурные улучшения -**Дата:** 19 марта 2026 г. +**Дата:** 10 апреля 2026 г. **Статус:** В процессе миграции **Цель:** Улучшение архитектуры, уменьшение дублирования, следование SOLID принципам +## Обновление 10 апреля 2026: canonical Department sync + +Для департаментов и членства в отделах введён единый контракт синхронизации: + +- `DepartmentService.sync_department_state(dept, *, created, changes, sync_head)` +- `DepartmentService.sync_department_delete(*, object_pk, dept_dn=None)` +- `DepartmentService.sync_member_state(employee, department, *, is_active, role=None)` + +Ключевые правила после обновления: + +- Django DB является источником истины для `Department`, `EmployeeDepartment.is_active` и role assignment. +- `employees/signals/ldap/department.py` и retry-исполнители в `employees/tasks.py` больше не содержат отдельной LDAP-логики для отдела. +- View-слой фиксирует diff через `_ldap_changes` и `_ldap_sync_head` до `save()`, чтобы post-save не вычислял изменения по уже обновлённой строке. +- Retry-очередь воспроизводит тот же canonical sync, что и live signals, а не отдельный legacy-сценарий. + --- ## Обзор изменений @@ -200,7 +215,8 @@ with _ldap() as conn: ### Этап 2: Постепенная миграция (следующий шаг) -🔲 Обновить DepartmentService для использования BaseService +✅ Обновить DepartmentService для использования BaseService +✅ Выделить canonical sync path для Department signals + retry 🔲 Обновить PositionService для использования BaseService 🔲 Рефакторить UserService с использованием подсервисов 🔲 Обновить вызовы GroupService на новый API diff --git a/backend/docs/reports/LDAP_MODULE_AUDIT.md b/backend/docs/reports/LDAP_MODULE_AUDIT.md index ce3a1508..0c4b03cc 100644 --- a/backend/docs/reports/LDAP_MODULE_AUDIT.md +++ b/backend/docs/reports/LDAP_MODULE_AUDIT.md @@ -1,7 +1,25 @@ # Аудит модуля employees/ldap -**Дата:** 18 марта 2026 г. -**Контекст:** После рефакторинга ViewSets (удалено -5576 строк) +**Дата:** 10 апреля 2026 г. +**Контекст:** Аудит после выравнивания Department LDAP sync и retry-очереди + +## Обновление 10 апреля 2026 + +### Каноническая модель Department sync + +- Источник истины для `Department`, `EmployeeDepartment.is_active` и ролей отдела теперь находится в Django DB. +- `employees/signals/ldap/department.py` и `employees/tasks.py` больше не содержат отдельную handwritten LDAP-логику для отдела. +- Оба пути делегируют в `DepartmentService`: + - `sync_department_state()` + - `sync_department_delete()` + - `sync_member_state()` +- Retry-очередь больше не вызывает устаревшие private-методы вроде `_move_user_to_department` и `_move_user_to_base_ou`. +- Diff для `Department` формируется до `save()` через временные атрибуты `_ldap_changes` и `_ldap_sync_head`; post-save больше не пытается вычислять изменения повторным чтением уже сохранённой строки. + +### Остаточные замечания + +- `DepartmentService` по-прежнему содержит legacy CRUD API, которое мутирует DB (`add_member`, `remove_member`, `set_member_role`), но live sync и retry используют только sync-oriented методы. +- `UserService` и часть legacy service-слоя всё ещё требуют отдельной консолидации. ## Текущее использование @@ -10,17 +28,14 @@ #### DirectoryService (employees/ldap/directory_service.py) Используется в: - `api/v1/employees/views/auth.py` - RegisterAPIView -- `employees/signals_ldap.py` - синхронизация Employee -- `employees/signals_department.py` - синхронизация Department -- `employees/signals_group.py` - синхронизация Group +- `employees/signals/ldap/employee.py` - синхронизация Employee +- `employees/signals/ldap/group.py` - синхронизация Group - `common/ldap_password_mixin.py` - смена пароля **Используемые методы:** - `create_user()` - auth.py (регистрация новых пользователей) - `update_user()` - signals_ldap.py, ldap_password_mixin.py - `delete_user()` - signals_ldap.py -- `update_department()` - signals_department.py -- `delete_department()` - signals_department.py - `group_create()` - signals_group.py - `group_delete()` - signals_group.py - `group_rename()` - signals_group.py @@ -31,9 +46,9 @@ #### Services (employees/ldap/services/*) Используются в сигналах через ленивый импорт: -- `DepartmentService` - signals_department.py -- `GroupService` - signals_department.py -- `UserService` - signals_department.py +- `DepartmentService` - `employees/signals/ldap/department.py`, `employees/tasks.py` +- `GroupService` - через `DepartmentService`, `employees/signals/ldap/group.py` +- `UserService` - через `DepartmentService`, `employees/signals/ldap/employee.py` - `SyncService` - management/commands/sync_directory.py #### ORM Models (employees/ldap/orm_models.py) diff --git a/backend/employees/ldap/services/department_service.py b/backend/employees/ldap/services/department_service.py index 96f818f2..b6b09415 100644 --- a/backend/employees/ldap/services/department_service.py +++ b/backend/employees/ldap/services/department_service.py @@ -11,7 +11,6 @@ from typing import Any, Dict, List, Optional, Tuple from django.conf import settings from django.db import transaction from django.utils import timezone -from ldap3 import BASE, Connection from ...models import ( Department, @@ -27,14 +26,13 @@ from ..errors import ( DirectoryLdapError, DirectoryServiceError, ) -from ..orm_models import LdapOrganizationalUnit -from ..infrastructure.connections import _ldap -from ..repositories.ldap_repository import ( - ensure_container_exists, +from ..orm_models import ( + LdapOrganizationalUnit, + LdapOrganizationalUnitGroup, + LdapUser, ) -from ..utils.dn_utils import _move_to_department -from ..utils.ldap_utils import group_type -from ..utils.text_utils import esc_filter, esc_rdn +from ..utils.ldap_utils import get_base_dn_for_employee +from ..utils.text_utils import esc_rdn from .base_service import BaseService from .constants import SyncDirection @@ -60,8 +58,257 @@ class DepartmentService(BaseService): self._group_service = group_service self._user_service = user_service + def _ensure_runtime_services(self) -> None: + """Ленивая инициализация зависимостей для sync/retry путей.""" + if self._group_service is None: + from .group_service import GroupService + + self._group_service = GroupService() + + if self._user_service is None: + from .user_service import UserService + + self._user_service = UserService(self._group_service) + + def _load_ldap_user( + self, + employee: Employee, + *, + expected_dn: Optional[str] = None, + ) -> LdapUser: + """Возвращает high-level LDAP модель пользователя. + + Ищет сначала по DN из sync state, затем по employeeNumber. + Если объект найден не по ожидаемому DN, корректирует sync state. + """ + candidate_dn = (expected_dn or "").strip() + ldap_user: Optional[LdapUser] = None + + if candidate_dn: + try: + ldap_user = LdapUser.objects.get(dn=candidate_dn) + except LdapUser.DoesNotExist: + ldap_user = None + + if ldap_user is None: + ldap_user = LdapUser.objects.filter( + employee_number=str(employee.pk) + ).first() + + if ldap_user is None: + raise DirectoryServiceError( + f"LDAP user not found for employee {employee.pk}" + ) + + actual_dn = str(ldap_user.dn) + if actual_dn != candidate_dn: + self._touch_state( + model="employee", + object_pk=employee.pk, + ldap_dn=actual_dn, + sync_dir=SyncDirection.AUTO, + ) + + return ldap_user + + def _move_employee_to_base_dn( + self, + employee: Employee, + target_base_dn: str, + *, + current_dn: Optional[str] = None, + ) -> str: + """Перемещает сотрудника через ORM-модель LdapUser.""" + ldap_user = self._load_ldap_user(employee, expected_dn=current_dn) + _, current_parent = self._split_rdn_parent(str(ldap_user.dn)) + if current_parent.lower() == target_base_dn.lower(): + return str(ldap_user.dn) + + ldap_user.base_dn = target_base_dn + ldap_user.save() + return str(ldap_user.dn) + + def _load_ou_group(self, group_dn: str) -> LdapOrganizationalUnitGroup: + """Возвращает high-level LDAP модель группы внутри OU отдела.""" + try: + return LdapOrganizationalUnitGroup.objects.get(dn=group_dn) + except LdapOrganizationalUnitGroup.DoesNotExist as e: + raise DirectoryServiceError(f"Group not found: {group_dn}") from e + + def _add_ou_group_member(self, group_dn: str, member_dn: str) -> None: + """Добавляет DN в группу отдела/роли через ORM модель.""" + group = self._load_ou_group(group_dn) + members = list(group.member or []) + if member_dn not in members: + members.append(member_dn) + group.member = members + group.save() + + def _remove_ou_group_member(self, group_dn: str, member_dn: str) -> None: + """Удаляет DN из группы отдела/роли через ORM модель.""" + group = self._load_ou_group(group_dn) + members = list(group.member or []) + if member_dn in members: + members.remove(member_dn) + group.member = members + group.save() + # ==================== Public API ==================== # + def sync_department_state( + self, + dept: Department, + *, + created: bool, + changes: Optional[Dict[str, Any]] = None, + sync_head: bool = False, + ) -> str: + """Приводит LDAP состояние отдела к текущему состоянию в Django. + + Метод предназначен для сигналов и retry-очереди: Django уже содержит + истинное состояние, а здесь выполняется только LDAP reconcile. + """ + self._ensure_runtime_services() + + normalized_changes = { + key: value for key, value in (changes or {}).items() if key in {"name", "description"} + } + + sync_state = LdapSyncState.objects.filter( + model="department", object_pk=str(dept.pk) + ).first() + needs_bootstrap = created or not sync_state or not sync_state.ldap_dn + + if needs_bootstrap: + dept_dn = self._ensure_department_ou(dept.name) + self._touch_state( + model="department", + object_pk=dept.pk, + ldap_dn=dept_dn, + last_django_modify_ts=timezone.now(), + sync_dir=SyncDirection.AUTO, + ) + self._set_ou_description(dept_dn, dept.description or "") + head_dn = None + if dept.head_id: + try: + head_dn = self._user_service._get_employee_dn(dept.head) + except Exception: + head_dn = None + self._set_ou_managed_by(dept_dn, head_dn) + self._reconcile_department_group(dept, dept_dn) + return dept_dn + + update_changes: Dict[str, Any] = {} + if "name" in normalized_changes: + update_changes["name"] = dept.name + if "description" in normalized_changes: + update_changes["description"] = dept.description + if sync_head: + update_changes["head"] = dept.head + + if update_changes: + self.update_department(dept, update_changes) + + return self._get_department_dn(dept) + + def sync_department_delete( + self, + *, + object_pk: str | int, + dept_dn: Optional[str] = None, + ) -> None: + """Удаляет LDAP следы отдела после удаления Django-записи.""" + self._ensure_runtime_services() + + object_pk = str(object_pk) + resolved_dept_dn = dept_dn or ( + LdapSyncState.objects.filter( + model="department", object_pk=object_pk + ) + .values_list("ldap_dn", flat=True) + .first() + ) + if not resolved_dept_dn: + return + + self._evict_all_users_from_department_ou(resolved_dept_dn) + + for role in DepartmentRole.objects.filter( + department_id=object_pk + ).exclude(ldap_group_dn=""): + self.delete_role_group(role) + + dept = Department.objects.filter(pk=object_pk).first() + if dept and dept.ldap_group_dn: + try: + self._load_ou_group(dept.ldap_group_dn).delete() + except Exception: + pass + + self._delete_department_ou(resolved_dept_dn) + + LdapSyncState.objects.filter( + model="department", object_pk=object_pk + ).delete() + + def sync_member_state( + self, + employee: Employee, + department: Department, + *, + is_active: bool, + role: Any = None, + ) -> None: + """Приводит LDAP-состояние членства сотрудника в отделе к DB state.""" + self._ensure_runtime_services() + + try: + emp_dn = self._user_service._get_employee_dn(employee) + except Exception: + self._logger.debug( + "Employee %s has no LDAP DN, skipping member sync", + employee.pk, + ) + return + + if is_active: + dept_dn = self.sync_department_state( + department, + created=False, + changes=None, + sync_head=False, + ) + emp_dn = self._move_employee_to_base_dn( + employee, + dept_dn, + current_dn=emp_dn, + ) + group_dn = self._ensure_department_group(department, dept_dn) + if group_dn: + self._add_ou_group_member(group_dn, emp_dn) + else: + grp_dn = (department.ldap_group_dn or "").strip() + if grp_dn: + try: + self._remove_ou_group_member(grp_dn, emp_dn) + except Exception: + pass + + target_base = get_base_dn_for_employee(employee) + emp_dn = self._move_employee_to_base_dn( + employee, + target_base, + current_dn=emp_dn, + ) + + self._reconcile_member_role_memberships( + employee, + department, + is_active=is_active, + role=role, + ) + def create_department(self, dto: DirectoryDepartmentDTO) -> Department: """Создаёт OU отдела в LDAP и согласованную запись Department в БД. @@ -76,69 +323,61 @@ class DepartmentService(BaseService): DirectoryDbError: Не удалось создать запись в БД (OU будет удалён). """ - # Используем GroupService через Dependency Injection - if not self._group_service: - raise RuntimeError( - "GroupService not initialized in DepartmentService" - ) + self._ensure_runtime_services() - with _ldap() as conn: - dept_dn: Optional[str] = None - group_dn: Optional[str] = None - try: - dept_dn = self._ensure_department_ou(conn, dto.name) - self._set_ou_description(conn, dept_dn, dto.description or "") - head_dn = None - if dto.head: - try: - head_dn = self._user_service._get_employee_dn(dto.head) - except Exception: - head_dn = None - self._set_ou_managed_by(conn, dept_dn, head_dn) - group_dn = self._group_service.create( - cn=dto.name, - parent_dn=dept_dn, - description=f"{dto.name} department members", - scope="global", - security_enabled=True, - ) - except Exception as e: - raise DirectoryLdapError(f"LDAP create OU failed: {e}") from e - - try: - with transaction.atomic(): - dept = Department.objects.create( - name=dto.name, - description=dto.description or "", - head=dto.head or None, - ldap_group_dn=group_dn or "", - ) - self._reconcile_department_group(conn, dept, dept_dn) - self._touch_state( - model="department", - object_pk=dept.pk, - ldap_dn=dept_dn, - last_django_modify_ts=timezone.now(), - sync_dir=SyncDirection.DJANGO, - ) - self._log_operation( - "create", - model="department", - object_id=dept.pk, - dn=dept_dn, - success=True, - ) - except Exception as e: + dept_dn: Optional[str] = None + group_dn: Optional[str] = None + try: + dept_dn = self._ensure_department_ou(dto.name) + self._set_ou_description(dept_dn, dto.description or "") + head_dn = None + if dto.head: try: - if group_dn: - self._group_service.delete(group_dn) - if dept_dn: - self._delete_department_ou(conn, dept_dn) - finally: - pass - raise DirectoryDbError(str(e)) from e + head_dn = self._user_service._get_employee_dn(dto.head) + except Exception: + head_dn = None + self._set_ou_managed_by(dept_dn, head_dn) + group_dn = self._ensure_department_group( + Department(name=dto.name, ldap_group_dn=""), + dept_dn, + ) + except Exception as e: + raise DirectoryLdapError(f"LDAP create OU failed: {e}") from e - return dept + try: + with transaction.atomic(): + dept = Department.objects.create( + name=dto.name, + description=dto.description or "", + head=dto.head or None, + ldap_group_dn=group_dn or "", + ) + self._reconcile_department_group(dept, dept_dn) + self._touch_state( + model="department", + object_pk=dept.pk, + ldap_dn=dept_dn, + last_django_modify_ts=timezone.now(), + sync_dir=SyncDirection.DJANGO, + ) + self._log_operation( + "create", + model="department", + object_id=dept.pk, + dn=dept_dn, + success=True, + ) + except Exception as e: + try: + if group_dn: + self._load_ou_group(group_dn).delete() + if dept_dn: + self._delete_department_ou(dept_dn) + finally: + pass + raise DirectoryDbError(str(e)) from e + + return dept def update_department( self, dept: Department, changes: Dict[str, Any] @@ -157,6 +396,8 @@ class DepartmentService(BaseService): DirectoryLdapError: Ошибка при обновлении в LDAP. DirectoryDbError: Ошибка при сохранении в БД. """ + self._ensure_runtime_services() + try: current_dn = self._get_department_dn(dept) except DirectoryServiceError as e: @@ -165,139 +406,76 @@ class DepartmentService(BaseService): ) from e new_dn = current_dn + target_name = str(changes.get("name") or dept.name or "").strip() + target_description = changes.get("description", dept.description) + target_head = changes.get("head", dept.head) - with _ldap() as conn: - try: - self._reconcile_department_group(conn, dept, new_dn) - name_changed = bool( - "name" in changes - and changes["name"] - and changes["name"] != dept.name - ) - if name_changed: - new_dn = self._rename_department_ou( - conn, current_dn, changes["name"] - ) + try: + ou = self._get_department_ou(current_dn) + desired_rdn = f"OU={target_name}" if target_name else "" + current_rdn = current_dn.split(",", 1)[0] + name_changed = bool( + "name" in changes and target_name and current_rdn != desired_rdn + ) - if "description" in changes: - self._set_ou_description( - conn, new_dn, changes.get("description") - ) - - if "head" in changes: - head = changes.get("head") - head_dn = None - if head: - try: - head_dn = self._user_service._get_employee_dn(head) - except Exception: - head_dn = None - self._set_ou_managed_by(conn, new_dn, head_dn) - - # Переименуем одноименную группу, если меняем название отдела - if name_changed: + if name_changed: + ou.ou = target_name + if "description" in changes: + ou.description = target_description or "" + if "head" in changes: + head_dn = None + if target_head: try: - group_dn = (dept.ldap_group_dn or "").strip() - if group_dn: - ok = conn.search( - group_dn, - "(objectClass=group)", - search_scope=BASE, - attributes=["distinguishedName"], - ) - if ok and conn.entries: - try: - new_group_dn = self._group_service.rename( - group_dn, new_cn=changes["name"] - ) - except Exception: - desc = ( - getattr(conn, "result", {}) or {} - ).get("description", "") - if desc == "entryAlreadyExists": - maybe = self._group_service.find_dn( - changes["name"], bases=[new_dn] - ) - if maybe: - new_group_dn = maybe - else: - raise - else: - group_dn = "" - if not group_dn: - ensured_dn = self._ensure_department_group( - conn, dept, new_dn - ) - try: - dep_cn = f"DEP_{changes['name']}" - new_group_dn = self._group_service.rename( - ensured_dn, new_cn=dep_cn - ) - except Exception: - desc = (getattr(conn, "result", {}) or {}).get( - "description", "" - ) - if desc == "entryAlreadyExists": - maybe = self._group_service.find_dn( - dep_cn, bases=[new_dn] - ) - if maybe: - new_group_dn = maybe - else: - raise - if new_group_dn != dept.ldap_group_dn: - Department.objects.filter(pk=dept.pk).update( - ldap_group_dn=new_group_dn - ) - dept.ldap_group_dn = new_group_dn - except Exception as e: - raise DirectoryLdapError( - f"LDAP rename department group failed: {e}" - ) from e + head_dn = self._user_service._get_employee_dn(target_head) + except Exception: + head_dn = None + ou.managed_by = head_dn or "" + if name_changed or "description" in changes or "head" in changes: + ou.save() + new_dn = str(ou.dn) + except Exception as e: + raise DirectoryLdapError(f"LDAP update OU failed: {e}") from e - except Exception as e: - raise DirectoryLdapError(f"LDAP update OU failed: {e}") from e + try: + with transaction.atomic(): + upd_fields: List[str] = [] + for k, v in changes.items(): + if hasattr(dept, k) and getattr(dept, k) != v: + setattr(dept, k, v) + upd_fields.append(k) + if upd_fields: + dept.save(update_fields=list(set(upd_fields))) + self._touch_state( + model="department", + object_pk=dept.pk, + **( + { + "ldap_dn": new_dn, + "last_django_modify_ts": timezone.now(), + "sync_dir": "django", + } + if new_dn != current_dn + else { + "last_django_modify_ts": timezone.now(), + "sync_dir": "django", + } + ), + ) + except Exception as e: + raise DirectoryDbError(str(e)) from e + + if new_dn != current_dn: try: - with transaction.atomic(): - upd_fields: List[str] = [] - for k, v in changes.items(): - if hasattr(dept, k) and getattr(dept, k) != v: - setattr(dept, k, v) - upd_fields.append(k) - if upd_fields: - dept.save(update_fields=list(set(upd_fields))) - - self._touch_state( - model="department", - object_pk=dept.pk, - **( - { - "ldap_dn": new_dn, - "last_django_modify_ts": timezone.now(), - "sync_dir": "django", - } - if new_dn != current_dn - else { - "last_django_modify_ts": timezone.now(), - "sync_dir": "django", - } - ), - ) + self._sync_descendant_dns_after_ou_rename( + old_dn=current_dn, new_dn=new_dn + ) except Exception as e: - raise DirectoryDbError(str(e)) from e - - if new_dn != current_dn: - try: - self._sync_descendant_dns_after_ou_rename( - old_dn=current_dn, new_dn=new_dn - ) - except Exception as e: - raise DirectoryDbError( - f"Cascade DN rewrite failed: {e}" - ) from e - self._reconcile_department_group(conn, dept, new_dn) - return dept + raise DirectoryDbError( + f"Cascade DN rewrite failed: {e}" + ) from e + self._reconcile_department_group(dept, new_dn) + return dept def delete_department(self, dept: Department) -> None: """Удаляет отдел: исключает сотрудников → удаляет группу → OU → БД. @@ -309,45 +487,46 @@ class DepartmentService(BaseService): DirectoryLdapError: Ошибка при удалении в LDAP. DirectoryDbError: Ошибка при удалении из БД. """ - with _ldap() as conn: - try: - dept_dn = self._get_department_dn(dept) - except DirectoryServiceError: - dept_dn = None + self._ensure_runtime_services() - try: - if dept.ldap_group_dn: - self._group_service.delete(dept.ldap_group_dn) - except Exception: - pass + try: + dept_dn = self._get_department_dn(dept) + except DirectoryServiceError: + dept_dn = None - if dept_dn: - try: - self._evict_all_users_from_department_ou(conn, dept_dn) - self._delete_department_ou(conn, dept_dn) - except Exception as e: - raise DirectoryLdapError( - f"LDAP delete OU failed: {e}" - ) from e + try: + if dept.ldap_group_dn: + self._load_ou_group(dept.ldap_group_dn).delete() + except Exception: + pass + if dept_dn: try: - with transaction.atomic(): - pk = dept.pk - dept_name = dept.name - dept.delete() - LdapSyncState.objects.filter( - model="department", object_pk=str(pk) - ).delete() - self._log_operation( - "delete", - model="department", - object_id=pk, - dn=dept_dn, - success=True, - extra={"name": dept_name}, - ) + self._evict_all_users_from_department_ou(dept_dn) + self._delete_department_ou(dept_dn) except Exception as e: - raise DirectoryDbError(str(e)) from e + raise DirectoryLdapError( + f"LDAP delete OU failed: {e}" + ) from e + + try: + with transaction.atomic(): + pk = dept.pk + dept_name = dept.name + dept.delete() + LdapSyncState.objects.filter( + model="department", object_pk=str(pk) + ).delete() + self._log_operation( + "delete", + model="department", + object_id=pk, + dn=dept_dn, + success=True, + extra={"name": dept_name}, + ) + except Exception as e: + raise DirectoryDbError(str(e)) from e def add_member(self, dept: Department, employee: Employee) -> None: """Добавляет сотрудника в OU отдела и в одноименную группу отдела. @@ -361,6 +540,8 @@ class DepartmentService(BaseService): DirectoryLdapError: Ошибка при добавлении в LDAP. DirectoryDbError: Ошибка при создании связи в БД. """ + self._ensure_runtime_services() + existing_link = EmployeeDepartment.objects.filter( employee_id=employee.id, is_active=True ).first() @@ -372,65 +553,53 @@ class DepartmentService(BaseService): ) emp_dn = self._user_service._get_employee_dn(employee) - with _ldap() as conn: + try: + dept_dn = None try: + dept_dn = self._get_department_dn(dept) + except DirectoryServiceError: dept_dn = None - try: - dept_dn = self._get_department_dn(dept) - except DirectoryServiceError: - dept_dn = None - ensured_dn = self._ensure_department_ou(conn, dept.name) - if not dept_dn or dept_dn != ensured_dn: - self._touch_state( - model="department", - object_pk=dept.pk, - ldap_dn=ensured_dn, - last_django_modify_ts=timezone.now(), - sync_dir=SyncDirection.AUTO, - ) - dept_dn = ensured_dn - except Exception as e: - raise DirectoryLdapError(f"LDAP ensure OU failed: {e}") from e + ensured_dn = self._ensure_department_ou(dept.name) + if not dept_dn or dept_dn != ensured_dn: + self._touch_state( + model="department", + object_pk=dept.pk, + ldap_dn=ensured_dn, + last_django_modify_ts=timezone.now(), + sync_dir=SyncDirection.AUTO, + ) + dept_dn = ensured_dn + except Exception as e: + raise DirectoryLdapError(f"LDAP ensure OU failed: {e}") from e - _, emp_parent = self._split_rdn_parent(emp_dn) - if emp_parent != dept_dn: - new_emp_dn = _move_to_department(conn, emp_dn, dept_dn) - try: - self._touch_state( - model="employee", - object_pk=employee.pk, - ldap_dn=new_emp_dn, - sync_dir=SyncDirection.LDAP, - ) - emp_dn = new_emp_dn - except Exception as e: - raise DirectoryDbError( - f"Failed to save new DN in DB: {e}" - ) from e + try: + emp_dn = self._move_employee_to_base_dn( + employee, + dept_dn, + current_dn=emp_dn, + ) + group_dn = self._ensure_department_group(dept, dept_dn) + if not group_dn: + raise RuntimeError( + "_ensure_department_group returned empty DN" + ) + self._add_ou_group_member(group_dn, emp_dn) + except Exception as e: + raise DirectoryLdapError( + f"LDAP add to department group failed: {e}" + ) from e - try: - group_dn = self._ensure_department_group(conn, dept, dept_dn) - if not group_dn: - raise RuntimeError( - "_ensure_department_group returned empty DN" - ) - self._group_service.add_members(group_dn, [emp_dn]) - except Exception as e: - raise DirectoryLdapError( - f"LDAP add to department group failed: {e}" - ) from e - - try: - with transaction.atomic(): - link, _ = EmployeeDepartment.objects.get_or_create( - employee_id=employee.id, - department_id=dept.id, - defaults={"is_active": True}, - ) - link.is_active = True - link.save(update_fields=["is_active"]) - except Exception as e: - raise DirectoryDbError(str(e)) from e + try: + with transaction.atomic(): + link, _ = EmployeeDepartment.objects.get_or_create( + employee_id=employee.id, + department_id=dept.id, + defaults={"is_active": True}, + ) + link.is_active = True + link.save(update_fields=["is_active"]) + except Exception as e: + raise DirectoryDbError(str(e)) from e def remove_member(self, dept: Department, employee: Employee) -> None: """Удаляет сотрудника из отдела: @@ -445,6 +614,8 @@ class DepartmentService(BaseService): DirectoryLdapError: Ошибка при удалении из LDAP. DirectoryDbError: Ошибка при удалении связи в БД. """ + self._ensure_runtime_services() + if dept.head_id == employee.id: raise DirectoryServiceError("Нельзя удалить руководителя отдела") @@ -453,55 +624,40 @@ class DepartmentService(BaseService): except DirectoryServiceError: emp_dn = None - with _ldap() as conn: + try: + grp_dn = (dept.ldap_group_dn or "").strip() + if grp_dn and emp_dn: + self._remove_ou_group_member(grp_dn, emp_dn) + except Exception: + pass + + if emp_dn: try: - grp_dn = (dept.ldap_group_dn or "").strip() - if grp_dn and emp_dn: - ok = conn.search( - search_base=grp_dn, - search_filter="(objectClass=group)", - search_scope=BASE, - attributes=["distinguishedName"], - ) - if ok and conn.entries: - self._group_service.remove_members(grp_dn, [emp_dn]) - except Exception: - pass - - if emp_dn: - from ..utils.ldap_utils import get_base_dn_for_employee - - try: - 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, target_base - ) - self._touch_state( - model="employee", - object_pk=employee.id, - ldap_dn=new_dn, - sync_dir=SyncDirection.LDAP, - ) - except Exception as e: - target_name = ( - "Dismissed OU" if not employee.is_active else "Users OU" - ) - raise DirectoryLdapError( - f"LDAP move to {target_name} failed: {e}" - ) from e + target_base = get_base_dn_for_employee(employee) + except RuntimeError as e: + raise DirectoryLdapError(str(e)) from e try: - with transaction.atomic(): - EmployeeDepartment.objects.filter( - employee_id=employee.id, department_id=dept.id - ).delete() + self._move_employee_to_base_dn( + employee, + target_base, + current_dn=emp_dn, + ) except Exception as e: - raise DirectoryDbError(str(e)) from e + target_name = ( + "Dismissed OU" if not employee.is_active else "Users OU" + ) + raise DirectoryLdapError( + f"LDAP move to {target_name} failed: {e}" + ) from e + + try: + with transaction.atomic(): + EmployeeDepartment.objects.filter( + employee_id=employee.id, department_id=dept.id + ).delete() + except Exception as e: + raise DirectoryDbError(str(e)) from e def set_head( self, dept: Department, head: Optional[Employee] @@ -520,50 +676,51 @@ class DepartmentService(BaseService): DirectoryLdapError: Ошибка при записи в LDAP. DirectoryDbError: Ошибка при сохранении в БД. """ - with _ldap() as conn: + self._ensure_runtime_services() + + try: + dept_dn = None try: + dept_dn = self._get_department_dn(dept) + except DirectoryServiceError: dept_dn = None - try: - dept_dn = self._get_department_dn(dept) - except DirectoryServiceError: - dept_dn = None - ensured_dn = self._ensure_department_ou(conn, dept.name) - if not dept_dn or dept_dn != ensured_dn: - self._touch_state( - model="department", - object_pk=dept.pk, - ldap_dn=ensured_dn, - last_django_modify_ts=timezone.now(), - sync_dir=SyncDirection.AUTO, - ) - dept_dn = ensured_dn - except Exception as e: - raise DirectoryLdapError( - f"LDAP ensure/get department OU failed: {e}" - ) from e + ensured_dn = self._ensure_department_ou(dept.name) + if not dept_dn or dept_dn != ensured_dn: + self._touch_state( + model="department", + object_pk=dept.pk, + ldap_dn=ensured_dn, + last_django_modify_ts=timezone.now(), + sync_dir=SyncDirection.AUTO, + ) + dept_dn = ensured_dn + except Exception as e: + raise DirectoryLdapError( + f"LDAP ensure/get department OU failed: {e}" + ) from e - head_dn: Optional[str] = None - if head is not None: - head_dn = self._user_service._get_employee_dn(head) - if not head_dn: - raise DirectoryServiceError("Head has no ldap_dn") + head_dn: Optional[str] = None + if head is not None: + head_dn = self._user_service._get_employee_dn(head) + if not head_dn: + raise DirectoryServiceError("Head has no ldap_dn") - try: - self._set_ou_managed_by(conn, dept_dn, head_dn) - except Exception as e: - raise DirectoryLdapError( - f"LDAP set managedBy failed: {e}" - ) from e + try: + self._set_ou_managed_by(dept_dn, head_dn) + except Exception as e: + raise DirectoryLdapError( + f"LDAP set managedBy failed: {e}" + ) from e - try: - with transaction.atomic(): - dept.head = head - dept.save(update_fields=["head"]) - except Exception as e: - raise DirectoryDbError(str(e)) from e - self._reconcile_department_group(conn, dept, dept_dn) + try: + with transaction.atomic(): + dept.head = head + dept.save(update_fields=["head"]) + except Exception as e: + raise DirectoryDbError(str(e)) from e + self._reconcile_department_group(dept, dept_dn) - return dept + return dept # ==================== Role CRUD (LDAP → DB) ==================== # @@ -592,52 +749,47 @@ class DepartmentService(BaseService): DirectoryLdapError: Ошибка создания группы в LDAP. DirectoryDbError: Ошибка создания записи в БД. """ + self._ensure_runtime_services() + # 1. Создаём группу в LDAP dept_dn = self._get_department_dn(department) role_name = self._sanitize_name(name) cn = f"ROLE_{role_name}" group_dn = f"CN={esc_rdn(cn)},{dept_dn}" - with _ldap() as conn: - try: - attrs = { - "sAMAccountName": cn[:20], - "description": description + try: + if not LdapOrganizationalUnitGroup.objects.filter(dn=group_dn).exists(): + role_group = LdapOrganizationalUnitGroup( + dn=group_dn, + cn=cn, + sam_account_name=cn[:20], + description=description or f"Role: {name} in {department.name}", - "groupType": group_type("global", security_enabled=True), - } - ok = conn.add(group_dn, ["top", "group"], attrs) - if not ok: - if "entryAlreadyExists" not in str(conn.result): - raise DirectoryLdapError( - f"LDAP create role group failed: {conn.result}" - ) - except DirectoryLdapError: - raise - except Exception as e: - raise DirectoryLdapError( - f"LDAP create role group failed: {e}" - ) from e + ) + role_group.save() + except Exception as e: + raise DirectoryLdapError( + f"LDAP create role group failed: {e}" + ) from e - # 2. Создаём запись в БД + # 2. Создаём запись в БД + try: + with transaction.atomic(): + role = DepartmentRole.objects.create( + department=department, + name=name, + ldap_group_dn=group_dn, + ) + if scoped_permissions: + role.scoped_permissions.set(scoped_permissions) + except Exception as e: try: - with transaction.atomic(): - role = DepartmentRole.objects.create( - department=department, - name=name, - ldap_group_dn=group_dn, - ) - if scoped_permissions: - role.scoped_permissions.set(scoped_permissions) - except Exception as e: - # Откатываем группу в LDAP - try: - self._group_service.delete(group_dn) - except Exception: - pass - raise DirectoryDbError(str(e)) from e + self._load_ou_group(group_dn).delete() + except Exception: + pass + raise DirectoryDbError(str(e)) from e - return role + return role def update_role( self, @@ -658,6 +810,8 @@ class DepartmentService(BaseService): DirectoryLdapError: Ошибка обновления в LDAP. DirectoryDbError: Ошибка сохранения в БД. """ + self._ensure_runtime_services() + new_name = changes.get("name") # Если меняется имя — переименовываем группу в LDAP @@ -666,9 +820,12 @@ class DepartmentService(BaseService): try: new_role_name = self._sanitize_name(new_name) new_cn = f"ROLE_{new_role_name}" - new_dn = self._group_service.rename( - role.ldap_group_dn, new_cn=new_cn + role_group = LdapOrganizationalUnitGroup.objects.get( + dn=role.ldap_group_dn ) + role_group.cn = new_cn + role_group.save() + new_dn = str(role_group.dn) role.ldap_group_dn = new_dn except Exception as e: raise DirectoryLdapError( @@ -711,10 +868,12 @@ class DepartmentService(BaseService): DirectoryLdapError: Ошибка удаления группы из LDAP. DirectoryDbError: Ошибка удаления записи из БД. """ + self._ensure_runtime_services() + # 1. Удаляем группу из LDAP if role.ldap_group_dn: try: - self._group_service.delete(role.ldap_group_dn) + self._load_ou_group(role.ldap_group_dn).delete() except Exception as e: raise DirectoryLdapError( f"LDAP delete role group failed: {e}" @@ -743,6 +902,8 @@ class DepartmentService(BaseService): DirectoryDbError: Ошибка при обновлении БД. DirectoryLdapError: Ошибка при синхронизации групп. """ + self._ensure_runtime_services() + # Обновляем EmployeeDepartment.role для обратной совместимости try: with transaction.atomic(): @@ -787,6 +948,8 @@ class DepartmentService(BaseService): DirectoryLdapError: Ошибка LDAP. DirectoryDbError: Ошибка БД. """ + self._ensure_runtime_services() + # 1. Сначала синхронизируем LDAP-группу роли try: self._sync_role_membership(employee, role, add=True) @@ -830,6 +993,8 @@ class DepartmentService(BaseService): Raises: DirectoryLdapError: Ошибка LDAP. """ + self._ensure_runtime_services() + # 1. Сначала удаляем из LDAP-группы try: self._sync_role_membership(employee, role, add=False) @@ -854,6 +1019,8 @@ class DepartmentService(BaseService): role: Роль. add: True — добавить в группу, False — удалить. """ + self._ensure_runtime_services() + # Создаём группу роли если нет if not role.ldap_group_dn: self._ensure_role_group(role) @@ -862,14 +1029,96 @@ class DepartmentService(BaseService): return # Не удалось создать группу user_dn = self._user_service._get_employee_dn(employee) + if add: + self._add_ou_group_member(role.ldap_group_dn, user_dn) + else: + self._remove_ou_group_member(role.ldap_group_dn, user_dn) - with _ldap(): - if add: - self._group_service.add_members(role.ldap_group_dn, [user_dn]) - else: - self._group_service.remove_members( - role.ldap_group_dn, [user_dn] - ) + def _resolve_member_roles( + self, + employee: Employee, + dept: Department, + role_hint: Any = None, + ) -> List[DepartmentRole]: + """Собирает целевой набор ролей для LDAP sync в рамках одного отдела.""" + roles_by_id: Dict[int, DepartmentRole] = {} + + def add_role(role_obj: Optional[DepartmentRole]) -> None: + if ( + role_obj is not None + and role_obj.department_id == dept.id + and role_obj.pk not in roles_by_id + ): + roles_by_id[role_obj.pk] = role_obj + + active_link = EmployeeDepartment.objects.filter( + employee_id=employee.id, + department_id=dept.id, + is_active=True, + ).select_related("role").first() + if active_link and active_link.role_id: + add_role(active_link.role) + + assigned_roles = DepartmentRole.objects.filter( + department_id=dept.id, + assignments__employee_id=employee.id, + assignments__is_active=True, + ).distinct() + for assigned_role in assigned_roles: + add_role(assigned_role) + + if isinstance(role_hint, DepartmentRole): + add_role(role_hint) + elif isinstance(role_hint, int) or ( + isinstance(role_hint, str) and role_hint.isdigit() + ): + role_obj = DepartmentRole.objects.filter( + department_id=dept.id, + pk=int(role_hint), + ).first() + add_role(role_obj) + elif isinstance(role_hint, str) and role_hint.strip(): + hint = role_hint.strip() + role_obj = DepartmentRole.objects.filter( + department_id=dept.id, + name=hint, + ).first() + if role_obj is None and ": " in hint: + role_name = hint.split(": ", 1)[1].strip() + role_obj = DepartmentRole.objects.filter( + department_id=dept.id, + name=role_name, + ).first() + add_role(role_obj) + + return list(roles_by_id.values()) + + def _reconcile_member_role_memberships( + self, + employee: Employee, + dept: Department, + *, + is_active: bool, + role: Any = None, + ) -> None: + """Синхронизирует LDAP группы ролей сотрудника по текущему DB state.""" + self._ensure_runtime_services() + + desired_role_ids = { + item.pk + for item in ( + self._resolve_member_roles(employee, dept, role) if is_active else [] + ) + } + dept_roles = list( + DepartmentRole.objects.filter(department_id=dept.id).order_by("pk") + ) + for dept_role in dept_roles: + self._sync_role_membership( + employee, + dept_role, + add=dept_role.pk in desired_role_ids, + ) def _ensure_role_group(self, role: DepartmentRole) -> str: """Гарантирует наличие группы роли ROLE_ в OU отдела. @@ -880,68 +1129,57 @@ class DepartmentService(BaseService): Returns: str: DN группы роли. """ + self._ensure_runtime_services() + dept = role.department dept_dn = self._get_department_dn(dept) - # Формат: ROLE_ (аналогично DEP_, POS_) role_name = self._sanitize_name(role.name) expected_cn = f"ROLE_{role_name}" - expected_rdn = f"CN={esc_rdn(expected_cn)}" + expected_dn = f"CN={esc_rdn(expected_cn)},{dept_dn}" saved_dn = (role.ldap_group_dn or "").strip() + role_group: Optional[LdapOrganizationalUnitGroup] = None - with _ldap() as conn: - # Проверяем существующую группу - if saved_dn: - ok = conn.search( - saved_dn, - "(objectClass=group)", - search_scope=BASE, - attributes=["distinguishedName"], + for candidate_dn in (saved_dn, expected_dn): + if not candidate_dn: + continue + try: + role_group = LdapOrganizationalUnitGroup.objects.get( + dn=candidate_dn ) - if ok and conn.entries: - # Группа существует — проверяем нужно ли переименовать - cur_rdn = saved_dn.split(",", 1)[0] - if cur_rdn != expected_rdn: - # Переименовываем - if not conn.modify_dn(saved_dn, expected_rdn): - raise RuntimeError( - f"LDAP rename role group failed: {conn.result}" - ) - base = ",".join(saved_dn.split(",")[1:]) - new_dn = f"{expected_rdn},{base}" - else: - new_dn = saved_dn + break + except LdapOrganizationalUnitGroup.DoesNotExist: + continue - if new_dn != role.ldap_group_dn: - DepartmentRole.objects.filter(pk=role.pk).update( - ldap_group_dn=new_dn - ) - role.ldap_group_dn = new_dn - return new_dn - - # Создаём новую группу - new_dn = f"{expected_rdn},{dept_dn}" - attrs = { - # SAM ограничен 20 символами - "sAMAccountName": expected_cn[:20], - "description": f"Role: {role.name} in {dept.name}", - "groupType": group_type("global", security_enabled=True), - } - ok = conn.add(new_dn, ["top", "group"], attrs) - if not ok: - if "entryAlreadyExists" in str(conn.result): - pass # Уже существует - else: - raise RuntimeError( - f"LDAP add role group failed: {conn.result}" - ) - - DepartmentRole.objects.filter(pk=role.pk).update( - ldap_group_dn=new_dn + if role_group is None: + role_group = LdapOrganizationalUnitGroup( + dn=expected_dn, + cn=expected_cn, + sam_account_name=expected_cn[:20], + description=f"Role: {role.name} in {dept.name}", ) - role.ldap_group_dn = new_dn - return new_dn + role_group.save() + else: + group_changed = False + _, current_parent = self._split_rdn_parent(str(role_group.dn)) + if current_parent.lower() != dept_dn.lower(): + role_group.base_dn = dept_dn + group_changed = True + if role_group.cn != expected_cn: + role_group.cn = expected_cn + group_changed = True + expected_description = f"Role: {role.name} in {dept.name}" + if (role_group.description or "") != expected_description: + role_group.description = expected_description + group_changed = True + if group_changed: + role_group.save() + + resolved_dn = str(role_group.dn) + DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn=resolved_dn) + role.ldap_group_dn = resolved_dn + return resolved_dn def rename_role_group(self, role: DepartmentRole, new_name: str) -> str: """Переименовывает группу роли в LDAP. @@ -953,6 +1191,8 @@ class DepartmentService(BaseService): Returns: str: Новый DN группы. """ + self._ensure_runtime_services() + if not role.ldap_group_dn: # Группы нет — создаём с новым именем role.name = new_name @@ -961,10 +1201,10 @@ class DepartmentService(BaseService): new_role_name = self._sanitize_name(new_name) new_cn = f"ROLE_{new_role_name}" - with _ldap(): - new_dn = self._group_service.rename( - role.ldap_group_dn, new_cn=new_cn - ) + role_group = LdapOrganizationalUnitGroup.objects.get(dn=role.ldap_group_dn) + role_group.cn = new_cn + role_group.save() + new_dn = str(role_group.dn) DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn=new_dn) role.ldap_group_dn = new_dn @@ -976,14 +1216,18 @@ class DepartmentService(BaseService): Args: role: Роль для удаления группы. """ + self._ensure_runtime_services() + if not role.ldap_group_dn: return - with _ldap(): - try: - self._group_service.delete(role.ldap_group_dn) - except Exception: - pass # Best effort + try: + role_group = LdapOrganizationalUnitGroup.objects.get( + dn=role.ldap_group_dn + ) + role_group.delete() + except LdapOrganizationalUnitGroup.DoesNotExist: + pass DepartmentRole.objects.filter(pk=role.pk).update(ldap_group_dn="") role.ldap_group_dn = "" @@ -1055,85 +1299,49 @@ class DepartmentService(BaseService): # ==================== Private OU Methods ==================== # - def _ensure_department_ou(self, conn: Connection, name: str) -> str: - """Гарантирует наличие OU отдела. + def _get_department_ou(self, dept_dn: str) -> LdapOrganizationalUnit: + """Возвращает high-level LDAP модель OU отдела по DN.""" + try: + return LdapOrganizationalUnit.objects.get(dn=dept_dn) + except LdapOrganizationalUnit.DoesNotExist as e: + raise DirectoryServiceError(f"OU not found: {dept_dn}") from e - Группы ролей (ROLE_*) создаются непосредственно в OU отдела, - а не в отдельном OU=Roles (убрано для корректной работы GPO). - - Args: - conn: LDAP соединение. - name: Название отдела. - - Returns: - str: DN созданного/существующего OU. - - Raises: - RuntimeError: Если не настроен LDAP_DEPARTMENTS_BASE - или ошибка создания. - """ + def _ensure_department_ou(self, name: str) -> str: + """Гарантирует наличие OU отдела через ORM-модель.""" base = getattr(settings, "LDAP_DEPARTMENTS_BASE", None) if not base: raise RuntimeError("LDAP_DEPARTMENTS_BASE is not configured") dn = f"OU={name},{base}" - ok = conn.search( - dn, "(objectClass=organizationalUnit)", search_scope=BASE - ) - if ok and conn.entries: - return dn - ok = conn.add(dn, ["top", "organizationalUnit"]) - if not ok: - raise RuntimeError(f"LDAP add OU failed: {conn.result}") - # NOTE: OU=Roles больше не создаётся. - # Группы ролей лежат прямо в OU отдела. - return dn + try: + ou = LdapOrganizationalUnit.objects.get(dn=dn) + except LdapOrganizationalUnit.DoesNotExist: + ou = LdapOrganizationalUnit( + dn=dn, + ou=name, + description="", + managed_by="", + ) + ou.save() + return str(ou.dn) - def _rename_department_ou( - self, conn: Connection, dept_dn: str, new_name: str - ) -> str: - """Переименовывает OU отдела и возвращает новый DN. + def _rename_department_ou(self, dept_dn: str, new_name: str) -> str: + """Переименовывает OU отдела через ModifyDnMixin.""" + ou = self._get_department_ou(dept_dn) + ou.ou = new_name + ou.save() + return str(ou.dn) - NOTE: Используется low-level ldap3 - для rename RDN (OU=OldName → OU=NewName). - ModifyDnMixin покрывает move (смену superior), но НЕ rename RDN. - Rename OU — это изменение атрибута ou, - что django-ldapdb не поддерживает. - - Args: - conn: LDAP соединение. - dept_dn: Текущий DN OU. - new_name: Новое имя отдела. - - Returns: - str: Новый DN после переименования. - - Raises: - RuntimeError: Если операция переименования не удалась. - """ - cn = f"OU={new_name}" - ok = conn.modify_dn(dept_dn, cn) - if not ok: - raise RuntimeError(f"LDAP rename OU failed: {conn.result}") - base = ",".join(dept_dn.split(",")[1:]) - return f"{cn},{base}" - - def _set_ou_managed_by( - self, conn: Connection, dept_dn: str, head_dn: Optional[str] - ) -> None: + def _set_ou_managed_by(self, dept_dn: str, head_dn: Optional[str]) -> None: """Устанавливает/очищает managedBy у OU в AD через ORM. Args: - conn: LDAP соединение (не используется, ORM). dept_dn: DN OU отдела. head_dn: DN руководителя или None. """ if not dept_dn: raise DirectoryServiceError("dept_dn пуст") - try: - ou = LdapOrganizationalUnit.objects.get(dn=dept_dn) - except LdapOrganizationalUnit.DoesNotExist: - raise DirectoryServiceError(f"OU not found: {dept_dn}") + ou = self._get_department_ou(dept_dn) current = (ou.managed_by or "").strip().lower() target = (head_dn or "").strip().lower() @@ -1145,12 +1353,11 @@ class DepartmentService(BaseService): ou.save() def _set_ou_description( - self, conn: Connection, dept_dn: str, description: Optional[str] + self, dept_dn: str, description: Optional[str] ) -> None: """Ставит/очищает description у OU (ORM). Args: - conn: LDAP соединение (не используется, ORM). dept_dn: DN OU отдела. description: Описание или None для удаления. """ @@ -1161,11 +1368,10 @@ class DepartmentService(BaseService): except LdapOrganizationalUnit.DoesNotExist: pass - def _delete_department_ou(self, conn: Connection, dept_dn: str) -> None: + def _delete_department_ou(self, dept_dn: str) -> None: """Удаляет OU (ORM, ignore DoesNotExist). Args: - conn: LDAP соединение (не используется, ORM). dept_dn: DN OU для удаления. """ try: @@ -1174,181 +1380,117 @@ class DepartmentService(BaseService): except LdapOrganizationalUnit.DoesNotExist: pass - def _evict_all_users_from_department_ou( - self, conn: Connection, dept_dn: str - ) -> None: - """Перемещает всех пользователей из OU отдела в Users base. + def _evict_all_users_from_department_ou(self, dept_dn: str) -> None: + """Перемещает всех пользователей из OU отдела через LdapUser ORM. Args: - conn: LDAP соединение. dept_dn: DN OU отдела. Raises: RuntimeError: Если не настроен LDAP_USERS_BASE. """ - users_base = getattr(settings, "LDAP_USERS_BASE", None) or getattr( - settings, "LDAP_USER_BASE", None + self._ensure_runtime_services() + + suffix = f",{dept_dn}".lower() + states = list( + LdapSyncState.objects.filter( + model="employee", + ldap_dn__iendswith=suffix, + ).exclude(ldap_dn="") ) - if not users_base: - raise RuntimeError("LDAP_USERS_BASE is not configured") - ok = conn.search( - dept_dn, - "(&(objectCategory=person)(objectClass=user))", - attributes=["distinguishedName"], - ) - if ok and conn.entries: - for e in conn.entries: - old_dn = str(e.entry_dn) - new_dn = self._user_service._move_user_to_base( - conn, old_dn, users_base - ) - try: - emp_pk = ( - LdapSyncState.objects.filter( - model="employee", ldap_dn__iexact=old_dn - ) - .values_list("object_pk", flat=True) - .first() - ) - if emp_pk: - self._touch_state( - model="employee", - object_pk=emp_pk, - ldap_dn=new_dn, - sync_dir=SyncDirection.LDAP, - ) - except Exception: - pass + for state in states: + employee = Employee.objects.filter(pk=state.object_pk).first() + if employee is None: + continue + target_base = get_base_dn_for_employee(employee) + self._move_employee_to_base_dn( + employee, + target_base, + current_dn=state.ldap_dn, + ) # ==================== Private Group Methods ==================== # - def _ensure_department_group( - self, conn: Connection, dept: Department, dept_dn: str - ) -> str: - """Гарантирует наличие группы отдела - с CN = 'DEP_<ИмяОтдела>'. + def _ensure_department_group(self, dept: Department, dept_dn: str) -> str: + """Гарантирует наличие группы отдела через ORM-модель.""" + self._ensure_runtime_services() - При необходимости переименовывает/создаёт - и синхронизирует dept.ldap_group_dn. - - Args: - conn: LDAP соединение. - dept: Модель отдела. - dept_dn: DN OU отдела. - - Returns: - str: DN группы отдела. - - Raises: - ValueError: Если имя отдела пустое. - RuntimeError: Если операции с группой не удались. - """ name = (dept.name or "").strip() if not name: raise ValueError("Department.name is empty") expected_cn = f"DEP_{name}" - expected_rdn = f"CN={esc_rdn(expected_cn)}" - + expected_dn = f"CN={esc_rdn(expected_cn)},{dept_dn}" + expected_description = f"{name} department members" saved_dn = (dept.ldap_group_dn or "").strip() - if saved_dn: - ok = conn.search( - search_base=saved_dn, - search_filter="(objectClass=group)", - search_scope=BASE, - attributes=["distinguishedName"], - ) - if ok and conn.entries: - cur_rdn = saved_dn.split(",", 1)[0] - if cur_rdn != expected_rdn: - if not conn.modify_dn(saved_dn, expected_rdn): - raise RuntimeError( - f"LDAP rename group failed: {conn.result}" - ) - base = ",".join(saved_dn.split(",")[1:]) - new_dn = f"{expected_rdn},{base}" - else: - new_dn = saved_dn - if new_dn != dept.ldap_group_dn: - Department.objects.filter(pk=dept.pk).update( - ldap_group_dn=new_dn - ) - dept.ldap_group_dn = new_dn - return new_dn - - ok = conn.search( - search_base=dept_dn, - search_filter=( - f"(&(objectClass=group)" - f"(cn={esc_filter(expected_cn)}))" + candidate_dns: List[str] = [] + for candidate in ( + saved_dn, + ( + f"{saved_dn.split(',', 1)[0]},{dept_dn}" + if saved_dn and "," in saved_dn + else "" ), - attributes=["distinguishedName"], - ) - if ok and conn.entries: - dn = str(conn.entries[0].entry_dn) - if dn != saved_dn: - Department.objects.filter(pk=dept.pk).update(ldap_group_dn=dn) - dept.ldap_group_dn = dn - return dn + expected_dn, + ): + if candidate and candidate not in candidate_dns: + candidate_dns.append(candidate) - if saved_dn: - old_rdn = saved_dn.split(",", 1)[0] - if old_rdn.startswith("CN="): - old_cn = old_rdn[3:] - if old_cn and old_cn != expected_cn: - ok = conn.search( - search_base=dept_dn, - search_filter=( - f"(&(objectClass=group)" - f"(cn={esc_filter(old_cn)}))" - ), - attributes=["distinguishedName"], - ) - if ok and conn.entries: - old_dn = str(conn.entries[0].entry_dn) - if not conn.modify_dn(old_dn, expected_rdn): - raise RuntimeError( - f"LDAP rename group failed: {conn.result}" - ) - base = ",".join(old_dn.split(",")[1:]) - dn = f"{expected_rdn},{base}" - Department.objects.filter(pk=dept.pk).update( - ldap_group_dn=dn - ) - dept.ldap_group_dn = dn - return dn + group: Optional[LdapOrganizationalUnitGroup] = None + for candidate_dn in candidate_dns: + try: + group = LdapOrganizationalUnitGroup.objects.get(dn=candidate_dn) + break + except LdapOrganizationalUnitGroup.DoesNotExist: + continue - dn = f"{expected_rdn},{dept_dn}" - attrs: Dict[str, Any] = { - "cn": expected_cn, - "sAMAccountName": expected_cn, - "groupType": group_type("global", True), - "description": f"{name} department members", - } - ok = conn.add(dn, ["top", "group"], attrs) - if not ok: - raise RuntimeError(f"LDAP add group failed: {conn.result}") + if group is None: + group = LdapOrganizationalUnitGroup( + dn=expected_dn, + cn=expected_cn, + sam_account_name=expected_cn[:20], + description=expected_description, + ) + group.save() + else: + group_changed = False + _, current_parent = self._split_rdn_parent(str(group.dn)) + if current_parent.lower() != dept_dn.lower(): + group.base_dn = dept_dn + group_changed = True + if group.cn != expected_cn: + group.cn = expected_cn + group_changed = True + if (group.description or "") != expected_description: + group.description = expected_description + group_changed = True + if group_changed: + group.save() - Department.objects.filter(pk=dept.pk).update(ldap_group_dn=dn) - dept.ldap_group_dn = dn - return dn + group_dn = str(group.dn) + if dept.pk: + Department.objects.filter(pk=dept.pk).update(ldap_group_dn=group_dn) + dept.ldap_group_dn = group_dn + return group_dn def _reconcile_department_group( - self, conn: Connection, dept: Department, dept_dn: Optional[str] + self, dept: Department, dept_dn: Optional[str] ) -> str: """Приводит состав группы отдела к активным EmployeeDepartment. Args: - conn: LDAP соединение. dept: Модель отдела. dept_dn: DN OU отдела (если None, будет запрошен). Returns: str: DN группы отдела. """ + self._ensure_runtime_services() + dept_dn = dept_dn or self._get_department_dn(dept) - group_dn = self._ensure_department_group(conn, dept, dept_dn) + group_dn = self._ensure_department_group(dept, dept_dn) + group = LdapOrganizationalUnitGroup.objects.get(dn=group_dn) active_emp_ids = list( EmployeeDepartment.objects.filter( @@ -1356,7 +1498,8 @@ class DepartmentService(BaseService): ).values_list("employee_id", flat=True) ) if not active_emp_ids: - self._group_service.replace_members(group_dn, []) + group.member = [] + group.save() return group_dn dn_map = dict( @@ -1367,7 +1510,8 @@ class DepartmentService(BaseService): ) member_dns = [dn for dn in dn_map.values() if dn] - self._group_service.replace_members(group_dn, member_dns) + group.member = member_dns + group.save() return group_dn # ==================== Utility Methods ==================== # @@ -1392,7 +1536,7 @@ class DepartmentService(BaseService): def _sync_descendant_dns_after_ou_rename( self, *, old_dn: str, new_dn: str ) -> Tuple[int, int]: - """Переписывает хвост DN у всех записей sync-state сотрудников. + """Переписывает хвост DN у sync-state сотрудников и role group DN. Вызывается после переименования OU отдела. @@ -1401,19 +1545,32 @@ class DepartmentService(BaseService): new_dn: Новый DN OU. Returns: - Tuple[int, int]: (обновлено в модели (всегда 0), - обновлено состояний). + Tuple[int, int]: (обновлено role group DN, + обновлено employee sync-state). """ old_suffix = f",{old_dn}".lower() new_suffix = f",{new_dn}" + role_updated_count = 0 + roles = list( + DepartmentRole.objects.filter(ldap_group_dn__iendswith=old_suffix) + ) + for role in roles: + old_role_dn = role.ldap_group_dn or "" + if not old_role_dn.lower().endswith(old_suffix): + continue + rdn = old_role_dn[: -len(old_suffix)] + role.ldap_group_dn = rdn + new_suffix + role.save(update_fields=["ldap_group_dn"]) + role_updated_count += 1 + states = list( LdapSyncState.objects.filter( model="employee", ldap_dn__iendswith=old_suffix ) ) if not states: - return 0, 0 + return role_updated_count, 0 now = timezone.now() updated_count = 0 @@ -1429,7 +1586,7 @@ class DepartmentService(BaseService): sync_dir=SyncDirection.AUTO, ) updated_count += 1 - return 0, updated_count + return role_updated_count, updated_count __all__ = ["DepartmentService"] diff --git a/backend/employees/ldap/tests/unit/test_department_service.py b/backend/employees/ldap/tests/unit/test_department_service.py index b348a15b..08325b98 100644 --- a/backend/employees/ldap/tests/unit/test_department_service.py +++ b/backend/employees/ldap/tests/unit/test_department_service.py @@ -90,8 +90,6 @@ class TestDepartmentServiceHelpers: @pytest.mark.django_db def test_reconcile_department_group( self, - mock_ldap_context, - mock_ldap_connection, mock_ldap_repository, mock_group_service, mock_user_service, @@ -113,23 +111,38 @@ class TestDepartmentServiceHelpers: mock_query = mock_emp_dept.objects.filter.return_value mock_query.values_list.return_value = [1, 2] - with patch.object( - service, - '_ensure_department_group', - return_value='CN=DEP_IT,DC=example,DC=com' - ): - with patch.object( - mock_user_service, - 'employee_ids_to_dns', - return_value=['CN=Test,OU=Users,DC=example,DC=com'] - ): - # Act - result = service._reconcile_department_group( - mock_ldap_connection, - sample_department, - dept_dn - ) + with patch( + 'employees.ldap.services.department_service.LdapSyncState.objects' + ) as mock_sync_objects: + mock_sync_filter = Mock() + mock_sync_filter.values_list.return_value = [ + ('1', 'CN=One,OU=Users,DC=example,DC=com'), + ('2', 'CN=Two,OU=Users,DC=example,DC=com'), + ] + mock_sync_objects.filter.return_value = mock_sync_filter - # Assert - assert 'DEP_' in result - assert mock_group_service.replace_members.called + mock_group = Mock() + + with patch.object( + service, + '_ensure_department_group', + return_value='CN=DEP_IT,DC=example,DC=com' + ): + with patch( + 'employees.ldap.services.department_service.' + 'LdapOrganizationalUnitGroup.objects.get', + return_value=mock_group + ): + # Act + result = service._reconcile_department_group( + sample_department, + dept_dn + ) + + # Assert + assert 'DEP_' in result + assert mock_group.member == [ + 'CN=One,OU=Users,DC=example,DC=com', + 'CN=Two,OU=Users,DC=example,DC=com', + ] + assert mock_group.save.called diff --git a/backend/employees/signals/ldap/department.py b/backend/employees/signals/ldap/department.py index 7843e6e3..737642ea 100644 --- a/backend/employees/signals/ldap/department.py +++ b/backend/employees/signals/ldap/department.py @@ -8,7 +8,6 @@ import logging from django.conf import settings from django.db.models.signals import post_delete, post_save from django.dispatch import receiver -from django.utils import timezone from employees.ldap import DepartmentService from employees.ldap.errors import ( @@ -27,177 +26,127 @@ def _is_ldap_enabled(): return getattr(settings, "LDAP_ENABLED", False) +def _department_changes(instance) -> dict: + """Возвращает временный diff для Department-синхронизации.""" + raw_changes = getattr(instance, "_ldap_changes", {}) or {} + if not isinstance(raw_changes, dict): + return {} + return { + key: value + for key, value in raw_changes.items() + if key in {"name", "description"} + } + + @receiver(post_save, sender="employees.Department") def sync_department_to_ldap_on_save(sender, instance, created, **kwargs): - """Синхронизирует отдел с LDAP при создании/обновлении. - - ВАЖНО: Создание/обновление LDAP OU - происходит автоматически при сохранении Department. - """ + """Синхронизирует отдел с LDAP при создании/обновлении.""" if not _is_ldap_enabled(): return - # Игнорируем вспомогательные сохранения if getattr(instance, "_skip_ldap_sync", False): return + changes = {} if created else _department_changes(instance) + sync_head = bool(getattr(instance, "_ldap_sync_head", False)) + + if not created and not changes and not sync_head: + return + try: - svc = DepartmentService() - - if created: - # Создание OU в LDAP - # DirectoryService.create_department создаст OU - # и вернет обновленный Department - # Но мы уже в post_save, поэтому просто создаем OU - try: - # Проверяем, есть ли уже LDAP sync state - sync_state = LdapSyncState.objects.filter( - model="department", object_pk=str(instance.pk) - ).first() - - if not sync_state or not sync_state.ldap_dn: - # Создаем OU в LDAP - from employees.ldap.services.department_service import ( - DepartmentService, - ) - from employees.ldap.services.group_service import ( - GroupService, - ) - from employees.ldap.services.user_service import UserService - - group_service = GroupService() - user_service = UserService(group_service) - dept_service = DepartmentService( - group_service, user_service - ) - - dept_service._ensure_department_ou(instance) - - if instance.description: - dept_service._set_ou_description( - instance, instance.description - ) - - if instance.head: - dept_service._set_ou_managed_by(instance, instance.head) - - except Exception as e: - logger.error( - f"LDAP OU creation failed for Department {instance.id}: { - e - }", - exc_info=True, - ) - _enqueue( - "department_save", - "department", - instance.pk, - { - "object_pk": str(instance.pk), - "created": True, - }, - ) - else: - # Обновление OU в LDAP - # Проверяем что изменилось - changes = {} - - # Проверим, есть ли изменения в name/description - old_instance = sender.objects.filter(pk=instance.pk).first() - if old_instance: - if instance.name != old_instance.name: - changes["name"] = instance.name - if instance.description != old_instance.description: - changes["description"] = instance.description - - if changes or instance.head_id: - # Есть изменения - синхронизируем - try: - if changes: - svc.update_department(instance, changes) - - # Обновляем managedBy если изменился head - if instance.head_id: - from employees.ldap.services.department_service import ( - DepartmentService, - ) - from employees.ldap.services.group_service import ( - GroupService, - ) - from employees.ldap.services.user_service import ( - UserService, - ) - - group_service = GroupService() - user_service = UserService(group_service) - dept_service = DepartmentService( - group_service, user_service - ) - dept_service._set_ou_managed_by(instance, instance.head) - - except Exception as e: - logger.error( - f"LDAP OU update failed for Department {instance.id}: " - f"{e}", - exc_info=True, - ) - _enqueue( - "department_save", - "department", - instance.pk, - { - "object_pk": str(instance.pk), - "created": False, - "changes": changes, - }, - ) - - except Exception as e: + DepartmentService().sync_department_state( + instance, + created=created, + changes=changes, + sync_head=sync_head, + ) + except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e: logger.error( - f"Unexpected error in LDAP sync for Department {instance.id}: {e}", + "LDAP sync failed for Department %s (created=%s): %s", + instance.id, + created, + e, exc_info=True, ) + payload = { + "object_pk": str(instance.pk), + "created": created, + "changes": changes, + } + if sync_head: + payload["sync_head"] = True + _enqueue( + "department_save", + "department", + instance.pk, + payload, + ) + except Exception as e: + logger.error( + "Unexpected error in LDAP sync for Department %s: %s", + instance.id, + e, + exc_info=True, + ) + finally: + if hasattr(instance, "_ldap_changes"): + delattr(instance, "_ldap_changes") + if hasattr(instance, "_ldap_sync_head"): + delattr(instance, "_ldap_sync_head") + if hasattr(instance, "_skip_ldap_sync"): + delattr(instance, "_skip_ldap_sync") @receiver(post_delete, sender="employees.Department") def sync_department_to_ldap_on_delete(sender, instance, **kwargs): - """Удаляет OU из LDAP при удалении Department.""" + """Удаляет LDAP-следы отдела при удалении Department.""" if not _is_ldap_enabled(): return + dept_dn = ( + LdapSyncState.objects.filter( + model="department", object_pk=str(instance.pk) + ) + .values_list("ldap_dn", flat=True) + .first() + ) + try: - svc = DepartmentService() - svc.delete_department(instance) - logger.info(f"Deleted Department {instance.id} from LDAP") + DepartmentService().sync_department_delete( + object_pk=instance.pk, + dept_dn=dept_dn, + ) + logger.info("Deleted Department %s from LDAP", instance.id) except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e: logger.error( - f"LDAP OU delete failed for Department {instance.id}: {e}", + "LDAP delete failed for Department %s: %s", + instance.id, + e, exc_info=True, ) + payload = {"object_pk": str(instance.pk)} + if dept_dn: + payload["dept_dn"] = dept_dn _enqueue( "department_delete", "department", instance.pk, - { - "object_pk": str(instance.pk), - }, + payload, ) except Exception as e: logger.error( - f"Unexpected error in LDAP delete for Department {instance.id}: { - e - }", + "Unexpected error in LDAP delete for Department %s: %s", + instance.id, + e, exc_info=True, ) @receiver(post_save, sender="employees.EmployeeDepartment") def sync_department_member_to_ldap(sender, instance, created, **kwargs): - """Синхронизирует добавление/изменение члена отдела с LDAP. + """Синхронизирует членство сотрудника в отделе с LDAP.""" + del created, sender, kwargs - is_active=True → перемещаем в OU отдела + добавляем в группу - is_active=False → перемещаем в Users/Dismissed OU + убираем из группы - """ if not _is_ldap_enabled(): return @@ -205,120 +154,17 @@ def sync_department_member_to_ldap(sender, instance, created, **kwargs): return try: - from employees.ldap.infrastructure.connections import _ldap - from employees.ldap.repositories.ldap_repository import ( - ensure_container_exists, + DepartmentService().sync_member_state( + instance.employee, + instance.department, + is_active=instance.is_active, + role=instance.role, ) - from employees.ldap.services.department_service import DepartmentService - from employees.ldap.services.group_service import GroupService - from employees.ldap.services.user_service import UserService - from employees.ldap.utils.dn_utils import _move_to_department - from employees.ldap.utils.ldap_utils import get_base_dn_for_employee - from employees.ldap.services.constants import SyncDirection - - group_service = GroupService() - user_service = UserService(group_service) - dept_service = DepartmentService(group_service, user_service) - - employee = instance.employee - department = instance.department - - # Получаем DN сотрудника - try: - emp_dn = user_service._get_employee_dn(employee) - except Exception: - logger.debug( - f"Employee {employee.id} has no LDAP DN, skipping member sync" - ) - return - - if instance.is_active: - # === Активация: перемещаем в OU отдела + добавляем в группу === - try: - dept_dn = dept_service._get_department_dn(department) - except Exception: - dept_dn = None - - with _ldap() as conn: - ensured_dn = dept_service._ensure_department_ou( - conn, department.name - ) - if not dept_dn or dept_dn != ensured_dn: - dept_service._touch_state( - model="department", - object_pk=department.pk, - ldap_dn=ensured_dn, - last_django_modify_ts=timezone.now(), - sync_dir=SyncDirection.AUTO, - ) - dept_dn = ensured_dn - - # Перемещаем пользователя в OU отдела - new_dn = _move_to_department(conn, emp_dn, dept_dn) - if new_dn != emp_dn: - dept_service._touch_state( - model="employee", - object_pk=employee.pk, - ldap_dn=new_dn, - sync_dir=SyncDirection.LDAP, - ) - - # Добавляем в группу отдела - group_dn = dept_service._ensure_department_group( - conn, department, dept_dn - ) - if group_dn: - group_service.add_members(group_dn, [new_dn]) - - # Устанавливаем роль если есть - if instance.role: - dept_service.set_member_role( - department, employee, instance.role - ) - else: - # === Деактивация: убираем из группы - # + перемещаем в Users/Dismissed OU === - with _ldap() as conn: - # Убираем из группы отдела - grp_dn = (department.ldap_group_dn or "").strip() - if grp_dn: - try: - group_service.remove_members(grp_dn, [emp_dn]) - except Exception as grp_err: - logger.warning( - f"Failed to remove {employee.id} " - f"from group {grp_dn}: {grp_err}" - ) - - # Определяем целевой OU - target_base = get_base_dn_for_employee(employee) - - # Проверяем: уже в целевом OU? - parts = emp_dn.split(",", 1) - already_there = ( - len(parts) == 2 and parts[1].lower() == target_base.lower() - ) - if not already_there: - ensure_container_exists(conn, target_base) - new_dn = user_service._move_user_to_base( - conn, emp_dn, target_base - ) - dept_service._touch_state( - model="employee", - object_pk=employee.pk, - ldap_dn=new_dn, - sync_dir=SyncDirection.LDAP, - ) - logger.info( - f"Moved employee {employee.id} " - f"from {emp_dn} to {new_dn}" - ) - except Exception as e: logger.error( - f"LDAP member sync failed for EmployeeDepartment {instance.id}: { - e - }", + "LDAP member sync failed for EmployeeDepartment %s: %s", + instance.id, + e, exc_info=True, ) _enqueue( @@ -332,3 +178,6 @@ def sync_department_member_to_ldap(sender, instance, created, **kwargs): "role": str(instance.role) if instance.role else None, }, ) + finally: + if hasattr(instance, "_skip_ldap_sync"): + delattr(instance, "_skip_ldap_sync") diff --git a/backend/employees/tasks.py b/backend/employees/tasks.py index 9e381dc1..49d1635c 100644 --- a/backend/employees/tasks.py +++ b/backend/employees/tasks.py @@ -13,6 +13,22 @@ from django.utils import timezone logger = logging.getLogger(__name__) +def _require_payload_value(payload: dict, key: str): + """Возвращает обязательное поле payload или поднимает ValueError.""" + value = payload.get(key) + if value in (None, ""): + raise ValueError(f"LDAP queue payload missing required field: {key}") + return value + + +def _payload_changes(payload: dict) -> dict: + """Нормализует поле changes и валидирует его тип.""" + changes = payload.get("changes") or {} + if not isinstance(changes, dict): + raise ValueError("LDAP queue payload field 'changes' must be a dict") + return changes + + # --------------------------------------------------------------------------- # Исполнители отложенных LDAP-операций # --------------------------------------------------------------------------- @@ -58,71 +74,88 @@ def _execute_employee_delete(payload: dict) -> None: def _execute_department_save(payload: dict) -> None: """Повторяет синхронизацию Department → LDAP.""" - from employees.ldap.services.department_service import DepartmentService - from employees.ldap.services.group_service import GroupService - from employees.ldap.services.user_service import UserService + from employees.ldap import DepartmentService from employees.models import Department - dept = Department.objects.get(pk=payload["object_pk"]) - group_service = GroupService() - user_service = UserService(group_service) - dept_service = DepartmentService(group_service, user_service) + object_pk = _require_payload_value(payload, "object_pk") + dept = Department.objects.filter(pk=object_pk).first() + if dept is None: + logger.info( + "Department %s no longer exists, skipping queued sync", + object_pk, + ) + return - if payload.get("created"): - dept_service._ensure_department_ou(dept) - if dept.description: - dept_service._set_ou_description(dept, dept.description) - if dept.head: - dept_service._set_ou_managed_by(dept, dept.head) - else: - changes = payload.get("changes", {}) - if changes: - svc = DepartmentService() - svc.update_department(dept, changes) - if dept.head_id: - dept_service._set_ou_managed_by(dept, dept.head) + DepartmentService().sync_department_state( + dept, + created=bool(payload.get("created")), + changes=_payload_changes(payload), + sync_head=bool(payload.get("sync_head")), + ) def _execute_department_delete(payload: dict) -> None: """Повторяет удаление Department OU из LDAP.""" from employees.ldap import DepartmentService - from employees.models import LdapSyncState - sync_state = LdapSyncState.objects.filter( - model="department", object_pk=payload["object_pk"] - ).first() - if not sync_state or not sync_state.ldap_dn: - return - - from types import SimpleNamespace - - dept_stub = SimpleNamespace( - pk=payload["object_pk"], id=payload["object_pk"] + DepartmentService().sync_department_delete( + object_pk=_require_payload_value(payload, "object_pk"), + dept_dn=payload.get("dept_dn"), ) - svc = DepartmentService() - svc.delete_department(dept_stub) def _execute_department_member(payload: dict) -> None: """Повторяет sync члена отдела → LDAP.""" - from employees.ldap.services.department_service import DepartmentService - from employees.ldap.services.group_service import GroupService - from employees.ldap.services.user_service import UserService - from employees.models import Employee, Department + from employees.ldap import DepartmentService + from employees.models import Department, Employee, EmployeeDepartment - emp = Employee.objects.get(pk=payload["employee_pk"]) - dept = Department.objects.get(pk=payload["department_pk"]) + employee_pk = _require_payload_value(payload, "employee_pk") + department_pk = _require_payload_value(payload, "department_pk") - group_service = GroupService() - user_service = UserService(group_service) - dept_service = DepartmentService(group_service, user_service) + emp = Employee.objects.filter(pk=employee_pk).first() + if emp is None: + logger.info( + "Employee %s no longer exists, skipping queued department member sync", + employee_pk, + ) + return - if payload.get("is_active"): - dept_service._move_user_to_department(emp, dept) - if payload.get("role"): - dept_service.set_member_role(dept, emp, payload["role"]) + dept = Department.objects.filter(pk=department_pk).first() + if dept is None: + logger.info( + "Department %s no longer exists, skipping queued member sync", + department_pk, + ) + return + + link = ( + EmployeeDepartment.objects.filter( + employee_id=employee_pk, + department_id=department_pk, + ) + .select_related("role") + .first() + ) + + is_active = bool(payload.get("is_active")) + role_hint = payload.get("role") + if link is not None: + is_active = link.is_active + role_hint = link.role or role_hint else: - dept_service._move_user_to_base_ou(emp) + logger.warning( + "EmployeeDepartment link missing for employee=%s department=%s, " + "falling back to queued payload state", + employee_pk, + department_pk, + ) + + DepartmentService().sync_member_state( + emp, + dept, + is_active=is_active, + role=role_hint, + ) def _execute_group_save(payload: dict) -> None: @@ -278,7 +311,9 @@ def process_ldap_queue_item(self, queue_id: int): item.save(update_fields=["status", "updated_at"]) try: - executor(item.payload) + payload = dict(item.payload or {}) + payload.setdefault("object_pk", item.object_pk) + executor(payload) item.mark_completed() logger.info( "LDAP queue item %d (%s %s:%s) completed on attempt %d", diff --git a/backend/tests/api/v1/employees/test_departments.py b/backend/tests/api/v1/employees/test_departments.py index 10c7d031..2df38085 100644 --- a/backend/tests/api/v1/employees/test_departments.py +++ b/backend/tests/api/v1/employees/test_departments.py @@ -46,8 +46,10 @@ # * `extract_results` — вытаскивает `results` из пагинированного ответа DRF или возвращает список как есть. import itertools +from unittest.mock import patch import pytest +from django.test import override_settings from django.contrib.auth import get_user_model from django.urls import reverse from employees.models import ( @@ -314,6 +316,128 @@ def test_partial_update_head_same_value_does_not_require_extra_perm( d.refresh_from_db() assert d.head_id == a.id + +@override_settings(LDAP_ENABLED=True) +def test_partial_update_passes_name_description_diff_to_ldap_sync( + api_client: APIClient, +): + with override_settings(LDAP_ENABLED=False): + head = make_user("patch-head@example.com") + d = Department.objects.create( + name="Dept", + description="old description", + head=head, + ) + emp = make_user("patch-manager@example.com") + role = make_role(d, "mgr", ["manage_department"]) + EmployeeDepartment.objects.create( + employee=emp, + department=d, + is_active=True, + role=role, + ) + + api_client.force_authenticate(user=emp) + url = reverse("api:v1:departments-detail", args=[d.pk]) + + with patch( + "employees.signals.ldap.department.DepartmentService.sync_department_state" + ) as mock_sync: + resp = api_client.patch( + url, + {"name": "Dept Renamed", "description": "new description"}, + format="json", + ) + + assert resp.status_code == status.HTTP_200_OK + mock_sync.assert_called_once_with( + d, + created=False, + changes={ + "name": "Dept Renamed", + "description": "new description", + }, + sync_head=False, + ) + + +@override_settings(LDAP_ENABLED=True) +def test_partial_update_head_passes_sync_head_to_ldap_sync( + api_client: APIClient, +): + with override_settings(LDAP_ENABLED=False): + head = make_user("head-old@example.com") + candidate = make_user("head-new@example.com") + d = Department.objects.create(name="Dept", head=head) + emp = make_user("head-manager@example.com") + role = make_role( + d, "mgr", ["manage_department", "change_department_head"] + ) + EmployeeDepartment.objects.create( + employee=emp, + department=d, + is_active=True, + role=role, + ) + + api_client.force_authenticate(user=emp) + url = reverse("api:v1:departments-detail", args=[d.pk]) + + with patch( + "employees.signals.ldap.department.DepartmentService.sync_department_state" + ) as mock_sync: + resp = api_client.patch(url, {"head_id": candidate.id}, format="json") + + assert resp.status_code == status.HTTP_200_OK + mock_sync.assert_called_once_with( + d, + created=False, + changes={}, + sync_head=True, + ) + + +@override_settings(LDAP_ENABLED=True) +def test_full_update_passes_department_diff_to_ldap_sync( + api_client: APIClient, +): + with override_settings(LDAP_ENABLED=False): + d = Department.objects.create( + name="Dept Full", + description="before update", + ) + emp = make_user("put-manager@example.com") + role = make_role(d, "mgr", ["manage_department"]) + EmployeeDepartment.objects.create( + employee=emp, + department=d, + is_active=True, + role=role, + ) + + api_client.force_authenticate(user=emp) + url = reverse("api:v1:departments-detail", args=[d.pk]) + + with patch( + "employees.signals.ldap.department.DepartmentService.sync_department_state" + ) as mock_sync: + resp = api_client.put( + url, + {"name": "Dept Full Updated", "description": "after update"}, + format="json", + ) + + assert resp.status_code == status.HTTP_200_OK + mock_sync.assert_called_once_with( + d, + created=False, + changes={ + "name": "Dept Full Updated", + "description": "after update", + }, + sync_head=False, + ) + # ---------- tests: action set_head ---------- def test_set_head_by_role_with_perm(api_client: APIClient): diff --git a/backend/tests/employees/test_ldap_sync_queue.py b/backend/tests/employees/test_ldap_sync_queue.py index 7dd9f5d9..260e019b 100644 --- a/backend/tests/employees/test_ldap_sync_queue.py +++ b/backend/tests/employees/test_ldap_sync_queue.py @@ -240,6 +240,143 @@ class TestProcessQueue: mock_task.delay.assert_not_called() +class TestDepartmentQueueExecutors: + + @override_settings(LDAP_ENABLED=True) + def test_department_member_retry_reconciles_active_membership( + self, db, user_factory + ): + from employees.models import Department, EmployeeDepartment, LdapSyncQueue + from employees.tasks import process_ldap_queue_item + + with override_settings(LDAP_ENABLED=False): + employee = user_factory(email="member-active@example.com") + department = Department.objects.create(name="Dept Active Queue") + link = EmployeeDepartment.objects.create( + employee=employee, + department=department, + is_active=True, + ) + + item = LdapSyncQueue.objects.create( + operation="department_member", + model_name="employee_department", + object_pk=str(link.pk), + payload={ + "employee_pk": str(employee.pk), + "department_pk": str(department.pk), + "is_active": True, + "role": None, + }, + ) + + with patch( + "employees.ldap.services.department_service.DepartmentService.sync_member_state" + ) as mock_sync: + process_ldap_queue_item(item.pk) + + item.refresh_from_db() + assert item.status == LdapSyncQueue.Status.COMPLETED + mock_sync.assert_called_once_with( + employee, + department, + is_active=True, + role=None, + ) + + @override_settings(LDAP_ENABLED=True) + def test_department_member_retry_reconciles_inactive_membership( + self, db, user_factory + ): + from employees.models import Department, EmployeeDepartment, LdapSyncQueue + from employees.tasks import process_ldap_queue_item + + with override_settings(LDAP_ENABLED=False): + employee = user_factory(email="member-inactive@example.com") + department = Department.objects.create(name="Dept Inactive Queue") + link = EmployeeDepartment.objects.create( + employee=employee, + department=department, + is_active=False, + ) + + item = LdapSyncQueue.objects.create( + operation="department_member", + model_name="employee_department", + object_pk=str(link.pk), + payload={ + "employee_pk": str(employee.pk), + "department_pk": str(department.pk), + "is_active": False, + "role": None, + }, + ) + + with patch( + "employees.ldap.services.department_service.DepartmentService.sync_member_state" + ) as mock_sync: + process_ldap_queue_item(item.pk) + + item.refresh_from_db() + assert item.status == LdapSyncQueue.Status.COMPLETED + mock_sync.assert_called_once_with( + employee, + department, + is_active=False, + role=None, + ) + + @override_settings(LDAP_ENABLED=True) + def test_department_save_retry_uses_canonical_sync( + self, db, user_factory + ): + from employees.models import Department, LdapSyncQueue + from employees.tasks import process_ldap_queue_item + + with override_settings(LDAP_ENABLED=False): + head = user_factory(email="dept-head-queue@example.com") + department = Department.objects.create( + name="Dept Queue Original", + description="old description", + ) + department.name = "Dept Queue Updated" + department.description = "new description" + department.head = head + department.save(update_fields=["name", "description", "head"]) + + item = LdapSyncQueue.objects.create( + operation="department_save", + model_name="department", + object_pk=str(department.pk), + payload={ + "object_pk": str(department.pk), + "created": False, + "changes": { + "name": "Dept Queue Updated", + "description": "new description", + }, + "sync_head": True, + }, + ) + + with patch( + "employees.ldap.services.department_service.DepartmentService.sync_department_state" + ) as mock_sync: + process_ldap_queue_item(item.pk) + + item.refresh_from_db() + assert item.status == LdapSyncQueue.Status.COMPLETED + mock_sync.assert_called_once_with( + department, + created=False, + changes={ + "name": "Dept Queue Updated", + "description": "new description", + }, + sync_head=True, + ) + + # --------------------------------------------------------------------------- # Сигналы: проверка enqueue при ошибке LDAP # ---------------------------------------------------------------------------