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
This commit is contained in:
igor_nadein 2026-02-16 17:04:08 +03:00
parent 423f14831d
commit df82e4f66d
5 changed files with 1150 additions and 36 deletions

View File

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

View File

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

View File

@ -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)
- Сигналы выполняются для всех создан<D0B0><D0BD>й, не только через 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, системы очередей, файловые хранилища.

View File

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

View File

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