diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py index 91cec7fd..5496233d 100644 --- a/backend/api/auth/serializers.py +++ b/backend/api/auth/serializers.py @@ -36,6 +36,13 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer): except Employee.DoesNotExist: pass # базовая валидация ниже вернёт 401 + # Проверяем что есть email (после подстановки из телефона или напрямую) + if not attrs.get(self.username_field): + raise AuthenticationFailed( + "Email or phone number required", + code="no_credentials" + ) + data = super().validate(attrs) # не выдаём токены, если email не подтверждён/аккаунт не активен diff --git a/backend/employees/admin.py b/backend/employees/admin.py index 51416c6d..18d4b73c 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -1,7 +1,7 @@ # backend/employees/admin.py from django import forms -from django.contrib import admin +from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.forms import ReadOnlyPasswordHashField, AdminPasswordChangeForm from django.contrib.auth.models import Group, Permission @@ -439,7 +439,13 @@ class EmployeeAdmin(DjangoUserAdmin): inlines = [EmployeeDepartmentInline] - actions = ["create_ldap_sync_records", "check_ldap_sync_status"] + actions = [ + "create_ldap_sync_records", + "check_ldap_sync_status", + "sync_from_django_to_ldap", + "sync_from_ldap_to_django", + "show_sync_diff", + ] def create_ldap_sync_records(self, request, queryset): """Создать записи синхронизации для выбранных сотрудников.""" @@ -524,6 +530,306 @@ class EmployeeAdmin(DjangoUserAdmin): self.message_user(request, message, level="info") check_ldap_sync_status.short_description = "Проверить статус LDAP синхронизации" + + @admin.action(description='🔄 Синхронизировать Django → LDAP (создать/обновить в Active Directory)') + def sync_from_django_to_ldap(self, request, queryset): + """Создает или обновляет пользователей в LDAP из Django. + + Django считается источником истины - данные из БД будут + записаны в Active Directory (создание или обновление). + """ + from employees.ldap.services import UserService + from employees.ldap.orm_models import LdapUser + + success_count = 0 + created_count = 0 + updated_count = 0 + error_count = 0 + + service = UserService() + + for employee in queryset: + try: + # Ищем существующего LDAP пользователя + ldap_user = None + if employee.is_ldap_managed: + # Пробуем найти по employee_number + ldap_user = LdapUser.objects.filter( + employee_number=str(employee.pk) + ).first() + + if ldap_user: + # ОБНОВЛЕНИЕ существующего LDAP пользователя + try: + changes = {} + if ldap_user.given_name != employee.first_name: + changes['first_name'] = employee.first_name + if ldap_user.sn != employee.last_name: + changes['last_name'] = employee.last_name + if ldap_user.mail != employee.email: + changes['email'] = employee.email + + ldap_phone = ldap_user.telephone_number or ldap_user.mobile + if employee.phone_number and ldap_phone != employee.phone_number: + changes['phone_number'] = employee.phone_number + + if changes: + service.update_user(employee, changes) + updated_count += 1 + + # Обновляем sync state + LdapSyncState.objects.update_or_create( + model='employee', + object_pk=str(employee.pk), + defaults={ + 'ldap_dn': ldap_user.dn, + 'last_sync_dir': 'django_to_ldap', + } + ) + else: + success_count += 1 # Уже синхронизирован + except Exception as e: + error_count += 1 + self.message_user( + request, + f'Ошибка обновления {employee.email} в LDAP: {e}', + level=messages.ERROR + ) + else: + # СОЗДАНИЕ нового LDAP пользователя + try: + # Генерируем временный пароль + import secrets + temp_password = secrets.token_urlsafe(16) + + # Создаем через сервис + ldap_user = service.create_user( + employee=employee, + password=temp_password, + ) + + created_count += 1 + employee.is_ldap_managed = True + employee.save(update_fields=['is_ldap_managed']) + + self.message_user( + request, + f'✅ Создан LDAP пользователь для {employee.email}. ' + f'Временный пароль: {temp_password}', + level=messages.SUCCESS + ) + except Exception as e: + error_count += 1 + self.message_user( + request, + f'Ошибка создания {employee.email} в LDAP: {e}', + level=messages.ERROR + ) + except Exception as e: + error_count += 1 + self.message_user( + request, + f'Ошибка обработки {employee.email}: {e}', + level=messages.ERROR + ) + + # Итоговое сообщение + if created_count > 0: + self.message_user( + request, + f'✅ Создано в LDAP: {created_count} пользователей', + level=messages.SUCCESS + ) + if updated_count > 0: + self.message_user( + request, + f'🔄 Обновлено в LDAP: {updated_count} пользователей', + level=messages.SUCCESS + ) + if success_count > 0: + self.message_user( + request, + f'✓ Уже синхронизировано: {success_count} пользователей', + level=messages.INFO + ) + if error_count > 0: + self.message_user( + request, + f'❌ Ошибок: {error_count}', + level=messages.WARNING + ) + + @admin.action(description='🔄 Синхронизировать LDAP → Django (обновить из Active Directory)') + def sync_from_ldap_to_django(self, request, queryset): + """Обновляет Django пользователей из LDAP. + + LDAP считается источником истины - данные из Active Directory + перезапишут данные в Django Employee. + """ + from employees.ldap.orm_models import LdapUser + + success_count = 0 + error_count = 0 + not_in_ldap = 0 + + for employee in queryset: + if not employee.is_ldap_managed: + not_in_ldap += 1 + continue + + try: + # Ищем LDAP пользователя + ldap_user = LdapUser.objects.filter( + employee_number=str(employee.pk) + ).first() + + if not ldap_user: + # Попробуем по email + ldap_user = LdapUser.objects.filter( + mail=employee.email + ).first() + + if not ldap_user: + error_count += 1 + self.message_user( + request, + f'❌ {employee.email}: не найден в LDAP', + level=messages.WARNING + ) + continue + + # Обновляем Django из LDAP + updated_fields = [] + + if ldap_user.given_name and ldap_user.given_name != employee.first_name: + employee.first_name = ldap_user.given_name + updated_fields.append('first_name') + + if ldap_user.sn and ldap_user.sn != employee.last_name: + employee.last_name = ldap_user.sn + updated_fields.append('last_name') + + if ldap_user.mail and ldap_user.mail != employee.email: + employee.email = ldap_user.mail + updated_fields.append('email') + + ldap_phone = ldap_user.telephone_number or ldap_user.mobile + if ldap_phone and ldap_phone != employee.phone_number: + employee.phone_number = ldap_phone + updated_fields.append('phone_number') + + if updated_fields: + employee.save(update_fields=updated_fields) + + # Обновляем LdapSyncState + LdapSyncState.objects.update_or_create( + model='employee', + object_pk=str(employee.pk), + defaults={ + 'ldap_dn': ldap_user.dn, + 'ldap_guid': str(ldap_user.object_guid) if hasattr(ldap_user, 'object_guid') else None, + 'last_sync_dir': 'ldap_to_django', + } + ) + + success_count += 1 + + except Exception as e: + error_count += 1 + self.message_user( + request, + f'Ошибка синхронизации {employee.email}: {e}', + level=messages.ERROR + ) + + if success_count: + self.message_user( + request, + f'✅ Успешно синхронизировано: {success_count} пользователей (LDAP → Django)', + level=messages.SUCCESS + ) + if not_in_ldap: + self.message_user( + request, + f'ℹ️ Пропущено локальных пользователей: {not_in_ldap}', + level=messages.INFO + ) + if error_count: + self.message_user( + request, + f'❌ Ошибок: {error_count}', + level=messages.WARNING + ) + + @admin.action(description='🔍 Показать различия Django ↔ LDAP') + def show_sync_diff(self, request, queryset): + """Показывает различия между Django и LDAP для выбранных пользователей.""" + from employees.ldap.orm_models import LdapUser + + diffs = [] + no_ldap_count = 0 + + for employee in queryset: + if not employee.is_ldap_managed: + continue + + try: + # Ищем LDAP пользователя + ldap_user = LdapUser.objects.filter( + employee_number=str(employee.pk) + ).first() + + if not ldap_user: + ldap_user = LdapUser.objects.filter(mail=employee.email).first() + + if not ldap_user: + no_ldap_count += 1 + diffs.append(f"❌ {employee.email}: не найден в LDAP") + continue + + user_diffs = [] + + # Сравниваем поля + if ldap_user.given_name != employee.first_name: + user_diffs.append( + f"Имя: Django='{employee.first_name}' vs LDAP='{ldap_user.given_name}'" + ) + + if ldap_user.sn != employee.last_name: + user_diffs.append( + f"Фамилия: Django='{employee.last_name}' vs LDAP='{ldap_user.sn}'" + ) + + if ldap_user.mail != employee.email: + user_diffs.append( + f"Email: Django='{employee.email}' vs LDAP='{ldap_user.mail}'" + ) + + ldap_phone = ldap_user.telephone_number or ldap_user.mobile + if employee.phone_number and ldap_phone and ldap_phone != employee.phone_number: + user_diffs.append( + f"Телефон: Django='{employee.phone_number}' vs LDAP='{ldap_phone}'" + ) + + if user_diffs: + diffs.append( + f"⚠️ {employee.email}: " + " | ".join(user_diffs) + ) + else: + diffs.append(f"✅ {employee.email}: данные совпадают") + + except Exception as e: + diffs.append(f"❌ {employee.email}: ошибка сравнения - {e}") + + if diffs: + message = "Результаты сравнения Django ↔ LDAP:\n\n" + "\n".join(diffs) + self.message_user(request, message, level=messages.INFO) + else: + self.message_user( + request, + "Выбраны только локальные пользователи (не управляются LDAP)", + level=messages.WARNING + ) @admin.register(Position) diff --git a/backend/employees/apps.py b/backend/employees/apps.py index 08dfe5fa..5ca3bc96 100644 --- a/backend/employees/apps.py +++ b/backend/employees/apps.py @@ -8,3 +8,82 @@ class EmployeesConfig(AppConfig): def ready(self): import employees.signals # Все сигналы (common, birthday, ldap) import employees.rules # django-rules: регистрация предикатов и правил доступа + + # Патч для django-ldapdb: исправление бага с Value(1) в .exists() + self._patch_ldapdb_compiler() + + def _patch_ldapdb_compiler(self): + """Патчит django-ldapdb для поддержки Value выражений. + + Проблема: ldapdb компилятор на строке 246 вызывает e[0].field.attname, + но у полей из Value() выражений (например, от .exists()) нет attname. + + Это приводит к: AttributeError: 'IntegerField' object has no attribute 'attname' + + Решение: Патчим results_iter чтобы безопасно проверять наличие attname. + """ + try: + from ldapdb.backends.ldap.compiler import SQLCompiler + from django.db.models import aggregates + from ldapdb.models.fields import ListField + + original_results_iter = SQLCompiler.results_iter + + def patched_results_iter(self, *args, **kwargs): + """Исправленная версия results_iter с поддержкой Value выражений.""" + # Вызываем оригинальный генератор, но оборачиваем в try-catch + for result in original_results_iter(self, *args, **kwargs): + yield result + + # Заменяем весь метод для правильной обработки + import ldap + from django.db.models.sql.constants import GET_ITERATOR_CHUNK_SIZE + + def safe_results_iter(self, results=None, do_slice=True): + """Безопасная версия results_iter с проверкой attname.""" + if results is None: + results = self.execute_sql( + result_type='multi', chunked_fetch=False, + chunk_size=GET_ITERATOR_CHUNK_SIZE, + ) + + if results is None: + return + + for dn, attrs in results: + row = [] + self.setup_query() + for e in self.select: + if isinstance(e[0], aggregates.Count): + value = 0 + input_field = e[0].get_source_expressions()[0].field + # Безопасная проверка attname + if getattr(input_field, 'attname', None) == 'dn': + value = 1 + elif hasattr(input_field, 'from_ldap'): + result = input_field.from_ldap( + attrs.get(input_field.db_column, []), + connection=self.connection) + if result: + value = 1 + if isinstance(input_field, ListField): + value = len(result) + row.append(value) + else: + # Безопасная проверка attname с getattr + if getattr(e[0].field, 'attname', None) == 'dn': + row.append(dn) + elif hasattr(e[0].field, 'from_ldap'): + row.append(e[0].field.from_ldap( + attrs.get(e[0].field.db_column, []), + connection=self.connection)) + else: + row.append(None) + + yield row + + SQLCompiler.results_iter = safe_results_iter + + except ImportError: + # ldapdb не установлен - это нормально для тестов без LDAP + pass diff --git a/backend/employees/ldap/ADMIN_GUIDE.md b/backend/employees/ldap/ADMIN_GUIDE.md new file mode 100644 index 00000000..8cdfaaf8 --- /dev/null +++ b/backend/employees/ldap/ADMIN_GUIDE.md @@ -0,0 +1,178 @@ +# LDAP Admin — Руководство по использованию + +## 📋 Обзор + +Добавлена админка для управления LDAP-моделями с поддержкой ручной синхронизации при рассинхроне. + +**Местоположение:** `/admin/ldap/ldapuser/` + +--- + +## ✨ Возможности + +### 1. **Просмотр LDAP-данных** +- Список всех пользователей из Active Directory +- Детальная информация о каждом пользователе +- Статус синхронизации с Django Employee +- Членство в группах +- Статус учетной записи (активна/заблокирована) + +### 2. **Ручное редактирование** +При клике на пользователя открывается форма редактирования: +- ✅ **Можно редактировать** все атрибуты (cn, givenName, sn, mail, telephoneNumber, mobile, userAccountControl) +- ⚠️ **Осторожно**: изменения идут напрямую в LDAP, минуя бизнес-логику +- 🔒 **DN нельзя редактировать** (это primary key) + +### 3. **Actions для синхронизации** + +#### 🔄 **LDAP → Django** (LDAP как источник истины) +``` +Выберите пользователей → Actions → "Синхронизировать LDAP → Django" +``` +- Копирует данные из Active Directory в Django Employee +- Используется когда LDAP содержит актуальные данные +- Обновляет: first_name, last_name, email, phone_number +- Устанавливает `last_sync_dir = 'ldap_to_django'` + +#### 🔄 **Django → LDAP** (Django как источник истины) +``` +Выберите пользователей → Actions → "Синхронизировать Django → LDAP" +``` +- Копирует данные из Django Employee в Active Directory +- Используется когда БД содержит актуальные данные +- Вызывает `UserService.update_user()` (с полной бизнес-логикой) +- Устанавливает `last_sync_dir = 'django_to_ldap'` + +#### 🔍 **Показать различия** +``` +Выберите пользователей → Actions → "Показать различия LDAP ↔ Django" +``` +- Сравнивает поля: имя, фамилия, email, телефон +- Показывает расхождения в сообщении +- Помогает принять решение о направлении синхронизации + +--- + +## 📊 Интерфейс + +### Список пользователей + +| Колонка | Описание | +|---------|----------| +| CN (Common Name) | Имя пользователя с иконкой 👤 | +| sAMAccountName | Логин для входа в Windows | +| Email | Почтовый адрес | +| Django Employee | Ссылка на связанную запись в БД (или ⚠️ если не найдена) | +| Синхронизация | Статус последней синхронизации (⬆️ Django→LDAP, ⬇️ LDAP→Django) | +| Статус | ✅ Активна или 🔒 Заблокирована | + +### Детальная страница + +**Секции:** +1. **🔑 Идентификация** — DN, CN, sAMAccountName, UPN, employeeNumber +2. **👤 Персональные данные** — Имя, фамилия, displayName, email +3. **📞 Контакты** — Телефон, мобильный +4. **⚙️ Управление учетной записью** — UAC флаги, описание +5. **🖼️ Дополнительно** — Фото (thumbnail) +6. **👥 Членство в группах** — Список AD-групп (до 10 + счетчик остальных) +7. **🔄 Статус синхронизации** — Подробная информация о связи с Django + +**Форматирование DN:** +- CN выделен синим +- OU выделен серым +- DC выделен светло-серым + +--- + +## 🎯 Сценарии использования + +### Сценарий 1: Рассинхрон после ручного изменения в AD + +**Проблема:** Админ изменил данные пользователя через Active Directory Users and Computers. + +**Решение:** +1. Перейдите в `/admin/ldap/ldapuser/` +2. Найдите пользователя (поиск по CN/email/логину) +3. Выберите → Actions → **"Показать различия"** +4. Если LDAP актуальнее → Actions → **"LDAP → Django"** +5. Проверьте в `/admin/employees/employee/` что данные обновились + +### Сценарий 2: Рассинхрон после изменения в Django + +**Проблема:** В Django Admin изменили телефон, но в LDAP старый номер. + +**Решение:** +1. Перейдите в `/admin/ldap/ldapuser/` +2. Найдите пользователя +3. Выберите → Actions → **"Django → LDAP"** +4. Проверьте что номер обновился в AD + +### Сценарий 3: Пользователь не связан с Employee + +**Индикаторы:** +- В списке: `Django Employee` = "-" +- На детальной странице: ⚠️ "Не связан с Django Employee" + +**Решение:** +1. Вручную установите `employee_number` в значение Employee.pk +2. Сохраните +3. Создайте LdapSyncState вручную в `/admin/employees/ldapsyncstate/` + +### Сценарий 4: Быстрое исправление атрибута + +**Проблема:** Нужно срочно изменить email/телефон в LDAP. + +**Решение:** +1. Откройте пользователя в админке +2. Измените поле `mail` или `telephone_number` +3. Нажмите "Сохранить" +4. ⚠️ **Внимание**: изменение напрямую в LDAP, без валидации +5. Рекомендуется после этого запустить **"Django → LDAP"** для согласованности + +--- + +## ⚙️ Технические детали + +### Ограничения + +1. **Пагинация:** Список ограничен первыми 100 пользователями (LDAP не поддерживает эффективную пагинацию) +2. **Создание запрещено:** `has_add_permission = False` — создавайте через signals +3. **Удаление:** Только для суперпользователей +4. **Поиск:** По CN, sAMAccountName, mail, employeeNumber, givenName, sn + +### Безопасность + +- ✅ Actions с проверкой существования Employee +- ✅ Try-except обработка ошибок +- ✅ Детальные сообщения об ошибках +- ⚠️ Прямое редактирование требует понимания структуры LDAP + +### Производительность + +- LDAP queries могут быть медленными +- Рекомендуется использовать поиск вместо просмотра всего списка +- Actions обрабатываются последовательно (не пакетно) + +--- + +## 🔮 Планы развития + +**В будущих версиях:** +- [ ] LdapGroupAdmin — управление группами +- [ ] LdapOrganizationalUnitAdmin — управление отделами (OU) +- [ ] Пакетная синхронизация с progress bar +- [ ] История изменений (audit log) +- [ ] Предпросмотр изменений перед синхронизацией +- [ ] Автоматическое определение источника истины по timestamp + +--- + +## 📞 Поддержка + +При возникновении проблем: +1. Проверьте логи Django (`logs/django.log`) +2. Проверьте статус LDAP-подключения +3. Убедитесь что `LdapSyncState` актуален +4. Используйте Action "Показать различия" для диагностики + +**Разработчик:** Система создана на базе django-ldapdb 1.5.1 diff --git a/backend/employees/ldap/__init__.py b/backend/employees/ldap/__init__.py index 4e4a4ae9..a99ab216 100644 --- a/backend/employees/ldap/__init__.py +++ b/backend/employees/ldap/__init__.py @@ -77,6 +77,9 @@ from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit # Подключения from .infrastructure.connections import LdapConfig, _ldap +# Админка (автоматическая регистрация) +from . import admin + def export_users(cfg=None): """Обёртка для SyncService().export_users(cfg).""" diff --git a/backend/employees/ldap/admin.py b/backend/employees/ldap/admin.py new file mode 100644 index 00000000..51704365 --- /dev/null +++ b/backend/employees/ldap/admin.py @@ -0,0 +1,476 @@ +"""Django Admin для LDAP ORM моделей. + +Предоставляет интерфейс для: +- Просмотра LDAP-данных +- Ручного редактирования полей (с осторожностью) +- Синхронизации при рассинхроне (выбор источника истины) +""" + +from django.contrib import admin, messages +from django.utils.html import format_html +from django.urls import reverse +from django.db.models import OuterRef, Exists + +from employees.models import Employee, LdapSyncState +from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit + + +class LdapSyncStateInline(admin.TabularInline): + """Inline для просмотра состояния синхронизации.""" + model = LdapSyncState + extra = 0 + can_delete = False + + fields = ( + 'model', + 'object_pk', + 'last_sync_dir', + 'last_ldap_modify_ts', + 'last_django_modify_ts', + 'updated_at', + ) + readonly_fields = fields + + def has_add_permission(self, request, obj=None): + return False + + +@admin.register(LdapUser) +class LdapUserAdmin(admin.ModelAdmin): + """Админка для LDAP пользователей с поддержкой синхронизации. + + Возможности: + - Просмотр всех LDAP-атрибутов + - Ручное редактирование (для исправления рассинхрона) + - Actions для синхронизации с выбором источника истины + """ + + list_display = ( + 'cn_display', + 'sam_account_name', + 'mail', + 'django_employee_link', + 'sync_status', + 'account_status', + ) + + list_filter = ( + 'user_account_control', + ) + + search_fields = ( + 'cn', + 'sam_account_name', + 'mail', + 'employee_number', + 'given_name', + 'sn', + ) + + readonly_fields = ( + 'dn_display', + 'member_of_display', + 'sync_info', + ) + + fieldsets = ( + ('🔑 Идентификация', { + 'fields': ( + 'dn_display', + 'cn', + 'sam_account_name', + 'user_principal_name', + 'employee_number', + ) + }), + ('👤 Персональные данные', { + 'fields': ( + 'given_name', + 'sn', + 'display_name', + 'mail', + ) + }), + ('📞 Контакты', { + 'fields': ( + 'telephone_number', + 'mobile', + ) + }), + ('⚙️ Управление учетной записью', { + 'fields': ( + 'user_account_control', + 'description', + ) + }), + ('🖼️ Дополнительно', { + 'classes': ('collapse',), + 'fields': ( + 'thumbnail_photo', + ) + }), + ('👥 Членство в группах', { + 'classes': ('collapse',), + 'fields': ( + 'member_of_display', + ) + }), + ('🔄 Статус синхронизации', { + 'fields': ( + 'sync_info', + ) + }), + ) + + actions = [ + 'sync_from_ldap_to_django', + 'sync_from_django_to_ldap', + 'show_sync_diff', + ] + + # Пагинация (LDAP может быть медленным) + list_per_page = 100 + + # Кастомные поля для отображения + + def cn_display(self, obj): + """CN с иконкой статуса.""" + if obj.cn: + return format_html('👤 {}', obj.cn) + return '-' + cn_display.short_description = 'CN (Common Name)' + + def dn_display(self, obj): + """DN с подсветкой компонентов.""" + if not obj.dn: + return '-' + + # Разбиваем DN на части для читаемости + parts = obj.dn.split(',') + html_parts = [] + + for part in parts: + if part.startswith('CN='): + html_parts.append(format_html('{}', part)) + elif part.startswith('OU='): + html_parts.append(format_html('{}', part)) + elif part.startswith('DC='): + html_parts.append(format_html('{}', part)) + else: + html_parts.append(part) + + return format_html(','.join(html_parts)) + dn_display.short_description = 'Distinguished Name' + + def member_of_display(self, obj): + """Список групп с форматированием.""" + if not obj.member_of: + return format_html('Не состоит в группах') + + groups_html = [] + for group_dn in obj.member_of[:10]: # Ограничиваем первыми 10 + # Извлекаем CN из DN + cn = group_dn.split(',')[0].replace('CN=', '') + groups_html.append(format_html('• {}', cn)) + + result = '
'.join(groups_html) + if len(obj.member_of) > 10: + result += format_html('
...и ещё {} групп', len(obj.member_of) - 10) + + return format_html(result) + member_of_display.short_description = 'Членство в группах' + + def django_employee_link(self, obj): + """Ссылка на Django Employee если есть.""" + if not obj.employee_number: + return format_html('-') + + try: + emp = Employee.objects.get(pk=int(obj.employee_number)) + url = reverse('admin:employees_employee_change', args=[emp.pk]) + return format_html( + '👤 {} {}', + url, + emp.first_name, + emp.last_name + ) + except (Employee.DoesNotExist, ValueError): + return format_html( + '⚠️ ID {} (не найден)', + obj.employee_number + ) + django_employee_link.short_description = 'Django Employee' + + def sync_status(self, obj): + """Статус синхронизации с Django.""" + if not obj.employee_number: + return format_html('❌ Не связан') + + try: + sync_state = LdapSyncState.objects.get( + model='employee', + object_pk=obj.employee_number + ) + + if sync_state.last_sync_dir == 'django_to_ldap': + icon = '⬆️' + color = '#0066cc' + text = 'Django → LDAP' + elif sync_state.last_sync_dir == 'ldap_to_django': + icon = '⬇️' + color = '#00cc66' + text = 'LDAP → Django' + else: + icon = '❓' + color = '#999' + text = 'Неизвестно' + + return format_html( + '{} {}
' + '{}', + color, icon, text, + sync_state.updated_at.strftime('%Y-%m-%d %H:%M') + ) + except LdapSyncState.DoesNotExist: + return format_html('⚠️ Нет записи') + sync_status.short_description = 'Синхронизация' + + def account_status(self, obj): + """Статус учетной записи (активна/заблокирована).""" + # UAC флаг ACCOUNTDISABLE = 0x2 (2) + is_disabled = bool(obj.user_account_control and (obj.user_account_control & 2)) + + if is_disabled: + return format_html('🔒 Заблокирована') + else: + return format_html('✅ Активна') + account_status.short_description = 'Статус' + + def sync_info(self, obj): + """Подробная информация о синхронизации.""" + if not obj.employee_number: + return format_html( + '
' + '⚠️ Не связан с Django Employee
' + 'Этот LDAP-пользователь не привязан к записи в БД Django.' + '
' + ) + + try: + emp = Employee.objects.get(pk=int(obj.employee_number)) + sync_state = LdapSyncState.objects.filter( + model='employee', + object_pk=obj.employee_number + ).first() + + if not sync_state: + return format_html( + '
' + '❌ Нет записи синхронизации
' + 'Связь с Employee ID {} установлена, но LdapSyncState отсутствует.' + '
', + obj.employee_number + ) + + return format_html( + '
' + '✅ Синхронизирован
' + '' + '' + '' + '' + '' + '
Employee ID:{}
LDAP DN:{}
Последняя синхронизация:{}
Направление:{}
' + '
', + emp.pk, + sync_state.ldap_dn or '-', + sync_state.updated_at.strftime('%Y-%m-%d %H:%M:%S'), + sync_state.last_sync_dir or 'не указано' + ) + except Employee.DoesNotExist: + return format_html( + '
' + '❌ Employee не найден
' + 'employee_number={} указывает на несуществующую запись.' + '
', + obj.employee_number + ) + sync_info.short_description = 'Информация о синхронизации' + + # Actions для синхронизации + + @admin.action(description='🔄 Синхронизировать LDAP → Django (LDAP как источник истины)') + def sync_from_ldap_to_django(self, request, queryset): + """Синхронизирует выбранных пользователей из LDAP в Django. + + LDAP считается источником истины - данные из Active Directory + перезапишут данные в Django Employee. + """ + from employees.ldap.services import UserService + + success_count = 0 + error_count = 0 + + for ldap_user in queryset: + if not ldap_user.employee_number: + error_count += 1 + continue + + try: + emp = Employee.objects.get(pk=int(ldap_user.employee_number)) + + # Обновляем Django из LDAP + emp.first_name = ldap_user.given_name or emp.first_name + emp.last_name = ldap_user.sn or emp.last_name + emp.email = ldap_user.mail or emp.email + emp.phone_number = ldap_user.telephone_number or ldap_user.mobile or emp.phone_number + emp.save() + + # Обновляем LdapSyncState + LdapSyncState.objects.update_or_create( + model='employee', + object_pk=str(emp.pk), + defaults={ + 'ldap_dn': ldap_user.dn, + 'last_sync_dir': 'ldap_to_django', + } + ) + + success_count += 1 + except Exception as e: + error_count += 1 + self.message_user( + request, + f'Ошибка синхронизации {ldap_user.cn}: {e}', + level=messages.ERROR + ) + + if success_count: + self.message_user( + request, + f'Успешно синхронизировано: {success_count} пользователей (LDAP → Django)', + level=messages.SUCCESS + ) + if error_count: + self.message_user( + request, + f'Ошибок: {error_count}', + level=messages.WARNING + ) + + @admin.action(description='🔄 Синхронизировать Django → LDAP (Django как источник истины)') + def sync_from_django_to_ldap(self, request, queryset): + """Синхронизирует выбранных пользователей из Django в LDAP. + + Django считается источником истины - данные из БД + перезапишут данные в Active Directory. + """ + from employees.ldap.services import UserService + + success_count = 0 + error_count = 0 + + service = UserService() + + for ldap_user in queryset: + if not ldap_user.employee_number: + error_count += 1 + continue + + try: + emp = Employee.objects.get(pk=int(ldap_user.employee_number)) + + # Обновляем LDAP из Django через сервис + changes = { + 'first_name': emp.first_name, + 'last_name': emp.last_name, + 'email': emp.email, + 'phone_number': emp.phone_number, + } + + service.update_user(emp, changes) + + success_count += 1 + except Exception as e: + error_count += 1 + self.message_user( + request, + f'Ошибка синхронизации {ldap_user.cn}: {e}', + level=messages.ERROR + ) + + if success_count: + self.message_user( + request, + f'Успешно синхронизировано: {success_count} пользователей (Django → LDAP)', + level=messages.SUCCESS + ) + if error_count: + self.message_user( + request, + f'Ошибок: {error_count}', + level=messages.WARNING + ) + + @admin.action(description='🔍 Показать различия LDAP ↔ Django') + def show_sync_diff(self, request, queryset): + """Показывает различия между LDAP и Django для выбранных пользователей.""" + diffs = [] + + for ldap_user in queryset: + if not ldap_user.employee_number: + continue + + try: + emp = Employee.objects.get(pk=int(ldap_user.employee_number)) + + user_diffs = [] + + # Сравниваем поля + if ldap_user.given_name != emp.first_name: + user_diffs.append(f"Имя: LDAP='{ldap_user.given_name}' vs Django='{emp.first_name}'") + + if ldap_user.sn != emp.last_name: + user_diffs.append(f"Фамилия: LDAP='{ldap_user.sn}' vs Django='{emp.last_name}'") + + if ldap_user.mail != emp.email: + user_diffs.append(f"Email: LDAP='{ldap_user.mail}' vs Django='{emp.email}'") + + ldap_phone = ldap_user.telephone_number or ldap_user.mobile + if ldap_phone and ldap_phone != emp.phone_number: + user_diffs.append(f"Телефон: LDAP='{ldap_phone}' vs Django='{emp.phone_number}'") + + if user_diffs: + diffs.append(f"{ldap_user.cn}: " + ", ".join(user_diffs)) + except Employee.DoesNotExist: + diffs.append(f"{ldap_user.cn}: Employee ID {ldap_user.employee_number} не найден") + + if diffs: + self.message_user( + request, + "Найдены различия:\n" + "\n".join(diffs), + level=messages.WARNING + ) + else: + self.message_user( + request, + "Различий не найдено. Все выбранные пользователи синхронизированы.", + level=messages.SUCCESS + ) + + # Переопределение разрешений (опционально - можно разрешить редактирование) + + def has_add_permission(self, request): + """Запрещаем создание через админку (используйте signals).""" + return False + + def has_delete_permission(self, request, obj=None): + """Разрешаем удаление только суперпользователям.""" + return request.user.is_superuser + + def get_queryset(self, request): + """Базовый queryset без slice - пагинация через list_per_page.""" + return super().get_queryset(request) + + +# TODO: Добавить LdapGroupAdmin и LdapOrganizationalUnitAdmin аналогично diff --git a/backend/employees/ldap/mixins.py b/backend/employees/ldap/mixins.py new file mode 100644 index 00000000..d2727721 --- /dev/null +++ b/backend/employees/ldap/mixins.py @@ -0,0 +1,224 @@ +"""Миксины для расширения функциональности django-ldapdb ORM моделей. + +Этот модуль предоставляет дополнительные возможности для LDAP моделей, +которые не поддерживаются django-ldapdb из коробки. +""" + +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class ModifyDnMixin: + """Миксин для поддержки перемещения LDAP объектов между OU через modify_dn. + + django-ldapdb не поддерживает перемещение объектов между OU из-за того, + что метод connection.rename_s() не передаёт параметр newsuperior. + + Этот миксин добавляет эту функциональность, отслеживая изменения base_dn + и выполняя low-level modify_dn операцию перед стандартным save(). + + Usage: + class LdapUser(ModifyDnMixin, LdapModel): + # ... поля модели + pass + + # Использование: + user = LdapUser.objects.get(cn="User") + user.base_dn = "OU=Dismissed,OU=company,DC=robotail,DC=local" + user.save() # Автоматически выполнит modify_dn! + + Attributes: + _original_base_dn (str): Оригинальный base_dn при загрузке объекта. + Используется для определения, нужно ли перемещение. + """ + + def __init__(self, *args, **kwargs): + """Инициализация миксина с сохранением оригинального base_dn.""" + super().__init__(*args, **kwargs) + # Сохраняем оригинальный base_dn для отслеживания изменений + self._original_base_dn = getattr(self, 'base_dn', None) + + def save(self, *args, **kwargs): + """Переопределённый save с поддержкой modify_dn при изменении base_dn. + + Логика: + 1. Проверяем, изменился ли base_dn + 2. Если да - выполняем LDAP modify_dn для перемещения объекта + 3. Обновляем _saved_dn для корректной работы стандартного save() + 4. Вызываем родительский save() для обновления атрибутов + + Args: + *args: Позиционные аргументы для Model.save() + **kwargs: Именованные аргументы для Model.save() + + Raises: + RuntimeError: Если modify_dn операция не удалась + """ + # Проверяем, нужно ли перемещение + current_base_dn = getattr(self, 'base_dn', None) + needs_move = ( + hasattr(self, '_saved_dn') and # Объект уже существует в LDAP + self._saved_dn and # DN не пустой + self._original_base_dn and # Был оригинальный base_dn + current_base_dn and # Есть новый base_dn + current_base_dn != self._original_base_dn # И он изменился + ) + + if needs_move: + # Выполняем перемещение через низкоуровневый modify_dn + from .infrastructure.connections import _ldap + + old_dn = self._saved_dn + new_rdn = self.build_rdn() + new_superior = current_base_dn + new_dn = f"{new_rdn},{new_superior}" + + logger.debug( + f"ModifyDnMixin: Moving LDAP object\n" + f" Old DN: {old_dn}\n" + f" New RDN: {new_rdn}\n" + f" New superior: {new_superior}\n" + f" New DN: {new_dn}" + ) + + with _ldap() as conn: + # LDAP modify_dn операция с newsuperior + success = conn.modify_dn( + old_dn, + new_rdn, + new_superior=new_superior + ) + + if not success: + error_msg = f"Failed to move LDAP object: {conn.result}" + logger.error(f"ModifyDnMixin: {error_msg}") + raise RuntimeError(error_msg) + + logger.info( + f"ModifyDnMixin: Successfully moved {old_dn} → {new_dn}" + ) + + # Обновляем _saved_dn для корректной работы последующего save() + # Это важно, чтобы стандартный _save_table() знал новый DN + self._saved_dn = new_dn + self.dn = new_dn + + # Обновляем _original_base_dn для следующих операций + self._original_base_dn = current_base_dn + + # Вызываем родительский save() для обновления атрибутов + return super().save(*args, **kwargs) + + @classmethod + def from_db(cls, db, field_names, values): + """Переопределяем from_db для сохранения оригинального base_dn при загрузке. + + Этот метод вызывается Django ORM при загрузке объекта из БД (или LDAP). + """ + instance = super().from_db(db, field_names, values) + # Сохраняем base_dn при загрузке из LDAP + instance._original_base_dn = getattr(instance, 'base_dn', None) + return instance + + +class LdapSyncStateMixin: + """Миксин для автоматического управления LdapSyncState при операциях с LDAP. + + Автоматически обновляет LdapSyncState при создании/обновлении/удалении + LDAP объектов, отслеживая изменения DN и другие метаданные. + + Usage: + class LdapUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel): + # Указываем, какая Django модель соответствует этой LDAP модели + _sync_model_name = 'employee' + _sync_pk_field = 'employee_number' # Поле LDAP с Django PK + + employee_number = CharField(db_column='employeeNumber') + # ... остальные поля + + Attributes: + _sync_model_name (str): Имя модели для LdapSyncState.model + _sync_pk_field (str): Имя поля, содержащего Django PK + """ + + _sync_model_name: Optional[str] = None + _sync_pk_field: Optional[str] = None + + def save(self, *args, **kwargs): + """Save с автоматическим обновлением LdapSyncState.""" + result = super().save(*args, **kwargs) + + # Обновляем LdapSyncState если настроен + if self._sync_model_name and self._sync_pk_field: + self._update_sync_state() + + return result + + def delete(self, *args, **kwargs): + """Delete с автоматическим удалением LdapSyncState.""" + result = super().delete(*args, **kwargs) + + # Удаляем LdapSyncState если настроен + if self._sync_model_name and self._sync_pk_field: + self._delete_sync_state() + + return result + + def _update_sync_state(self): + """Обновляет или создаёт запись LdapSyncState для этого объекта.""" + from employees.models import LdapSyncState + from django.utils import timezone + + # Получаем Django PK из LDAP атрибута + django_pk = getattr(self, self._sync_pk_field, None) + if not django_pk: + logger.warning( + f"LdapSyncStateMixin: No {self._sync_pk_field} set, " + f"skipping sync state update for {self.dn}" + ) + return + + # Обновляем состояние + state, created = LdapSyncState.objects.get_or_create( + model=self._sync_model_name, + object_pk=str(django_pk) + ) + + state.touch( + ldap_dn=str(self.dn), + sync_dir='ldap', + last_ldap_modify_ts=timezone.now(), + ) + + action = "Created" if created else "Updated" + logger.debug( + f"LdapSyncStateMixin: {action} sync state for " + f"{self._sync_model_name}#{django_pk} DN={self.dn}" + ) + + def _delete_sync_state(self): + """Удаляет запись LdapSyncState для этого объекта.""" + from employees.models import LdapSyncState + + django_pk = getattr(self, self._sync_pk_field, None) + if not django_pk: + return + + deleted_count, _ = LdapSyncState.objects.filter( + model=self._sync_model_name, + object_pk=str(django_pk) + ).delete() + + if deleted_count: + logger.debug( + f"LdapSyncStateMixin: Deleted sync state for " + f"{self._sync_model_name}#{django_pk}" + ) + + +__all__ = [ + 'ModifyDnMixin', + 'LdapSyncStateMixin', +] diff --git a/backend/employees/ldap/orm_models.py b/backend/employees/ldap/orm_models.py index c7d22c1b..23829e51 100644 --- a/backend/employees/ldap/orm_models.py +++ b/backend/employees/ldap/orm_models.py @@ -107,6 +107,9 @@ from ldapdb.models.fields import ( ListField, ) +# Миксины для расширения функциональности LDAP моделей +from .mixins import ModifyDnMixin, LdapSyncStateMixin + def get_users_base(): """Получает base DN для пользователей из settings. @@ -144,31 +147,39 @@ def get_base_dn(): 'DC=robotail,DC=local')) -class LdapUser(LdapModel): +class LdapUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel): """LDAP модель для пользователя Active Directory. Использует objectClass: top, person, organizationalPerson, user. Только для WRITE операций (POST/PUT/DELETE). + Миксины: + - LdapSyncStateMixin: автоматически обновляет LdapSyncState при save/delete + - ModifyDnMixin: поддержка перемещения между OU через base_dn изменение + ВАЖНО О base_dn: - base_dn используется только для SUBTREE поиска через .objects.filter() - НЕ определяет местоположение создаваемых/перемещаемых объектов - Реально пользователи находятся в разных OU: • CN=User,OU=,OU=Departments,OU=company,... (активные) • CN=User,OU=Dismissed,OU=company,... (уволенные) + - При изменении base_dn ModifyDnMixin автоматически выполнит modify_dn! - Операции, НЕ поддерживаемые django-ldapdb ORM: - - CREATE: создание идёт через ldap3.Connection.add() с явным DN - (формируется на основе department_dn или LDAP_USERS_BASE) - - MOVE: перемещение между OU через ldap3.Connection.modify_dn() - (см. utils.dn_utils._move_to_department) - - DELETE: удаление через ldap3.Connection.delete() для контроля ошибок + Операции через ORM: + - CREATE: используйте UserService.create_user() (создание + sync state) + - UPDATE: изменение атрибутов через .save() (автообновление sync state) + - MOVE: user.base_dn = new_dn; user.save() (автоматический modify_dn!) + - DELETE: .delete() (автоматическое удаление sync state) Модель используется для: - UPDATE: изменение атрибутов существующих объектов через .save() - READ: поиск через .objects.get(dn=...) для последующего обновления """ + # Конфигурация LdapSyncStateMixin + _sync_model_name = 'employee' + _sync_pk_field = 'employee_number' + # Базовая конфигурация # base_dn покрывает все возможные местоположения пользователей для SUBTREE search base_dn = get_users_base() @@ -179,9 +190,7 @@ class LdapUser(LdapModel): cn = CharField(db_column='cn') # Идентификация - # object_guid закомментирован - это binary поле (16 байт) которое не может быть декодировано как UTF-8 - # GUID читается через ldap3 напрямую в utils/ldap_utils.py:get_guid_str() - # object_guid = CharField(db_column='objectGUID') + object_guid = CharField(db_column='objectGUID') sam_account_name = CharField(db_column='sAMAccountName') user_principal_name = CharField(db_column='userPrincipalName') @@ -208,52 +217,13 @@ class LdapUser(LdapModel): # Членство в группах member_of = ListField(db_column='memberOf', blank=True) - # Временные метки - закомментированы из-за бага django-ldapdb с timezone в Python 3.13 - # когда_created = DateTimeField(db_column='whenCreated') - # when_changed = DateTimeField(db_column='whenChanged') + # Временные метки + when_created = DateTimeField(db_column='whenCreated') + when_changed = DateTimeField(db_column='whenChanged') class Meta: managed = False # Django не управляет схемой LDAP - def build_rdn(self): - """Строит Relative Distinguished Name для пользователя. - - В AD пользователи идентифицируются по CN (Common Name). - RDN формата: CN=имя_пользователя - """ - if not self.cn: - raise ValueError("Cannot build RDN: cn attribute is required") - return f"cn={self.cn}" - - def save(self, *args, **kwargs): - """Переопределённый save для автоматической синхронизации зависимых полей. - - Автоматически обновляет: - - displayName = "givenName sn" (из имени и фамилии) - - ВАЖНО: cn (Common Name) НЕ обновляется автоматически, так как это часть RDN. - Изменение cn требует modify_dn операции, которая выполняется в UserService._update_user_in_ldap. - """ - # Автосинхронизация displayName при изменении имени/фамилии - if self.given_name or self.sn: - # Формируем displayName из имени и фамилии - parts = [] - if self.given_name and self.given_name.strip(): - parts.append(self.given_name.strip()) - if self.sn and self.sn.strip() and self.sn != ".": - parts.append(self.sn.strip()) - - new_display = " ".join(parts) if parts else None - - # Обновляем displayName если он изменился - if new_display and new_display != self.display_name: - self.display_name = new_display - - # cn НЕ обновляем здесь - это делается в UserService через modify_dn - - # Вызываем родительский save - super().save(*args, **kwargs) - def __str__(self): return f"{self.display_name} ({self.sam_account_name})" @@ -261,22 +231,26 @@ class LdapUser(LdapModel): return f"" -class LdapGroup(LdapModel): +class LdapGroup(ModifyDnMixin, LdapModel): """LDAP модель для группы Active Directory. Использует objectClass: top, group. Только для WRITE операций (POST/PUT/DELETE). + Миксины: + - ModifyDnMixin: поддержка перемещения между OU через base_dn изменение + ВАЖНО О base_dn: - base_dn используется только для SUBTREE поиска через .objects.filter() - Группы могут находиться в разных OU: • CN=Group,OU=Groups,DC=... (глобальные группы) • CN=ROLE_*,OU=Roles,OU=,OU=Departments,... (роли отделов) + - При изменении base_dn ModifyDnMixin автоматически выполнит modify_dn! Операции через django-ldapdb: - CREATE: создание через GroupService.create() (ldap3.Connection.add) - UPDATE: изменение атрибутов через .save() - - RENAME: переименование через GroupService.rename() (ldap3.Connection.modify_dn) + - MOVE: group.base_dn = new_dn; group.save() (автоматический modify_dn!) - DELETE: удаление через ldap3.Connection.delete() При создании ролей отделов DN формируется как: @@ -292,7 +266,7 @@ class LdapGroup(LdapModel): cn = CharField(db_column='cn') # Идентификация - # object_guid = CharField(db_column='objectGUID') # binary поле (16 байт UUID), не может быть CharField + object_guid = CharField(db_column='objectGUID') sam_account_name = CharField(db_column='sAMAccountName') # Описание @@ -303,22 +277,12 @@ class LdapGroup(LdapModel): member_of = ListField(db_column='memberOf', blank=True) # Временные метки - # when_created = DateTimeField(db_column='whenCreated') # баг django-ldapdb с Python 3.13: tzinfo TypeError - # when_changed = DateTimeField(db_column='whenChanged') # баг django-ldapdb с Python 3.13: tzinfo TypeError + when_created = DateTimeField(db_column='whenCreated') + when_changed = DateTimeField(db_column='whenChanged') class Meta: managed = False - def build_rdn(self): - """Строит Relative Distinguished Name для группы. - - В AD группы идентифицируются по CN (Common Name). - RDN формата: CN=имя_группы - """ - if not self.cn: - raise ValueError("Cannot build RDN: cn attribute is required") - return f"cn={self.cn}" - def __str__(self): return f"Group: {self.cn}" @@ -326,21 +290,25 @@ class LdapGroup(LdapModel): return f"" -class LdapOrganizationalUnit(LdapModel): +class LdapOrganizationalUnit(ModifyDnMixin, LdapModel): """LDAP модель для Organizational Unit (отдел). Использует objectClass: top, organizationalUnit. Только для WRITE операций (POST/PUT/DELETE). + Миксины: + - ModifyDnMixin: поддержка перемещения между контейнерами через base_dn + ВАЖНО О base_dn: - base_dn используется только для SUBTREE поиска - OU отделов располагаются под LDAP_DEPARTMENTS_BASE: OU=,OU=Departments,OU=company,... + - При изменении base_dn ModifyDnMixin автоматически выполнит modify_dn! Операции через django-ldapdb: - CREATE: создание через .save() — работает с ORM - UPDATE: изменение через .save() (например, managedBy) - - RENAME: переименование через modify_dn (не поддерживается ORM, используется ldap3) + - MOVE: ou.base_dn = new_dn; ou.save() (автоматический modify_dn!) - DELETE: удаление через ldap3.Connection.delete() При создании отдела DN формируется как: @@ -351,12 +319,16 @@ class LdapOrganizationalUnit(LdapModel): base_dn = get_base_dn() object_classes = ['top', 'organizationalUnit'] + # RDN атрибут для построения Distinguished Name + # Пример: ou=IT → dn=OU=IT,OU=Departments,DC=... + rdn_attributes = ['ou'] + # Основные атрибуты # dn наследуется от ldapdb.models.Model как primary_key (Distinguished Name) ou = CharField(db_column='ou') # Идентификация - # object_guid = CharField(db_column='objectGUID') # binary поле (16 байт UUID), не может быть CharField + object_guid = CharField(db_column='objectGUID') # Описание description = CharField(db_column='description', blank=True) @@ -365,22 +337,12 @@ class LdapOrganizationalUnit(LdapModel): managed_by = CharField(db_column='managedBy', blank=True) # Временные метки - # when_created = DateTimeField(db_column='whenCreated') # баг django-ldapdb с Python 3.13: tzinfo TypeError - # when_changed = DateTimeField(db_column='whenChanged') # баг django-ldapdb с Python 3.13: tzinfo TypeError + when_created = DateTimeField(db_column='whenCreated') + when_changed = DateTimeField(db_column='whenChanged') class Meta: managed = False - def build_rdn(self): - """Строит Relative Distinguished Name для Organizational Unit. - - В AD OU идентифицируются по атрибуту OU. - RDN формата: OU=имя_подразделения - """ - if not self.ou: - raise ValueError("Cannot build RDN: ou attribute is required") - return f"ou={self.ou}" - def __str__(self): return f"OU: {self.ou}" diff --git a/backend/employees/ldap/services/department_service.py b/backend/employees/ldap/services/department_service.py index 268c9790..0123cf32 100644 --- a/backend/employees/ldap/services/department_service.py +++ b/backend/employees/ldap/services/department_service.py @@ -22,7 +22,9 @@ from ..errors import ( ) from ..orm_models import LdapOrganizationalUnit from ..infrastructure.connections import _ldap -from ..repositories.ldap_repository import LdapRepository +from ..repositories.ldap_repository import ( + ensure_container_exists, +) from ..utils.dn_utils import _move_to_department from ..utils.ldap_utils import group_type from ..utils.text_utils import esc_filter, esc_rdn @@ -465,7 +467,7 @@ class DepartmentService(BaseService): raise DirectoryLdapError(str(e)) from e try: - LdapRepository(conn).ensure_container_exists(target_base) + ensure_container_exists(conn, target_base) new_dn = self._user_service._move_user_to_base( conn, emp_dn, target_base ) diff --git a/backend/employees/ldap/services/group_service.py b/backend/employees/ldap/services/group_service.py index 50bc55c2..c0f53d8c 100644 --- a/backend/employees/ldap/services/group_service.py +++ b/backend/employees/ldap/services/group_service.py @@ -88,7 +88,18 @@ class GroupService(BaseService): scope: str = "global", security_enabled: bool = True, ) -> str: - """Внутренний метод создания группы (для использования с существующим conn).""" + """Внутренний метод создания группы через ORM. + + Args: + conn: LDAP соединение (сохраняется в сигнатуре для обратной совместимости). + cn: Имя группы. + parent_dn: Контейнер. + description: Описание. + scope: Тип группы. + security_enabled: Флаг безопасности. + """ + import ldap as _ldap_module + base = parent_dn or getattr(settings, "LDAP_GROUPS_BASE", None) if not base: raise RuntimeError( @@ -96,61 +107,41 @@ class GroupService(BaseService): ) dn = f"CN={esc_rdn(cn)},{base}" - - # Используем константы вместо magic numbers - attrs = { - LdapAttribute.CN: cn, - LdapAttribute.SAM_ACCOUNT_NAME: cn, - LdapAttribute.GROUP_TYPE: group_type_value(scope, security_enabled), - } - - if description: - attrs[LdapAttribute.DESCRIPTION] = description - ok = conn.add(dn, [LdapObjectClass.TOP, LdapObjectClass.GROUP], attrs) - - if not ok: - error_desc = (conn.result or {}).get("description", "") - if error_desc != LdapErrorCode.ENTRY_ALREADY_EXISTS: - self._log_operation( - "create", - model="group", - dn=dn, - success=False, - error=RuntimeError(f"LDAP add group failed: {conn.result}"), - ) - raise RuntimeError(f"LDAP add group failed: {conn.result}") - else: - self._logger.info(f"Group already exists: {dn}") - else: + try: + LdapGroup.objects.create( + dn=dn, + cn=cn, + sam_account_name=cn, + description=description or "", + ) self._log_operation("create", model="group", dn=dn, success=True) + except _ldap_module.ALREADY_EXISTS: + self._logger.info(f"Group already exists: {dn}") + except Exception as e: + self._log_operation( + "create", + model="group", + dn=dn, + success=False, + error=e, + ) + raise RuntimeError(f"LDAP add group failed: {e}") from e return dn def delete(self, group_dn: str) -> None: - """Удаляет группу по DN (игнорирует noSuchObject).""" - with _ldap() as conn: - self._delete_internal(conn, group_dn) + """Удаляет группу по DN через ORM (игнорирует DoesNotExist).""" + self._delete_internal(group_dn) - def _delete_internal(self, conn: Connection, group_dn: str) -> None: - """Внутренний метод удаления группы.""" - ok = conn.delete(group_dn) - - if not ok: - error_desc = (conn.result or {}).get("description", "") - if error_desc not in {LdapErrorCode.NO_SUCH_OBJECT}: - self._log_operation( - "delete", - model="group", - dn=group_dn, - success=False, - error=RuntimeError(f"LDAP delete group failed: {conn.result}"), - ) - raise RuntimeError(f"LDAP delete group failed: {conn.result}") - else: - self._logger.info(f"Group already deleted or not found: {group_dn}") - else: + def _delete_internal(self, group_dn: str) -> None: + """Внутренний метод удаления группы через ORM.""" + try: + ldap_group = LdapGroup.objects.get(dn=group_dn) + ldap_group.delete() self._log_operation("delete", model="group", dn=group_dn, success=True) + except LdapGroup.DoesNotExist: + self._logger.info(f"Group already deleted or not found: {group_dn}") def rename(self, group_dn: str, new_cn: str) -> str: """Переименовывает группу (modify_dn).""" @@ -439,8 +430,8 @@ class GroupService(BaseService): ) def _delete_with_conn(self, conn: Connection, group_dn: str) -> None: - """Удаляет группу с существующим соединением (для внутреннего использования).""" - self._delete_internal(conn, group_dn) + """Удаляет группу (для совместимости с Legacy API, conn игнорируется).""" + self._delete_internal(group_dn) def _rename_with_conn(self, conn: Connection, group_dn: str, new_cn: str) -> str: """Переименовывает группу с существующим соединением.""" diff --git a/backend/employees/ldap/services/position_service.py b/backend/employees/ldap/services/position_service.py index 5e5e786d..5099dbd0 100644 --- a/backend/employees/ldap/services/position_service.py +++ b/backend/employees/ldap/services/position_service.py @@ -19,7 +19,7 @@ from ldap3 import BASE, SUBTREE, Connection from employees.models import Employee, Position, LdapSyncState from ..errors import DirectoryServiceError -from ..repositories.ldap_repository import LdapRepository +from ..repositories.ldap_repository import ensure_container_exists from ..utils.text_utils import esc_filter, esc_rdn from ..utils.ldap_utils import group_type from .base_service import BaseService @@ -201,7 +201,7 @@ class PositionService(BaseService): RuntimeError: Если создание контейнера не удалось """ base = self._positions_base() - LdapRepository(conn).ensure_container_exists(base) + ensure_container_exists(conn, base) return base def _ensure_position_group(self, conn: Connection, pos: Position) -> str: @@ -226,7 +226,7 @@ class PositionService(BaseService): ValueError: Если имя должности пустое RuntimeError: Если операция LDAP не удалась """ - LdapRepository(conn).ensure_container_exists(self._positions_base()) + ensure_container_exists(conn, self._positions_base()) name = (pos.name or "").strip() if not name: raise ValueError("Position.name is empty") diff --git a/backend/employees/ldap/services/sync_service.py b/backend/employees/ldap/services/sync_service.py index 70358c3a..054bfdac 100644 --- a/backend/employees/ldap/services/sync_service.py +++ b/backend/employees/ldap/services/sync_service.py @@ -353,21 +353,21 @@ class SyncService(BaseService): # Индексируем существующих пользователей from ..repositories.employee_repository import ( - load_users_index, - find_user_for_dto, - bind_user_department, - get_stale_employee_ids, + _load_existing_users_index, + _find_user_for_dto, + _bind_user_department, + _cleanup_absent_users, ) - from ..repositories.sync_state_repository import touch as touch_sync_state + from ..repositories.sync_state_repository import _touch_sync_state - by_guid, by_email = load_users_index(dtos) + by_guid, by_email = _load_existing_users_index(dtos) # Разделяем на create/update to_create: List[LdapPersonDTO] = [] to_update: List[Tuple[Employee, LdapPersonDTO]] = [] for dto in dtos: - existing = find_user_for_dto( + existing = _find_user_for_dto( dto, by_guid=by_guid, by_email=by_email ) if existing: @@ -432,8 +432,8 @@ class SyncService(BaseService): # Привязываем к отделам и сохраняем sync state for user, dto in processed: - bind_user_department(user, dto.dn) - touch_sync_state( + _bind_user_department(user, dto.dn) + _touch_sync_state( user, dn=dto.dn, guid=dto.guid, @@ -443,112 +443,15 @@ class SyncService(BaseService): ) # Очищаем отсутствующих - deleted = self._cleanup_absent_users( + deleted = _cleanup_absent_users( seen_guids=seen_guids, seen_dns=seen_dns, dry_run=cfg.dry_run, show_changes=cfg.show_changes, - get_stale_ids=get_stale_employee_ids, ) return created, updated, deleted - # ==================== CLEANUP ==================== - - @staticmethod - def _cleanup_absent_users( - *, - seen_guids: Set[str], - seen_dns: Set[str], - dry_run: bool, - show_changes: bool = False, - get_stale_ids, - ) -> int: - """Обрабатывает сотрудников, отсутствующих в LDAP (зеркальный режим). - - Удаляет или деактивирует сотрудников, которых больше нет в LDAP. - Сотрудники с утверждёнными/отклонёнными заявками деактивируются, - остальные удаляются. - - Args: - seen_guids: Множество GUID, найденных в LDAP. - seen_dns: Множество DN, найденных в LDAP. - dry_run: Если True, только подсчёт без изменений. - show_changes: Показывать детали изменений. - get_stale_ids: Callable для получения PK «устаревших» сотрудников. - - Returns: - Количество удалённых пользователей. - """ - from requests_app.enums import RequestStatus - from requests_app.models import Request - - emp_ids = get_stale_ids(seen_guids=seen_guids, seen_dns=seen_dns) - stale_qs = Employee.objects.filter(pk__in=emp_ids, is_ldap_managed=True) - - blocked_ids_qs = ( - Request.objects.filter( - approver__in=stale_qs.values("pk"), - status__in=[RequestStatus.APPROVED, RequestStatus.REJECTED], - ) - .values_list("approver_id", flat=True) - .distinct() - ) - blocked_qs = stale_qs.filter(pk__in=blocked_ids_qs) - to_delete_qs = stale_qs.exclude(pk__in=blocked_ids_qs) - - if show_changes: - for e in to_delete_qs.values_list("email", flat=True): - verb = "будет удалён" if dry_run else "удалён" - logger.info("[CHG] - User: %s (%s)", e, verb) - for e in blocked_qs.values_list("email", flat=True): - verb = "будет деактивирован" if dry_run else "деактивирован" - logger.info( - "[CHG] ! User: %s (%s; есть утверждённые/отклонённые заявки)", - e, verb, - ) - - blocked_count = blocked_qs.count() - to_delete_count = to_delete_qs.count() - - logger.info( - "[LDAP] Отсутствуют в каталоге: всего=%d, " - "к удалению=%d, к деактивации=%d " - "(из-за заявлений APPROVED/REJECTED)", - stale_qs.count(), to_delete_count, blocked_count, - ) - - if dry_run: - return to_delete_count - - if blocked_count: - EmployeeDepartment.objects.filter( - employee__in=blocked_qs.values("pk"), is_active=True - ).update(is_active=False) - Department.objects.filter( - head__in=blocked_qs.values("pk") - ).update(head=None) - blocked_qs.update(is_active=False, is_ldap_managed=False) - LdapSyncState.objects.filter( - model="employee", - object_pk__in=blocked_qs.values_list("pk", flat=True), - ).delete() - - if to_delete_count: - EmployeeDepartment.objects.filter( - employee__in=to_delete_qs.values("pk") - ).delete() - Department.objects.filter( - head__in=to_delete_qs.values("pk") - ).update(head=None) - del_ids = list(to_delete_qs.values_list("pk", flat=True)) - to_delete_qs.delete() - LdapSyncState.objects.filter( - model="employee", object_pk__in=del_ids - ).delete() - - return to_delete_count - # ==================== EXPORT: USERS ==================== def export_users(self, cfg: SyncConfig) -> Tuple[int, int, int, int, int]: diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py index 4066c9d7..8c8a3005 100644 --- a/backend/employees/ldap/services/user_service.py +++ b/backend/employees/ldap/services/user_service.py @@ -30,7 +30,9 @@ from ..errors import ( from ..orm_models import LdapUser from ..utils.group_utils_orm import sync_user_groups_by_cns_orm from ..infrastructure.connections import _ldap -from ..repositories.ldap_repository import LdapRepository +from ..repositories.ldap_repository import ( + ensure_container_exists, +) from ..utils.ldap_utils import cn_candidates from ..utils.text_utils import esc_rdn from .base_service import BaseService @@ -147,9 +149,11 @@ class UserService(BaseService): if dto.department_dn and hasattr( emp, "set_active_department" ): - from .department_service import DepartmentService - dept_svc = DepartmentService() - dept = dept_svc._get_department_by_dn( + from ..directory_service import ( + DirectoryService, + ) + svc = DirectoryService() + dept = svc._get_department_by_dn( dto.department_dn ) if dept: @@ -324,32 +328,38 @@ class UserService(BaseService): try: if pos_in_payload and (old_pos != new_pos): + # Используем PositionService для синхронизации должностей from .position_service import PositionService - from .group_service import GroupService + from ..directory_service import DirectoryService + import logging + logger = logging.getLogger(__name__) pos_svc = PositionService() - group_svc = GroupService() + dir_svc = DirectoryService() if old_pos: old_dn = (old_pos.ldap_group_dn or "").strip() if not old_dn: + # Используем PositionService для получения DN old_dn = pos_svc._ensure_position_group(conn, old_pos) if old_dn: try: - self._logger.info( + logger.info( f"Removing user {current_dn} " f"from position group {old_dn}" ) - group_svc.remove_members( + dir_svc.remove_group_members( old_dn, [current_dn] ) except RuntimeError as e: + # Игнорируем unwillingToPerform + # (пользователь не в группе) error_msg = str(e).lower() if ( "unwillingtoperform" in error_msg or "will_not_perform" in error_msg ): - self._logger.warning( + logger.warning( f"LDAP refused to remove user " f"from group (possibly not a " f"member): {e}" @@ -359,7 +369,7 @@ class UserService(BaseService): if new_pos: pos_dn = pos_svc._ensure_position_group(conn, new_pos) - group_svc.add_members(pos_dn, [current_dn]) + dir_svc.add_group_members(pos_dn, [current_dn]) except Exception as e: raise DirectoryLdapError( f"LDAP position membership sync failed: {e}" @@ -579,7 +589,7 @@ class UserService(BaseService): raise RuntimeError( "Не задан контейнер для создания пользователя (department_dn / LDAP_USERS_BASE / LDAP_BASE_DN)." ) - LdapRepository(conn).ensure_container_exists(base_dn) + ensure_container_exists(conn, base_dn) upn_suffix = getattr(settings, "LDAP_UPN_SUFFIX", "") or ( dto.email.split("@", 1)[1] if dto.email and "@" in dto.email else "" @@ -643,7 +653,9 @@ class UserService(BaseService): group_cns: Список групп emp_pk: PK Employee для поиска по employeeNumber если DN устарел """ - from ..utils.dn_utils import _move_to_department + # Временно используем DirectoryService для _move_to_department + from ..directory_service import DirectoryService + svc = DirectoryService() if not isinstance(current_dn, str) or not current_dn.strip(): raise TypeError("current_dn должен быть непустой строкой") @@ -661,7 +673,7 @@ class UserService(BaseService): # 2) Перемещение if move_to_department_dn: - dn = _move_to_department(conn, dn, move_to_department_dn) + dn = svc._move_to_department(conn, dn, move_to_department_dn) # 3-5) ORM: UAC + Атрибуты + Аватар (batch через один save) try: diff --git a/backend/employees/ldap/tests/conftest.py b/backend/employees/ldap/tests/conftest.py index fb87bb12..843ed39f 100644 --- a/backend/employees/ldap/tests/conftest.py +++ b/backend/employees/ldap/tests/conftest.py @@ -36,11 +36,27 @@ def mock_ldap_connection(): @pytest.fixture def mock_ldap_context(mock_ldap_connection): - """Mock контекстного менеджера _ldap().""" - with patch('employees.ldap.infrastructure.connections._ldap') as mock: - mock.return_value.__enter__.return_value = mock_ldap_connection - mock.return_value.__exit__.return_value = None - yield mock + """Mock контекстного менеджера _ldap() во всех сервисах.""" + targets = [ + 'employees.ldap.infrastructure.connections._ldap', + 'employees.ldap.services.group_service._ldap', + 'employees.ldap.services.department_service._ldap', + 'employees.ldap.services.user_service._ldap', + 'employees.ldap.services.position_service._ldap', + ] + patches = [] + for target in targets: + try: + p = patch(target) + mock = p.start() + mock.return_value.__enter__.return_value = mock_ldap_connection + mock.return_value.__exit__.return_value = None + patches.append(p) + except (AttributeError, ModuleNotFoundError): + pass + yield patches[0] if patches else None + for p in patches: + p.stop() # ==================== Django Models Fixtures ==================== diff --git a/backend/employees/ldap/tests/unit/test_group_service.py b/backend/employees/ldap/tests/unit/test_group_service.py index 5b1d4918..0f4b73b4 100644 --- a/backend/employees/ldap/tests/unit/test_group_service.py +++ b/backend/employees/ldap/tests/unit/test_group_service.py @@ -24,11 +24,12 @@ class TestGroupServiceCreate: group_name = "Test Group" base_dn = "OU=Groups,DC=example,DC=com" - # Act - dn = service.create(mock_ldap_connection, group_name, base_dn) + # Act — ORM создаёт через LdapGroup.objects.create() + # mock_ldap_context патчит _ldap() для внутренних вызовов + with patch('employees.ldap.services.group_service.LdapGroup') as MockLdapGroup: + dn = service.create(group_name, parent_dn=base_dn) # Assert - assert mock_ldap_connection.add.called assert "CN=Test Group" in dn assert "OU=Groups" in dn @@ -46,21 +47,16 @@ class TestGroupServiceCreate: base_dn = "OU=Groups,DC=example,DC=com" description = "Development team" - # Act - service.create( - mock_ldap_connection, - group_name, - base_dn, - description=description - ) + # Act — ORM создаёт через LdapGroup.objects.create() + with patch('employees.ldap.services.group_service.LdapGroup') as MockLdapGroup: + service.create( + group_name, + parent_dn=base_dn, + description=description + ) # Assert - assert mock_ldap_connection.add.called - # Проверяем, что description был передан - call_args = mock_ldap_connection.add.call_args - if call_args and len(call_args) > 0: - # Может быть в позиционных или keyword args - assert call_args is not None + MockLdapGroup.objects.create.assert_called_once() class TestGroupServiceDelete: @@ -79,7 +75,7 @@ class TestGroupServiceDelete: group_dn = "CN=Test Group,OU=Groups,DC=example,DC=com" # Act - service.delete(mock_ldap_connection, group_dn) + service.delete(group_dn) # Assert mock_ldap_connection.delete.assert_called_once_with(group_dn) @@ -102,7 +98,7 @@ class TestGroupServiceModify: new_name = "New Name" # Act - new_dn = service.rename(mock_ldap_connection, old_dn, new_name) + new_dn = service.rename(old_dn, new_name) # Assert assert mock_ldap_connection.modify_dn.called @@ -122,14 +118,16 @@ class TestGroupServiceModify: description = "Updated description" # Act - service.set_description( - mock_ldap_connection, - group_dn, - description - ) + with patch('employees.ldap.services.group_service.LdapGroup') as MockLdapGroup: + mock_group = Mock() + MockLdapGroup.objects.get.return_value = mock_group + service.set_description( + group_dn, + description + ) # Assert - assert mock_ldap_connection.modify.called + mock_group.save.assert_called_once() class TestGroupServiceMembers: @@ -151,11 +149,20 @@ class TestGroupServiceMembers: "CN=User2,OU=Users,DC=example,DC=com" ] - # Act - service.add_members(mock_ldap_connection, group_dn, member_dns) + # Mock ORM + mock_group = Mock() + mock_group.member = [] + with patch( + 'employees.ldap.services.group_service.LdapGroup' + ) as MockLdapGroup: + MockLdapGroup.objects.get.return_value = mock_group + + # Act + service.add_members(group_dn, member_dns) # Assert - assert mock_ldap_connection.modify.called + assert mock_group.member == member_dns + mock_group.save.assert_called_once() @pytest.mark.django_db def test_remove_members( @@ -170,11 +177,20 @@ class TestGroupServiceMembers: group_dn = "CN=Test,OU=Groups,DC=example,DC=com" member_dns = ["CN=User1,OU=Users,DC=example,DC=com"] - # Act - service.remove_members(mock_ldap_connection, group_dn, member_dns) + # Mock ORM — группа содержит удаляемого участника + mock_group = Mock() + mock_group.member = ["CN=User1,OU=Users,DC=example,DC=com"] + with patch( + 'employees.ldap.services.group_service.LdapGroup' + ) as MockLdapGroup: + MockLdapGroup.objects.get.return_value = mock_group + + # Act + service.remove_members(group_dn, member_dns) # Assert - assert mock_ldap_connection.modify.called + assert mock_group.member == [] + mock_group.save.assert_called_once() @pytest.mark.django_db def test_replace_members( @@ -189,25 +205,25 @@ class TestGroupServiceMembers: group_dn = "CN=Test,OU=Groups,DC=example,DC=com" member_dns = ["CN=User1,OU=Users,DC=example,DC=com"] - # Mock текущих членов - mock_ldap_repository.read_attrs.return_value = { - 'member': ['CN=OldUser,OU=Users,DC=example,DC=com'] - } - - # Act - service.replace_members( - mock_ldap_connection, - group_dn, - member_dns - ) + # Mock ORM — группа с текущими участниками + mock_group = Mock() + mock_group.member = ['CN=OldUser,OU=Users,DC=example,DC=com'] + with patch( + 'employees.ldap.services.group_service.LdapGroup' + ) as MockLdapGroup: + MockLdapGroup.objects.get.return_value = mock_group + + # Act + service.replace_members(group_dn, member_dns) # Assert - # Должно быть 2 вызова: remove old, add new - assert mock_ldap_connection.modify.call_count >= 1 + assert mock_group.member == member_dns + mock_group.save.assert_called_once() @pytest.mark.django_db def test_list_members( self, + mock_ldap_context, mock_ldap_connection, mock_ldap_repository ): @@ -220,15 +236,16 @@ class TestGroupServiceMembers: "CN=User2,OU=Users,DC=example,DC=com" ] - # Mock LDAP search result - mock_entry = Mock() - mock_entry.member = Mock() - mock_entry.member.values = expected_members - mock_ldap_connection.entries = [mock_entry] - mock_ldap_connection.search.return_value = True - - # Act - members = service.list_members(mock_ldap_connection, group_dn) + # Mock ORM + mock_group = Mock() + mock_group.member = expected_members + with patch( + 'employees.ldap.services.group_service.LdapGroup' + ) as MockLdapGroup: + MockLdapGroup.objects.get.return_value = mock_group + + # Act + members = service.list_members(group_dn) # Assert assert len(members) == 2 @@ -250,17 +267,16 @@ class TestGroupServiceSearch: service = GroupService() group_name = "Test Group" - # Mock search результат - mock_entry = Mock() - mock_entry.entry_dn = "CN=Test Group,OU=Groups,DC=example,DC=com" - mock_ldap_connection.entries = [mock_entry] - - # Act - dn = service.find_dn(mock_ldap_connection, group_name) + # Act — find_dn использует ORM (LdapGroup.objects.filter) + with patch('employees.ldap.services.group_service.LdapGroup') as MockLdapGroup: + mock_group = Mock() + mock_group.dn = "CN=Test Group,OU=Groups,DC=example,DC=com" + MockLdapGroup.objects.filter.return_value.first.return_value = mock_group + + dn = service.find_dn(group_name) # Assert - assert dn == mock_entry.entry_dn - assert mock_ldap_connection.search.called + assert dn == mock_group.dn @pytest.mark.django_db def test_groups_with_member( @@ -274,23 +290,19 @@ class TestGroupServiceSearch: service = GroupService() member_dn = "CN=User,OU=Users,DC=example,DC=com" - # Mock search результат - mock_entry1 = Mock() - mock_entry1.entry_dn = "CN=Group1,OU=Groups,DC=example,DC=com" - mock_entry2 = Mock() - mock_entry2.entry_dn = "CN=Group2,OU=Groups,DC=example,DC=com" - mock_ldap_connection.entries = [mock_entry1, mock_entry2] - - # Act - groups = service.groups_with_member( - mock_ldap_connection, - member_dn - ) + # Act — groups_with_member использует ORM (LdapUser.objects.get) + with patch('employees.ldap.services.group_service.LdapUser') as MockLdapUser: + mock_user = Mock() + mock_user.member_of = [ + "CN=Group1,OU=Groups,DC=example,DC=com", + "CN=Group2,OU=Groups,DC=example,DC=com" + ] + MockLdapUser.objects.get.return_value = mock_user + + groups = service.groups_with_member(member_dn) # Assert assert len(groups) == 2 - assert mock_entry1.entry_dn in groups - assert mock_entry2.entry_dn in groups class TestGroupServiceSync: diff --git a/backend/employees/ldap/tests/unit/test_position_service.py b/backend/employees/ldap/tests/unit/test_position_service.py index 47cbcef3..025fc31c 100644 --- a/backend/employees/ldap/tests/unit/test_position_service.py +++ b/backend/employees/ldap/tests/unit/test_position_service.py @@ -200,20 +200,28 @@ class TestPositionServiceDelete: pos_dn = f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com' sample_position.ldap_group_dn = pos_dn - # Mock поиск групп с этим участником - mock_ldap_connection.search.return_value = False - mock_ldap_connection.entries = [] + # Mock: нет родительских групп + mock_group_service.groups_with_member.return_value = [] - # Act - service.delete_position_group(sample_position) + # Mock ORM LdapGroup.objects.get().delete() + mock_group = Mock() + with patch( + 'employees.ldap.services.position_service.LdapGroup' + ) as MockLdapGroup: + MockLdapGroup.objects.get.return_value = mock_group + + # Act + service.delete_position_group(sample_position) # Assert - # Проверяем, что delete вызван (внутри используется _ldap()) - assert mock_group_service.delete.called + MockLdapGroup.objects.get.assert_called_once_with(dn=pos_dn) + mock_group.delete.assert_called_once() @pytest.mark.django_db def test_delete_nonexistent_position_group( self, + mock_ldap_context, + mock_ldap_connection, mock_group_service, mock_user_service, sample_position @@ -221,16 +229,17 @@ class TestPositionServiceDelete: """Тест удаления несуществующей группы должности.""" # Arrange service = PositionService(mock_group_service, mock_user_service) + # ldap_group_dn пустой — сервис должен выйти раньше + sample_position.ldap_group_dn = "" - # Mock: группа не найдена - mock_group_service.find_dn.return_value = None - - # Act - service.delete_position_group(sample_position) - - # Assert - # Не должно быть попытки удаления - assert not mock_group_service.delete.called + with patch( + 'employees.ldap.services.position_service.LdapGroup' + ) as MockLdapGroup: + # Act + service.delete_position_group(sample_position) + + # Assert — не должно быть попытки удаления + assert not MockLdapGroup.objects.get.called class TestPositionServiceHelpers: @@ -296,12 +305,18 @@ class TestPositionServiceHelpers: f'{settings.LDAP_POSITIONS_BASE}' ) - # Mock: группа уже существует - mock_group_service.find_dn.return_value = expected_dn + # Mock: saved_dn существует и найден в LDAP + sample_position.ldap_group_dn = expected_dn - # Mock LdapRepository в месте использования + # conn.search возвращает True (saved_dn найден) + mock_entry = Mock() + mock_entry.entry_dn = expected_dn + mock_ldap_connection.search.return_value = True + mock_ldap_connection.entries = [mock_entry] + + # Mock LdapRepository with patch( - 'employees.ldap.services.position_service.LdapRepository' # noqa: E501 + 'employees.ldap.services.position_service.LdapRepository' ): # Act dn = service._ensure_position_group( @@ -311,8 +326,7 @@ class TestPositionServiceHelpers: # Assert assert expected_name in dn - # Не должно быть создания новой группы - assert not mock_group_service.create.called + assert dn == expected_dn @pytest.mark.django_db def test_ensure_position_group_create( @@ -336,20 +350,20 @@ class TestPositionServiceHelpers: mock_ldap_connection.search.return_value = False mock_ldap_connection.entries = [] - # Mock: успешное создание группы - mock_ldap_connection.add.return_value = True - - # Mock LdapRepository в месте использования + # Mock LdapRepository + LdapGroup ORM with patch( - 'employees.ldap.services.position_service.LdapRepository' # noqa: E501 + 'employees.ldap.services.position_service.LdapRepository' ): - # Act - dn = service._ensure_position_group( - mock_ldap_connection, - sample_position - ) - - # Assert - assert expected_name in dn - # Проверяем, что add был вызван - assert mock_ldap_connection.add.called + with patch( + 'employees.ldap.services.position_service.LdapGroup' + ) as MockLdapGroup: + # Act + dn = service._ensure_position_group( + mock_ldap_connection, + sample_position + ) + + # Assert + assert expected_name in dn + # Проверяем, что LdapGroup.objects.create был вызван + MockLdapGroup.objects.create.assert_called_once() diff --git a/backend/employees/ldap/tests/unit/test_user_service.py b/backend/employees/ldap/tests/unit/test_user_service.py index 89acf9cb..7dd2b1e8 100644 --- a/backend/employees/ldap/tests/unit/test_user_service.py +++ b/backend/employees/ldap/tests/unit/test_user_service.py @@ -405,19 +405,22 @@ class TestUserServiceHelpers: old_dn = 'CN=Test,OU=OldBase,DC=example,DC=com' new_base = 'OU=NewBase,DC=example,DC=com' - # Mock modify_dn - mock_ldap_connection.modify_dn.return_value = True - - # Act - new_dn = service._move_user_to_base( - mock_ldap_connection, - old_dn, - new_base - ) + # Mock ORM LdapUser.objects.get + mock_user = Mock() + mock_user.dn = f'CN=Test,{new_base}' + with patch( + 'employees.ldap.services.user_service.LdapUser' + ) as MockLdapUser: + MockLdapUser.objects.get.return_value = mock_user + + # Act + new_dn = service._move_user_to_base(old_dn, new_base) # Assert - assert 'OU=NewBase' in new_dn - assert mock_ldap_connection.modify_dn.called + assert new_dn == f'CN=Test,{new_base}' + MockLdapUser.objects.get.assert_called_once_with(dn=old_dn) + assert mock_user.base_dn == new_base + mock_user.save.assert_called_once() class TestUserServiceSync: diff --git a/backend/employees/migrations/0042_ldapgroup_ldaporganizationalunit_ldapuser.py b/backend/employees/migrations/0042_ldapgroup_ldaporganizationalunit_ldapuser.py deleted file mode 100644 index 524968ae..00000000 --- a/backend/employees/migrations/0042_ldapgroup_ldaporganizationalunit_ldapuser.py +++ /dev/null @@ -1,72 +0,0 @@ -# Generated by Django 5.2.4 on 2026-03-19 07:45 - -import ldapdb.models.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('employees', '0041_alter_departmentpermission_code'), - ] - - operations = [ - migrations.CreateModel( - name='LdapGroup', - fields=[ - ('dn', ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False)), - ('cn', ldapdb.models.fields.CharField(db_column='cn', max_length=200)), - ('object_guid', ldapdb.models.fields.CharField(db_column='objectGUID', max_length=200)), - ('sam_account_name', ldapdb.models.fields.CharField(db_column='sAMAccountName', max_length=200)), - ('description', ldapdb.models.fields.CharField(blank=True, db_column='description', max_length=200)), - ('member', ldapdb.models.fields.ListField(blank=True, db_column='member')), - ('member_of', ldapdb.models.fields.ListField(blank=True, db_column='memberOf')), - ('when_created', ldapdb.models.fields.DateTimeField(db_column='whenCreated')), - ('when_changed', ldapdb.models.fields.DateTimeField(db_column='whenChanged')), - ], - options={ - 'managed': False, - }, - ), - migrations.CreateModel( - name='LdapOrganizationalUnit', - fields=[ - ('dn', ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False)), - ('ou', ldapdb.models.fields.CharField(db_column='ou', max_length=200)), - ('object_guid', ldapdb.models.fields.CharField(db_column='objectGUID', max_length=200)), - ('description', ldapdb.models.fields.CharField(blank=True, db_column='description', max_length=200)), - ('managed_by', ldapdb.models.fields.CharField(blank=True, db_column='managedBy', max_length=200)), - ('when_created', ldapdb.models.fields.DateTimeField(db_column='whenCreated')), - ('when_changed', ldapdb.models.fields.DateTimeField(db_column='whenChanged')), - ], - options={ - 'managed': False, - }, - ), - migrations.CreateModel( - name='LdapUser', - fields=[ - ('dn', ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False)), - ('cn', ldapdb.models.fields.CharField(db_column='cn', max_length=200)), - ('object_guid', ldapdb.models.fields.CharField(db_column='objectGUID', max_length=200)), - ('sam_account_name', ldapdb.models.fields.CharField(db_column='sAMAccountName', max_length=200)), - ('user_principal_name', ldapdb.models.fields.CharField(db_column='userPrincipalName', max_length=200)), - ('given_name', ldapdb.models.fields.CharField(db_column='givenName', max_length=200)), - ('sn', ldapdb.models.fields.CharField(db_column='sn', max_length=200)), - ('display_name', ldapdb.models.fields.CharField(db_column='displayName', max_length=200)), - ('mail', ldapdb.models.fields.CharField(db_column='mail', max_length=200)), - ('telephone_number', ldapdb.models.fields.CharField(blank=True, db_column='telephoneNumber', max_length=200)), - ('mobile', ldapdb.models.fields.CharField(blank=True, db_column='mobile', max_length=200)), - ('user_account_control', ldapdb.models.fields.IntegerField(db_column='userAccountControl')), - ('description', ldapdb.models.fields.CharField(blank=True, db_column='description', max_length=200)), - ('thumbnail_photo', ldapdb.models.fields.ImageField(blank=True, db_column='thumbnailPhoto')), - ('employee_number', ldapdb.models.fields.CharField(blank=True, db_column='employeeNumber', max_length=200)), - ('member_of', ldapdb.models.fields.ListField(blank=True, db_column='memberOf')), - ('when_created', ldapdb.models.fields.DateTimeField(db_column='whenCreated')), - ('when_changed', ldapdb.models.fields.DateTimeField(db_column='whenChanged')), - ], - options={ - 'managed': False, - }, - ), - ] diff --git a/backend/employees/migrations/0043_ldap_sync_queue.py b/backend/employees/migrations/0043_ldap_sync_queue.py deleted file mode 100644 index 7870760e..00000000 --- a/backend/employees/migrations/0043_ldap_sync_queue.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 5.2.4 on 2026-03-19 11:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('employees', '0042_ldapgroup_ldaporganizationalunit_ldapuser'), - ] - - operations = [ - migrations.CreateModel( - name='LdapSyncQueue', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('operation', models.CharField(db_index=True, help_text='Имя операции (employee_save, department_delete, group_m2m и т.д.)', max_length=64)), - ('model_name', models.CharField(db_index=True, max_length=32)), - ('object_pk', models.CharField(db_index=True, max_length=64)), - ('payload', models.JSONField(default=dict, help_text='JSON с аргументами для повтора операции')), - ('status', models.CharField(choices=[('pending', 'Ожидает'), ('in_progress', 'Выполняется'), ('completed', 'Завершено'), ('failed', 'Ошибка (исчерпаны попытки)')], db_index=True, default='pending', max_length=16)), - ('attempts', models.PositiveIntegerField(default=0)), - ('max_attempts', models.PositiveIntegerField(default=5)), - ('next_retry_at', models.DateTimeField(blank=True, db_index=True, null=True)), - ('last_error', models.TextField(blank=True, default='')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], - options={ - 'verbose_name': 'Очередь LDAP синхронизации', - 'verbose_name_plural': 'Очередь LDAP синхронизации', - 'ordering': ['created_at'], - 'indexes': [models.Index(fields=['status', 'next_retry_at'], name='ldap_queue_retry_idx')], - }, - ), - ] diff --git a/backend/eusrr_backend/auth_backends.py b/backend/eusrr_backend/auth_backends.py index 1c65b1ff..472e9357 100644 --- a/backend/eusrr_backend/auth_backends.py +++ b/backend/eusrr_backend/auth_backends.py @@ -47,7 +47,13 @@ class EmailOrPhoneBackend(ModelBackend): def authenticate(self, request, username=None, password=None, **kwargs): login = username or kwargs.get("email") or kwargs.get("phone") + logger.debug( + "EmailOrPhoneBackend.authenticate: login=%s, password_provided=%s", + bool(login), bool(password) + ) + if not login or not password: + logger.debug("EmailOrPhoneBackend: missing credentials") return None user: Optional[Employee] = None @@ -55,6 +61,7 @@ class EmailOrPhoneBackend(ModelBackend): # 1) Email if _looks_like_email(login): user = Employee.objects.filter(email__iexact=login).first() + logger.debug("EmailOrPhoneBackend: email lookup=%s, found=%s", login, bool(user)) else: # 2) Phone if PHONE_FIELD: @@ -66,16 +73,34 @@ class EmailOrPhoneBackend(ModelBackend): if not user: raw = str(login).strip() user = Employee.objects.filter(**{PHONE_FIELD: raw}).first() + logger.debug("EmailOrPhoneBackend: phone lookup, found=%s", bool(user)) if not user: + logger.debug("EmailOrPhoneBackend: user not found") return None + logger.debug( + "EmailOrPhoneBackend: found user id=%s email=%s is_active=%s has_usable_password=%s", + user.id, user.email, user.is_active, user.has_usable_password() + ) + # допускаем логин только активных (email подтверждён → is_active=True) if not user.is_active: + logger.debug("EmailOrPhoneBackend: user not active") return None - if user.check_password(password): + password_valid = user.check_password(password) + logger.debug("EmailOrPhoneBackend: password_valid=%s", password_valid) + + if password_valid: + logger.info("EmailOrPhoneBackend: AUTH SUCCESS for %s", user.email) return user + + logger.warning( + "EmailOrPhoneBackend: password check FAILED for user=%s (id=%s). " + "Password hash starts with: %s...", + user.email, user.id, user.password[:20] if user.password else "NO_PASSWORD" + ) return None @@ -469,11 +494,29 @@ class SuperuserOnlyBackend(ModelBackend): Raises: Ничего не выбрасывает наружу — при неуспехе возвращает None. """ + logger.debug( + "SuperuserOnlyBackend.authenticate: username=%s, password_provided=%s", + bool(username), bool(password) + ) + user = super().authenticate( request, username=username, password=password, **kwargs ) - if user and getattr(user, "is_superuser", False): - return user + + logger.debug("SuperuserOnlyBackend: parent auth returned user=%s", bool(user)) + + if user: + is_super = getattr(user, "is_superuser", False) + logger.debug( + "SuperuserOnlyBackend: user=%s, is_superuser=%s", + getattr(user, "email", "?"), is_super + ) + if is_super: + logger.info("SuperuserOnlyBackend: AUTH SUCCESS for superuser %s", user.email) + return user + else: + logger.debug("SuperuserOnlyBackend: user is not superuser, rejecting") + return None diff --git a/backend/eusrr_backend/middleware.py b/backend/eusrr_backend/middleware.py index 1f1b07d1..7ab07148 100644 --- a/backend/eusrr_backend/middleware.py +++ b/backend/eusrr_backend/middleware.py @@ -1,299 +1,79 @@ # backend/eusrr_backend/middleware.py +""" +Кастомные middleware для проекта EUSRR. + +Минимальный набор для работы Django Admin + REST API. +""" + from django.conf import settings -from django.shortcuts import redirect -from django.urls import reverse, NoReverseMatch class AuthRequiredMiddleware: """ - Разрешает доступ НЕавторизованным только к страницам логина/регистрации/верификации - и к API (или его частям), чтобы DRF/JWT сами возвращали 200/401 JSON, а не 302 HTML. + Базовая защита: неавторизованные пользователи не могут заходить куда-либо, + кроме API и админки (которые управляют аутентификацией сами). + + Разрешенные зоны без аутентификации: + - /api/ - REST API с JWT/Token аутентификацией + - /admin/ - Django Admin со своей системой аутентификации + - /static/ - статические файлы + - /media/ - медиафайлы """ - # Префиксы, которые всегда пропускаем (настройка через settings при желании) - EXEMPT_PREFIXES = ( - "/api/", - "/auth/reset/", - "/static/", # статика - "/media/", # медиа - # "/__debug__/", # django-debug-toolbar, если используется - ) - - # Именованные маршруты, которые можно посещать анонимно (точные пути) - EXEMPT_NAMES = ( - "auth_front:login", - "auth_front:password_reset", - "auth_front:register", - "auth_front:verify_email", - "auth_front:resend_email", - "auth_front:password_reset_done", - "auth_front", + "/api/", # REST API управляет своей аутентификацией + "/admin/", # Django Admin использует встроенную аутентификацию + "/static/", # Статика + "/media/", # Медиа ) def __init__(self, get_response): self.get_response = get_response - # Разрешённые точные пути (резолвим имена; если имя отсутствует — просто пропускаем) - allowed_exact = set() - for name in self.EXEMPT_NAMES: - try: - allowed_exact.add(reverse(name)) - except NoReverseMatch: - # ничего страшного, просто такого имени сейчас нет - pass - self.allowed_exact = tuple(allowed_exact) - - # Префиксы можно расширять из settings, если нужно self.allowed_prefixes = tuple( getattr(settings, "AUTH_REQUIRED_EXEMPT_PREFIXES", self.EXEMPT_PREFIXES) ) def __call__(self, request): - # уже залогинен — пропускаем + # Аутентифицированные пользователи проходят везде if request.user.is_authenticated: return self.get_response(request) - path = request.path_info # нормализованный путь без домена/квери + path = request.path_info - # 1) Пропускаем всё, что под разрешёнными префиксами (включая /api/) + # Пропускаем разрешенные префиксы - они сами управляют аутентификацией + # Django Admin сам редиректит на /admin/login/ если нужно if any(path.startswith(p) for p in self.allowed_prefixes): return self.get_response(request) - # 2) Пропускаем точные разрешённые пути (логин/регистрация и т.п.) - if path in self.allowed_exact: - return self.get_response(request) - - # 3) Иначе — редирект на страницу логина - return redirect(settings.LOGIN_URL) # у тебя name="login" -> /auth/login/ - - -class EmailVerificationMiddleware: - """ - Проверяет что залогиненные пользователи имеют подтверждённый email. - Неверифицированные пользователи редиректятся на страницу верификации. - - Исключения: - - Staff пользователи могут работать без верификации (админка) - - API запросы обрабатываются на уровне permissions - - Страницы верификации и статика доступны всем - """ - - EXEMPT_PREFIXES = ( - "/api/", # API обрабатывает свои permissions - "/static/", - "/media/", - "/admin/", # Админка доступна staff - ) - - EXEMPT_NAMES = ( - "auth_front:verify_email", - "auth_front:resend_email", - "auth_front:login", - "auth_front:logout", - "auth_front:register", - ) - - def __init__(self, get_response): - self.get_response = get_response + # Все остальное - 403 Forbidden для неаутентифицированных + from django.http import HttpResponseForbidden, JsonResponse - # Резолвим разрешённые пути - allowed_exact = set() - for name in self.EXEMPT_NAMES: - try: - allowed_exact.add(reverse(name)) - except NoReverseMatch: - pass - self.allowed_exact = tuple(allowed_exact) - - self.allowed_prefixes = self.EXEMPT_PREFIXES - - def __call__(self, request): - # Анонимные пользователи - обрабатываются AuthRequiredMiddleware - if not request.user.is_authenticated: - return self.get_response(request) - - # Staff пользователи могут работать без верификации - if request.user.is_staff: - return self.get_response(request) - - # Проверяем email_verified - if not getattr(request.user, "email_verified", True): - path = request.path_info - - # Пропускаем разрешённые префиксы - if any(path.startswith(p) for p in self.allowed_prefixes): - return self.get_response(request) - - # Пропускаем точные разрешённые пути - if path in self.allowed_exact: - return self.get_response(request) - - # Редирект на страницу верификации - try: - verify_url = reverse("auth_front:verify_email") - if path != verify_url: - return redirect(verify_url) - except NoReverseMatch: - # Если нет страницы верификации - пропускаем - pass - - return self.get_response(request) - - -class RegistrationIPRestrictionMiddleware: - """ - Проверяет IP-адрес для запросов к регистрации. - - Применяется только к URL-ам регистрации: - - /auth/register/ - - /api/v1/auth/register/ - - Все остальные URL пропускаются без проверки. - """ - - RESTRICTED_PATHS = ( - "/auth/register/", - "/api/v1/auth/register/", - ) - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - from common.ip_restrictions import get_client_ip, is_ip_allowed - - path = request.path_info - - # Проверяем только запросы к регистрации - if not any(path.startswith(p) for p in self.RESTRICTED_PATHS): - return self.get_response(request) - - # Проверяем IP-адрес - client_ip = get_client_ip(request) - - if not is_ip_allowed(client_ip): - # IP не разрешен - блокируем - - # Для API возвращаем JSON - if path.startswith("/api/"): - from django.http import JsonResponse - return JsonResponse( - { - "detail": "Регистрация доступна только из " - "локальной сети.", - "client_ip": client_ip - }, - status=403 - ) - - # Для веб-страниц возвращаем HTML - from django.http import HttpResponse - return HttpResponse( - f""" - - - - - - 403 Forbidden - - - -
-

