refactor(ldap): replace ExternalSystemSyncMixin with Django signals
- Удалён ExternalSystemSyncMixin из EmployeeViewSet - ViewSet теперь использует временные атрибуты (_ldap_password, _ldap_avatar, _ldap_changes) - Создан employees/signals_ldap.py с post_save/post_delete handlers - Сигналы автоматически синхронизируют Employee с LDAP через DirectoryService - Убран дублирующий блок required_perms_by_action из EmployeeViewSet - Упрощены perform_create и perform_update (-170 строк) Архитектурное улучшение: разделение ответственности ViewSet (HTTP) и signals (синхронизация)
This commit is contained in:
parent
50169ec09d
commit
5585758ede
@ -6,7 +6,6 @@ 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
|
||||
@ -36,7 +35,7 @@ from ._helpers import Employee, _is_ldap_enabled
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet):
|
||||
class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
/api/v1/employees/
|
||||
|
||||
@ -49,7 +48,7 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet):
|
||||
|
||||
Поиск: last_name, first_name, patronymic, email, phone_number
|
||||
|
||||
Использует ExternalSystemSyncMixin для автоматической синхронизации с LDAP.
|
||||
LDAP синхронизация происходит через Django сигналы (signals_ldap.py).
|
||||
"""
|
||||
|
||||
serializer_class = EmployeeSerializer
|
||||
@ -64,195 +63,6 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet):
|
||||
"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 с полными данными
|
||||
password = self.request.data.get('password')
|
||||
avatar_file = self.request.data.get('avatar')
|
||||
avatar_bytes = None
|
||||
|
||||
if avatar_file and hasattr(avatar_file, 'read'):
|
||||
try:
|
||||
if hasattr(avatar_file, 'seek'):
|
||||
avatar_file.seek(0)
|
||||
avatar_bytes = avatar_file.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dto = DirectoryUserDTO(
|
||||
username=instance.username if instance.username else None,
|
||||
first_name=instance.first_name,
|
||||
last_name=instance.last_name,
|
||||
email=instance.email,
|
||||
phone_e164=str(
|
||||
instance.phone_number) if instance.phone_number else '',
|
||||
department_dn=self.request.data.get('department_dn'),
|
||||
group_cns=self.request.data.get('group_cns', []) or [],
|
||||
initial_password=password,
|
||||
avatar_bytes=avatar_bytes,
|
||||
is_active=instance.is_active,
|
||||
)
|
||||
return {'dto': dto, 'password': password}
|
||||
|
||||
elif action in ['update', 'partial_update']:
|
||||
# При обновлении передаем инстанс и изменения
|
||||
changes = {}
|
||||
request_data = dict(self.request.data)
|
||||
|
||||
# Поля которые идут в LDAP
|
||||
ldap_fields = {'first_name', 'last_name', 'email',
|
||||
'phone_number', 'is_active', 'password'}
|
||||
for field in ldap_fields:
|
||||
if field in request_data:
|
||||
changes[field] = request_data[field]
|
||||
|
||||
# Аватар -> avatar_bytes
|
||||
avatar_file = request_data.get('avatar')
|
||||
if avatar_file and hasattr(avatar_file, 'read'):
|
||||
try:
|
||||
if hasattr(avatar_file, 'seek'):
|
||||
avatar_file.seek(0)
|
||||
changes['avatar_bytes'] = avatar_file.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Должность
|
||||
if 'position' in request_data or 'position_id' in request_data:
|
||||
changes['position'] = request_data.get(
|
||||
'position') or request_data.get('position_id')
|
||||
|
||||
# Отдел (для перемещения) - резолвим DN здесь в ViewSet
|
||||
move_to_department_dn = None
|
||||
if 'department' in request_data or 'department_id' in request_data:
|
||||
dept_val = request_data.get(
|
||||
'department') or request_data.get('department_id')
|
||||
if dept_val:
|
||||
# Если передан ID - получаем инстанс из БД
|
||||
if isinstance(dept_val, int):
|
||||
from employees.models import Department
|
||||
dept = Department.objects.filter(id=dept_val).first()
|
||||
else:
|
||||
dept = dept_val
|
||||
|
||||
# Резолвим DN отдела
|
||||
if dept:
|
||||
try:
|
||||
from employees.ldap.services.department_service import \
|
||||
DepartmentService
|
||||
dept_svc = DepartmentService()
|
||||
move_to_department_dn = dept_svc._get_department_dn(
|
||||
dept)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to resolve department DN: {e}")
|
||||
|
||||
# Группы - берем готовый список CN если передан
|
||||
group_cns = request_data.get('group_cns')
|
||||
|
||||
return {
|
||||
'instance': instance,
|
||||
'changes': changes,
|
||||
'move_to_department_dn': move_to_department_dn,
|
||||
'group_cns': group_cns
|
||||
}
|
||||
|
||||
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 handle_external_sync_result(self, instance, result, action):
|
||||
"""Обработка успешного результата синхронизации с LDAP.
|
||||
|
||||
Вызывается миксином после успешной синхронизации.
|
||||
"""
|
||||
if action != 'create' or not isinstance(result, dict):
|
||||
return
|
||||
|
||||
dn = result.get('dn')
|
||||
guid = result.get('guid')
|
||||
|
||||
if not dn:
|
||||
return
|
||||
|
||||
# Сохраняем sync state
|
||||
st, _ = LdapSyncState.objects.get_or_create(
|
||||
model="employee", object_pk=str(instance.pk)
|
||||
)
|
||||
st.touch(
|
||||
ldap_dn=dn,
|
||||
ldap_guid=guid,
|
||||
sync_dir="ldap",
|
||||
last_django_modify_ts=timezone.now(),
|
||||
)
|
||||
|
||||
# Записываем Django PK в LDAP employeeNumber для обратной связки
|
||||
try:
|
||||
from employees.ldap.orm_models import LdapUser
|
||||
|
||||
ldap_user = LdapUser.objects.get(dn=dn)
|
||||
employee_id_attr = getattr(
|
||||
settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber")
|
||||
setattr(ldap_user, 'employee_number', str(instance.pk))
|
||||
ldap_user.save()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set employeeNumber in LDAP: {e}")
|
||||
|
||||
# Назначаем должность если указана
|
||||
if instance.position_id:
|
||||
try:
|
||||
svc = self.external_sync_service or DirectoryService()
|
||||
svc.assign_position(instance, instance.position)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to assign position in LDAP: {e}")
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "create":
|
||||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||||
@ -391,40 +201,43 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet):
|
||||
return EmployeeSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Кастомная логика создания: unusable password для LDAP пользователей.
|
||||
|
||||
Миксин автоматически обернет в транзакцию и вызовет LDAP sync.
|
||||
"""
|
||||
"""Создание сотрудника. LDAP синхронизация через сигналы."""
|
||||
password = self.request.data.get("password")
|
||||
ldap_enabled = _is_ldap_enabled()
|
||||
|
||||
# Создаем пользователя в БД
|
||||
if ldap_enabled:
|
||||
# LDAP-managed пользователь с unusable password
|
||||
# LDAP-managed: unusable password в БД, реальный пароль пойдёт в LDAP через сигнал
|
||||
instance = serializer.save(is_ldap_managed=True)
|
||||
if hasattr(instance, "set_unusable_password"):
|
||||
instance.set_unusable_password()
|
||||
instance.save(update_fields=["password"])
|
||||
# Сохраняем пароль во временном атрибуте для сигнала
|
||||
instance._ldap_password = password
|
||||
instance._ldap_avatar = self.request.FILES.get('avatar')
|
||||
else:
|
||||
# Обычный пользователь с паролем в БД
|
||||
# Standalone: пароль в БД
|
||||
instance = serializer.save(is_ldap_managed=False)
|
||||
instance.set_password(password)
|
||||
instance.save(update_fields=["password"])
|
||||
|
||||
# Навыки (если есть)
|
||||
# Навыки
|
||||
skills_ids = self.request.data.get("skills_ids", [])
|
||||
if skills_ids:
|
||||
instance.skills.set(skills_ids)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Обработка изменения email для верификации.
|
||||
|
||||
Миксин автоматически обернет в транзакцию и вызовет LDAP sync.
|
||||
"""
|
||||
"""Обновление сотрудника. LDAP синхронизация через сигналы."""
|
||||
instance = serializer.instance
|
||||
old_email = instance.email
|
||||
|
||||
# Стандартное сохранение (миксин добавит LDAP sync)
|
||||
# Сохраняем данные для LDAP в временных атрибутах (для сигнала)
|
||||
if _is_ldap_enabled() and instance.is_ldap_managed:
|
||||
instance._ldap_changes = dict(self.request.data)
|
||||
if 'avatar' in self.request.FILES:
|
||||
instance._ldap_avatar = self.request.FILES['avatar']
|
||||
|
||||
# Стандартное сохранение (сигнал добавит LDAP sync)
|
||||
serializer.save()
|
||||
|
||||
# Если email изменился - сбрасываем верификацию
|
||||
|
||||
@ -8,4 +8,5 @@ class EmployeesConfig(AppConfig):
|
||||
def ready(self):
|
||||
import employees.signals
|
||||
import employees.signals_birthday # Сигналы для синхронизации дней рождений с django-scheduler
|
||||
import employees.signals_ldap # Сигналы для автоматической синхронизации с LDAP
|
||||
import employees.rules # django-rules: регистрация предикатов и правил доступа
|
||||
|
||||
177
backend/employees/signals_ldap.py
Normal file
177
backend/employees/signals_ldap.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Django signals для автоматической синхронизации Employee с LDAP."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from employees.ldap.directory_service import DirectoryService, DirectoryUserDTO
|
||||
from employees.ldap.errors import (
|
||||
DirectoryDbError,
|
||||
DirectoryLdapError,
|
||||
DirectoryServiceError,
|
||||
)
|
||||
from employees.models import LdapSyncState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_ldap_enabled():
|
||||
"""Проверяет, включена ли интеграция с LDAP."""
|
||||
return getattr(settings, "LDAP_ENABLED", False)
|
||||
|
||||
|
||||
@receiver(post_save, sender='employees.Employee')
|
||||
def sync_employee_to_ldap_on_save(sender, instance, created, **kwargs):
|
||||
"""Синхронизирует сотрудника с LDAP при создании/обновлении.
|
||||
|
||||
Срабатывает после Employee.save(). Использует временные атрибуты
|
||||
_ldap_password, _ldap_avatar, _ldap_changes для передачи данных из ViewSet.
|
||||
"""
|
||||
if not _is_ldap_enabled():
|
||||
return
|
||||
|
||||
if not instance.is_ldap_managed:
|
||||
return
|
||||
|
||||
# Игнорируем вспомогательные сохранения (например, set_unusable_password)
|
||||
if getattr(instance, '_skip_ldap_sync', False):
|
||||
return
|
||||
|
||||
try:
|
||||
svc = DirectoryService()
|
||||
|
||||
if created:
|
||||
# Создание в LDAP
|
||||
password = getattr(instance, '_ldap_password', None)
|
||||
avatar_file = getattr(instance, '_ldap_avatar', None)
|
||||
avatar_bytes = None
|
||||
|
||||
if avatar_file and hasattr(avatar_file, 'read'):
|
||||
try:
|
||||
if hasattr(avatar_file, 'seek'):
|
||||
avatar_file.seek(0)
|
||||
avatar_bytes = avatar_file.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dto = DirectoryUserDTO(
|
||||
username=instance.username or None,
|
||||
first_name=instance.first_name,
|
||||
last_name=instance.last_name,
|
||||
email=instance.email,
|
||||
phone_e164=str(instance.phone_number) if instance.phone_number else '',
|
||||
department_dn=None, # Пока не передаем
|
||||
group_cns=[],
|
||||
initial_password=password or 'ChangeMe123!', # Fallback пароль
|
||||
avatar_bytes=avatar_bytes,
|
||||
is_active=instance.is_active,
|
||||
)
|
||||
|
||||
# Создаём пользователя в LDAP
|
||||
emp = svc.create_user(dto)
|
||||
|
||||
# Получаем DN из sync state
|
||||
sync_state = LdapSyncState.objects.filter(
|
||||
model='employee',
|
||||
object_pk=str(emp.pk)
|
||||
).first()
|
||||
|
||||
if sync_state and sync_state.ldap_dn:
|
||||
# Записываем employeeNumber
|
||||
try:
|
||||
from employees.ldap.orm_models import LdapUser
|
||||
ldap_user = LdapUser.objects.get(dn=sync_state.ldap_dn)
|
||||
ldap_user.employee_number = str(emp.pk)
|
||||
ldap_user.save()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set employeeNumber: {e}")
|
||||
|
||||
# Назначаем должность если есть
|
||||
if instance.position_id:
|
||||
try:
|
||||
svc.assign_position(instance, instance.position)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to assign position in LDAP: {e}")
|
||||
|
||||
else:
|
||||
# Обновление в LDAP
|
||||
changes = getattr(instance, '_ldap_changes', {})
|
||||
if not changes:
|
||||
# Если изменений нет - это вспомогательное save(), пропускаем
|
||||
return
|
||||
|
||||
avatar_file = getattr(instance, '_ldap_avatar', None)
|
||||
if avatar_file and hasattr(avatar_file, 'read'):
|
||||
try:
|
||||
if hasattr(avatar_file, 'seek'):
|
||||
avatar_file.seek(0)
|
||||
changes['avatar_bytes'] = avatar_file.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Минимальный набор изменений для LDAP
|
||||
ldap_changes = {}
|
||||
for field in ['first_name', 'last_name', 'email', 'phone_number', 'is_active', 'password']:
|
||||
if field in changes:
|
||||
ldap_changes[field] = changes[field]
|
||||
|
||||
if 'avatar_bytes' in changes:
|
||||
ldap_changes['avatar_bytes'] = changes['avatar_bytes']
|
||||
|
||||
if ldap_changes:
|
||||
svc.update_user(
|
||||
emp=instance,
|
||||
changes=ldap_changes,
|
||||
group_cns=None,
|
||||
move_to_department_dn=None,
|
||||
)
|
||||
|
||||
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
|
||||
logger.error(
|
||||
f"LDAP sync failed for Employee {instance.id} "
|
||||
f"(created={created}): {e}",
|
||||
exc_info=True
|
||||
)
|
||||
# Не прерываем операцию - БД уже сохранена
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error in LDAP sync for Employee {instance.id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
finally:
|
||||
# Очищаем временные атрибуты
|
||||
if hasattr(instance, '_ldap_password'):
|
||||
delattr(instance, '_ldap_password')
|
||||
if hasattr(instance, '_ldap_avatar'):
|
||||
delattr(instance, '_ldap_avatar')
|
||||
if hasattr(instance, '_ldap_changes'):
|
||||
delattr(instance, '_ldap_changes')
|
||||
|
||||
|
||||
@receiver(post_delete, sender='employees.Employee')
|
||||
def sync_employee_to_ldap_on_delete(sender, instance, **kwargs):
|
||||
"""Удаляет пользователя из LDAP при удалении Employee."""
|
||||
if not _is_ldap_enabled():
|
||||
return
|
||||
|
||||
if not instance.is_ldap_managed:
|
||||
return
|
||||
|
||||
try:
|
||||
svc = DirectoryService()
|
||||
svc.delete_user(instance)
|
||||
logger.info(f"Deleted Employee {instance.id} from LDAP")
|
||||
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
|
||||
logger.error(
|
||||
f"LDAP delete failed for Employee {instance.id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
# Не прерываем - запись из БД уже удалена
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error in LDAP delete for Employee {instance.id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user