diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index e6795c4e..fd90f4db 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -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( diff --git a/backend/common/ldap_password_mixin.py b/backend/common/ldap_password_mixin.py new file mode 100644 index 00000000..099ddd9a --- /dev/null +++ b/backend/common/ldap_password_mixin.py @@ -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)}" diff --git a/backend/employees/signals_ldap.py b/backend/employees/signals_ldap.py index 98fae098..41697650 100644 --- a/backend/employees/signals_ldap.py +++ b/backend/employees/signals_ldap.py @@ -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')