refactor(api): вынос LDAP логики из RegisterAPIView в миксин

ПРОБЛЕМА:
- RegisterAPIView содержал ~30 строк LDAP-специфичной логики
- DirectoryService, DirectoryUserDTO, обработка исключений - всё в основном методе
- Нарушение принципа единственной ответственности
- Сложность тестирования и переиспользования

РЕШЕНИЕ:
Создан api/v1/employees/views/mixins.py с миксинами:

1. LdapUserCreationMixin:
   - create_ldap_user() - создание пользователя в LDAP через DirectoryService
   - create_db_user() - создание пользователя напрямую в БД (fallback без LDAP)
   - Централизованная обработка DirectoryLdapError/DirectoryDbError
   - Возвращает (Employee, None) при успехе или (None, Response) при ошибке

2. LdapPasswordMixin:
   - update_ldap_password() - обновление пароля в LDAP
   - Подготовка для использования в PasswordChangeView и других endpoints

ИЗМЕНЕНИЯ:
- auth.py: RegisterAPIView использует LdapUserCreationMixin
- Блок if ldap_enabled заменён на вызовы self.create_ldap_user() / self.create_db_user()
- _helpers.py: удалён _is_ldap_enabled (перемещён в mixins.py)
- Уменьшено количество строк в RegisterAPIView: ~190 → ~170

ПРЕИМУЩЕСТВА:
- Чёткое разделение: общая логика регистрации vs LDAP-специфика
- Переиспользование: миксины можно применить в других views
- Тестируемость: можно мокать методы миксина
- Читаемость: основной метод фокусируется на бизнес-логике

Django check: успешно
This commit is contained in:
lizerk 2026-03-18 19:28:01 +03:00
parent ee920dd365
commit aa70c23f0f
3 changed files with 198 additions and 34 deletions

View File

@ -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/

View File

@ -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:

View File

@ -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
)