diff --git a/backend/api/v1/employees/views/_helpers.py b/backend/api/v1/employees/views/_helpers.py index 98b467aa..4c6166b8 100644 --- a/backend/api/v1/employees/views/_helpers.py +++ b/backend/api/v1/employees/views/_helpers.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -from django.conf import settings from django.contrib.auth import get_user_model from django.db.models import Q from employees.utils import _detect_phone_field @@ -19,14 +18,6 @@ Employee = get_user_model() PHONE_FIELD = _detect_phone_field() -def _is_ldap_enabled() -> bool: - """Проверяет, включена ли интеграция с LDAP. - - Используется только в RegisterAPIView для создания LDAP пользователей. - """ - return getattr(settings, "LDAP_ENABLED", False) - - class HistoryActionMixin: """ Добавляет GET /{basename}/{pk}/history/ diff --git a/backend/api/v1/employees/views/auth.py b/backend/api/v1/employees/views/auth.py index 6f4f4add..28c3ed11 100644 --- a/backend/api/v1/employees/views/auth.py +++ b/backend/api/v1/employees/views/auth.py @@ -10,8 +10,6 @@ from django.core.files.base import ContentFile from django.db import transaction from django.utils import timezone from django.utils.crypto import get_random_string -from employees.ldap.directory_service import DirectoryService, DirectoryUserDTO -from employees.ldap.errors import DirectoryDbError, DirectoryLdapError from employees.models import Position, Skill from employees.utils import _normalize_phone from rest_framework import status @@ -22,7 +20,8 @@ from rest_framework.views import APIView from ..serializers import (EmailSerializer, EmailVerifySerializer, RegisterSerializer) -from ._helpers import Employee, _is_ldap_enabled +from ._helpers import Employee +from .mixins import LdapUserCreationMixin, _is_ldap_enabled logger = logging.getLogger(__name__) @@ -95,8 +94,11 @@ class VerifyEmailAPIView(APIView): return Response({"ok": True, "user_id": user.id}, status=200) -class RegisterAPIView(APIView): - """Регистрация: создаём учётку в LDAP (disabled) с паролем, в БД — set_unusable_password.""" +class RegisterAPIView(LdapUserCreationMixin, APIView): + """Регистрация: создаём учётку в LDAP (disabled) с паролем, в БД — set_unusable_password. + + Использует LdapUserCreationMixin для вынесения LDAP-специфичной логики. + """ throttle_scope = "anon" permission_classes = [AllowAny] @@ -188,40 +190,27 @@ class RegisterAPIView(APIView): if ldap_enabled: # Режим с LDAP: создаём disabled учётку в LDAP + пароль - svc = DirectoryService() - dto = DirectoryUserDTO( + emp, error_response = self.create_ldap_user( first_name=v["first_name"], last_name=v["last_name"], email=email, phone_e164=phone_norm, - department_dn=None, - group_cns=[], - initial_password=password, # пароль идёт только в LDAP + password=password, avatar_bytes=avatar_bytes, - is_active=False, # disabled до верификации + is_active=False, # disabled до верификации email ) - try: - emp = svc.create_user(dto) - except DirectoryLdapError as e: - return Response( - {"ok": False, "error": "ldap_error", "detail": str(e)}, status=502 - ) - except DirectoryDbError as e: - return Response( - {"ok": False, "error": "db_error", "detail": str(e)}, status=500 - ) + if error_response: + return error_response else: # Режим без LDAP: создаём пользователя напрямую в БД - emp = Employee.objects.create( + emp = self.create_db_user( first_name=v["first_name"], last_name=v["last_name"], email=email, phone_number=phone_norm, + password=password, is_active=False, # не активен до верификации email - is_ldap_managed=False, ) - # Устанавливаем пароль в БД - emp.set_password(password) # 2) Заполняем доп.поля БД if avatar_bytes: diff --git a/backend/api/v1/employees/views/mixins.py b/backend/api/v1/employees/views/mixins.py new file mode 100644 index 00000000..36f4efda --- /dev/null +++ b/backend/api/v1/employees/views/mixins.py @@ -0,0 +1,184 @@ +"""Миксины для API views с LDAP-специфичной логикой.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Optional + +from django.conf import settings +from employees.ldap.directory_service import DirectoryService, DirectoryUserDTO +from employees.ldap.errors import DirectoryDbError, DirectoryLdapError +from rest_framework.response import Response + +if TYPE_CHECKING: + from employees.models import Employee + +logger = logging.getLogger(__name__) + + +def _is_ldap_enabled() -> bool: + """Проверяет, включена ли интеграция с LDAP.""" + return getattr(settings, "LDAP_ENABLED", False) + + +class LdapUserCreationMixin: + """Миксин для создания пользователей в LDAP при регистрации. + + Используется в RegisterAPIView для вынесения LDAP-специфичной логики + из основного метода регистрации. + + Методы: + create_ldap_user(): Создаёт пользователя в LDAP через DirectoryService + create_db_user(): Создаёт пользователя напрямую в БД (fallback без LDAP) + """ + + def create_ldap_user( + self, + *, + first_name: str, + last_name: str, + email: str, + phone_e164: str, + password: str, + avatar_bytes: Optional[bytes] = None, + is_active: bool = False, + ) -> tuple[Optional['Employee'], Optional[Response]]: + """Создаёт disabled пользователя в LDAP с паролем. + + Args: + first_name: Имя + last_name: Фамилия + email: Email (нормализованный, lowercase) + phone_e164: Телефон в E.164 формате + password: Пароль (идёт только в LDAP, в БД будет unusable) + avatar_bytes: Байты аватара (опционально) + is_active: False до верификации email + + Returns: + tuple[Employee | None, Response | None]: + - (Employee, None) при успехе + - (None, Response) при ошибке (Response для возврата клиенту) + """ + svc = DirectoryService() + dto = DirectoryUserDTO( + first_name=first_name, + last_name=last_name, + email=email, + phone_e164=phone_e164, + department_dn=None, + group_cns=[], + initial_password=password, # пароль идёт только в LDAP + avatar_bytes=avatar_bytes, + is_active=is_active, # disabled до верификации email + ) + + try: + emp = svc.create_user(dto) + return emp, None + except DirectoryLdapError as e: + logger.error(f"LDAP user creation failed: {e}", exc_info=True) + return None, Response( + {"ok": False, "error": "ldap_error", "detail": str(e)}, + status=502 + ) + except DirectoryDbError as e: + logger.error(f"DB error during LDAP user creation: {e}", exc_info=True) + return None, Response( + {"ok": False, "error": "db_error", "detail": str(e)}, + status=500 + ) + + def create_db_user( + self, + *, + first_name: str, + last_name: str, + email: str, + phone_number: str, + password: str, + is_active: bool = False, + ) -> 'Employee': + """Создаёт пользователя напрямую в БД (режим без LDAP). + + Args: + first_name: Имя + last_name: Фамилия + email: Email (нормализованный, lowercase) + phone_number: Телефон в E.164 формате + password: Пароль (устанавливается через set_password) + is_active: False до верификации email + + Returns: + Employee: Созданный пользователь + """ + from employees.models import Employee + + emp = Employee.objects.create( + first_name=first_name, + last_name=last_name, + email=email, + phone_number=phone_number, + is_active=is_active, + is_ldap_managed=False, + ) + # Устанавливаем пароль в БД + emp.set_password(password) + emp.save(update_fields=['password']) + + return emp + + +class LdapPasswordMixin: + """Миксин для endpoints, работающих с паролями LDAP пользователей. + + Используется в PasswordChangeView, PasswordResetView и других + endpoints, которым нужно обновлять пароли в LDAP. + + Методы: + update_ldap_password(): Обновляет пароль пользователя в LDAP + """ + + def update_ldap_password( + self, + employee: 'Employee', + new_password: str, + ) -> tuple[bool, Optional[Response]]: + """Обновляет пароль пользователя в LDAP. + + Args: + employee: Employee instance + new_password: Новый пароль + + Returns: + tuple[bool, Response | None]: + - (True, None) при успехе + - (False, Response) при ошибке (Response для возврата клиенту) + """ + if not employee.is_ldap_managed: + # Не LDAP пользователь - устанавливаем пароль в БД + employee.set_password(new_password) + employee.save(update_fields=['password']) + return True, None + + # LDAP пользователь - обновляем через DirectoryService + try: + svc = DirectoryService() + svc.update_user( + emp=employee, + changes={'password': new_password}, + group_cns=None, + move_to_department_dn=None, + ) + return True, None + except DirectoryLdapError as e: + logger.error(f"LDAP password update failed for user {employee.id}: {e}", exc_info=True) + return False, Response( + {"ok": False, "error": "ldap_error", "detail": str(e)}, + status=502 + ) + except Exception as e: + logger.error(f"Unexpected error updating LDAP password for user {employee.id}: {e}", exc_info=True) + return False, Response( + {"ok": False, "error": "password_update_failed", "detail": str(e)}, + status=500 + )