diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index d6d46e8c..e5c34921 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -48,7 +48,7 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): - POST {id}/add_skill|remove_skill — требуется employees.manage_employee_skills. Поиск: last_name, first_name, patronymic, email, phone_number - + Использует ExternalSystemSyncMixin для автоматической синхронизации с LDAP. """ @@ -63,11 +63,11 @@ class EmployeeViewSet(ExternalSystemSyncMixin, 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 @@ -76,69 +76,129 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): 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 с полными данными + 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, + username=instance.username if instance.username else None, first_name=instance.first_name, last_name=instance.last_name, email=instance.email, - phone_number=instance.phone_number or '', + 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, - 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 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.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, dn, + {employee_id_attr: str(instance.pk)}, + do_write=True + ) + 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 _perform_user_update(self, instance: Employee, request_data, is_partial=True): """Общая логика обновления пользователя для me PATCH и partial_update. @@ -447,178 +507,84 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): return EmployeeSerializer def create(self, request, *args, **kwargs): - """Создание пользователя админом: пароль уходит ТОЛЬКО в LDAP, локальный — unusable.""" + """Создание пользователя админом. + + Использует ExternalSystemSyncMixin для автоматической синхронизации с LDAP. + Миксин управляет транзакциями и откатывает БД если LDAP упал. + """ if not (request.user.is_staff or request.user.is_superuser): return Response({"detail": "Only staff can create users."}, status=403) - ser = self.get_serializer(data=request.data) - ser.is_valid(raise_exception=True) - vd = dict(ser.validated_data) - + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Валидация обязательных полей + if not serializer.validated_data.get("email"): + return Response({"detail": "email обязателен"}, status=400) + if not serializer.validated_data.get("phone_number"): + return Response({"detail": "phone_number обязателен"}, status=400) + if not request.data.get("password"): + return Response({"detail": "password обязателен"}, status=400) + + # Проверка контактов + vd = serializer.validated_data if not (vd.get("whatsapp") or vd.get("telegram") or vd.get("wechat")): return Response( - { - "detail": "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram" - }, + {"detail": "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"}, status=400, ) - - email = vd.pop("email", None) - phone_number = vd.pop("phone_number", None) - password = request.data.get("password") - if not email or not phone_number or not password: - return Response( - {"detail": "email, phone_number и password обязательны"}, status=400 - ) - + + # Миксин автоматически вызовет perform_create() -> LDAP sync + return super().create(request, *args, **kwargs) + + def perform_create(self, serializer): + """Переопределяем для кастомной логики создания. + + ExternalSystemSyncMixin автоматически обернет это в транзакцию + и вызовет LDAP синхронизацию после успешного сохранения. + """ + password = self.request.data.get("password") avatar_bytes = None - avatar_file = vd.pop("avatar", None) + avatar_file = serializer.validated_data.get("avatar") + if avatar_file and hasattr(avatar_file, "read"): try: avatar_bytes = avatar_file.read() + if hasattr(avatar_file, "seek"): + avatar_file.seek(0) except Exception: - avatar_bytes = None - + pass + ldap_enabled = _is_ldap_enabled() - + + # Создаем пользователя в БД if ldap_enabled: - # NEW ARCHITECTURE: View управляет БД, LDAP сервис делает только LDAP - try: - 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 Exception as e: - return Response( - {"detail": f"Ошибка создания пользователя: {str(e)}"}, - status=500, - ) + # LDAP-managed пользователь с unusable password + instance = serializer.save(is_ldap_managed=True) + if hasattr(instance, "set_unusable_password"): + instance.set_unusable_password() + instance.save(update_fields=["password"]) else: - try: - with transaction.atomic(): - 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=False, - ) - user.set_password(password) - for k in ( - "patronymic", - "birth_date", - "telegram", - "whatsapp", - "wechat", - "position", - "gender", - ): - if k in vd: - setattr(user, k + ("_id" if k == - "position" else ""), vd[k]) - if avatar_bytes: - user.avatar.save( - f"avatar_{user.id}.jpg", - ContentFile(avatar_bytes), - save=False, - ) - user.save() - 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 Exception as e: - return Response( - {"detail": f"Ошибка создания пользователя: {str(e)}"}, - status=500, - ) + # Обычный пользователь с паролем в БД + instance = serializer.save(is_ldap_managed=False) + instance.set_password(password) + instance.save(update_fields=["password"]) + + # Сохраняем аватар если есть + if avatar_bytes: + instance.avatar.save( + f"avatar_{instance.id}.jpg", + ContentFile(avatar_bytes), + save=False, + ) + instance.save(update_fields=["avatar"]) + + # Навыки + skills_ids = self.request.data.get("skills_ids", []) + if skills_ids: + instance.skills.set(skills_ids) + + # Миксин вызовет LDAP синхронизацию автоматически после этого @action(detail=False, methods=["get", "patch"]) def me(self, request): diff --git a/backend/common/external_sync_mixin.py b/backend/common/external_sync_mixin.py index 2e023ad2..62cfd389 100644 --- a/backend/common/external_sync_mixin.py +++ b/backend/common/external_sync_mixin.py @@ -4,24 +4,25 @@ Использование: 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} """ +import logging +from functools import wraps + 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__) @@ -29,12 +30,12 @@ logger = logging.getLogger(__name__) class ExternalSystemSyncMixin: """ Миксин для автоматической синхронизации операций ViewSet с внешними системами. - + Паттерн: 1. Выполняет DB операцию в транзакции 2. При успехе - вызывает метод внешней системы 3. При ошибке внешней системы - откатывает DB транзакцию - + Attributes: external_sync_service: Сервис для работы с внешней системой (например, DirectoryService) external_sync_enabled: Флаг включения синхронизации (по умолчанию True) @@ -43,67 +44,68 @@ class ExternalSystemSyncMixin: 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}") + 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: Тип операции @@ -111,47 +113,55 @@ class ExternalSystemSyncMixin: """ 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) + 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}") + instance.save(update_fields=list( + result['update_db'].keys())) + # Вызываем кастомный обработчик результата если определен + if hasattr(self, 'handle_external_sync_result'): + self.handle_external_sync_result(instance, result, action) + + 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) + 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: Тип операции @@ -159,31 +169,34 @@ class ExternalSystemSyncMixin: """ 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 = getattr( + self.external_sync_service, sync_method_name) sync_method(**external_data) - - logger.info(f"External sync success for {action} on {instance}") - + + 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) + + error_response = self.handle_external_sync_error( + e, instance, action) if error_response: raise ExternalSyncError(error_response) raise @@ -191,6 +204,7 @@ class ExternalSystemSyncMixin: class ExternalSyncError(Exception): """Исключение с Response для возврата клиенту.""" + def __init__(self, response): self.response = response super().__init__() @@ -199,12 +213,12 @@ class ExternalSyncError(Exception): 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 -> метод внешнего сервиса @@ -215,32 +229,33 @@ def with_external_sync(external_service_attr='external_sync_service', method_map # Выполняем оригинальный метод 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']) - + instance = self.get_queryset().get( + id=response.data['id']) + # Вызываем метод внешней системы sync_method = getattr(service, sync_method_name) sync_method(instance=instance) - + except Exception as e: transaction.set_rollback(True) logger.error(f"External sync failed: {e}") raise - + return response return wrapper return decorator diff --git a/backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md b/backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md index 8ea19c50..1cb1098e 100644 --- a/backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md +++ b/backend/docs/guides/EXTERNAL_SYNC_MIXIN_GUIDE.md @@ -36,13 +36,13 @@ 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 { @@ -51,7 +51,7 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): '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': @@ -67,11 +67,11 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): 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 сервер недоступен. Попробуйте позже.'}, @@ -82,7 +82,7 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): {'detail': f'Ошибка LDAP операции: {error.message}'}, status=status.HTTP_400_BAD_REQUEST ) - + # Для остальных ошибок - стандартная обработка return super().handle_external_sync_error(error, instance, action) ``` @@ -96,7 +96,7 @@ 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', @@ -140,10 +140,10 @@ try: # 1. Сохраняем в БД db_operation(serializer) instance = serializer.instance - + # 2. Синхронизируем с LDAP ldap_service.create_user_ldap(user=instance) - + # Если все ОК - commit except LDAPError as e: # При ошибке LDAP - автоматический rollback БД @@ -157,14 +157,14 @@ except LDAPError as e: ```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} ``` @@ -178,7 +178,7 @@ class DepartmentViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): def create_user_ldap(self, user): """Создает пользователя в LDAP и возвращает DN.""" dn = self._user_service.create_in_ldap(user) - + # Возвращаем данные для обновления БД return { 'update_db': { @@ -201,11 +201,11 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): # Синхронизируем только если 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', @@ -220,18 +220,18 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): 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'}) ``` @@ -244,13 +244,13 @@ 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: @@ -258,11 +258,11 @@ class EmployeeViewSet(viewsets.ModelViewSet): 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... # Дублирование кода в каждом методе! ``` @@ -278,14 +278,14 @@ class EmployeeViewSet(viewsets.ModelViewSet): ```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} ``` @@ -323,17 +323,17 @@ 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() ``` @@ -349,7 +349,7 @@ class DirectoryService: """ТОЛЬКО 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.""" @@ -368,10 +368,10 @@ 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')} ``` diff --git a/backend/employees/ldap/directory_service.py b/backend/employees/ldap/directory_service.py index 5255e298..ccab7021 100644 --- a/backend/employees/ldap/directory_service.py +++ b/backend/employees/ldap/directory_service.py @@ -6,7 +6,7 @@ from employees.models import LdapSyncState from ldap3 import Connection from ..models import Department, DepartmentRole, Employee, Position -from .domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO +from .domain.dtos import DirectoryDepartmentDTO, DirectoryUserDTO # ------------------------- Сервис LDAP → DB ------------------------- # @@ -28,9 +28,9 @@ class DirectoryService: def __init__(self): """Инициализация сервиса и подсервисов.""" from .services.department_service import DepartmentService - from .services.user_service import UserService from .services.group_service import GroupService from .services.position_service import PositionService + from .services.user_service import UserService self._user_service = UserService() self._group_service = GroupService(directory_service=self) @@ -68,28 +68,28 @@ class DirectoryService: 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( @@ -98,16 +98,16 @@ class DirectoryService: 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. """ @@ -115,12 +115,12 @@ class DirectoryService: def delete_user_in_ldap_only(self, instance: Employee) -> None: """Удаляет пользователя ТОЛЬКО из LDAP. - + Новая архитектура: View удаляет из БД, затем удаляет из LDAP. - + Args: instance (Employee): Сотрудник для удаления из LDAP. - + Raises: DirectoryLdapError: Ошибка удаления из LDAP. """ @@ -240,14 +240,14 @@ class DirectoryService: assigned_by: Optional[Employee] = None, ): """Назначает роль сотруднику (не требует членства в отделе). - + NOTE: Метод делегирует к DepartmentService. - + Args: employee: Сотрудник. role: Роль для назначения. assigned_by: Кто назначил (опционально). - + Returns: RoleAssignment: Созданное/обновлённое назначение. """ @@ -255,9 +255,9 @@ class DirectoryService: def revoke_role(self, employee: Employee, role: DepartmentRole) -> None: """Отзывает роль у сотрудника. - + NOTE: Метод делегирует к DepartmentService. - + Args: employee: Сотрудник. role: Роль для отзыва. @@ -271,14 +271,14 @@ class DirectoryService: permission_codes: Optional[List[str]] = None, ) -> DepartmentRole: """Создаёт роль отдела с LDAP-группой. - + NOTE: Метод делегирует к DepartmentService. - + Args: dept: Отдел. name: Название роли. permission_codes: Коды прав (опционально). - + Returns: DepartmentRole: Созданная роль. """ @@ -300,13 +300,13 @@ class DirectoryService: self, role: DepartmentRole, name: Optional[str] = None ) -> DepartmentRole: """Обновляет роль (переименование LDAP-группы + БД). - + NOTE: Метод делегирует к DepartmentService. - + Args: role: Роль. name: Новое название (опционально). - + Returns: DepartmentRole: Обновлённая роль. """ @@ -318,9 +318,9 @@ class DirectoryService: def delete_role(self, role: DepartmentRole) -> None: """Удаляет роль: LDAP-группу + БД. - + NOTE: Метод делегирует к DepartmentService. - + Args: role: Роль для удаления. """ @@ -328,16 +328,16 @@ class DirectoryService: def move_user_to_base(self, employee: Employee, base_dn: str) -> str: """Перемещает пользователя в указанный базовый OU. - + NOTE: Метод делегирует к UserService. - + Args: employee: Сотрудник для перемещения. base_dn: DN базового OU. - + Returns: str: Новый DN пользователя. - + Raises: DirectoryLdapError: Ошибка LDAP. DirectoryServiceError: Пользователь не имеет DN. @@ -350,7 +350,8 @@ class DirectoryService: if not sync_state or not sync_state.ldap_dn: from .errors import DirectoryServiceError - raise DirectoryServiceError(f"Employee {employee.pk} has no LDAP DN") + raise DirectoryServiceError( + f"Employee {employee.pk} has no LDAP DN") with _ldap() as conn: new_dn = self._user_service._move_user_to_base( diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py index 47285c85..06deddba 100644 --- a/backend/employees/ldap/services/user_service.py +++ b/backend/employees/ldap/services/user_service.py @@ -15,26 +15,16 @@ from ldap3 import MODIFY_REPLACE, Connection from ...models import Employee, LdapSyncState, Position from ..domain.dtos import DirectoryUserDTO -from ..errors import ( - DirectoryDbError, - DirectoryLdapError, - DirectoryServiceError, -) -from ..utils.group_utils import sync_user_groups_by_cns +from ..errors import (DirectoryDbError, DirectoryLdapError, + DirectoryServiceError) from ..infrastructure.connections import _ldap -from ..repositories.ldap_repository import ( - ensure_container_exists, - is_taken, - ldap_modify_or_ignore, - modify_user_attrs, - read_attrs, -) +from ..repositories.ldap_repository import (ensure_container_exists, is_taken, + ldap_modify_or_ignore, + modify_user_attrs, read_attrs) +from ..utils.group_utils import sync_user_groups_by_cns from ..utils.image_utils import normalize_avatar_to_jpeg -from ..utils.ldap_utils import ( - build_logins_for_user, - cn_candidates, - get_guid_str, -) +from ..utils.ldap_utils import (build_logins_for_user, cn_candidates, + get_guid_str) from ..utils.text_utils import esc_rdn # Константы UAC (User Account Control) @@ -53,7 +43,7 @@ class UserService: ): """ Инициализация сервиса. - + Args: ldap_repository: Репозиторий для работы с LDAP (optional) employee_repository: Репозиторий для работы с Employee (optional) @@ -67,21 +57,21 @@ class UserService: 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. """ @@ -89,25 +79,26 @@ class UserService: 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) + 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( @@ -119,12 +110,12 @@ class UserService: ) except Exception: pass - + return { 'dn': dn, 'guid': guid, } - + except Exception as e: raise DirectoryLdapError(f"LDAP create failed: {e}") from e @@ -134,17 +125,17 @@ class UserService: 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. """ @@ -152,15 +143,16 @@ class UserService: 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') + dept_val = changes.get( + 'department') or changes.get('department_id') if dept_val: from .department_service import DepartmentService dept_svc = DepartmentService() @@ -169,18 +161,19 @@ class UserService: 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) + 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, @@ -189,30 +182,30 @@ class UserService: 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. """ @@ -220,21 +213,23 @@ class UserService: dn = self._get_employee_dn(instance) except DirectoryServiceError: # Если нет DN - ничего удалять не нужно - logger.warning(f"Cannot get DN for employee {instance.pk}, skipping LDAP delete") + 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 + raise DirectoryLdapError( + f"LDAP hard delete failed: {e}") from e # ==================== OLD ARCHITECTURE: LDAP + DB ==================== # @@ -322,7 +317,8 @@ class UserService: # 3) Post-actions (best effort) try: if dto.group_cns: - sync_user_groups_by_cns(conn, dn, set(dto.group_cns), do_write=True) + sync_user_groups_by_cns( + conn, dn, set(dto.group_cns), do_write=True) if dto.avatar_bytes: # Увеличен размер до 384px для максимального качества в LDAP avatar = normalize_avatar_to_jpeg( @@ -368,8 +364,9 @@ class UserService: is_active_val = bool(changes.get("is_active")) except Exception: is_active_val = None - - old_pos = getattr(emp, "position", None) if hasattr(emp, "position") else None + + old_pos = getattr(emp, "position", None) if hasattr( + emp, "position") else None new_pos = None pos_in_payload = False if "position" in changes or "position_id" in changes: @@ -387,7 +384,7 @@ class UserService: new_pos = Position.objects.filter(pk=int(val)).first() except Exception: new_pos = None - + with _ldap() as conn: ldap_changes = dict(changes) try: @@ -447,7 +444,8 @@ class UserService: if move_to_department_dn and hasattr(emp, "set_active_department"): from .department_service import DepartmentService dept_svc = DepartmentService() - dept = dept_svc._get_department_by_dn(move_to_department_dn) + dept = dept_svc._get_department_by_dn( + move_to_department_dn) if dept: emp.set_active_department(dept) @@ -455,7 +453,8 @@ class UserService: if saved_avatar_bytes and hasattr(emp, "avatar"): from django.core.files.base import ContentFile filename = f"avatar_{emp.pk}.jpg" - emp.avatar.save(filename, ContentFile(saved_avatar_bytes), save=False) + emp.avatar.save(filename, ContentFile( + saved_avatar_bytes), save=False) if "avatar" not in updated_fields: updated_fields.append("avatar") @@ -463,18 +462,19 @@ class UserService: emp.save(update_fields=list(set(updated_fields))) except Exception as e: raise DirectoryDbError(str(e)) from e - + try: if pos_in_payload and (old_pos != new_pos): # Используем PositionService для синхронизации должностей - from .position_service import PositionService - from ..directory_service import DirectoryService import logging + + from ..directory_service import DirectoryService + from .position_service import PositionService logger = logging.getLogger(__name__) - + pos_svc = PositionService() dir_svc = DirectoryService() - + if old_pos: old_dn = (old_pos.ldap_group_dn or "").strip() if not old_dn: @@ -539,7 +539,8 @@ class UserService: try: modify_user_attrs(conn, dn, {"userAccountControl": 0x0202}) except Exception as e: - raise DirectoryLdapError(f"LDAP soft-disable failed: {e}") from e + raise DirectoryLdapError( + f"LDAP soft-disable failed: {e}") from e try: with transaction.atomic(): @@ -555,7 +556,8 @@ class UserService: try: self._hard_delete_user_in_ldap(conn, dn) except Exception as e: - raise DirectoryLdapError(f"LDAP hard delete failed: {e}") from e + raise DirectoryLdapError( + f"LDAP hard delete failed: {e}") from e # ==================== DN ↔ ID Conversion Methods ==================== # @@ -572,7 +574,8 @@ class UserService: return [] id_strs = [str(i) for i in ids if isinstance(i, int)] return list( - LdapSyncState.objects.filter(model="employee", object_pk__in=id_strs) + LdapSyncState.objects.filter( + model="employee", object_pk__in=id_strs) .exclude(ldap_dn__isnull=True) .exclude(ldap_dn__exact="") .values_list("ldap_dn", flat=True) @@ -641,13 +644,15 @@ class UserService: msg = conn.result or {} raw = (msg.get("message") or "").upper() if "0000052D" in raw: - raise RuntimeError("Пароль не соответствует политике сложности AD") + raise RuntimeError( + "Пароль не соответствует политике сложности AD") raise RuntimeError(f"LDAP set password failed: {msg}") def _enable_user(self, conn: Connection, dn: str) -> None: """Включает учётку (UAC=512).""" ldap_modify_or_ignore( - conn, dn, {"userAccountControl": [(MODIFY_REPLACE, [UAC_ENABLED])]}, set() + conn, dn, {"userAccountControl": [ + (MODIFY_REPLACE, [UAC_ENABLED])]}, set() ) def _unique_logins( @@ -659,12 +664,15 @@ class UserService: last_name=dto.last_name, email=dto.email, upn_suffix=upn_suffix, - is_taken_sam=lambda s: is_taken(conn, attributes={"sAMAccountName": s}), - is_taken_upn=lambda u: is_taken(conn, attributes={"userPrincipalName": u}), + is_taken_sam=lambda s: is_taken( + conn, attributes={"sAMAccountName": s}), + is_taken_upn=lambda u: is_taken( + conn, attributes={"userPrincipalName": u}), guid=getattr(dto, "ldap_guid", None), ) if not sam or not upn: - raise ValueError("Не удалось сгенерировать уникальные sAMAccountName/UPN") + raise ValueError( + "Не удалось сгенерировать уникальные sAMAccountName/UPN") return sam, upn def _phone_write_attr(self) -> str: @@ -732,7 +740,8 @@ class UserService: ensure_container_exists(conn, base_dn) upn_suffix = getattr(settings, "LDAP_UPN_SUFFIX", "") or ( - dto.email.split("@", 1)[1] if dto.email and "@" in dto.email else "" + dto.email.split( + "@", 1)[1] if dto.email and "@" in dto.email else "" ) if not upn_suffix: raise ValueError( @@ -770,14 +779,14 @@ class UserService: group_cns: Optional[Iterable[str]] = None, ) -> str: """Применяет LDAP-изменения к существующему пользователю и возвращает (возможный новый) DN. - + NOTE: Этот метод слишком большой (~150 строк) и должен быть рефакторен в будущем. Сейчас просто перенесём как есть для совместимости. """ # Временно используем DirectoryService для _move_to_department from ..directory_service import DirectoryService svc = DirectoryService() - + if not isinstance(current_dn, str) or not current_dn.strip(): raise TypeError("current_dn должен быть непустой строкой") if not isinstance(model_changes, dict): @@ -799,7 +808,8 @@ class UserService: # 3) UAC if is_active_val is not None: uac_val = UAC_ENABLED if is_active_val else UAC_DISABLED - modify_user_attrs(conn, dn, {"userAccountControl": uac_val}, do_write=True) + modify_user_attrs( + conn, dn, {"userAccountControl": uac_val}, do_write=True) # 4) Прочие атрибуты ldap_attrs_map = { @@ -815,7 +825,7 @@ class UserService: val = model_changes[model_key] if val or ldap_attr == "sn": attrs_to_update[ldap_attr] = val if val else "." - + if attrs_to_update: modify_user_attrs(conn, dn, attrs_to_update, do_write=True) @@ -827,7 +837,8 @@ class UserService: avatar_bytes, size_px=384, max_kb=100 ) if avatar: - modify_user_attrs(conn, dn, {"thumbnailPhoto": avatar}, do_write=True) + modify_user_attrs( + conn, dn, {"thumbnailPhoto": avatar}, do_write=True) avatar_saved = True # Флаг для сохранения в БД # 6) Группы @@ -839,45 +850,45 @@ class UserService: def _get_employee_dn(self, employee: Employee) -> str: """Возвращает DN сотрудника из LdapSyncState или модели. - + Args: employee: Экземпляр Employee. - + Returns: str: Полный DN пользователя в LDAP. - + Raises: DirectoryServiceError: Если DN не найден. """ # Сначала проверяем LdapSyncState dn = ( LdapSyncState.objects.filter( - model="employee", + model="employee", object_pk=str(employee.pk) ) .values_list("ldap_dn", flat=True) .first() ) - + # Если не найден в LdapSyncState, проверяем поле модели if not dn and hasattr(employee, 'ldap_dn'): dn = employee.ldap_dn - + if not dn: raise DirectoryServiceError("Employee has no ldap_dn") return dn def _move_user_to_base(self, conn: Connection, user_dn: str, base_dn: str) -> str: """Перемещает пользовательский объект в указанный контейнер. - + Args: conn: LDAP соединение. user_dn: Текущий DN пользователя. base_dn: DN целевого контейнера. - + Returns: str: Новый DN пользователя после перемещения. - + Raises: RuntimeError: Если операция перемещения не удалась. """ diff --git a/backend/tests/api/v1/communications/test_poll_complete.py b/backend/tests/api/v1/communications/test_poll_complete.py index 8fe07b45..35b77106 100644 --- a/backend/tests/api/v1/communications/test_poll_complete.py +++ b/backend/tests/api/v1/communications/test_poll_complete.py @@ -153,7 +153,8 @@ class TestPollVoting: @pytest.fixture def simple_poll(self, private_chat, user1): """Простое голосование с опциями""" - message = Message.objects.create(chat=private_chat, author=user1, content='Poll') + message = Message.objects.create( + chat=private_chat, author=user1, content='Poll') poll = Poll.objects.create( message=message, author=user1, @@ -170,7 +171,8 @@ class TestPollVoting: poll, opt1, opt2 = simple_poll url = f'/api/v1/communications/polls/{poll.id}/vote/' - response = auth_client.post(url, {'option_ids': [opt1.id]}, format='json') + response = auth_client.post( + url, {'option_ids': [opt1.id]}, format='json') assert response.status_code == status.HTTP_200_OK results = response.data['results'] @@ -182,7 +184,8 @@ class TestPollVoting: poll, opt1, opt2 = simple_poll url = f'/api/v1/communications/polls/{poll.id}/vote/' - response = auth_client.post(url, {'option_ids': [opt1.id, opt2.id]}, format='json') + response = auth_client.post( + url, {'option_ids': [opt1.id, opt2.id]}, format='json') assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -195,7 +198,8 @@ class TestPollVoting: auth_client.post(url, {'option_ids': [opt1.id]}, format='json') # Второе голосование - response = auth_client.post(url, {'option_ids': [opt2.id]}, format='json') + response = auth_client.post( + url, {'option_ids': [opt2.id]}, format='json') assert response.status_code == status.HTTP_200_OK results = response.data['results'] @@ -211,7 +215,8 @@ class TestPollVoting: poll.save() url = f'/api/v1/communications/polls/{poll.id}/vote/' - response = auth_client.post(url, {'option_ids': [opt1.id]}, format='json') + response = auth_client.post( + url, {'option_ids': [opt1.id]}, format='json') assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -229,7 +234,8 @@ class TestPollVoting: poll, opt1, opt2 = simple_poll url = f'/api/v1/communications/polls/{poll.id}/vote/' - response = auth_client.post(url, {'option_ids': [99999]}, format='json') + response = auth_client.post( + url, {'option_ids': [99999]}, format='json') assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -241,7 +247,8 @@ class TestMultipleChoicePolls: @pytest.fixture def multiple_choice_poll(self, private_chat, user1): """Голосование с multiple choice""" - message = Message.objects.create(chat=private_chat, author=user1, content='Poll') + message = Message.objects.create( + chat=private_chat, author=user1, content='Poll') poll = Poll.objects.create( message=message, author=user1, @@ -312,7 +319,8 @@ class TestAnonymousPolls: @pytest.fixture def anonymous_poll(self, private_chat, user1): """Анонимное голосование""" - message = Message.objects.create(chat=private_chat, author=user1, content='Poll') + message = Message.objects.create( + chat=private_chat, author=user1, content='Poll') poll = Poll.objects.create( message=message, author=user1, @@ -328,7 +336,8 @@ class TestAnonymousPolls: poll, opt1, opt2 = anonymous_poll url = f'/api/v1/communications/polls/{poll.id}/vote/' - response = auth_client.post(url, {'option_ids': [opt1.id]}, format='json') + response = auth_client.post( + url, {'option_ids': [opt1.id]}, format='json') assert response.status_code == status.HTTP_200_OK @@ -355,16 +364,20 @@ class TestQuizPolls: @pytest.fixture def quiz_poll(self, private_chat, user1): """Викторина с правильным ответом""" - message = Message.objects.create(chat=private_chat, author=user1, content='Quiz') + message = Message.objects.create( + chat=private_chat, author=user1, content='Quiz') poll = Poll.objects.create( message=message, author=user1, question='2 + 2 = ?', is_quiz=True ) - opt1 = PollOption.objects.create(poll=poll, text='3', position=0, is_correct=False) - opt2 = PollOption.objects.create(poll=poll, text='4', position=1, is_correct=True) - opt3 = PollOption.objects.create(poll=poll, text='5', position=2, is_correct=False) + opt1 = PollOption.objects.create( + poll=poll, text='3', position=0, is_correct=False) + opt2 = PollOption.objects.create( + poll=poll, text='4', position=1, is_correct=True) + opt3 = PollOption.objects.create( + poll=poll, text='5', position=2, is_correct=False) return poll, opt1, opt2, opt3 def test_vote_correct_answer(self, auth_client, quiz_poll): @@ -372,7 +385,8 @@ class TestQuizPolls: poll, opt1, opt2, opt3 = quiz_poll url = f'/api/v1/communications/polls/{poll.id}/vote/' - response = auth_client.post(url, {'option_ids': [opt2.id]}, format='json') + response = auth_client.post( + url, {'option_ids': [opt2.id]}, format='json') assert response.status_code == status.HTTP_200_OK @@ -381,7 +395,8 @@ class TestQuizPolls: poll, opt1, opt2, opt3 = quiz_poll url = f'/api/v1/communications/polls/{poll.id}/vote/' - response = auth_client.post(url, {'option_ids': [opt1.id]}, format='json') + response = auth_client.post( + url, {'option_ids': [opt1.id]}, format='json') assert response.status_code == status.HTTP_200_OK @@ -406,7 +421,8 @@ class TestPollClosing: @pytest.fixture def poll_with_votes(self, private_chat, user1, user2): """Голосование с голосами""" - message = Message.objects.create(chat=private_chat, author=user1, content='Poll') + message = Message.objects.create( + chat=private_chat, author=user1, content='Poll') poll = Poll.objects.create( message=message, author=user1, @@ -444,7 +460,8 @@ class TestPollClosing: url = f'/api/v1/communications/polls/{poll.id}/close/' response = auth_client.post(url) - assert response.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_400_BAD_REQUEST] + assert response.status_code in [ + status.HTTP_403_FORBIDDEN, status.HTTP_400_BAD_REQUEST] def test_close_already_closed_poll(self, auth_client, poll_with_votes): """Попытка закрыть уже закрытый опрос""" @@ -456,11 +473,13 @@ class TestPollClosing: response = auth_client.post(url) # Должно быть успешно или 400 (опрос уже закрыт) - assert response.status_code in [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST] + assert response.status_code in [ + status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST] def test_poll_auto_closes_after_deadline(self, private_chat, user1): """Голосование автоматически закрывается после дедлайна""" - message = Message.objects.create(chat=private_chat, author=user1, content='Poll') + message = Message.objects.create( + chat=private_chat, author=user1, content='Poll') # Создаем poll с прошедшим дедлайном past_time = timezone.now() - timedelta(hours=1) @@ -489,7 +508,8 @@ class TestPollResults: @pytest.fixture def poll_with_results(self, private_chat, user1, user2): """Голосование с результатами""" - message = Message.objects.create(chat=private_chat, author=user1, content='Poll') + message = Message.objects.create( + chat=private_chat, author=user1, content='Poll') poll = Poll.objects.create( message=message, author=user1, @@ -562,7 +582,8 @@ class TestPollEdgeCases: def test_poll_with_empty_question_fails(self, auth_client, private_chat, user1): """Голосование с пустым вопросом должно провалиться""" - message = Message.objects.create(chat=private_chat, author=user1, content='Poll') + message = Message.objects.create( + chat=private_chat, author=user1, content='Poll') url = '/api/v1/communications/polls/' data = { @@ -576,7 +597,8 @@ class TestPollEdgeCases: def test_poll_with_very_long_question(self, auth_client, private_chat, user1): """Голосование с очень длинным вопросом (> 500 символов)""" - message = Message.objects.create(chat=private_chat, author=user1, content='Poll') + message = Message.objects.create( + chat=private_chat, author=user1, content='Poll') url = '/api/v1/communications/polls/' data = { @@ -608,7 +630,8 @@ class TestPollEdgeCases: def test_vote_in_poll_without_options_fails(self, auth_client, private_chat, user1): """Голосование в опросе без опций должно провалиться""" - message = Message.objects.create(chat=private_chat, author=user1, content='Poll') + message = Message.objects.create( + chat=private_chat, author=user1, content='Poll') poll = Poll.objects.create( message=message, author=user1, diff --git a/backend/tests/api/v1/employees/test_ldap_optional_helpers.py b/backend/tests/api/v1/employees/test_ldap_optional_helpers.py index 851582f6..abe86edc 100644 --- a/backend/tests/api/v1/employees/test_ldap_optional_helpers.py +++ b/backend/tests/api/v1/employees/test_ldap_optional_helpers.py @@ -4,16 +4,13 @@ - _is_ldap_enabled() - _ldap_try() """ -import pytest from unittest.mock import Mock -from rest_framework.response import Response +import pytest from api.v1.employees.views._helpers import _is_ldap_enabled, _ldap_try -from employees.ldap.errors import ( - DirectoryLdapError, - DirectoryServiceError, - DirectoryDbError, -) +from employees.ldap.errors import (DirectoryDbError, DirectoryLdapError, + DirectoryServiceError) +from rest_framework.response import Response pytestmark = pytest.mark.django_db @@ -46,10 +43,10 @@ def test_is_ldap_enabled_returns_false_when_not_set(settings): def test_ldap_try_success_with_ldap_enabled(settings): """H4: Успешное выполнение функции при LDAP_ENABLED=True""" settings.LDAP_ENABLED = True - + mock_fn = Mock() result = _ldap_try(mock_fn) - + assert result is None mock_fn.assert_called_once() @@ -57,12 +54,12 @@ def test_ldap_try_success_with_ldap_enabled(settings): def test_ldap_try_ldap_error_returns_502(settings): """H5: Ошибка DirectoryLdapError возвращает Response(502)""" settings.LDAP_ENABLED = True - + def failing_fn(): raise DirectoryLdapError("LDAP connection failed") - + result = _ldap_try(failing_fn) - + assert isinstance(result, Response) assert result.status_code == 502 assert "LDAP sync failed" in str(result.data) @@ -71,10 +68,10 @@ def test_ldap_try_ldap_error_returns_502(settings): def test_ldap_try_skips_when_ldap_disabled(settings): """H6: Функция не вызывается при LDAP_ENABLED=False""" settings.LDAP_ENABLED = False - + mock_fn = Mock() result = _ldap_try(mock_fn) - + assert result is None mock_fn.assert_not_called() @@ -82,12 +79,12 @@ def test_ldap_try_skips_when_ldap_disabled(settings): def test_ldap_try_service_error_returns_502(settings): """H7: DirectoryServiceError возвращает Response(502)""" settings.LDAP_ENABLED = True - + def failing_fn(): raise DirectoryServiceError("Service error") - + result = _ldap_try(failing_fn) - + assert isinstance(result, Response) assert result.status_code == 502 @@ -95,11 +92,11 @@ def test_ldap_try_service_error_returns_502(settings): def test_ldap_try_db_error_returns_502(settings): """H7: DirectoryDbError возвращает Response(502)""" settings.LDAP_ENABLED = True - + def failing_fn(): raise DirectoryDbError("Database error") - + result = _ldap_try(failing_fn) - + assert isinstance(result, Response) assert result.status_code == 502