refactor(employees): simplify create() using ExternalSystemSyncMixin

Replaced 150+ lines of manual transaction management with mixin:

Before:
- Manual transaction.atomic()
- Manual LDAP sync calls
- Duplicate code for LDAP/non-LDAP modes
- Manual error handling and rollback
- ~150 lines of boilerplate

After:
- create() delegates to super() - миксин управляет всем
- perform_create() - только БД логика (30 lines)
- handle_external_sync_result() - обработка LDAP результата
- Миксин автоматически управляет транзакциями и откатами

Changes:
1. create(): simplified to validation + super().create()
2. perform_create(): creates Employee in DB, sets password/avatar/skills
3. prepare_external_data(): builds DTO with avatar_bytes, group_cns
4. handle_external_sync_result(): saves DN/GUID to LdapSyncState,
   sets employeeNumber, assigns position

5. ExternalSystemSyncMixin: added handle_external_sync_result() hook
   for custom post-sync actions

Code reduction: 150 lines -> 80 lines
Benefits: cleaner, DRYer, automatic error handling

manage.py check: no issues
This commit is contained in:
igor_nadein 2026-02-16 17:07:15 +03:00
parent df82e4f66d
commit 59b8f56c5a
7 changed files with 434 additions and 421 deletions

View File

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

View File

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

View File

@ -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')}
```

View File

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

View File

@ -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: Если операция перемещения не удалась.
"""

View File

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

View File

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