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:
parent
423f14831d
commit
df82e4f66d
@ -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():
|
||||
|
||||
246
backend/common/external_sync_mixin.py
Normal file
246
backend/common/external_sync_mixin.py
Normal 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
|
||||
498
backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md
Normal file
498
backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md
Normal 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, системы очередей, файловые хранилища.
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user