refactor(ldap): упростить архитектуру сигналов + добавить LdapPasswordMixin

- Убрана логика создания LDAP пользователей из сигналов
- Сигналы ТОЛЬКО синхронизируют изменения существующих LDAP пользователей
- Создание LDAP пользователей - ответственность RegisterAPIView
- Убран _ldap_password из perform_create (создание через API не создает LDAP пользователей)
- Создан LdapPasswordMixin для endpoint'ов работы с паролями
- perform_create теперь только создает Employee в БД (административное создание)

Архитектурное улучшение: разделение ответственности
- Auth views (RegisterAPIView) - создают LDAP пользователей напрямую
- Сигналы - только синхронизация изменений
- LdapPasswordMixin - для endpoint'ов смены пароля
This commit is contained in:
lizerk 2026-03-18 16:47:04 +03:00
parent f2f8a67dd3
commit e2a15f57a3
3 changed files with 149 additions and 77 deletions

View File

@ -201,24 +201,18 @@ class EmployeeViewSet(viewsets.ModelViewSet):
return EmployeeSerializer
def perform_create(self, serializer):
"""Создание сотрудника. LDAP синхронизация через сигналы."""
password = self.request.data.get("password")
"""Создание сотрудника через API.
ПРИМЕЧАНИЕ: Создание LDAP пользователей происходит через RegisterAPIView.
Этот endpoint для административного создания Employee без LDAP синхронизации.
"""
ldap_enabled = _is_ldap_enabled()
# Создаем пользователя в БД
if ldap_enabled:
# LDAP-managed: unusable password в БД, реальный пароль пойдёт в LDAP через сигнал
instance = serializer.save(is_ldap_managed=True)
if hasattr(instance, "set_unusable_password"):
instance.set_unusable_password()
instance.save(update_fields=["password"])
# Сохраняем пароль во временном атрибуте для сигнала
instance._ldap_password = password
instance._ldap_avatar = self.request.FILES.get('avatar')
else:
# Standalone: пароль в БД
instance = serializer.save(is_ldap_managed=False)
instance.set_password(password)
# Создаем пользователя в БД (без пароля - административное создание)
instance = serializer.save(is_ldap_managed=ldap_enabled)
if hasattr(instance, "set_unusable_password"):
instance._skip_ldap_sync = True # Не синхронизировать через сигналы
instance.set_unusable_password()
instance.save(update_fields=["password"])
# Навыки
@ -231,18 +225,19 @@ class EmployeeViewSet(viewsets.ModelViewSet):
instance = serializer.instance
old_email = instance.email
# Сохраняем данные для LDAP в временных атрибутах (для сигнала)
# Сохраняем изменения для LDAP синхронизации
if _is_ldap_enabled() and instance.is_ldap_managed:
instance._ldap_changes = dict(self.request.data)
if 'avatar' in self.request.FILES:
instance._ldap_avatar = self.request.FILES['avatar']
# Стандартное сохранение (сигнал добавит LDAP sync)
# Стандартное сохранение (сигнал синхронизирует с LDAP)
serializer.save()
# Если email изменился - сбрасываем верификацию
new_email = instance.email
if new_email and new_email.lower() != old_email.lower():
instance._skip_ldap_sync = True
instance.email_verified = False
instance.email_activation_code = get_random_string(6, "0123456789")
instance.save(

View File

@ -0,0 +1,106 @@
"""Миксин для API endpoint'ов, работающих с паролями в LDAP."""
import logging
from django.conf import settings
from employees.ldap.directory_service import DirectoryService
from employees.ldap.errors import DirectoryDbError, DirectoryLdapError, DirectoryServiceError
from employees.models import LdapSyncState
logger = logging.getLogger(__name__)
def _is_ldap_enabled():
"""Проверяет, включена ли интеграция с LDAP."""
return getattr(settings, "LDAP_ENABLED", False)
class LdapPasswordMixin:
"""Миксин для endpoint'ов, которые работают с паролями.
Автоматически синхронизирует изменения паролей с LDAP.
Использование:
class ChangePasswordAPIView(LdapPasswordMixin, APIView):
def post(self, request):
user = request.user
new_password = request.data['new_password']
# Меняем пароль в БД
user.set_password(new_password)
user.save()
# Синхронизируем с LDAP (автоматически через миксин)
self.sync_password_to_ldap(user, new_password)
return Response({'ok': True})
"""
def sync_password_to_ldap(self, employee_instance, new_password):
"""Синхронизирует пароль с LDAP для данного Employee.
Args:
employee_instance: инстанс Employee
new_password: новый пароль (plaintext)
Returns:
tuple: (success: bool, error: str or None)
"""
if not _is_ldap_enabled():
return True, None
if not employee_instance.is_ldap_managed:
return True, None
# Проверяем наличие LDAP пользователя
sync_state = LdapSyncState.objects.filter(
model='employee',
object_pk=str(employee_instance.pk)
).first()
if not sync_state or not (sync_state.ldap_dn or sync_state.ldap_guid):
logger.warning(
f"Employee {employee_instance.id} has no LDAP sync state, "
"password not synced to LDAP"
)
return False, "no_ldap_user"
try:
svc = DirectoryService()
svc.update_user(
emp=employee_instance,
changes={'password': new_password},
group_cns=None,
move_to_department_dn=None,
)
logger.info(f"Password synced to LDAP for Employee {employee_instance.id}")
return True, None
except DirectoryLdapError as e:
logger.error(
f"LDAP error during password sync for Employee {employee_instance.id}: {e}",
exc_info=True
)
return False, f"ldap_error: {str(e)}"
except DirectoryServiceError as e:
logger.error(
f"Service error during password sync for Employee {employee_instance.id}: {e}",
exc_info=True
)
return False, f"service_error: {str(e)}"
except DirectoryDbError as e:
logger.error(
f"DB error during password sync for Employee {employee_instance.id}: {e}",
exc_info=True
)
return False, f"db_error: {str(e)}"
except Exception as e:
logger.error(
f"Unexpected error during password sync for Employee {employee_instance.id}: {e}",
exc_info=True
)
return False, f"unexpected_error: {str(e)}"

View File

@ -25,10 +25,12 @@ def _is_ldap_enabled():
@receiver(post_save, sender='employees.Employee')
def sync_employee_to_ldap_on_save(sender, instance, created, **kwargs):
"""Синхронизирует сотрудника с LDAP при создании/обновлении.
"""Синхронизирует изменения Employee с существующим LDAP пользователем.
Срабатывает после Employee.save(). Использует временные атрибуты
_ldap_password, _ldap_avatar, _ldap_changes для передачи данных из ViewSet.
ВАЖНО: Создание LDAP пользователей происходит в RegisterAPIView.
Этот сигнал ТОЛЬКО синхронизирует изменения существующих пользователей.
Использует временные атрибуты _ldap_changes, _ldap_avatar для передачи данных.
"""
if not _is_ldap_enabled():
return
@ -36,68 +38,37 @@ def sync_employee_to_ldap_on_save(sender, instance, created, **kwargs):
if not instance.is_ldap_managed:
return
# Игнорируем вспомогательные сохранения (например, set_unusable_password)
# Игнорируем вспомогательные сохранения и новые записи без LDAP
if getattr(instance, '_skip_ldap_sync', False):
return
# Проверяем, существует ли пользователь в LDAP
sync_state = LdapSyncState.objects.filter(
model='employee',
object_pk=str(instance.pk)
).first()
if not sync_state or not (sync_state.ldap_dn or sync_state.ldap_guid):
# Пользователь не существует в LDAP - пропускаем синхронизацию
# Создание LDAP пользователей - ответственность RegisterAPIView
logger.debug(f"Employee {instance.id} has no LDAP sync state, skipping sync")
return
try:
svc = DirectoryService()
if created:
# Создание в LDAP
password = getattr(instance, '_ldap_password', None)
avatar_file = getattr(instance, '_ldap_avatar', None)
avatar_bytes = None
if avatar_file and hasattr(avatar_file, 'read'):
try:
if hasattr(avatar_file, 'seek'):
avatar_file.seek(0)
avatar_bytes = avatar_file.read()
except Exception:
pass
dto = DirectoryUserDTO(
username=instance.username or None,
first_name=instance.first_name,
last_name=instance.last_name,
email=instance.email,
phone_e164=str(instance.phone_number) if instance.phone_number else '',
department_dn=None, # Пока не передаем
group_cns=[],
initial_password=password or 'ChangeMe123!', # Fallback пароль
avatar_bytes=avatar_bytes,
is_active=instance.is_active,
)
# Создаём пользователя в LDAP
emp = svc.create_user(dto)
# Получаем DN из sync state
sync_state = LdapSyncState.objects.filter(
model='employee',
object_pk=str(emp.pk)
).first()
if sync_state and sync_state.ldap_dn:
# Записываем employeeNumber
try:
from employees.ldap.orm_models import LdapUser
ldap_user = LdapUser.objects.get(dn=sync_state.ldap_dn)
ldap_user.employee_number = str(emp.pk)
ldap_user.save()
except Exception as e:
logger.warning(f"Failed to set employeeNumber: {e}")
# Назначаем должность если есть
if instance.position_id:
try:
svc.assign_position(instance, instance.position)
except Exception as e:
logger.warning(f"Failed to assign position in LDAP: {e}")
# Новая запись с существующим LDAP DN (импорт из LDAP)
# Синхронизируем employeeNumber
try:
from employees.ldap.orm_models import LdapUser
ldap_user = LdapUser.objects.get(dn=sync_state.ldap_dn)
ldap_user.employee_number = str(instance.pk)
ldap_user.save()
except Exception as e:
logger.warning(f"Failed to set employeeNumber: {e}")
else:
# Обновление в LDAP
# Обновление существующего LDAP пользователя
changes = getattr(instance, '_ldap_changes', {})
if not changes:
# Если изменений нет - это вспомогательное save(), пропускаем
@ -143,12 +114,12 @@ def sync_employee_to_ldap_on_save(sender, instance, created, **kwargs):
)
finally:
# Очищаем временные атрибуты
if hasattr(instance, '_ldap_password'):
delattr(instance, '_ldap_password')
if hasattr(instance, '_ldap_avatar'):
delattr(instance, '_ldap_avatar')
if hasattr(instance, '_ldap_changes'):
delattr(instance, '_ldap_changes')
if hasattr(instance, '_skip_ldap_sync'):
delattr(instance, '_skip_ldap_sync')
@receiver(post_delete, sender='employees.Employee')