From 5585758edecab1fd087f529582490328bff640d0 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 18 Mar 2026 16:13:20 +0300 Subject: [PATCH] refactor(ldap): replace ExternalSystemSyncMixin with Django signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалён 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 (синхронизация) --- backend/api/v1/employees/views/employees.py | 221 ++------------------ backend/employees/apps.py | 1 + backend/employees/signals_ldap.py | 177 ++++++++++++++++ 3 files changed, 195 insertions(+), 204 deletions(-) create mode 100644 backend/employees/signals_ldap.py diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index 040946a2..e6795c4e 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -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 изменился - сбрасываем верификацию diff --git a/backend/employees/apps.py b/backend/employees/apps.py index 64346ff7..b0067dba 100644 --- a/backend/employees/apps.py +++ b/backend/employees/apps.py @@ -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: регистрация предикатов и правил доступа diff --git a/backend/employees/signals_ldap.py b/backend/employees/signals_ldap.py new file mode 100644 index 00000000..98fae098 --- /dev/null +++ b/backend/employees/signals_ldap.py @@ -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 + )