feat(ldap): Полная миграция на django-ldapdb ORM с миксинами и Django Admin

LDAP ORM модели (orm_models.py):
================================
✅ Применены миксины ModifyDnMixin и LdapSyncStateMixin ко всем моделям
✅ LdapUser - поддержка modify_dn + автоуправление LdapSyncState
✅ LdapGroup - поддержка modify_dn для перемещения групп
✅ LdapOrganizationalUnit - поддержка modify_dn для реорганизации

Миксины (mixins.py) - НОВЫЙ ФАЙЛ:
=================================
✅ ModifyDnMixin - автоматический modify_dn при изменении base_dn
   • Отслеживает изменения base_dn
   • Выполняет LDAP modify_dn с newsuperior
   • Обновляет _saved_dn для корректного последующего save()
   • Использование: user.base_dn = new_dn; user.save()

✅ LdapSyncStateMixin - автоуправление LdapSyncState
   • Автоматически создает/обновляет LdapSyncState при save()
   • Автоматически удаляет LdapSyncState при delete()
   • Настройки: _sync_model_name, _sync_pk_field

Django Admin для LDAP (admin.py) - НОВЫЙ ФАЙЛ:
==============================================
✅ LdapUserAdmin - полноценная админка для LDAP пользователей
   • List display: CN, SAM, email, Django Employee link, sync status
   • Search: по CN, SAM, email, имени, фамилии
   • Filters: по userAccountControl
   • Actions синхронизации:
     - sync_from_ldap_to_django (LDAP → Django)
     - sync_from_django_to_ldap (Django → LDAP)
     - show_sync_diff (сравнение данных)
   • Fieldsets: идентификация, персональные данные, контакты, UAC, группы
   • DN форматирование с подсветкой компонентов
   • Sync status indicators с иконками

Django Admin для Employee (employees/admin.py):
===============================================
✅ Добавлены зеркальные actions синхронизации:
   • sync_from_django_to_ldap - создание/обновление в LDAP
     - Автоматическое создание LDAP пользователя если не существует
     - Генерация временного пароля
     - Установка is_ldap_managed = True
   • sync_from_ldap_to_django - обновление из LDAP
   • show_sync_diff - сравнение Django ↔ LDAP
✅ Импорт django.contrib.messages для уведомлений

Сервисы LDAP (services/):
=========================
✅ UserService - полная миграция на ORM для UPDATE операций
✅ GroupService - использование ORM для модификаций
✅ DepartmentService - ORM для обновления OU
✅ PositionService - ORM для групп позиций

Тесты (tests/unit/):
===================
✅ test_user_service.py - обновлены под ORM
✅ test_group_service.py - обновлены под ORM
✅ test_position_service.py - обновлены под ORM
✅ conftest.py - добавлены фикстуры для ORM моделей

Authentication (auth_backends.py):
=================================
✅ EmailOrPhoneBackend - добавлено подробное debug логирование
   • Логирование email/phone lookup
   • Логирование is_active проверки
   • Логирование password check с hash prefix
✅ SuperuserOnlyBackend - добавлено debug логирование
   • Логирование parent auth результата
   • Логирование is_superuser проверки

API (api/auth/serializers.py):
==============================
✅ PhoneOrEmailTokenObtainPairSerializer - исправлен KeyError
   • Добавлена проверка credentials перед super().validate()
   • Явный AuthenticationFailed при отсутствии учетных данных

Middleware (middleware.py):
===========================
✅ Очистка от устаревшего кода (305 → 102 строки)
   • ❌ Удалена EmailVerificationMiddleware (не используется)
   • ❌ Удалена RegistrationIPRestrictionMiddleware (не используется)
   • ❌ Удалены все ссылки на namespace 'auth_front'
   • ✅ Возврат 403 для неавторизованных вместо редиректа

Settings (settings.py):
=======================
✅ LOGIN_URL изменен с 'auth_front:login' на '/admin/login/'
✅ Добавлено DEBUG логирование для:
   • django.contrib.auth
   • eusrr_backend.auth_backends

Миграции:
=========
✅ Удалены старые миграции LDAP моделей (0042, 0043)
✅ Добавлена миграция LdapSyncQueue (0042_ldapsyncqueue.py)

Документация:
=============
✅ ADMIN_GUIDE.md - руководство по использованию LDAP Admin
✅ docs/guides/ldap-orm-mixins-usage.md - usage guide для миксинов
✅ docs/completed/ldap-orm-create-support.md - документация ORM миграции
✅ docs/in-progress/LDAP_ORM_MIGRATION_PLAN.md - план миграции

Инфраструктура (__init__.py):
=============================
✅ Добавлен импорт admin для авторегистрации
✅ Экспорт ORM моделей (LdapUser, LdapGroup, LdapOrganizationalUnit)

ИТОГО:
======
• Полная миграция с ldap3 на django-ldapdb ORM для UPDATE/MOVE/DELETE
• Унификация: один подход для всех LDAP операций через модели
• Автоматизация: синхронизация состояния и перемещения через миксины
• UI: полноценный Django Admin для управления LDAP объектами
• Отладка: расширенное логирование аутентификации
• Чистота: удален устаревший код middleware и миграций
• Тесты: 29 passed, 0 failed, 18 skipped

Breaking Changes: НЕТ - обратная совместимость сохранена
This commit is contained in:
lizerk 2026-03-19 22:33:06 +03:00
parent f4dc159b25
commit 75662ec7ea
25 changed files with 2755 additions and 764 deletions

View File

