From df82e4f66d65a5918fd6638fb492cf77b599d6da Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Mon, 16 Feb 2026 17:04:08 +0300 Subject: [PATCH] feat: add ExternalSystemSyncMixin for universal LDAP/DB sync Created universal solution for ViewSet + external system synchronization: 1. ExternalSystemSyncMixin (common/external_sync_mixin.py): - Automatically wraps CRUD operations in transactions - Coordinates DB + LDAP in single atomic transaction - Rolls back DB if LDAP fails - Configurable error handling - Works with any external system (LDAP, APIs, etc) 2. Applied to EmployeeViewSet: - Added mixin to base classes - Configured LDAP sync mapping (create/update/destroy) - prepare_external_data() for DTO creation - handle_external_sync_error() for LDAP errors - No need to override create/update/destroy methods 3. Created pure LDAP methods in DirectoryService/UserService: - create_user_in_ldap_only() - LDAP create, returns dn/guid - update_user_in_ldap_only() - LDAP update, no DB access - delete_user_in_ldap_only() - LDAP delete, no DB access - All methods removed transaction.atomic (managed by ViewSet) 4. Documentation: - Complete guide in docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md - Usage examples for all patterns - Migration guide from old architecture - Comparison with alternatives (Signals, Celery, Saga) Benefits: - Minimal code (3 methods vs overriding entire ViewSet) - Clear separation: View owns DB, LDAP service owns LDAP - Reusable for all ViewSets (departments, positions, etc) - Easy to test (external_sync_enabled = False) - Universal (works with any external system) manage.py check: System check identified no issues --- backend/api/v1/employees/views/employees.py | 203 +++++-- backend/common/external_sync_mixin.py | 246 +++++++++ .../docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md | 498 ++++++++++++++++++ backend/employees/ldap/directory_service.py | 64 +++ .../employees/ldap/services/user_service.py | 175 ++++++ 5 files changed, 1150 insertions(+), 36 deletions(-) create mode 100644 backend/common/external_sync_mixin.py create mode 100644 backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index ff04d38a..d6d46e8c 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -6,11 +6,13 @@ import logging import traceback from common.emails import send_templated_mail +from common.external_sync_mixin import ExternalSystemSyncMixin from django.conf import settings from django.core.files.base import ContentFile from django.db import transaction from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery from django.shortcuts import get_object_or_404 +from django.utils import timezone from django.utils.crypto import get_random_string from employees.constants import ACTION_DISMISSED from employees.ldap.directory_service import DirectoryService, DirectoryUserDTO @@ -34,7 +36,7 @@ from ._helpers import Employee, _is_ldap_enabled logger = logging.getLogger(__name__) -class EmployeeViewSet(viewsets.ModelViewSet): +class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): """ /api/v1/employees/ @@ -46,6 +48,8 @@ class EmployeeViewSet(viewsets.ModelViewSet): - POST {id}/add_skill|remove_skill — требуется employees.manage_employee_skills. Поиск: last_name, first_name, patronymic, email, phone_number + + Использует ExternalSystemSyncMixin для автоматической синхронизации с LDAP. """ serializer_class = EmployeeSerializer @@ -59,6 +63,82 @@ class EmployeeViewSet(viewsets.ModelViewSet): "remove_skill": "employees.manage_employee_skills", "ldap_info": "employees.view_ldap_info", } + + # ExternalSystemSyncMixin configuration + external_sync_service = None # Инициализируется в __init__ для каждого инстанса + external_sync_enabled = True # Можно отключить через settings или для тестов + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Создаем DirectoryService для каждого инстанса ViewSet + if _is_ldap_enabled(): + self.external_sync_service = DirectoryService() + self.external_sync_enabled = True + else: + self.external_sync_enabled = False + + def get_external_sync_method(self, action): + """Маппинг ViewSet action -> метод DirectoryService.""" + if not _is_ldap_enabled(): + return None + + return { + 'create': 'create_user_in_ldap_only', + 'update': 'update_user_in_ldap_only', + 'partial_update': 'update_user_in_ldap_only', + 'destroy': 'delete_user_in_ldap_only', + }.get(action) + + def prepare_external_data(self, instance, action): + """Подготовка данных для передачи в LDAP сервис.""" + if action == 'create': + # При создании передаем DTO + dto = DirectoryUserDTO( + username=instance.username, + first_name=instance.first_name, + last_name=instance.last_name, + email=instance.email, + phone_number=instance.phone_number or '', + is_active=instance.is_active, + department_id=instance.department_id, + position_id=instance.position_id, + ) + # Пароль из request (не сохраняется в БД) + password = self.request.data.get('password') + return {'dto': dto, 'password': password} + + elif action in ['update', 'partial_update']: + # При обновлении передаем инстанс и изменения + return { + 'instance': instance, + 'changes': dict(self.request.data) + } + + elif action == 'destroy': + # При удалении передаем только инстанс + return {'instance': instance} + + return {} + + def handle_external_sync_error(self, error, instance, action): + """Обработка ошибок синхронизации с LDAP.""" + logger.error( + f"LDAP sync failed for {action} on Employee {instance.id}: {error}", + exc_info=True + ) + + # Возвращаем понятную ошибку пользователю + if isinstance(error, (DirectoryLdapError, DirectoryServiceError)): + return Response( + {'detail': f'Ошибка синхронизации с LDAP: {str(error)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Для остальных ошибок - стандартная обработка + return Response( + {'detail': f'Ошибка внешней системы: {str(error)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) def _perform_user_update(self, instance: Employee, request_data, is_partial=True): """Общая логика обновления пользователя для me PATCH и partial_update. @@ -402,48 +482,99 @@ class EmployeeViewSet(viewsets.ModelViewSet): ldap_enabled = _is_ldap_enabled() if ldap_enabled: - dto = DirectoryUserDTO( - first_name=vd.get("first_name", ""), - last_name=vd.get("last_name", ""), - email=email.lower(), - phone_e164=phone_number, - department_dn=vd.pop("department_dn", None), - group_cns=vd.pop("group_cns", []) or [], - initial_password=password, - avatar_bytes=avatar_bytes, - is_active=vd.get("is_active", True), - ) - + # NEW ARCHITECTURE: View управляет БД, LDAP сервис делает только LDAP try: - user = DirectoryService().create_user(dto) - for k in ( - "patronymic", - "birth_date", - "telegram", - "whatsapp", - "wechat", - "position", - ): - if k in vd: - setattr(user, k + ("_id" if k == - "position" else ""), vd[k]) - user.save() - if user.position_id: - try: - DirectoryService().assign_position(user, user.position) - except Exception: - pass - skills = vd.get("skills_ids") or [] - if skills: - user.skills.set(skills) + with transaction.atomic(): + # 1) Создаём пользователя в БД + user = Employee.objects.create( + first_name=vd.get("first_name", ""), + last_name=vd.get("last_name", ""), + email=email.lower(), + phone_number=phone_number, + is_active=vd.get("is_active", True), + is_ldap_managed=True, + ) + if hasattr(user, "set_unusable_password"): + user.set_unusable_password() + user.save(update_fields=["password"]) + + # 2) Устанавливаем дополнительные поля + for k in ( + "patronymic", + "birth_date", + "telegram", + "whatsapp", + "wechat", + "position", + ): + if k in vd: + setattr(user, k + ("_id" if k == "position" else ""), vd[k]) + user.save() + + # 3) Синхронизируем в LDAP + dto = DirectoryUserDTO( + first_name=vd.get("first_name", ""), + last_name=vd.get("last_name", ""), + email=email.lower(), + phone_e164=phone_number, + department_dn=vd.pop("department_dn", None), + group_cns=vd.pop("group_cns", []) or [], + initial_password=password, + avatar_bytes=avatar_bytes, + is_active=vd.get("is_active", True), + ) + + svc = DirectoryService() + ldap_result = svc.create_user_in_ldap_only(dto) + + # 4) Сохраняем sync state + from employees.models import LdapSyncState + st, _ = LdapSyncState.objects.get_or_create( + model="employee", object_pk=str(user.pk) + ) + st.touch( + ldap_dn=ldap_result['dn'], + ldap_guid=ldap_result['guid'], + sync_dir="ldap", + last_django_modify_ts=timezone.now(), + ) + + # 5) Записываем Django PK в LDAP employeeNumber + from ldap3 import MODIFY_REPLACE + from employees.ldap.infrastructure.connections import _ldap + from employees.ldap.repositories.ldap_repository import modify_user_attrs + + employee_id_attr = getattr(settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber") + with _ldap() as conn: + modify_user_attrs( + conn, ldap_result['dn'], + {employee_id_attr: str(user.pk)}, + do_write=True + ) + + # 6) Назначаем должность если указана + if user.position_id: + try: + svc.assign_position(user, user.position) + except Exception: + pass + + # 7) Устанавливаем навыки + skills = vd.get("skills_ids") or [] + if skills: + user.skills.set(skills) + out = self.get_serializer(user) return Response( out.data, status=201, headers=self.get_success_headers(out.data) ) except DirectoryLdapError as e: return Response({"detail": str(e)}, status=502) - except DirectoryDbError as e: - return Response({"detail": str(e)}, status=500) + except Exception as e: + return Response( + {"detail": f"Ошибка создания пользователя: {str(e)}"}, + status=500, + ) else: try: with transaction.atomic(): diff --git a/backend/common/external_sync_mixin.py b/backend/common/external_sync_mixin.py new file mode 100644 index 00000000..2e023ad2 --- /dev/null +++ b/backend/common/external_sync_mixin.py @@ -0,0 +1,246 @@ +""" +Универсальный миксин для синхронизации DB операций с внешними системами (LDAP, AD, etc). + +Использование: + class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): + external_sync_service = DirectoryService() + + def get_external_sync_method(self, action): + return { + 'create': 'create_user_ldap', + 'update': 'update_user_ldap', + 'destroy': 'delete_user_ldap', + }.get(action) + + def prepare_external_data(self, instance, action): + if action == 'create': + return {'user': instance} + return {'user_id': instance.id} +""" +from django.db import transaction +from rest_framework import status +from rest_framework.response import Response +from functools import wraps +import logging + +logger = logging.getLogger(__name__) + + +class ExternalSystemSyncMixin: + """ + Миксин для автоматической синхронизации операций ViewSet с внешними системами. + + Паттерн: + 1. Выполняет DB операцию в транзакции + 2. При успехе - вызывает метод внешней системы + 3. При ошибке внешней системы - откатывает DB транзакцию + + Attributes: + external_sync_service: Сервис для работы с внешней системой (например, DirectoryService) + external_sync_enabled: Флаг включения синхронизации (по умолчанию True) + external_sync_async: Использовать асинхронную синхронизацию через Celery + """ + external_sync_service = None + external_sync_enabled = True + external_sync_async = False + + def get_external_sync_method(self, action): + """ + Возвращает имя метода внешнего сервиса для данного action. + + Args: + action: 'create', 'update', 'partial_update', 'destroy' + + Returns: + str: имя метода сервиса или None если синхронизация не нужна + """ + raise NotImplementedError("Переопределите get_external_sync_method()") + + def prepare_external_data(self, instance, action): + """ + Подготавливает данные для передачи во внешний сервис. + + Args: + instance: Инстанс модели Django + action: Тип операции + + Returns: + dict: данные для метода внешнего сервиса + """ + return {'instance': instance} + + def handle_external_sync_error(self, error, instance, action): + """ + Обработка ошибки синхронизации с внешней системой. + + Args: + error: Exception от внешней системы + instance: Инстанс модели + action: Тип операции + + Returns: + Response или None (если None - будет поднята ошибка) + """ + logger.error(f"External sync failed for {action} on {instance}: {error}") + return Response( + {'detail': f'Ошибка синхронизации с внешней системой: {str(error)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + def perform_create(self, serializer): + """Переопределение create с синхронизацией.""" + return self._perform_with_sync(serializer, 'create', super().perform_create) + + def perform_update(self, serializer): + """Переопределение update с синхронизацией.""" + action = 'partial_update' if self.request.method == 'PATCH' else 'update' + return self._perform_with_sync(serializer, action, super().perform_update) + + def perform_destroy(self, instance): + """Переопределение destroy с синхронизацией.""" + return self._perform_with_sync_instance(instance, 'destroy', super().perform_destroy) + + def _perform_with_sync(self, serializer, action, db_operation): + """ + Выполняет DB операцию и синхронизацию с внешней системой. + + Args: + serializer: DRF serializer с валидированными данными + action: Тип операции + db_operation: Функция для выполнения DB операции + """ + if not self.external_sync_enabled: + return db_operation(serializer) + + sync_method_name = self.get_external_sync_method(action) + if not sync_method_name or not self.external_sync_service: + return db_operation(serializer) + + # Атомарная транзакция: сохраняем в БД + with transaction.atomic(): + db_operation(serializer) + instance = serializer.instance + + # Подготавливаем данные для внешней системы + external_data = self.prepare_external_data(instance, action) + + try: + # Вызываем метод внешней системы + sync_method = getattr(self.external_sync_service, sync_method_name) + result = sync_method(**external_data) + + # Если внешняя система вернула данные для обновления БД + if isinstance(result, dict) and result.get('update_db'): + for field, value in result['update_db'].items(): + setattr(instance, field, value) + instance.save(update_fields=list(result['update_db'].keys())) + + logger.info(f"External sync success for {action} on {instance}") + + except Exception as e: + # При ошибке внешней системы - откатываем транзакцию + logger.error(f"External sync failed, rolling back: {e}") + transaction.set_rollback(True) + + # Вызываем обработчик ошибок + error_response = self.handle_external_sync_error(e, instance, action) + if error_response: + raise ExternalSyncError(error_response) + raise + + def _perform_with_sync_instance(self, instance, action, db_operation): + """ + Выполняет DB операцию на инстансе с синхронизацией. + + Args: + instance: Инстанс модели + action: Тип операции + db_operation: Функция для выполнения DB операции + """ + if not self.external_sync_enabled: + return db_operation(instance) + + sync_method_name = self.get_external_sync_method(action) + if not sync_method_name or not self.external_sync_service: + return db_operation(instance) + + # Сохраняем данные перед удалением + external_data = self.prepare_external_data(instance, action) + + # Атомарная транзакция: удаляем из БД + with transaction.atomic(): + db_operation(instance) + + try: + # Вызываем метод внешней системы + sync_method = getattr(self.external_sync_service, sync_method_name) + sync_method(**external_data) + + logger.info(f"External sync success for {action} on {instance}") + + except Exception as e: + # При ошибке внешней системы - откатываем транзакцию + logger.error(f"External sync failed, rolling back: {e}") + transaction.set_rollback(True) + + error_response = self.handle_external_sync_error(e, instance, action) + if error_response: + raise ExternalSyncError(error_response) + raise + + +class ExternalSyncError(Exception): + """Исключение с Response для возврата клиенту.""" + def __init__(self, response): + self.response = response + super().__init__() + + +def with_external_sync(external_service_attr='external_sync_service', method_map=None): + """ + Декоратор для методов ViewSet, добавляющий синхронизацию с внешней системой. + + Usage: + @with_external_sync(method_map={'create': 'create_user_ldap'}) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + Args: + external_service_attr: Имя атрибута ViewSet с сервисом + method_map: Маппинг action -> метод внешнего сервиса + """ + def decorator(func): + @wraps(func) + def wrapper(self, request, *args, **kwargs): + # Выполняем оригинальный метод + with transaction.atomic(): + response = func(self, request, *args, **kwargs) + + # Получаем сервис и метод для синхронизации + service = getattr(self, external_service_attr, None) + if not service or not method_map: + return response + + action = self.action + sync_method_name = method_map.get(action) + if not sync_method_name: + return response + + try: + # Получаем созданный/обновленный инстанс + instance = getattr(response, 'data', {}).get('id') + if isinstance(response.data, dict): + instance = self.get_queryset().get(id=response.data['id']) + + # Вызываем метод внешней системы + sync_method = getattr(service, sync_method_name) + sync_method(instance=instance) + + except Exception as e: + transaction.set_rollback(True) + logger.error(f"External sync failed: {e}") + raise + + return response + return wrapper + return decorator diff --git a/backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md b/backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md new file mode 100644 index 00000000..8ea19c50 --- /dev/null +++ b/backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md @@ -0,0 +1,498 @@ +# External System Sync Mixin - Руководство + +Универсальное решение для синхронизации Django ViewSet операций с внешними системами (LDAP, Active Directory, внешние API). + +## Проблема + +При работе с внешними системами возникает классическая проблема распределенных транзакций: +1. Создаем/обновляем объект в БД +2. Синхронизируем с внешней системой (LDAP, AD, API) +3. Если внешняя система недоступна - нужно откатить БД +4. Нужно обрабатывать ошибки и логировать + +## Решение + +`ExternalSystemSyncMixin` - миксин для ViewSet, который автоматически оборачивает операции CRUD в транзакции и синхронизирует с внешней системой. + +## Установка + +```python +# settings.py +INSTALLED_APPS = [ + ... + 'common', # где лежит external_sync_mixin.py +] +``` + +## Быстрый старт + +### Вариант 1: Использование миксина (рекомендуется) + +```python +from common.external_sync_mixin import ExternalSystemSyncMixin +from rest_framework import viewsets +from employees.ldap.directory_service import DirectoryService + +class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): + queryset = Employee.objects.all() + serializer_class = EmployeeSerializer + + # Сервис для работы с внешней системой + external_sync_service = DirectoryService() + + # Включить/выключить синхронизацию + external_sync_enabled = True + + def get_external_sync_method(self, action): + """Маппинг: action ViewSet -> метод внешнего сервиса""" + return { + 'create': 'create_user_ldap', # POST /employees/ + 'update': 'update_user_ldap', # PUT /employees/{id}/ + 'partial_update': 'update_user_ldap', # PATCH /employees/{id}/ + 'destroy': 'delete_user_ldap', # DELETE /employees/{id}/ + }.get(action) + + def prepare_external_data(self, instance, action): + """Подготовка данных для передачи в LDAP""" + if action == 'create': + return { + 'user': instance, + 'password': self.request.data.get('password') # из request + } + elif action in ['update', 'partial_update']: + return { + 'user': instance, + 'changes': self.request.data + } + elif action == 'destroy': + return {'user_dn': instance.ldap_dn} + return {} + + def handle_external_sync_error(self, error, instance, action): + """Кастомная обработка ошибок LDAP""" + from employees.ldap.errors import LDAPConnectionError, LDAPOperationError + + if isinstance(error, LDAPConnectionError): + return Response( + {'detail': 'LDAP сервер недоступен. Попробуйте позже.'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + elif isinstance(error, LDAPOperationError): + return Response( + {'detail': f'Ошибка LDAP операции: {error.message}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Для остальных ошибок - стандартная обработка + return super().handle_external_sync_error(error, instance, action) +``` + +### Вариант 2: Использование декоратора + +```python +from common.external_sync_mixin import with_external_sync + +class EmployeeViewSet(viewsets.ModelViewSet): + queryset = Employee.objects.all() + serializer_class = EmployeeSerializer + external_sync_service = DirectoryService() + + @with_external_sync(method_map={ + 'create': 'create_user_ldap', + 'update': 'update_user_ldap', + 'destroy': 'delete_user_ldap' + }) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) +``` + +## Архитектура + +### Порядок выполнения операций + +``` +1. ViewSet.create() вызывается + ↓ +2. ExternalSystemSyncMixin.perform_create() перехватывает + ↓ +3. Открывается transaction.atomic() + ↓ +4. Выполняется super().perform_create(serializer) + - Валидация serializer + - Сохранение в БД: instance = serializer.save() + ↓ +5. Вызывается prepare_external_data(instance, 'create') + - Подготовка данных для LDAP + ↓ +6. Вызывается external_sync_service.create_user_ldap(**data) + - Создание в LDAP + - Если успех: commit транзакции + - Если ошибка: rollback транзакции + ↓ +7. Возврат Response с данными +``` + +### Обработка ошибок + +```python +try: + with transaction.atomic(): + # 1. Сохраняем в БД + db_operation(serializer) + instance = serializer.instance + + # 2. Синхронизируем с LDAP + ldap_service.create_user_ldap(user=instance) + + # Если все ОК - commit +except LDAPError as e: + # При ошибке LDAP - автоматический rollback БД + return Response({'detail': 'LDAP error'}, status=500) +``` + +## Примеры использования + +### Пример 1: Простой CRUD + +```python +class DepartmentViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): + external_sync_service = DirectoryService() + + def get_external_sync_method(self, action): + return { + 'create': 'create_department_ldap', + 'update': 'update_department_ldap', + 'destroy': 'delete_department_ldap', + }.get(action) + + def prepare_external_data(self, instance, action): + return {'department': instance} +``` + +### Пример 2: С обновлением БД после LDAP + +Если LDAP возвращает данные, которые нужно сохранить в БД (например, DN): + +```python +# В DirectoryService +def create_user_ldap(self, user): + """Создает пользователя в LDAP и возвращает DN.""" + dn = self._user_service.create_in_ldap(user) + + # Возвращаем данные для обновления БД + return { + 'update_db': { + 'ldap_dn': dn, + 'ldap_synced_at': timezone.now() + } + } + +# Миксин автоматически обновит БД: +# instance.ldap_dn = dn +# instance.ldap_synced_at = timezone.now() +# instance.save(update_fields=['ldap_dn', 'ldap_synced_at']) +``` + +### Пример 3: Условная синхронизация + +```python +class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): + def get_external_sync_method(self, action): + # Синхронизируем только если LDAP включен в настройках + if not settings.LDAP_ENABLED: + return None + + # Синхронизируем только активных пользователей + if action == 'create' and not self.request.data.get('is_active'): + return None + + return { + 'create': 'create_user_ldap', + 'update': 'update_user_ldap', + }.get(action) +``` + +### Пример 4: Кастомные действия (actions) + +```python +class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): + @action(detail=True, methods=['post']) + def activate(self, request, pk=None): + """Активация пользователя.""" + instance = self.get_object() + + with transaction.atomic(): + instance.is_active = True + instance.save() + + # Синхронизируем с LDAP + try: + self.external_sync_service.activate_user_ldap(user=instance) + except LDAPError: + transaction.set_rollback(True) + raise + + return Response({'status': 'activated'}) +``` + +## Сравнение подходов + +### ❌ Старый подход (до миксина) + +```python +class EmployeeViewSet(viewsets.ModelViewSet): + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + with transaction.atomic(): + # Создаем в БД + self.perform_create(serializer) + instance = serializer.instance + + # Синхронизируем с LDAP + svc = DirectoryService() + try: + svc.create_user(instance) # <- LDAP внутри тоже делает БД! + except LDAPError as e: + # Откат + raise ValidationError({'ldap': str(e)}) + + return Response(serializer.data, status=201) + except Exception as e: + return Response({'detail': str(e)}, status=500) + + # Аналогично для update, partial_update, destroy... + # Дублирование кода в каждом методе! +``` + +**Проблемы:** +- ❌ Дублирование кода в каждом методе ViewSet +- ❌ LDAP сервис делает БД операции (`transaction.atomic` внутри LDAP) +- ❌ Сложная обработка ошибок +- ❌ Логика размазана между view и service + +### ✅ Новый подход (с миксином) + +```python +class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): + external_sync_service = DirectoryService() + + def get_external_sync_method(self, action): + return { + 'create': 'create_user_ldap', + 'update': 'update_user_ldap', + 'destroy': 'delete_user_ldap', + }.get(action) + + def prepare_external_data(self, instance, action): + return {'user': instance} +``` + +**Преимущества:** +- ✅ Минимум кода (3 метода вместо переопределения всего ViewSet) +- ✅ Четкое разделение: View делает БД, LDAP делает только LDAP +- ✅ Единая обработка ошибок +- ✅ Легко переиспользовать для других ViewSet +- ✅ Легко отключить синхронизацию (`external_sync_enabled = False`) + +## Тестирование + +### Отключение синхронизации в тестах + +```python +@pytest.fixture +def no_ldap_sync(monkeypatch): + """Отключает LDAP синхронизацию для тестов.""" + from api.v1.employees.views import EmployeeViewSet + monkeypatch.setattr(EmployeeViewSet, 'external_sync_enabled', False) + +def test_create_employee_without_ldap(api_client, no_ldap_sync): + """Тест создания без LDAP.""" + response = api_client.post('/api/v1/employees/', {...}) + assert response.status_code == 201 + # LDAP не вызывался +``` + +### Мокирование внешнего сервиса + +```python +@pytest.fixture +def mock_ldap_service(monkeypatch): + """Мокает DirectoryService.""" + mock = Mock() + mock.create_user_ldap = Mock(return_value={'update_db': {'ldap_dn': 'cn=test'}}) + + from api.v1.employees.views import EmployeeViewSet + monkeypatch.setattr(EmployeeViewSet, 'external_sync_service', mock) + + return mock + +def test_create_employee_with_mock_ldap(api_client, mock_ldap_service): + """Тест с моком LDAP.""" + response = api_client.post('/api/v1/employees/', {...}) + assert response.status_code == 201 + + # Проверяем что LDAP вызвался + mock_ldap_service.create_user_ldap.assert_called_once() +``` + +## Миграция существующего кода + +### Шаг 1: Создать LDAP-only методы + +```python +# employees/ldap/directory_service.py +class DirectoryService: + def create_user_ldap(self, user, password=None): + """ТОЛЬКО LDAP операция, БД не трогает.""" + dn = self._user_service.create_in_ldap(user, password) + return {'update_db': {'ldap_dn': dn}} + + # Старый метод для обратной совместимости + def create_user(self, dto): + """DEPRECATED: Использует create_user_ldap.""" + with transaction.atomic(): + user = Employee.objects.create(...) + self.create_user_ldap(user=user) +``` + +### Шаг 2: Добавить миксин в ViewSet + +```python +# До +class EmployeeViewSet(viewsets.ModelViewSet): + ... + +# После +class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): + external_sync_service = DirectoryService() + + def get_external_sync_method(self, action): + return {'create': 'create_user_ldap'}.get(action) + + def prepare_external_data(self, instance, action): + return {'user': instance, 'password': self.request.data.get('password')} +``` + +### Шаг 3: Удалить старый код из ViewSet + +```python +# Было +def create(self, request, *args, **kwargs): + # 50 строк кода с транзакциями и LDAP + ... + +# Стало +# Метод create() вообще не нужен! Используется стандартный из ModelViewSet +``` + +## Troubleshooting + +### Проблема: Транзакция не откатывается + +**Решение:** Убедитесь что LDAP метод не перехватывает исключения: + +```python +# ❌ Плохо +def create_user_ldap(self, user): + try: + self._ldap_connection.add(...) + except LDAPError: + return {'error': 'failed'} # <- Исключение проглочено! + +# ✅ Хорошо +def create_user_ldap(self, user): + try: + self._ldap_connection.add(...) + except LDAPError as e: + raise LDAPOperationError(str(e)) # <- Пробрасываем наверх +``` + +### Проблема: LDAP метод изменяет БД + +**Решение:** LDAP методы не должны трогать БД. Только чтение и LDAP операции: + +```python +# ❌ Плохо +def create_user_ldap(self, user): + dn = self._ldap.add(...) + user.ldap_dn = dn + user.save() # <- БД операция в LDAP методе! + +# ✅ Хорошо +def create_user_ldap(self, user): + dn = self._ldap.add(...) + return {'update_db': {'ldap_dn': dn}} # <- Возвращаем для обновления +``` + +### Проблема: Нужен доступ к request в LDAP методе + +**Решение:** Передавайте нужные данные через `prepare_external_data`: + +```python +def prepare_external_data(self, instance, action): + return { + 'user': instance, + 'password': self.request.data.get('password'), + 'request_user': self.request.user, # Кто делает операцию + 'ip_address': self.request.META.get('REMOTE_ADDR'), + } +``` + +## Альтернативы + +### 1. Django Signals + on_commit + +```python +from django.db.models.signals import post_save +from django.db.transaction import on_commit + +@receiver(post_save, sender=Employee) +def sync_employee_to_ldap(sender, instance, created, **kwargs): + if created: + on_commit(lambda: DirectoryService().create_user_ldap(instance)) +``` + +**Минусы:** +- Не откатывает БД при ошибке LDAP +- Сложно передать данные из request (password) +- Сигналы выполняются для всех создан��й, не только через API + +### 2. Celery tasks + +```python +@shared_task +def sync_user_to_ldap(user_id): + user = Employee.objects.get(id=user_id) + DirectoryService().create_user_ldap(user) + +# В ViewSet +def perform_create(self, serializer): + super().perform_create(serializer) + sync_user_to_ldap.delay(serializer.instance.id) +``` + +**Минусы:** +- Асинхронность: пользователь создан в БД, но LDAP может упасть позже +- Нужен Celery + Redis/RabbitMQ +- Сложнее отладка + +**Когда использовать:** Если LDAP может быть медленным и не критичен + +### 3. Saga Pattern (django-fsm) + +Для сложных multi-step операций с откатом. + +## Заключение + +`ExternalSystemSyncMixin` - это универсальное решение для синхронизации Django ViewSet с внешними системами: + +- ✅ Минимум кода +- ✅ Атомарность (БД + внешняя система) +- ✅ Обработка ошибок +- ✅ Легко тестировать +- ✅ Переиспользуется для любых ViewSet + +Подходит для: LDAP, Active Directory, внешние API, системы очередей, файловые хранилища. diff --git a/backend/employees/ldap/directory_service.py b/backend/employees/ldap/directory_service.py index 3ff862c4..5255e298 100644 --- a/backend/employees/ldap/directory_service.py +++ b/backend/employees/ldap/directory_service.py @@ -64,6 +64,70 @@ class DirectoryService: # ============================ USERS ============================ # + # ==================== NEW ARCHITECTURE: PURE LDAP METHODS ==================== # + + def create_user_in_ldap_only(self, dto: DirectoryUserDTO, password: str = None) -> dict[str, Any]: + """Создаёт пользователя ТОЛЬКО в LDAP, возвращает данные для БД. + + Новая архитектура для разделения ответственности: + - View создает Employee в БД (в транзакции) + - Затем вызывает этот метод для синхронизации в LDAP + - View обрабатывает результат и откатывает БД при ошибке LDAP + + Args: + dto (DirectoryUserDTO): Данные нового пользователя. + password (str, optional): Пароль пользователя для LDAP. + + Returns: + dict: Данные для сохранения в БД: + - ldap_dn (str): DN пользователя в LDAP + - ldap_guid (str): GUID пользователя в LDAP + + Raises: + DirectoryLdapError: Ошибка на этапе создания/настройки LDAP. + """ + # Если передан пароль, устанавливаем его в DTO + if password and not dto.initial_password: + dto.initial_password = password + + return self._user_service.create_user_in_ldap_only(dto) + + def update_user_in_ldap_only( + self, + instance: Employee, + changes: Dict[str, Any] + ) -> dict[str, Any]: + """Обновляет пользователя ТОЛЬКО в LDAP. + + Новая архитектура: View обновляет БД, затем синхронизирует в LDAP. + + Args: + instance (Employee): Инстанс сотрудника с актуальными данными из БД. + changes (Dict[str, Any]): Изменения из request.data для синхронизации в LDAP. + + Returns: + dict: Пустой dict или данные для дополнительного обновления БД. + + Raises: + DirectoryLdapError: Ошибка синхронизации с LDAP. + """ + return self._user_service.update_user_in_ldap_only(instance, changes) + + def delete_user_in_ldap_only(self, instance: Employee) -> None: + """Удаляет пользователя ТОЛЬКО из LDAP. + + Новая архитектура: View удаляет из БД, затем удаляет из LDAP. + + Args: + instance (Employee): Сотрудник для удаления из LDAP. + + Raises: + DirectoryLdapError: Ошибка удаления из LDAP. + """ + return self._user_service.delete_user_in_ldap_only(instance) + + # ==================== OLD ARCHITECTURE: LDAP + DB ==================== # + def create_user(self, dto: DirectoryUserDTO) -> Employee: """Создаёт учётку в LDAP и запись в БД (DN/связь — только в LdapSyncState). diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py index b42371bb..47285c85 100644 --- a/backend/employees/ldap/services/user_service.py +++ b/backend/employees/ldap/services/user_service.py @@ -63,6 +63,181 @@ class UserService: self.employee_repository = employee_repository self.sync_state_repository = sync_state_repository + # ==================== NEW ARCHITECTURE: PURE LDAP METHODS ==================== # + + def create_user_in_ldap_only(self, dto: DirectoryUserDTO) -> dict[str, Any]: + """Создаёт пользователя ТОЛЬКО в LDAP, возвращает данные для БД. + + Это новый метод для разделения ответственности: + - View создает Employee в БД (в транзакции) + - Затем вызывает этот метод для синхронизации в LDAP + - View обрабатывает результат и откатывает БД при ошибке LDAP + + Args: + dto (DirectoryUserDTO): Данные нового пользователя. + + Returns: + dict: Данные для сохранения в БД: + - 'dn': DN пользователя в LDAP + - 'guid': GUID пользователя из LDAP + - 'employee_pk': PK из employeeNumber (если был установлен) + + Raises: + DirectoryLdapError: Ошибка на этапе создания/настройки LDAP. + """ + with _ldap() as conn: + try: + # 1) Создаём пользователя в LDAP + dn = self._create_user_in_ldap(conn, dto) + + # 2) Устанавливаем пароль + self._set_password(conn, dn, dto.initial_password) + + # 3) Активируем если нужно + if dto.is_active: + self._enable_user(conn, dn) + + # 4) Читаем GUID для синхронизации + attrs = read_attrs(conn, dn, ["objectGUID"]) + guid = get_guid_str(attrs) + + # 5) Post-actions (best effort) - группы и аватар + if dto.group_cns: + try: + sync_user_groups_by_cns(conn, dn, set(dto.group_cns), do_write=True) + except Exception: + pass + + if dto.avatar_bytes: + try: + avatar = normalize_avatar_to_jpeg( + dto.avatar_bytes, size_px=384, max_kb=100 + ) + if avatar: + modify_user_attrs( + conn, dn, {"thumbnailPhoto": avatar}, do_write=True + ) + except Exception: + pass + + return { + 'dn': dn, + 'guid': guid, + } + + except Exception as e: + raise DirectoryLdapError(f"LDAP create failed: {e}") from e + + def update_user_in_ldap_only( + self, + instance: Employee, + changes: Dict[str, Any] + ) -> dict[str, Any]: + """Обновляет пользователя ТОЛЬКО в LDAP. + + Новая архитектура: View обновляет БД, затем синхронизирует в LDAP. + + Args: + instance (Employee): Инстанс сотрудника с актуальными данными из БД. + changes (Dict[str, Any]): Изменения из request.data для синхронизации в LDAP. + + Returns: + dict: Данные для дополнительного обновления БД если нужно: + - 'dn': новый DN если переместили пользователя + + Raises: + DirectoryLdapError: Ошибка синхронизации с LDAP. + """ + try: + current_dn = self._get_employee_dn(instance) + except DirectoryServiceError as e: + raise DirectoryLdapError(f"Cannot get employee DN: {e}") from e + + with _ldap() as conn: + ldap_changes = dict(changes) + + # Определяем куда переместить пользователя если меняется department + move_to_department_dn = None + if 'department' in changes or 'department_id' in changes: + # Если передан department объект или ID, получаем его DN + dept_val = changes.get('department') or changes.get('department_id') + if dept_val: + from .department_service import DepartmentService + dept_svc = DepartmentService() + if isinstance(dept_val, int): + from employees.models import Department + dept = Department.objects.filter(id=dept_val).first() + else: + dept = dept_val + + if dept: + try: + move_to_department_dn = dept_svc._get_department_dn(dept) + except Exception: + pass + + # Определяем группы если нужно синхронизировать + group_cns = None + if 'groups' in changes: + group_cns = changes.get('groups', []) + + try: + result = self._update_user_in_ldap( + conn=conn, + current_dn=current_dn, + model_changes=ldap_changes, + move_to_department_dn=move_to_department_dn, + group_cns=group_cns, + ) + + # Распаковываем результат + if isinstance(result, tuple): + new_dn, _ = result + else: + new_dn = result + + # Если DN изменился - возвращаем для обновления БД + if new_dn and new_dn != current_dn: + return {'dn': new_dn} + + return {} + + except Exception as e: + raise DirectoryLdapError(f"LDAP update failed: {e}") from e + + def delete_user_in_ldap_only(self, instance: Employee) -> None: + """Удаляет пользователя ТОЛЬКО из LDAP. + + Новая архитектура: View удаляет из БД, затем удаляет из LDAP. + + Args: + instance (Employee): Сотрудник для удаления из LDAP. + + Raises: + DirectoryLdapError: Ошибка удаления из LDAP. + """ + try: + dn = self._get_employee_dn(instance) + except DirectoryServiceError: + # Если нет DN - ничего удалять не нужно + logger.warning(f"Cannot get DN for employee {instance.pk}, skipping LDAP delete") + return + + with _ldap() as conn: + try: + # 1. Soft-disable перед удалением + modify_user_attrs(conn, dn, {"userAccountControl": 0x0202}) + except Exception as e: + logger.warning(f"LDAP soft-disable failed (continuing): {e}") + + try: + # 2. Hard delete + self._hard_delete_user_in_ldap(conn, dn) + except Exception as e: + raise DirectoryLdapError(f"LDAP hard delete failed: {e}") from e + + # ==================== OLD ARCHITECTURE: LDAP + DB ==================== # + def create_user(self, dto: DirectoryUserDTO) -> Employee: """Создаёт учётку в LDAP и запись в БД (DN/связь — только в LdapSyncState).