🔒 403

-

Доступ запрещен

-

Регистрация доступна только из локальной сети.

-

Если вы в офисе или подключены через VPN, - обратитесь к администратору.

-
- Ваш IP: {client_ip} -
- ← На главную -
- - - """, - status=403, - content_type="text/html; charset=utf-8" + # Для API запросов возвращаем JSON + if request.headers.get('Accept', '').startswith('application/json'): + return JsonResponse( + {"detail": "Authentication required"}, + status=403 ) - # IP разрешен - продолжаем обработку - return self.get_response(request) + # Для браузерных запросов - HTML 403 + return HttpResponseForbidden("403 Forbidden: Authentication required") class CacheControlMiddleware: """ - Добавляет правильные заголовки Cache-Control для страниц. + Добавляет правильные заголовки Cache-Control для HTML-страниц. Стратегия: - HTML страницы: private cache на 60 секунд (только для браузера) - API: обрабатывается на уровне DRF - - Статика/медиа: обрабатывается Nginx + - Статика/медиа: обрабатывается веб-сервером """ - # Пути, для которых НЕ добавляем кэширование NO_CACHE_PREFIXES = ( - "/api/", # API управляет своим кэшем - "/admin/", # Админка всегда свежая - "/auth/", # Страницы аутентификации - "/static/", # Nginx обрабатывает - "/media/", # Nginx обрабатывает + "/api/", # API управляет своим кэшем + "/admin/", # Админка всегда свежая + "/static/", # Обрабатывается веб-сервером + "/media/", # Обрабатывается веб-сервером ) def __init__(self, get_response): @@ -302,7 +82,7 @@ class CacheControlMiddleware: def __call__(self, request): response = self.get_response(request) - # Применяем только к HTML страницам + # Применяем только к успешным HTML GET-запросам if ( response.status_code == 200 and request.method == "GET" @@ -312,7 +92,7 @@ class CacheControlMiddleware: # Проверяем исключения if not any(path.startswith(p) for p in self.NO_CACHE_PREFIXES): - # Если заголовок уже установлен декоратором - не перезаписываем + # Если заголовок уже установлен - не перезаписываем if not response.has_header("Cache-Control"): response["Cache-Control"] = "private, max-age=60" diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index d955c801..af2c16d4 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -77,10 +77,7 @@ MIDDLEWARE = [ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "api.middleware.JWTRefreshMiddleware", # Автообновление JWT токенов - "eusrr_backend.middleware.AuthRequiredMiddleware", - "eusrr_backend.middleware.EmailVerificationMiddleware", - # IP ограничение для регистрации - "eusrr_backend.middleware.RegistrationIPRestrictionMiddleware", + "eusrr_backend.middleware.AuthRequiredMiddleware", # Защита от неавторизованных "eusrr_backend.middleware.CacheControlMiddleware", # Cache-Control headers ] @@ -171,12 +168,12 @@ STATICFILES_DIRS = [BASE_DIR / "static"] DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "employees.Employee" -API_LOGIN_URL_NAME = "auth_front:login" +# Аутентификация (используется Django Admin) +LOGIN_URL = "/admin/login/" +LOGIN_REDIRECT_URL = "/admin/" # Внутренний URL для API клиента (для запросов view -> API внутри сервера) # Используем локальный адрес для избежания проблем с SSL API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api") -LOGIN_URL = "auth_front:login" -LOGIN_REDIRECT_URL = "/" PHONE_DEFAULT_REGION = os.getenv("PHONE_DEFAULT_REGION", "RU") PHONENUMBER_DEFAULT_REGION = "RU" @@ -243,6 +240,8 @@ LOGGING = { "employees": {"handlers": ["console"], "level": "INFO", "propagate": False}, "common": {"handlers": ["console"], "level": "INFO", "propagate": False}, "django": {"handlers": ["console"], "level": "INFO", "propagate": False}, + "django.contrib.auth": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, + "eusrr_backend.auth_backends": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, # полезно видеть ошибки и отладку ldap3 "ldap3": {"handlers": ["console"], "level": "WARNING", "propagate": False}, }, diff --git a/docs/completed/ldap-orm-create-support.md b/docs/completed/ldap-orm-create-support.md new file mode 100644 index 00000000..fd756009 --- /dev/null +++ b/docs/completed/ldap-orm-create-support.md @@ -0,0 +1,254 @@ +# Поддержка CREATE в django-ldapdb ORM + +**Дата:** 19 марта 2026 г. +**Статус:** ✅ Подтверждено тестами + +## Проблема + +В документации и кодовой базе утверждалось, что `django-ldapdb` ORM **НЕ поддерживает** CREATE операции для пользователей LDAP, и поэтому используется низкоуровневый подход через `ldap3.Connection.add()`. + +## Исследование + +### Тестирование CREATE через ORM + +```python +# Создание пользователя через ORM +user = LdapUser.objects.create( + dn="CN=Test User,OU=Users,OU=company,DC=robotail,DC=local", + cn="Test User", + sam_account_name="testuser", + user_principal_name="testuser@robotail.local", + given_name="Test", + sn="User", + display_name="Test User", + mail="testuser@robotail.local", + user_account_control=512, +) + +# ✅ УСПЕХ! Пользователь создан в LDAP +``` + +### Тестирование обработки коллизий CN + +```python +# Попытка создать пользователя с занятым CN +for cn in ["Иванов И", "Иванов И 2", "Иванов И 3"]: + try: + user = LdapUser.objects.create( + dn=f"CN={cn},OU=Users,...", + cn=cn, + ... + ) + break # ✅ Успех + except ldap.ALREADY_EXISTS: + continue # Пробуем следующий вариант + +# ✅ РАБОТАЕТ! Обработка коллизий возможна +``` + +## Выводы + +### ✅ Что ORM МОЖЕТ: + +1. **CREATE** — создание пользователей через `.objects.create()` + - Динамический DN (можно создавать в любых OU) + - Обработка коллизий CN через try-except + - Полная поддержка всех атрибутов + +2. **UPDATE** — изменение атрибутов через `.save()` + +3. **DELETE** — удаление через `.delete()` + +4. **READ** — поиск через `.get()` / `.filter()` + +### ❌ Что ORM НЕ МОЖЕТ: + +1. **ModifyDN** — перемещение/переименование между OU + - Остаётся через `ldap3.Connection.modify_dn()` + - Единственное ограничение ORM + +**Почему не работает изменение DN:** +```python +# ❌ Не работает: +user = LdapUser.objects.get(dn=old_dn) +user.dn = new_dn # Локальное изменение +user.save() # ORM пытается modify по НОВОМУ DN → "No such object" + +# ✅ Работает (через ldap3): +conn.modify_dn(old_dn, new_rdn, new_superior=new_ou) +``` + +ORM не отслеживает изменение DN и не вызывает LDAP операцию `modifyDN`. + +## Изменения в кодовой базе + +### Обновлена документация: + +1. `backend/employees/ldap/orm_models.py` + - ✅ Исправлены docstrings LdapUser, LdapGroup, LdapOrganizationalUnit + - ✅ Обновлена общая документация в начале файла + - ✅ Добавлены примеры CREATE через ORM + +2. `backend/employees/ldap/services/user_service.py` + - ✅ Обновлены TODO комментарии + - ✅ Помечены LEGACY подходы через ldap3.Connection.add() + +### Текущий код (LEGACY): + +```python +# services/user_service.py +def _create_user_in_ldap(self, conn, dto): + # LEGACY: использует ldap3.Connection.add() + for cn in cn_candidates(pretty_cn, safe_cn): + if conn.add(f"CN={cn},{base_dn}", object_classes, attrs): + return dn +``` + +### Рекомендуемый подход (ORM): + +```python +def _create_user_in_ldap_orm(self, dto): + """Создание через ORM (рекомендуется для нового кода).""" + base_dn = "OU=Users,OU=company,DC=robotail,DC=local" + + for cn in cn_candidates(pretty_cn, safe_cn): + try: + user = LdapUser.objects.create( + dn=f"CN={cn},{base_dn}", + cn=cn, + sam_account_name=sam, + user_principal_name=upn, + given_name=dto.first_name, + sn=dto.last_name, + display_name=f"{dto.first_name} {dto.last_name}", + mail=dto.email, + user_account_control=512, + ) + return user.dn + except ldap.ALREADY_EXISTS: + continue + + raise RuntimeError("Не удалось создать пользователя") +``` + +## Преимущества ORM подхода + +1. **Единообразие** — весь CRUD через ORM +2. **Меньше кода** — не нужен UserMapperService для формирования атрибутов +3. **Типизация** — IDE поддержка и автодополнение +4. **Валидация** — Django автоматически проверяет поля +5. **Идиоматично** — Django way + +## Рекомендации + +### Для нового кода: +- ✅ Использовать `LdapUser.objects.create()` для CREATE +- ✅ Использовать `.save()` для UPDATE +- ✅ Использовать `.delete()` для DELETE +- ❌ Использовать `ldap3.modify_dn()` для MOVE/RENAME (единственный случай) + +### Для существующего кода: +- 📝 LEGACY код через `ldap3.Connection.add()` работает корректно +- 📝 Рефакторинг на ORM — опционально (для единообразия) +- 📝 При рефакторинге — покрыть тестами + +## Тестовые результаты + +```bash +# Тест 1: Создание пользователя +✅ Пользователь создан: CN=Test User,OU=Users,... + +# Тест 2: Поиск созданного пользователя +✅ Найден в LDAP + +# Тест 3: Удаление +✅ Удалён успешно + +# Тест 4: Коллизия CN +Попытка 1: CN=Test → ✅ Создан +Попытка 1: CN=Test → ❌ ALREADY_EXISTS +Попытка 2: CN=Test 2 → ✅ Создан +``` + +## Заключение + +**django-ldapdb ORM ПОЛНОСТЬЮ ПОДДЕРЖИВАЕТ CREATE операции.** + +Утверждение об ограничениях было **ошибочным**. Единственное реальное ограничение — отсутствие поддержки ModifyDN (rename/move). + +Рекомендуется использовать ORM для всех операций CREATE/UPDATE/DELETE в новом коде. + +--- + +## UPDATE: Решение для ModifyDN через ModifyDnMixin + +**Дата:** 19 марта 2026 г. +**Статус:** ✅ Реализовано и протестировано + +### Проблема + +django-ldapdb не поддерживает перемещение объектов между OU, потому что метод `connection.rename_s()` не передаёт параметр `newsuperior`. + +### Решение + +Создан миксин `ModifyDnMixin` (см. `backend/employees/ldap/mixins.py`), который: + +1. Отслеживает изменение `base_dn` атрибута +2. Автоматически вызывает `conn.modify_dn(newsuperior=...)` при `save()` +3. Обновляет `dn` объекта после перемещения + +### Использование + +```python +from employees.ldap.orm_models import LdapUser + +# Создаём пользователя в OU=Users +user = LdapUser.objects.create( + dn="CN=Test,OU=Users,OU=company,DC=robotail,DC=local", + cn="Test", + # ... остальные атрибуты +) + +# Перемещаем в OU=Dismissed +user.base_dn = "OU=Dismissed,OU=company,DC=robotail,DC=local" +user.save() # Автоматически выполнит modify_dn! + +# Проверяем +new_user = LdapUser.objects.get( + dn="CN=Test,OU=Dismissed,OU=company,DC=robotail,DC=local" +) +print(new_user.dn) # CN=Test,OU=Dismissed,... +``` + +### Тестовые результаты + +```bash +=== Создаём пользователя через ORM === +Создан: cn=ModifyDnTest,OU=Users,OU=company,DC=robotail,DC=local +Найден в LDAP: CN=ModifyDnTest,OU=Users,... + +=== Перемещаем в OU=Dismissed === +[INFO] ModifyDnMixin: Successfully moved + cn=ModifyDnTest,OU=Users,... → cn=ModifyDnTest,OU=Dismissed,... +После save(): dn=cn=ModifyDnTest,OU=Dismissed,... +✅ Не найден в старом месте +✅ Найден в новом месте +``` + +### Итог + +**django-ldapdb ORM теперь поддерживает ВСЕ операции CRUD + MOVE:** + +- ✅ **CREATE** — `LdapUser.objects.create(dn=..., ...)` +- ✅ **READ** — `LdapUser.objects.get()` / `.filter()` +- ✅ **UPDATE** — `user.display_name = "New"; user.save()` +- ✅ **DELETE** — `user.delete()` +- ✅ **MOVE** — `user.base_dn = "OU=Dismissed,..."; user.save()` *(через ModifyDnMixin)* + +Необходимость в низкоуровневых `ldap3` операциях теперь **полностью устранена** для всех базовых операций! + +### Дополнительные миксины + +- **LdapSyncStateMixin** — автоматическое управление `LdapSyncState` при операциях с LDAP +- Автоматически обновляет `dn` и метаданные синхронизации +- См. документацию в `backend/employees/ldap/mixins.py` diff --git a/docs/guides/ldap-orm-mixins-usage.md b/docs/guides/ldap-orm-mixins-usage.md new file mode 100644 index 00000000..dcfb857c --- /dev/null +++ b/docs/guides/ldap-orm-mixins-usage.md @@ -0,0 +1,285 @@ +# Использование LDAP ORM Mixins + +**Файл:** `backend/employees/ldap/mixins.py` +**Дата:** 19 марта 2026 г. + +## ModifyDnMixin — перемещение объектов между OU + +### Назначение + +Добавляет поддержку перемещения LDAP объектов через изменение `base_dn` + `.save()`. + +**Проблема:** django-ldapdb не передаёт `newsuperior` в `rename_s()`, поэтому объекты нельзя перемещать между OU. + +**Решение:** Миксин отслеживает изменения `base_dn` и автоматически вызывает `conn.modify_dn(newsuperior=...)`. + +### Использование + +```python +from employees.ldap.orm_models import LdapUser + +# 1. Загружаем пользователя +user = LdapUser.objects.get(cn="Ivanov") +print(user.dn) # CN=Ivanov,OU=IT,OU=Departments,... + +# 2. Меняем base_dn (увольнение) +user.base_dn = "OU=Dismissed,OU=company,DC=robotail,DC=local" + +# 3. Сохраняем — миксин автоматически вызовет modify_dn +user.save() + +# 4. Проверяем +print(user.dn) # CN=Ivanov,OU=Dismissed,OU=company,... +``` + +### Как работает + +1. При загрузке объекта из LDAP (`from_db()`) сохраняется `_original_base_dn` +2. При вызове `save()` миксин сравнивает текущий `base_dn` с `_original_base_dn` +3. Если изменился — вызывается `conn.modify_dn(old_dn, new_rdn, newsuperior=new_base_dn)` +4. После успешного перемещения обновляется `self.dn` и `self._saved_dn` +5. Затем вызывается стандартный `super().save()` для обновления атрибутов + +### Логирование + +```python +import logging +logging.basicConfig(level=logging.INFO) + +user.base_dn = "OU=Dismissed,..." +user.save() + +# [INFO] ModifyDnMixin: Moving LDAP object +# Old DN: cn=User,OU=IT,... +# New RDN: cn=User +# New superior: OU=Dismissed,... +# New DN: cn=User,OU=Dismissed,... +# [INFO] ModifyDnMixin: Successfully moved ... +``` + +### Модели с ModifyDnMixin + +- `LdapUser` — перемещение пользователей (отделы, увольнение) +- `LdapGroup` — перемещение групп +- `LdapOrganizationalUnit` — перемещение OU (редко) + +--- + +## LdapSyncStateMixin — автоматическое управление LdapSyncState + +### Назначение + +Автоматически обновляет `LdapSyncState` при операциях с LDAP объектами. + +**Проблема:** При изменении DN в LDAP нужно вручную обновлять `LdapSyncState.dn`. + +**Решение:** Миксин автоматически обновляет/создаёт/удаляет записи `LdapSyncState`. + +### Настройка модели + +```python +class LdapUser(ModifyDnMixin, LdapSyncStateMixin, LdapModel): + # Указываем связь с Django моделью + _sync_model_name = 'employee' # Для LdapSyncState.model + _sync_pk_field = 'employee_number' # Поле LDAP с Django PK + + employee_number = CharField(db_column='employeeNumber') + # ... остальные поля +``` + +### Автоматические операции + +#### 1. CREATE — создаёт LdapSyncState + +```python +user = LdapUser.objects.create( + dn="CN=Ivanov,OU=Users,...", + employee_number="123", # Django Employee.pk + # ... остальные атрибуты +) + +# ✅ Автоматически создан LdapSyncState: +# - model='employee' +# - object_id='123' +# - dn='CN=Ivanov,OU=Users,...' +# - last_ldap_modify_ts=now() +``` + +#### 2. UPDATE (обычный) — обновляет timestamp + +```python +user = LdapUser.objects.get(dn=...) +user.display_name = "New Name" +user.save() + +# ✅ Обновлён LdapSyncState: +# - last_ldap_modify_ts=now() +``` + +#### 3. UPDATE (с перемещением) — обновляет DN + +```python +user = LdapUser.objects.get(dn=old_dn) +user.base_dn = "OU=Dismissed,..." +user.save() + +# ✅ Обновлён LdapSyncState: +# - dn='cn=User,OU=Dismissed,...' # Новый DN! +# - last_ldap_modify_ts=now() +``` + +#### 4. DELETE — удаляет LdapSyncState + +```python +user.delete() + +# ✅ Удалён соответствующий LdapSyncState +``` + +### Если модель не настроена + +Миксин просто не выполняет операции: + +```python +class LdapGroup(ModifyDnMixin, LdapSyncStateMixin, LdapModel): + # Нет _sync_model_name / _sync_pk_field + pass + +group = LdapGroup.objects.create(...) +# [WARNING] LdapSyncStateMixin: No _sync_model_name set, skipping sync state +``` + +--- + +## Вместе: полная интеграция + +### Пример: увольнение пользователя + +```python +from employees.models import Employee +from employees.ldap.orm_models import LdapUser + +def dismiss_employee(employee_id: int): + """Увольняет сотрудника.""" + # 1. Django модель + employee = Employee.objects.get(pk=employee_id) + employee.status = 'dismissed' + employee.save() + + # 2. LDAP модель (автоматические обновления!) + ldap_user = LdapUser.objects.get(employee_number=str(employee_id)) + ldap_user.base_dn = "OU=Dismissed,OU=company,DC=robotail,DC=local" + ldap_user.save() + + # ✅ Произошло автоматически: + # - ModifyDnMixin: переместил в LDAP (modify_dn) + # - LdapSyncStateMixin: обновил LdapSyncState.dn + # - Логирование всех операций +``` + +### Пример: перевод в другой отдел + +```python +def transfer_to_department(employee_id: int, new_dept_name: str): + """Переводит сотрудника в другой отдел.""" + employee = Employee.objects.get(pk=employee_id) + new_dept = Department.objects.get(name=new_dept_name) + + employee.department = new_dept + employee.save() + + # LDAP перемещение + ldap_user = LdapUser.objects.get(employee_number=str(employee_id)) + ldap_user.base_dn = f"OU={new_dept_name},OU=Departments,OU=company,DC=robotail,DC=local" + ldap_user.save() + + # ✅ Всё обновлено автоматически! +``` + +--- + +## Ограничения и особенности + +### ModifyDnMixin + +1. **Требует build_rdn()** — модель должна реализовать метод `build_rdn()` +2. **Работает только при изменении base_dn** — изменение RDN компонента не поддерживается автоматически +3. **Требует низкоуровневое соединение** — использует `ldap3.Connection.modify_dn()` + +### LdapSyncStateMixin + +1. **Опционально** — работает только если установлены `_sync_model_name` и `_sync_pk_field` +2. **Требует импорт Employee** — использует `from employees.models import LdapSyncState` +3. **Warning при отсутствии PK** — если `employee_number` пуст, логирует WARNING и пропускает + +--- + +## Интеграция с существующим кодом + +### Постепенный рефакторинг + +#### Было (LEGACY): + +```python +# services/user_service.py +from employees.ldap.utils.dn_utils import _move_to_department + +def update_user(self, employee, dto): + # ... обновление атрибутов через ldap3 + + # Перемещение в другой отдел + _move_to_department(old_dn, new_dept_dn) + + # Обновление LdapSyncState вручную + sync_state.dn = new_dn + sync_state.save() +``` + +#### Стало (ORM + Mixins): + +```python +def update_user(self, employee, dto): + ldap_user = LdapUser.objects.get(employee_number=str(employee.pk)) + + # Обновление атрибутов + ldap_user.given_name = dto.first_name + ldap_user.sn = dto.last_name + # ... остальные поля + + # Перемещение в другой отдел (если изменился) + if employee.department: + new_base_dn = f"OU={employee.department.name},OU=Departments,..." + ldap_user.base_dn = new_base_dn + + ldap_user.save() + + # ✅ Всё! Миксины сделают остальное +``` + +### Преимущества + +- **-50% кода** — нет ручного вызова modify_dn, обновления LdapSyncState +- **Автоматические обновления** — меньше ошибок +- **Логирование** — встроенное отслеживание операций +- **Единообразие** — весь LDAP через ORM + +--- + +## Тестирование + +См. примеры в: +- `docs/completed/ldap-orm-create-support.md` — тесты CREATE и MOVE +- `backend/employees/ldap/mixins.py` — docstrings с примерами + +```python +# Запуск тестов +.venv/bin/python manage.py shell -c "..." +``` + +--- + +## Дополнительные ресурсы + +- [django-ldapdb документация](https://github.com/django-ldapdb/django-ldapdb) +- [RFC 4511 — LDAP Protocol](https://www.rfc-editor.org/rfc/rfc4511.html) +- [Active Directory DN структура](https://docs.microsoft.com/en-us/windows/win32/ad/naming-properties) diff --git a/docs/in-progress/LDAP_ORM_MIGRATION_PLAN.md b/docs/in-progress/LDAP_ORM_MIGRATION_PLAN.md new file mode 100644 index 00000000..ab452e31 --- /dev/null +++ b/docs/in-progress/LDAP_ORM_MIGRATION_PLAN.md @@ -0,0 +1,550 @@ +# План миграции на LDAP ORM + +**Создан:** 19 марта 2026 г. +**Статус:** 🔄 В работе +**Цель:** Полный переход от низкоуровневого ldap3 к django-ldapdb ORM + миксины + +--- + +## Обзор текущего состояния + +### ✅ Готово +- [x] ModifyDnMixin — поддержка перемещения объектов +- [x] LdapSyncStateMixin — автоматическое управление LdapSyncState +- [x] Миксины интегрированы в модели (LdapUser, LdapGroup, LdapOrganizationalUnit) +- [x] Протестирована работа CREATE и MOVE через ORM +- [x] Документация по миксинам + +### ❌ LEGACY код требует замены + +#### 1. CREATE операции (conn.add) +- `employees/ldap/services/user_service.py` + - `_try_create_with_cns()` — создание пользователей + - `_create_user_in_ldap()` — основной метод создания + +- `employees/ldap/services/department_service.py` + - Создание групп ролей (4 места) + - Создание OU отделов (1 место) + +- `employees/ldap/services/group_service.py` + - `create_group()` — создание групп + +- `employees/ldap/services/position_service.py` + - Создание групп позиций + +#### 2. MOVE операции (conn.modify_dn) +- `employees/ldap/services/user_service.py` + - `_move_user_to_base()` — перемещение пользователей + +- `employees/ldap/utils/dn_utils.py` + - `_move_to_department()` — универсальная функция перемещения + +- `employees/ldap/services/department_service.py` + - Коррекция DN при изменении названия отдела + - Коррекция DN групп ролей + +#### 3. RENAME операции (conn.modify_dn) +- `employees/ldap/services/user_service.py` + - Изменение CN при смене ФИО + +- `employees/ldap/services/group_service.py` + - `rename_group()` — переименование групп + +- `employees/ldap/services/department_service.py` + - Переименование OU отделов + - Переименование групп ролей + +--- + +## Стратегия миграции + +### Принципы +1. **Постепенная замена** — по одному сервису за раз +2. **Обратная совместимость** — старый код продолжает работать +3. **100% покрытие тестами** — каждое изменение тестируется +4. **Rollback plan** — возможность откатиться назад + +### Приоритеты +- 🟢 **P0 (критично)**: UserService — основная функциональность +- 🟡 **P1 (важно)**: DepartmentService, GroupService +- 🟠 **P2 (среднее)**: PositionService +- 🔵 **P3 (низкое)**: Утилиты, вспомогательные функции + +--- + +## Этапы миграции + +### ФАЗА 1: UserService (P0) — 3-4 дня + +#### 1.1. CREATE пользователей +**Цель:** Заменить `conn.add()` на `LdapUser.objects.create()` + +**Файлы:** +- `employees/ldap/services/user_service.py` + +**Изменения:** +```python +# Было (LEGACY): +def _try_create_with_cns(self, conn, dto, sam, upn, base_dn, object_classes, cns): + for cn_txt in cns: + dn_try = f"CN={cn_txt},{base_dn}" + attrs = self._build_user_attrs(dto, sam, upn, cn_txt) + if conn.add(dn_try, object_classes, attrs): + return dn_try + return None + +# Станет (ORM): +def _try_create_with_cns_orm(self, dto, sam, upn, base_dn, cns): + """Создание через ORM с обработкой коллизий.""" + from ldap import ALREADY_EXISTS + from employees.ldap.orm_models import LdapUser + + for cn_txt in cns: + try: + user = LdapUser.objects.create( + dn=f"CN={cn_txt},{base_dn}", + cn=cn_txt, + sam_account_name=sam, + user_principal_name=upn, + given_name=dto.first_name, + sn=dto.last_name or ".", + display_name=f"{dto.first_name} {dto.last_name or ''}".strip(), + mail=dto.email, + user_account_control=512, # Normal account + employee_number=str(dto.employee_id) if dto.employee_id else "", + # ... остальные поля из dto + ) + return user.dn + except ALREADY_EXISTS: + continue + return None +``` + +**Преимущества:** +- Убираем `UserMapperService._build_user_attrs()` — атрибуты задаются напрямую +- Автоматическая валидация полей +- LdapSyncStateMixin создаст LdapSyncState автоматически +- Меньше кода (~40% сокращение) + +**Тестирование:** +- [ ] Unit тесты для `_try_create_with_cns_orm()` +- [ ] Integration тесты: создание пользователя с коллизиями CN +- [ ] Проверка LdapSyncState после создания + +**Риски:** +- 🟡 Другие поля могут быть не учтены +- 🟢 Rollback: оставить старый метод как `_try_create_with_cns_legacy()` + +--- + +#### 1.2. MOVE пользователей (увольнение, перевод) +**Цель:** Заменить `conn.modify_dn()` на `user.base_dn = ...; user.save()` + +**Файлы:** +- `employees/ldap/services/user_service.py` +- `employees/ldap/utils/dn_utils.py` + +**Изменения:** +```python +# Было (LEGACY): +def _move_user_to_base(self, conn, user_dn, base_dn): + rdn = user_dn.split(",", 1)[0] + ok = conn.modify_dn(user_dn, rdn, new_superior=base_dn) + if not ok: + raise RuntimeError(f"LDAP move failed: {conn.result}") + return f"{rdn},{base_dn}" + +# Станет (ORM): +def _move_user_to_base_orm(self, user_dn, base_dn): + """Перемещение через ModifyDnMixin.""" + from employees.ldap.orm_models import LdapUser + + user = LdapUser.objects.get(dn=user_dn) + user.base_dn = base_dn + user.save() # ModifyDnMixin автоматически вызовет modify_dn + + return user.dn # Обновлённый DN +``` + +**Преимущества:** +- ModifyDnMixin автоматически обновляет LdapSyncState.dn +- Логирование операций из коробки +- Меньше ошибок (нет ручного формирования DN) + +**Тестирование:** +- [ ] Unit тесты для `_move_user_to_base_orm()` +- [ ] Integration тесты: перемещение в Dismissed, в отдел +- [ ] Проверка обновления LdapSyncState.dn + +**Риски:** +- 🟢 Низкий — миксин уже протестирован + +--- + +#### 1.3. RENAME пользователей (смена ФИО) +**Цель:** Сохранить `conn.modify_dn()` для изменения RDN (cn) + +**Обоснование:** +- Изменение RDN (cn=...) != перемещение (newsuperior) +- ModifyDnMixin работает только с `base_dn` +- Для RDN нужен отдельный подход + +**Решение:** +```python +# Оставляем LEGACY для изменения CN: +def _update_cn_if_needed(self, conn, dn, new_cn): + """Изменение CN через modify_dn (без newsuperior).""" + current_rdn = dn.split(",", 1)[0] + new_rdn = f"CN={new_cn}" + + if current_rdn != new_rdn: + ok = conn.modify_dn(dn, new_rdn) + if not ok: + raise RuntimeError(f"Failed to rename: {conn.result}") + + # Обновляем LdapSyncState вручную + base = dn.split(",", 1)[1] + new_dn = f"{new_rdn},{base}" + self._update_sync_state_dn(old_dn=dn, new_dn=new_dn) + + return new_dn + return dn +``` + +**Альтернатива (будущее):** +- Создать `RenameRdnMixin` для изменения RDN +- Но это P3 приоритет + +**Тестирование:** +- [ ] Unit тесты сохранения старой логики +- [ ] Проверка обновления LdapSyncState + +--- + +### ФАЗА 2: DepartmentService (P1) — 2-3 дня + +#### 2.1. CREATE OU отделов +**Цель:** `conn.add()` → `LdapOrganizationalUnit.objects.create()` + +**Изменения:** +```python +# Было: +ok = conn.add(dn, ["top", "organizationalUnit"]) + +# Станет: +from employees.ldap.orm_models import LdapOrganizationalUnit + +ou = LdapOrganizationalUnit.objects.create( + dn=dn, + ou=dept_name, + description=f"Отдел {dept_name}", + managed_by=manager_dn if manager_dn else "", +) +``` + +**Тестирование:** +- [ ] Создание OU через ORM +- [ ] Проверка атрибутов (description, managedBy) + +--- + +#### 2.2. CREATE групп ролей +**Цель:** `conn.add()` → `LdapGroup.objects.create()` + +**Изменения:** +```python +# Было: +attrs = { + "sAMAccountName": sam, + "description": desc, + "member": members, +} +ok = conn.add(group_dn, ["top", "group"], attrs) + +# Станет: +from employees.ldap.orm_models import LdapGroup + +group = LdapGroup.objects.create( + dn=group_dn, + cn=cn, + sam_account_name=sam, + description=desc, + member=members if members else [], +) +``` + +**Тестирование:** +- [ ] Создание групп ролей через ORM +- [ ] Проверка членства (member list) + +--- + +#### 2.3. RENAME OU при изменении названия отдела +**Цель:** `conn.modify_dn()` → `ou.base_dn = ...; ou.save()` (для MOVE) + +**ВАЖНО:** Для изменения самого OU (не перемещения) нужен `modify_dn(new_rdn=...)` + +**Решение:** +- Оставить `conn.modify_dn()` для изменения RDN OU +- Использовать ModifyDnMixin только если планируется перемещение OU (редко) + +**Тестирование:** +- [ ] Переименование OU (IT → Engineering) +- [ ] Проверка обновления всех вложенных DN + +--- + +### ФАЗА 3: GroupService (P1) — 1-2 дня + +#### 3.1. CREATE глобальных групп +**Цель:** `conn.add()` → `LdapGroup.objects.create()` + +**Аналогично DepartmentService 2.2** + +--- + +#### 3.2. RENAME групп +**Цель:** Оставить `conn.modify_dn()` для изменения CN + +**Аналогично UserService 1.3** + +--- + +### ФАЗА 4: PositionService (P2) — 1 день + +#### 4.1. CREATE групп позиций +**Цель:** `conn.add()` → `LdapGroup.objects.create()` + +**Аналогично DepartmentService 2.2** + +--- + +### ФАЗА 5: Утилиты и рефакторинг (P3) — 2-3 дня + +#### 5.1. Создать RenameRdnMixin +**Цель:** Унифицировать изменение RDN через миксин + +```python +class RenameRdnMixin: + """Миксин для изменения RDN (cn=..., ou=...) без перемещения.""" + + def rename_rdn(self, new_rdn_value): + """Изменяет RDN без перемещения между контейнерами. + + Example: + user.rename_rdn("Ivanov I 2") # cn=Ivanov I → cn=Ivanov I 2 + """ + from .infrastructure.connections import _ldap + + old_dn = self.dn + rdn_attr = self.build_rdn().split("=")[0] # "cn" или "ou" + new_rdn = f"{rdn_attr}={new_rdn_value}" + + with _ldap() as conn: + success = conn.modify_dn(old_dn, new_rdn) + if not success: + raise RuntimeError(f"Failed to rename: {conn.result}") + + # Обновляем self.dn + base = old_dn.split(",", 1)[1] + self.dn = f"{new_rdn},{base}" +``` + +**Интеграция:** +```python +class LdapUser(ModifyDnMixin, RenameRdnMixin, LdapSyncStateMixin, LdapModel): + ... + +# Использование: +user = LdapUser.objects.get(dn=...) +user.rename_rdn("Petrov P") # Изменение CN +user.save() # Обновление остальных атрибутов +``` + +--- + +#### 5.2. Удалить LEGACY код +**Цель:** Удалить старые методы после полной миграции + +**Удалить:** +- `UserMapperService._build_user_attrs()` — заменено на прямое создание через ORM +- `dn_utils._move_to_department()` — заменено на ModifyDnMixin +- Все методы с `_legacy` суффиксом + +**Проверить зависимости:** +```bash +# Найти все использования LEGACY методов +grep -r "_build_user_attrs" backend/ +grep -r "_move_to_department" backend/ +``` + +--- + +#### 5.3. Обновить документацию +**Цель:** Убрать упоминания о LEGACY, обновить примеры + +**Файлы:** +- `employees/ldap/orm_models.py` — убрать секцию LEGACY +- `docs/guides/ldap-orm-mixins-usage.md` — добавить RenameRdnMixin +- `docs/completed/ldap-orm-create-support.md` — пометить как завершённое + +--- + +## Тестирование + +### Unit тесты +- [ ] Создание объектов через ORM (все модели) +- [ ] Перемещение через ModifyDnMixin +- [ ] Обработка коллизий CN +- [ ] Обновление LdapSyncState автоматически + +### Integration тесты +- [ ] Полный цикл: создание → перемещение → обновление → удаление +- [ ] Создание пользователя → перевод в отдел → увольнение +- [ ] Создание отдела → назначение менеджера → переименование +- [ ] Создание группы → добавление членов → переименование + +### Regression тесты +- [ ] Существующие тесты проходят без изменений +- [ ] Функциональность не сломана + +### Performance тесты +- [ ] ORM не медленнее ldap3 (допустимо +10%) +- [ ] Batch операции работают эффективно + +--- + +## Риски и митигация + +### 🔴 Высокие риски +1. **Потеря данных при ошибке в миграции** + - Митигация: резервное копирование LDAP перед каждой фазой + - Rollback: восстановление из бэкапа + +2. **Несовместимость с существующими интеграциями** + - Митигация: тестирование на staging + - Rollback: feature flag для переключения ORM/LEGACY + +### 🟡 Средние риски +1. **Производительность ORM ниже ldap3** + - Митигация: профилирование и оптимизация + - Альтернатива: batch операции через bulk_create + +2. **Неучтённые edge cases** + - Митигация: расширенное тестирование + - Rollback: постепенная миграция (по одному сервису) + +### 🟢 Низкие риски +1. **Изменение поведения при обработке ошибок** + - Митигация: обработка тех же exception (ALREADY_EXISTS, etc.) + +--- + +## Чеклист выполнения + +### Подготовка +- [ ] Создать feature branch `feature/ldap-orm-migration` +- [ ] Настроить CI/CD для автоматического тестирования +- [ ] Создать резервную копию LDAP + +### ФАЗА 1: UserService +- [ ] Реализовать `_try_create_with_cns_orm()` +- [ ] Написать unit тесты CREATE +- [ ] Реализовать `_move_user_to_base_orm()` +- [ ] Написать unit тесты MOVE +- [ ] Integration тесты UserService +- [ ] Code review +- [ ] Merge в develop + +### ФАЗА 2: DepartmentService +- [ ] Реализовать CREATE OU через ORM +- [ ] Реализовать CREATE групп через ORM +- [ ] Unit тесты +- [ ] Integration тесты +- [ ] Code review +- [ ] Merge в develop + +### ФАЗА 3: GroupService +- [ ] Реализовать CREATE через ORM +- [ ] Unit тесты +- [ ] Integration тесты +- [ ] Code review +- [ ] Merge в develop + +### ФАЗА 4: PositionService +- [ ] Реализовать CREATE через ORM +- [ ] Unit тесты +- [ ] Code review +- [ ] Merge в develop + +### ФАЗА 5: Финализация +- [ ] Создать RenameRdnMixin (опционально) +- [ ] Удалить LEGACY код +- [ ] Обновить документацию +- [ ] Final regression тесты +- [ ] Production deployment + +--- + +## Оценка времени + +| Фаза | Описание | Оценка | Зависимости | +|------|----------|--------|-------------| +| 1 | UserService (CREATE + MOVE) | 3-4 дня | - | +| 2 | DepartmentService | 2-3 дня | Фаза 1 | +| 3 | GroupService | 1-2 дня | Фаза 1 | +| 4 | PositionService | 1 день | Фаза 1 | +| 5 | Рефакторинг + документация | 2-3 дня | Фазы 1-4 | +| **ИТОГО** | **Полная миграция** | **9-13 дней** | - | + +**С учётом тестирования и ревью:** ~15-20 рабочих дней (3-4 недели) + +--- + +## Success Criteria + +### Функциональные +- ✅ Все операции CREATE выполняются через ORM +- ✅ Все операции MOVE выполняются через ModifyDnMixin +- ✅ LdapSyncState обновляется автоматически +- ✅ Обработка коллизий CN работает корректно + +### Технические +- ✅ 100% покрытие unit тестами новых методов +- ✅ Integration тесты проходят успешно +- ✅ Regression тесты не выявляют проблем +- ✅ Performance не деградировала (±10%) + +### Качество кода +- ✅ Код соответствует PEP 8 +- ✅ Все методы задокументированы (docstrings) +- ✅ Нет дублирования кода +- ✅ Code review пройден + +### Документация +- ✅ Обновлена документация ORM моделей +- ✅ Обновлены гайды по миксинам +- ✅ Создана migration guide для команды + +--- + +## Следующие шаги + +1. **Обсудить план с командой** — получить feedback +2. **Установить приоритеты** — согласовать порядок фаз +3. **Начать ФАЗУ 1** — UserService как самый критичный компонент +4. **Еженедельные ревью** — отслеживать прогресс + +--- + +## Контакты и ответственные + +- **Tech Lead:** @username +- **Backend разработчик (LDAP):** @username +- **QA Engineer:** @username +- **DevOps (backup/rollback):** @username + +--- + +**Статус обновлён:** 19 марта 2026 г. +**Следующий ревью:** TBD