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:
parent
f4dc159b25
commit
75662ec7ea
@ -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 не подтверждён/аккаунт не активен
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
178
backend/employees/ldap/ADMIN_GUIDE.md
Normal file
178
backend/employees/ldap/ADMIN_GUIDE.md
Normal 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
|
||||
@ -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)."""
|
||||
|
||||
476
backend/employees/ldap/admin.py
Normal file
476
backend/employees/ldap/admin.py
Normal 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 аналогично
|
||||
224
backend/employees/ldap/mixins.py
Normal file
224
backend/employees/ldap/mixins.py
Normal 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',
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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:
|
||||
"""Переименовывает группу с существующим соединением."""
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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},
|
||||
},
|
||||
|
||||
254
docs/completed/ldap-orm-create-support.md
Normal file
254
docs/completed/ldap-orm-create-support.md
Normal 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`
|
||||
285
docs/guides/ldap-orm-mixins-usage.md
Normal file
285
docs/guides/ldap-orm-mixins-usage.md
Normal 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)
|
||||
550
docs/in-progress/LDAP_ORM_MIGRATION_PLAN.md
Normal file
550
docs/in-progress/LDAP_ORM_MIGRATION_PLAN.md
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user