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:
parent
f2f8a67dd3
commit
e2a15f57a3
@ -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(
|
||||
|
||||
106
backend/common/ldap_password_mixin.py
Normal file
106
backend/common/ldap_password_mixin.py
Normal 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)}"
|
||||
@ -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')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user