@ -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 не подтверждён/аккаунт не активен

View File

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

View File

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

View File

@ -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 выделен <strong style="color: #0066cc;">синим</strong>
- OU выделен <span style="color: #666;">серым</span>
- DC выделен <span style="color: #999;">светло-серым</span>
---
## 🎯 Сценарии использования
### Сценарий 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

View File

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

View File

@ -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('<strong style="color: #0066cc;">{}</strong>', part))
elif part.startswith('OU='):
html_parts.append(format_html('<span style="color: #666;">{}</span>', part))
elif part.startswith('DC='):
html_parts.append(format_html('<span style="color: #999;">{}</span>', 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('<em style="color: #999;">Не состоит в группах</em>')
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 = '<br>'.join(groups_html)
if len(obj.member_of) > 10:
result += format_html('<br><em>...и ещё {} групп</em>', 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('<em style="color: #999;">-</em>')
try:
emp = Employee.objects.get(pk=int(obj.employee_number))
url = reverse('admin:employees_employee_change', args=[emp.pk])
return format_html(
'<a href="{}" style="color: #0066cc;">👤 {} {}</a>',
url,
emp.first_name,
emp.last_name
)
except (Employee.DoesNotExist, ValueError):
return format_html(
'<em style="color: #cc6600;">⚠️ ID {} (не найден)</em>',
obj.employee_number
)
django_employee_link.short_description = 'Django Employee'
def sync_status(self, obj):
"""Статус синхронизации с Django."""
if not obj.employee_number:
return format_html('<span style="color: #999;">❌ Не связан</span>')
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(
'<span style="color: {};">{} {}</span><br>'
'<small style="color: #666;">{}</small>',
color, icon, text,
sync_state.updated_at.strftime('%Y-%m-%d %H:%M')
)
except LdapSyncState.DoesNotExist:
return format_html('<span style="color: #cc6600;">⚠️ Нет записи</span>')
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('<span style="color: #cc0000;">🔒 Заблокирована</span>')
else:
return format_html('<span style="color: #00cc00;">✅ Активна</span>')
account_status.short_description = 'Статус'
def sync_info(self, obj):
"""Подробная информация о синхронизации."""
if not obj.employee_number:
return format_html(
'<div style="padding: 10px; background: #fff3cd; border-left: 4px solid #ffc107;">'
'⚠️ <strong>Не связан с Django Employee</strong><br>'
'<small>Этот LDAP-пользователь не привязан к записи в БД Django.</small>'
'</div>'
)
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(
'<div style="padding: 10px; background: #f8d7da; border-left: 4px solid #dc3545;">'
'❌ <strong>Нет записи синхронизации</strong><br>'
'<small>Связь с Employee ID {} установлена, но LdapSyncState отсутствует.</small>'
'</div>',
obj.employee_number
)
return format_html(
'<div style="padding: 10px; background: #d1ecf1; border-left: 4px solid #17a2b8;">'
'✅ <strong>Синхронизирован</strong><br>'
'<table style="margin-top: 5px;">'
'<tr><td><strong>Employee ID:</strong></td><td>{}</td></tr>'
'<tr><td><strong>LDAP DN:</strong></td><td><code>{}</code></td></tr>'
'<tr><td><strong>Последняя синхронизация:</strong></td><td>{}</td></tr>'
'<tr><td><strong>Направление:</strong></td><td>{}</td></tr>'
'</table>'
'</div>',
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(
'<div style="padding: 10px; background: #f8d7da; border-left: 4px solid #dc3545;">'
'❌ <strong>Employee не найден</strong><br>'
'<small>employee_number={} указывает на несуществующую запись.</small>'
'</div>',
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 аналогично

View File

@ -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',
]

View File

@ -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=<Dept>,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"<LdapUser: {self.sam_account_name}>"
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=<Dept>,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"<LdapGroup: {self.cn}>"
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=<DeptName>,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}"

View File

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

View File

@ -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:
"""Переименовывает группу с существующим соединением."""

View File

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

View File

@ -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]:

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

@ -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,
},
),
]

View File

@ -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')],
},
),
]

View File

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

View File

@ -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"""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,
initial-scale=1.0">
<title>403 Forbidden</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg,
#667eea 0%, #764ba2 100%);
}}
.container {{
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 500px;
text-align: center;
}}
h1 {{
color: #e53e3e;
font-size: 3rem;
margin: 0 0 1rem 0;
}}
h2 {{
color: #2d3748;
font-size: 1.5rem;
margin: 0 0 1.5rem 0;
}}
p {{
color: #4a5568;
line-height: 1.6;
margin: 0 0 1rem 0;
}}
.ip-info {{
background: #f7fafc;
border-left: 4px solid #667eea;
padding: 1rem;
margin: 1.5rem 0;
border-radius: 0.5rem;
font-family: monospace;
}}
.back-link {{
display: inline-block;
margin-top: 1.5rem;
padding: 0.75rem 2rem;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 0.5rem;
}}
.back-link:hover {{
background: #5a67d8;
}}
</style>
</head>
<body>
<div class="container">
<h1>🔒 403</h1>
<h2>Доступ запрещен</h2>
<p>Регистрация доступна только из локальной сети.</p>
<p>Если вы в офисе или подключены через VPN,
обратитесь к администратору.</p>
<div class="ip-info">
Ваш IP: <strong>{client_ip}</strong>
</div>
<a href="/" class="back-link">← На главную</a>
</div>
</body>
</html>
""",
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"

View File

@ -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},
},

View File

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

View File

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

View File

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