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:
parent
df82e4f66d
commit
59b8f56c5a
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')}
|
||||
```
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: Если операция перемещения не удалась.
|
||||
"""
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user