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