refactor: переход на строгий AD-only LDAP, регистрация + верификация email
- Удалён LDAP_SERVER_TYPE и все OpenLDAP ветки кода - user_service.py: только AD (sAMAccountName, UAC, microsoft.modify_password) - auth_backends.py: AD фильтры и атрибуты (objectCategory, objectGUID) - Удалён server_configs.py (больше не нужен) - Удалён OpenLDAP Docker окружение (docker-compose.ldap.yml, ldap/, QUICKSTART_LDAP.md) - Фронтенд: мульти-шаговая регистрация, AvatarCropper 3:4, verify-email страница - Убран CSRF для JWT API, IP-ограничения работают независимо - Полевые ошибки регистрации с красной подсветкой на фронте
This commit is contained in:
parent
bf9bc8f341
commit
0539f50233
@ -57,12 +57,15 @@ class RegisterSerializer(serializers.Serializer):
|
||||
avatar = Base64ImageField(required=True)
|
||||
patronymic = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
|
||||
gender = serializers.ChoiceField(
|
||||
gender = serializers.IntegerField(
|
||||
required=True,
|
||||
choices=((1, "Мужской"), (2, "Женский")),
|
||||
min_value=1,
|
||||
max_value=2,
|
||||
error_messages={
|
||||
'required': 'Поле "Пол" обязательно для заполнения.',
|
||||
'invalid_choice': 'Укажите пол: 1 - Мужской, 2 - Женский.'
|
||||
'invalid': 'Укажите пол: 1 - Мужской, 2 - Женский.',
|
||||
'min_value': 'Укажите пол: 1 - Мужской, 2 - Женский.',
|
||||
'max_value': 'Укажите пол: 1 - Мужской, 2 - Женский.',
|
||||
}
|
||||
)
|
||||
|
||||
@ -131,21 +134,10 @@ class RegisterSerializer(serializers.Serializer):
|
||||
Отвалидированные атрибуты.
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: Если нет контактов или телефон невалиден.
|
||||
serializers.ValidationError: Если телефон невалиден.
|
||||
"""
|
||||
# контакты: нужен хотя бы один
|
||||
if not (
|
||||
(attrs.get("telegram") or "").strip()
|
||||
or (attrs.get("whatsapp") or "").strip()
|
||||
or (attrs.get("wechat") or "").strip()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"non_field_errors": [
|
||||
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
|
||||
]
|
||||
}
|
||||
)
|
||||
# Контактные поля telegram/whatsapp/wechat опциональны
|
||||
# Валидация "хотя бы одно поле обязательно" удалена
|
||||
|
||||
# alias "phone" → phone_number (если не задан)
|
||||
if not attrs.get("phone_number") and attrs.get("phone"):
|
||||
|
||||
@ -30,7 +30,9 @@ class AnonymousAPIView(APIView):
|
||||
"""Базовый класс для анонимных (публичных) API endpoints.
|
||||
|
||||
Все auth-related views наследуются от этого класса для DRY.
|
||||
Используется только JWT (без SessionAuthentication) → CSRF не требуется.
|
||||
"""
|
||||
authentication_classes = [] # Отключаем SessionAuthentication для публичных endpoints
|
||||
throttle_scope = "anon"
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@ -110,20 +112,6 @@ class RegisterAPIView(LdapUserCreationMixin, AnonymousAPIView):
|
||||
logger.warning(f"[REGISTER] Received data: {request.data}")
|
||||
logger.warning(f"[REGISTER] Content-Type: {request.content_type}")
|
||||
|
||||
# 0) хотя бы один контакт
|
||||
if not (
|
||||
request.data.get("telegram")
|
||||
or request.data.get("whatsapp")
|
||||
or request.data.get("wechat")
|
||||
):
|
||||
logger.warning("[REGISTER] No contact provided")
|
||||
return Response(
|
||||
{
|
||||
"detail": "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
ser = RegisterSerializer(data=request.data)
|
||||
if not ser.is_valid():
|
||||
logger.warning(f"[REGISTER] Validation errors: {ser.errors}")
|
||||
@ -135,7 +123,9 @@ class RegisterAPIView(LdapUserCreationMixin, AnonymousAPIView):
|
||||
phone_norm = _normalize_phone(
|
||||
v.get("phone_number") or request.data.get("phone")
|
||||
)
|
||||
logger.warning(f"[REGISTER] Phone normalization: input={v.get('phone_number')}, normalized={phone_norm}")
|
||||
if not phone_norm:
|
||||
logger.error(f"[REGISTER] Phone normalization FAILED for: {v.get('phone_number')}")
|
||||
return Response({"ok": False, "error": "invalid_phone"}, status=400)
|
||||
|
||||
existing_phone = Employee.objects.filter(
|
||||
@ -161,8 +151,10 @@ class RegisterAPIView(LdapUserCreationMixin, AnonymousAPIView):
|
||||
|
||||
user = Employee.objects.filter(email__iexact=email).first()
|
||||
if user:
|
||||
logger.warning(f"[REGISTER] User exists: email={email}, verified={user.email_verified}")
|
||||
if user.email_verified:
|
||||
# Email уже верифицирован - нельзя регистрироваться
|
||||
logger.error(f"[REGISTER] Email already taken and verified: {email}")
|
||||
return Response({"ok": False, "error": "email_taken"}, status=400)
|
||||
else:
|
||||
# Есть неверифицированный пользователь - повторная отправка кода
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.db import transaction
|
||||
from employees.models import Position
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
@ -20,10 +21,10 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
GET list/retrieve — аутентифицированные
|
||||
POST/PUT/PATCH/DEL — staff/superuser ИЛИ пользователь с model perms
|
||||
Экшены:
|
||||
POST /{id}/set-groups
|
||||
POST /{id}/add-groups
|
||||
POST /{id}/remove-groups
|
||||
GET /{id}/permissions
|
||||
POST /{id}/set-groups — заменить все группы
|
||||
POST /{id}/add-groups — добавить группы
|
||||
POST /{id}/remove-groups — удалить группы
|
||||
GET /{id}/permissions — получить права должности
|
||||
"""
|
||||
|
||||
queryset = Position.objects.all().prefetch_related("groups")
|
||||
@ -42,75 +43,123 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
}
|
||||
|
||||
def _validate_groups_payload(self, request):
|
||||
"""Валидация payload с группами.
|
||||
|
||||
Returns:
|
||||
tuple: (QuerySet групп, Response с ошибкой или None)
|
||||
"""
|
||||
ids = request.data.get("groups")
|
||||
if not isinstance(ids, list):
|
||||
return None, Response(
|
||||
{"detail": "Поле 'groups' должно быть списком id"}, status=400
|
||||
)
|
||||
qs = Group.objects.filter(id__in=ids)
|
||||
if qs.count() != len(set(ids)):
|
||||
return None, Response({"detail": "Некоторые группы не найдены"}, status=400)
|
||||
|
||||
unique_ids = list(set(ids))
|
||||
qs = Group.objects.filter(id__in=unique_ids)
|
||||
|
||||
if qs.count() != len(unique_ids):
|
||||
found_ids = set(qs.values_list('id', flat=True))
|
||||
missing_ids = set(unique_ids) - found_ids
|
||||
return None, Response(
|
||||
{
|
||||
"detail": "Некоторые группы не найдены",
|
||||
"missing_ids": list(missing_ids)
|
||||
},
|
||||
status=400
|
||||
)
|
||||
return qs, None
|
||||
|
||||
def get_permissions(self):
|
||||
"""Права доступа: чтение для всех аутентифицированных, изменения для админов."""
|
||||
if self.action in {"list", "retrieve", "permissions"}:
|
||||
return [IsAuthenticated()]
|
||||
if self.action == "create":
|
||||
return [AdminOrActionOrModelPerms()]
|
||||
return [AdminOrActionOrModelPerms()]
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
@transaction.atomic
|
||||
def set_groups(self, request, pk=None):
|
||||
"""Устанавливает группы для должности. Синхронизация LDAP требует реализации сигналов."""
|
||||
"""Заменяет все группы должности на указанные.
|
||||
|
||||
POS-группа должности автоматически вкладывается в соответствующие AD-группы
|
||||
через сигнал m2m_changed для Position.groups (см. signals/ldap/position.py).
|
||||
"""
|
||||
pos = self.get_object()
|
||||
qs, err = self._validate_groups_payload(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
old_groups = set(pos.groups.values_list('id', flat=True))
|
||||
pos.groups.set(qs)
|
||||
return Response(
|
||||
{
|
||||
"ok": True,
|
||||
"group_ids": list(pos.groups.values_list("id", flat=True)),
|
||||
}
|
||||
)
|
||||
new_groups = set(pos.groups.values_list('id', flat=True))
|
||||
|
||||
return Response({
|
||||
"ok": True,
|
||||
"position_id": pos.id,
|
||||
"position_name": pos.name,
|
||||
"group_ids": list(new_groups),
|
||||
"added": list(new_groups - old_groups),
|
||||
"removed": list(old_groups - new_groups),
|
||||
})
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
@transaction.atomic
|
||||
def add_groups(self, request, pk=None):
|
||||
"""Добавляет группы к должности. Синхронизация LDAP требует реализации сигналов."""
|
||||
"""Добавляет группы к должности.
|
||||
|
||||
POS-группа должности автоматически вкладывается в соответствующие AD-группы
|
||||
через сигнал m2m_changed для Position.groups (см. signals/ldap/position.py).
|
||||
"""
|
||||
pos = self.get_object()
|
||||
qs, err = self._validate_groups_payload(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
old_groups = set(pos.groups.values_list('id', flat=True))
|
||||
pos.groups.add(*qs)
|
||||
return Response(
|
||||
{
|
||||
"ok": True,
|
||||
"group_ids": list(pos.groups.values_list("id", flat=True)),
|
||||
}
|
||||
)
|
||||
new_groups = set(pos.groups.values_list('id', flat=True))
|
||||
|
||||
return Response({
|
||||
"ok": True,
|
||||
"position_id": pos.id,
|
||||
"position_name": pos.name,
|
||||
"group_ids": list(new_groups),
|
||||
"added": list(new_groups - old_groups),
|
||||
})
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
@transaction.atomic
|
||||
def remove_groups(self, request, pk=None):
|
||||
"""Удаляет группы у должности. Синхронизация LDAP требует реализации сигналов."""
|
||||
"""Удаляет группы у должности.
|
||||
|
||||
POS-группа должности автоматически удаляется из соответствующих AD-групп
|
||||
через сигнал m2m_changed для Position.groups (см. signals/ldap/position.py).
|
||||
"""
|
||||
pos = self.get_object()
|
||||
qs, err = self._validate_groups_payload(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
old_groups = set(pos.groups.values_list('id', flat=True))
|
||||
pos.groups.remove(*qs)
|
||||
return Response(
|
||||
{
|
||||
"ok": True,
|
||||
"group_ids": list(pos.groups.values_list("id", flat=True)),
|
||||
}
|
||||
)
|
||||
new_groups = set(pos.groups.values_list('id', flat=True))
|
||||
|
||||
return Response({
|
||||
"ok": True,
|
||||
"position_id": pos.id,
|
||||
"position_name": pos.name,
|
||||
"group_ids": list(new_groups),
|
||||
"removed": list(old_groups - new_groups),
|
||||
})
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def permissions(self, request, pk=None):
|
||||
"""Возвращает все права (permissions), которые получает должность через свои группы."""
|
||||
pos = self.get_object()
|
||||
perms = (
|
||||
Permission.objects.filter(group__positions=pos)
|
||||
.select_related("content_type")
|
||||
.distinct()
|
||||
.order_by('content_type__app_label', 'codename')
|
||||
)
|
||||
data = [
|
||||
{
|
||||
@ -122,16 +171,21 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
}
|
||||
for p in perms
|
||||
]
|
||||
return Response({"count": len(data), "results": data}, status=200)
|
||||
return Response({
|
||||
"position_id": pos.id,
|
||||
"position_name": pos.name,
|
||||
"count": len(data),
|
||||
"results": data
|
||||
})
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Создание должности. Синхронизация LDAP требует реализации сигналов."""
|
||||
"""Создание должности."""
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Обновление должности. Синхронизация LDAP требует реализации сигналов."""
|
||||
"""Обновление должности."""
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Удаление должности. Синхронизация LDAP требует реализации сигналов."""
|
||||
return super().perform_destroy(instance)
|
||||
"""Удаление должности."""
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@ -1 +0,0 @@
|
||||
"""Auth API v2."""
|
||||
@ -1,21 +0,0 @@
|
||||
"""Authentication views for API v2."""
|
||||
from api.v1.employees.views import (
|
||||
RegisterAPIView as V1RegisterAPIView,
|
||||
ResendEmailAPIView as V1ResendEmailAPIView,
|
||||
VerifyEmailAPIView as V1VerifyEmailAPIView,
|
||||
)
|
||||
|
||||
|
||||
class RegisterAPIView(V1RegisterAPIView):
|
||||
"""API v2 для регистрации пользователей."""
|
||||
pass
|
||||
|
||||
|
||||
class ResendEmailAPIView(V1ResendEmailAPIView):
|
||||
"""API v2 для повторной отправки email подтверждения."""
|
||||
pass
|
||||
|
||||
|
||||
class VerifyEmailAPIView(V1VerifyEmailAPIView):
|
||||
"""API v2 для подтверждения email."""
|
||||
pass
|
||||
@ -1 +0,0 @@
|
||||
"""Calendar API v2."""
|
||||
@ -1,21 +0,0 @@
|
||||
"""Calendar API v2 Views."""
|
||||
from api.v1.calendar.views import (
|
||||
CalendarEventsViewSet as V1CalendarEventsViewSet,
|
||||
CalendarSubscriptionViewSet as V1CalendarSubscriptionViewSet,
|
||||
CalendarViewSet as V1CalendarViewSet,
|
||||
)
|
||||
|
||||
|
||||
class CalendarEventsViewSet(V1CalendarEventsViewSet):
|
||||
"""API v2 для событий календаря."""
|
||||
pass
|
||||
|
||||
|
||||
class CalendarViewSet(V1CalendarViewSet):
|
||||
"""API v2 для календарей."""
|
||||
pass
|
||||
|
||||
|
||||
class CalendarSubscriptionViewSet(V1CalendarSubscriptionViewSet):
|
||||
"""API v2 для подписок на календари."""
|
||||
pass
|
||||
@ -1 +0,0 @@
|
||||
"""Communications API v2."""
|
||||
@ -1,21 +0,0 @@
|
||||
"""Communications API v2 Views."""
|
||||
from communications.api.viewsets import (
|
||||
ChatViewSet as V1ChatViewSet,
|
||||
MessageViewSet as V1MessageViewSet,
|
||||
PollViewSet as V1PollViewSet,
|
||||
)
|
||||
|
||||
|
||||
class ChatViewSet(V1ChatViewSet):
|
||||
"""API v2 для чатов."""
|
||||
pass
|
||||
|
||||
|
||||
class MessageViewSet(V1MessageViewSet):
|
||||
"""API v2 для сообщений."""
|
||||
pass
|
||||
|
||||
|
||||
class PollViewSet(V1PollViewSet):
|
||||
"""API v2 для опросов."""
|
||||
pass
|
||||
@ -1,14 +0,0 @@
|
||||
"""Documents API v2 Views."""
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from api.v1.documents.views import DocumentViewSet as V1DocumentViewSet
|
||||
|
||||
|
||||
class DocumentViewSet(V1DocumentViewSet):
|
||||
"""
|
||||
API v2 для документов.
|
||||
|
||||
Наследуется от v1, но может быть расширен в будущем.
|
||||
"""
|
||||
pass
|
||||
@ -1,12 +0,0 @@
|
||||
"""Documents API v2 Views."""
|
||||
from api.v1.documents.views import DocumentViewSet as V1DocumentViewSet
|
||||
|
||||
|
||||
class DocumentViewSet(V1DocumentViewSet):
|
||||
"""
|
||||
API v2 для документов.
|
||||
|
||||
Наследуется от v1, использует ту же логику.
|
||||
В будущем можно добавить специфичные для v2 улучшения.
|
||||
"""
|
||||
pass
|
||||
@ -1,31 +0,0 @@
|
||||
"""Extended employees views for API v2."""
|
||||
from api.v1.employees.views.roles import DepartmentRoleViewSet as V1DepartmentRoleViewSet
|
||||
from api.v1.employees.views.actions import EmployeeActionViewSet as V1EmployeeActionViewSet
|
||||
from api.v1.employees.views.groups import GroupViewSet as V1GroupViewSet
|
||||
from api.v1.employees.views.positions import PositionViewSet as V1PositionViewSet
|
||||
from api.v1.employees.views.skills import SkillViewSet as V1SkillViewSet
|
||||
|
||||
|
||||
class PositionViewSet(V1PositionViewSet):
|
||||
"""API v2 для должностей."""
|
||||
pass
|
||||
|
||||
|
||||
class DepartmentRoleViewSet(V1DepartmentRoleViewSet):
|
||||
"""API v2 для ролей в отделах."""
|
||||
pass
|
||||
|
||||
|
||||
class SkillViewSet(V1SkillViewSet):
|
||||
"""API v2 для навыков."""
|
||||
pass
|
||||
|
||||
|
||||
class EmployeeActionViewSet(V1EmployeeActionViewSet):
|
||||
"""API v2 для действий с сотрудниками."""
|
||||
pass
|
||||
|
||||
|
||||
class GroupViewSet(V1GroupViewSet):
|
||||
"""API v2 для групп."""
|
||||
pass
|
||||
@ -1,205 +0,0 @@
|
||||
"""Serializers for Employee and Department API v2."""
|
||||
from django.contrib.auth import get_user_model
|
||||
from employees.models import Department, Employee, Position
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
class EmployeeBriefSerializer(serializers.ModelSerializer):
|
||||
"""Краткая информация о сотруднике."""
|
||||
|
||||
display_name = serializers.SerializerMethodField()
|
||||
full_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Employee
|
||||
fields = (
|
||||
'id',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'patronymic',
|
||||
'email',
|
||||
'phone_number',
|
||||
'display_name',
|
||||
'full_name',
|
||||
'avatar',
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
def get_display_name(self, obj) -> str:
|
||||
"""Читабельное имя: ФИО -> email -> phone -> 'Сотрудник #id'"""
|
||||
parts = [obj.last_name or '',
|
||||
obj.first_name or '', obj.patronymic or '']
|
||||
fio = ' '.join(p.strip() for p in parts if p)
|
||||
if fio:
|
||||
return fio
|
||||
if getattr(obj, 'email', None):
|
||||
return obj.email
|
||||
if getattr(obj, 'phone_number', None):
|
||||
return obj.phone_number
|
||||
return f'Сотрудник #{obj.id}'
|
||||
|
||||
def get_full_name(self, obj) -> str:
|
||||
"""Полное имя."""
|
||||
parts = [
|
||||
obj.last_name or '',
|
||||
obj.first_name or '',
|
||||
getattr(obj, 'patronymic', '') or '',
|
||||
]
|
||||
return ' '.join(p.strip() for p in parts if p)
|
||||
|
||||
|
||||
class EmployeeSerializer(serializers.ModelSerializer):
|
||||
"""Employee serializer for v2 API.
|
||||
|
||||
Отличия от v1:
|
||||
- Упрощенная логика (без прямой работы с LDAP)
|
||||
- LDAP синхронизация через сигналы
|
||||
"""
|
||||
|
||||
position = serializers.StringRelatedField(read_only=True)
|
||||
position_id = serializers.PrimaryKeyRelatedField(
|
||||
source='position',
|
||||
queryset=Position.objects.all(),
|
||||
write_only=True,
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
department = serializers.SerializerMethodField()
|
||||
full_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Employee
|
||||
fields = [
|
||||
'id',
|
||||
'email',
|
||||
'password',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'patronymic',
|
||||
'full_name',
|
||||
'phone_number',
|
||||
'birth_date',
|
||||
'gender',
|
||||
'telegram',
|
||||
'whatsapp',
|
||||
'wechat',
|
||||
'avatar',
|
||||
'position',
|
||||
'position_id',
|
||||
'department',
|
||||
'is_active',
|
||||
'is_ldap_managed',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at',
|
||||
'updated_at', 'is_ldap_managed', 'full_name']
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': True, 'required': False},
|
||||
}
|
||||
|
||||
def get_full_name(self, obj) -> str:
|
||||
"""Полное имя."""
|
||||
parts = [obj.last_name or '',
|
||||
obj.first_name or '', obj.patronymic or '']
|
||||
return ' '.join(p.strip() for p in parts if p)
|
||||
|
||||
def get_department(self, obj):
|
||||
"""Получить основной отдел сотрудника."""
|
||||
from employees.models import EmployeeDepartment
|
||||
dept_link = EmployeeDepartment.objects.filter(
|
||||
employee=obj,
|
||||
is_active=True
|
||||
).select_related('department').first()
|
||||
|
||||
if dept_link:
|
||||
return {
|
||||
'id': dept_link.department.id,
|
||||
'name': dept_link.department.name
|
||||
}
|
||||
return None
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Создание сотрудника с обработкой пароля.
|
||||
|
||||
Специальная логика:
|
||||
- Извлекает пароль из validated_data
|
||||
- Сохраняет его во временном атрибуте _raw_password для LDAP сигнала
|
||||
- Использует create_user() для правильной установки пароля
|
||||
- LDAP синхронизация происходит через post_save сигнал
|
||||
"""
|
||||
# Извлекаем пароль из validated_data
|
||||
password = validated_data.pop('password', None)
|
||||
|
||||
# Создаем пользователя
|
||||
employee = Employee(**validated_data)
|
||||
|
||||
if password:
|
||||
# Сохраняем открытый пароль для LDAP сигнала
|
||||
employee._raw_password = password
|
||||
# Устанавливаем хэшированный пароль
|
||||
employee.set_password(password)
|
||||
|
||||
employee.save()
|
||||
return employee
|
||||
|
||||
|
||||
class DepartmentSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для Department API v2.
|
||||
|
||||
Чтение:
|
||||
- id, name, description, head (полная информация), employees_count
|
||||
|
||||
Запись:
|
||||
- name, description, head_id (FK)
|
||||
"""
|
||||
|
||||
head = EmployeeBriefSerializer(read_only=True)
|
||||
head_id = serializers.PrimaryKeyRelatedField(
|
||||
source='head',
|
||||
queryset=Employee.objects.all(),
|
||||
write_only=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
employees_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Department
|
||||
fields = (
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'head',
|
||||
'head_id',
|
||||
'employees_count',
|
||||
)
|
||||
read_only_fields = ('id', 'employees_count')
|
||||
|
||||
def validate_name(self, value):
|
||||
"""Валидация имени отдела."""
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError(
|
||||
'Название отдела не может быть пустым')
|
||||
|
||||
# Проверка уникальности (с учетом update)
|
||||
instance = self.instance
|
||||
qs = Department.objects.filter(name__iexact=value.strip())
|
||||
if instance:
|
||||
qs = qs.exclude(pk=instance.pk)
|
||||
|
||||
if qs.exists():
|
||||
raise serializers.ValidationError(
|
||||
'Отдел с таким названием уже существует')
|
||||
|
||||
return value.strip()
|
||||
|
||||
def validate_head_id(self, value):
|
||||
"""Валидация руководителя отдела."""
|
||||
if value and not value.is_active:
|
||||
raise serializers.ValidationError(
|
||||
'Руководитель должен быть активным пользователем')
|
||||
return value
|
||||
@ -1 +0,0 @@
|
||||
"""Feed API v2."""
|
||||
@ -1,15 +0,0 @@
|
||||
"""Feed API v2 Views."""
|
||||
from api.v1.feed.views import (
|
||||
CommentViewSet as V1CommentViewSet,
|
||||
PostViewSet as V1PostViewSet,
|
||||
)
|
||||
|
||||
|
||||
class PostViewSet(V1PostViewSet):
|
||||
"""API v2 для постов."""
|
||||
pass
|
||||
|
||||
|
||||
class CommentViewSet(V1CommentViewSet):
|
||||
"""API v2 для комментариев."""
|
||||
pass
|
||||
@ -1 +0,0 @@
|
||||
"""Requests API v2."""
|
||||
@ -1,11 +0,0 @@
|
||||
"""Requests API v2 Views."""
|
||||
from api.v1.requests_app.views import RequestViewSet as V1RequestViewSet
|
||||
|
||||
|
||||
class RequestViewSet(V1RequestViewSet):
|
||||
"""
|
||||
API v2 для заявок.
|
||||
|
||||
Наследуется от v1, использует ту же логику.
|
||||
"""
|
||||
pass
|
||||
@ -1,74 +0,0 @@
|
||||
"""URL configuration for API v2."""
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .auth.views import RegisterAPIView, ResendEmailAPIView, VerifyEmailAPIView
|
||||
from .calendar.views import (
|
||||
CalendarEventsViewSet,
|
||||
CalendarSubscriptionViewSet,
|
||||
CalendarViewSet,
|
||||
)
|
||||
from .communications.views import ChatViewSet, MessageViewSet, PollViewSet
|
||||
from .documents.views import DocumentViewSet
|
||||
from .employees.extended_views import (
|
||||
DepartmentRoleViewSet,
|
||||
EmployeeActionViewSet,
|
||||
GroupViewSet,
|
||||
PositionViewSet,
|
||||
SkillViewSet,
|
||||
)
|
||||
from .employees.views import DepartmentViewSet, EmployeeViewSet
|
||||
from .feed.views import CommentViewSet, PostViewSet
|
||||
from .requests.views import RequestViewSet
|
||||
|
||||
app_name = "v2"
|
||||
|
||||
router = DefaultRouter()
|
||||
|
||||
# Employees & Departments
|
||||
router.register(r"employees", EmployeeViewSet, basename="employee")
|
||||
router.register(r"departments", DepartmentViewSet, basename="department")
|
||||
router.register(r"positions", PositionViewSet, basename="positions")
|
||||
router.register(r"department-roles", DepartmentRoleViewSet,
|
||||
basename="department-roles")
|
||||
router.register(r"skills", SkillViewSet, basename="skills")
|
||||
router.register(r"employee-actions", EmployeeActionViewSet,
|
||||
basename="employee-actions")
|
||||
router.register(r"groups", GroupViewSet, basename="groups")
|
||||
|
||||
# Documents
|
||||
router.register(r"documents", DocumentViewSet, basename="documents")
|
||||
|
||||
# Requests (Заявки)
|
||||
router.register(r"requests", RequestViewSet, basename="requests")
|
||||
|
||||
# Calendar
|
||||
router.register(r"calendar/events", CalendarEventsViewSet, basename="events")
|
||||
router.register(r"calendar/calendars", CalendarViewSet, basename="calendars")
|
||||
router.register(
|
||||
r"calendar/subscriptions",
|
||||
CalendarSubscriptionViewSet,
|
||||
basename="subscriptions",
|
||||
)
|
||||
|
||||
# Communications (Чаты)
|
||||
router.register(r"chats", ChatViewSet, basename="chats")
|
||||
router.register(r"messages", MessageViewSet, basename="messages")
|
||||
router.register(r"polls", PollViewSet, basename="polls")
|
||||
|
||||
# Feed (Лента новостей)
|
||||
router.register(r"posts", PostViewSet, basename="posts")
|
||||
router.register(r"comments", CommentViewSet, basename="comments")
|
||||
|
||||
urlpatterns = [
|
||||
# Auth endpoints
|
||||
path("auth/register/", RegisterAPIView.as_view(), name="register"),
|
||||
path("auth/resend-email/", ResendEmailAPIView.as_view(), name="resend-email"),
|
||||
path("auth/verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"),
|
||||
|
||||
# Notifications (из самого модуля notifications)
|
||||
path("notifications/", include("notifications.api.urls")),
|
||||
|
||||
# Router URLs (все ViewSets)
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from employees.models import LdapSyncState
|
||||
from employees.models import LdapSyncState, Position
|
||||
|
||||
from ..models import Department, Employee
|
||||
from .domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO
|
||||
@ -26,12 +26,16 @@ class DirectoryService:
|
||||
from .services.department_service import DepartmentService
|
||||
from .services.user_service import UserService
|
||||
from .services.group_service import GroupService
|
||||
from .services.position_service import PositionService
|
||||
|
||||
self._user_service = UserService()
|
||||
self._group_service = GroupService(directory_service=self)
|
||||
self._department_service = DepartmentService(
|
||||
group_service=self._group_service, user_service=self._user_service
|
||||
)
|
||||
self._position_service = PositionService(
|
||||
group_service=self._group_service, user_service=self._user_service
|
||||
)
|
||||
|
||||
# ====================== ВСПОМОГАТЕЛЬНЫЕ ХЕЛПЕРЫ ====================== #
|
||||
|
||||
@ -237,3 +241,33 @@ class DirectoryService:
|
||||
|
||||
with _ldap() as conn:
|
||||
self._group_service.replace_members(conn, group_dn, exact_member_dns)
|
||||
|
||||
# ======================== POSITIONS ======================== #
|
||||
|
||||
def position_reconcile(self, position: Position) -> str:
|
||||
"""Синхронизирует должность с LDAP.
|
||||
|
||||
Приводит должность к консистентному состоянию:
|
||||
1) Создаёт/обновляет POS-группу (CN=POS_<name>)
|
||||
2) Вкладывает POS-группу в целевые группы (из position.groups)
|
||||
3) Синхронизирует участников POS-группы (сотрудники с этой должностью)
|
||||
|
||||
Args:
|
||||
position: Должность для синхронизации
|
||||
|
||||
Returns:
|
||||
DN POS-группы должности
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если LDAP_POSITIONS_BASE не настроен
|
||||
ValueError: Если имя должности пустое
|
||||
"""
|
||||
return self._position_service.reconcile_position(position)
|
||||
|
||||
def position_delete(self, position: Position) -> None:
|
||||
"""Удаляет POS-группу должности из LDAP.
|
||||
|
||||
Args:
|
||||
position: Должность с POS-группой для удаления
|
||||
"""
|
||||
return self._position_service.delete_position_group(position)
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from django.conf import settings
|
||||
@ -37,6 +38,8 @@ from ..utils.text_utils import esc_rdn
|
||||
UAC_ENABLED = 512
|
||||
UAC_DISABLED = 514
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Сервис для управления пользователями в LDAP и Django."""
|
||||
@ -103,9 +106,13 @@ class UserService:
|
||||
emp.set_unusable_password()
|
||||
emp.save(update_fields=["password"])
|
||||
|
||||
# ORM: чтение objectGUID + запись employeeNumber
|
||||
# Чтение objectGUID + запись employeeNumber (AD)
|
||||
ldap_user = LdapUser.objects.get(dn=dn)
|
||||
guid = str(ldap_user.object_guid) if ldap_user.object_guid else None
|
||||
|
||||
# Записываем Django PK в LDAP employeeNumber для связки
|
||||
ldap_user.employee_number = str(emp.pk)
|
||||
ldap_user.save()
|
||||
|
||||
self._touch_state(
|
||||
model="employee",
|
||||
@ -116,10 +123,6 @@ class UserService:
|
||||
last_django_modify_ts=timezone.now(),
|
||||
)
|
||||
|
||||
# ORM: записываем Django PK в LDAP employeeNumber для связки
|
||||
ldap_user.employee_number = str(emp.pk)
|
||||
ldap_user.save()
|
||||
|
||||
# Временно импортируем для совместимости
|
||||
# TODO: Убрать после полного рефакторинга
|
||||
if dto.department_dn and hasattr(emp, "set_active_department"):
|
||||
@ -460,12 +463,7 @@ class UserService:
|
||||
return state
|
||||
|
||||
def _set_password(self, conn: Connection, dn: str, new_password: str) -> None:
|
||||
"""Меняет пароль пользователя в AD (control).
|
||||
|
||||
TODO(ldap-orm): Невозможно заменить на ORM.
|
||||
AD modify_password — extended operation, django-ldapdb не поддерживает.
|
||||
Остаётся через ldap3 навсегда.
|
||||
"""
|
||||
"""Меняет пароль пользователя через AD extended operation."""
|
||||
ok = conn.extend.microsoft.modify_password(dn, new_password)
|
||||
if not ok:
|
||||
msg = conn.result or {}
|
||||
@ -475,7 +473,7 @@ class UserService:
|
||||
raise RuntimeError(f"LDAP set password failed: {msg}")
|
||||
|
||||
def _enable_user(self, conn: Connection, dn: str) -> None:
|
||||
"""Включает учётку (UAC=512) через ORM."""
|
||||
"""Включает учётку (UAC=512)."""
|
||||
ldap_user = LdapUser.objects.get(dn=dn)
|
||||
ldap_user.user_account_control = UAC_ENABLED
|
||||
ldap_user.save()
|
||||
@ -516,22 +514,23 @@ class UserService:
|
||||
def _build_user_attrs(
|
||||
self, dto: DirectoryUserDTO, sam: str, upn: str, cn_text: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Собирает атрибуты для создания user в AD, отбрасывая пустые значения."""
|
||||
"""Собирает атрибуты для создания AD user, отбрасывая пустые значения."""
|
||||
if not all(isinstance(x, str) for x in (sam, upn, cn_text)):
|
||||
raise TypeError("sam, upn и cn_text должны быть строками")
|
||||
|
||||
base_attrs: Dict[str, Any] = {
|
||||
attrs: Dict[str, Any] = {
|
||||
"cn": cn_text,
|
||||
"sAMAccountName": sam,
|
||||
"userPrincipalName": upn,
|
||||
"userAccountControl": UAC_DISABLED,
|
||||
"givenName": dto.first_name or None,
|
||||
"sn": dto.last_name or ".", # sn не может быть пустым
|
||||
"sn": dto.last_name or ".",
|
||||
"displayName": cn_text or None,
|
||||
"mail": (dto.email or None),
|
||||
self._phone_write_attr(): (dto.phone_e164 or None),
|
||||
"sAMAccountName": sam,
|
||||
"userPrincipalName": upn,
|
||||
"userAccountControl": UAC_DISABLED, # disabled до верификации email
|
||||
}
|
||||
return {k: v for k, v in base_attrs.items() if v not in (None, "", [])}
|
||||
|
||||
return {k: v for k, v in attrs.items() if v not in (None, "", [])}
|
||||
|
||||
def _try_add_with_cn_list(
|
||||
self,
|
||||
@ -587,6 +586,7 @@ class UserService:
|
||||
cn_list = cn_candidates(pretty_cn, safe_cn)
|
||||
|
||||
object_classes = ["top", "person", "organizationalPerson", "user"]
|
||||
|
||||
dn = self._try_add_with_cn_list(
|
||||
conn, base_dn, object_classes, dto, sam, upn, cn_list
|
||||
)
|
||||
|
||||
@ -222,13 +222,8 @@ class Employee(AbstractUser):
|
||||
("view_ldap_info", "Может просматривать LDAP информацию сотрудников"),
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if not (self.whatsapp or self.telegram or self.wechat):
|
||||
raise ValidationError(
|
||||
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram",
|
||||
code="contact_required",
|
||||
)
|
||||
# Контактные поля telegram/whatsapp/wechat опциональны
|
||||
# Валидация "хотя бы одно поле" удалена
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.last_name} {self.first_name}"
|
||||
@ -606,7 +601,7 @@ class LdapSyncState(models.Model):
|
||||
model (str): Имя модели в Django ('employee','department','group','dept_role','position').
|
||||
object_pk (str): Первичный ключ объекта (строкой, чтобы покрыть UUID/INT).
|
||||
ldap_dn (str): Последний известный DN в LDAP.
|
||||
ldap_guid (str | None): GUID из LDAP (objectGUID/entryUUID), если применимо.
|
||||
ldap_guid (str | None): objectGUID из Active Directory.
|
||||
last_ldap_modify_ts (datetime | None): Последний modifyTimestamp/whenChanged из LDAP.
|
||||
last_django_modify_ts (datetime | None): Последний updated_at из Django на момент фиксации.
|
||||
last_sync_dir (str): 'ldap'|'django'|'auto' — направление последнего успешного синка.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""LDAP signals для автоматической синхронизации моделей с Active Directory."""
|
||||
|
||||
from . import employee, department, group
|
||||
from . import employee, department, group, position
|
||||
|
||||
__all__ = ['employee', 'department', 'group']
|
||||
__all__ = ['employee', 'department', 'group', 'position']
|
||||
|
||||
127
backend/employees/signals/ldap/position.py
Normal file
127
backend/employees/signals/ldap/position.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Django signals для автоматической синхронизации Position с LDAP.
|
||||
|
||||
Файл: employees/signals/ldap/position.py
|
||||
Отвечает за синхронизацию POS-групп должностей с Active Directory при изменении Position.groups.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import m2m_changed, post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from employees.ldap.directory_service import DirectoryService
|
||||
from employees.ldap.errors import (
|
||||
DirectoryDbError,
|
||||
DirectoryLdapError,
|
||||
DirectoryServiceError,
|
||||
)
|
||||
from employees.models import Position
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_ldap_enabled():
|
||||
"""Проверяет, включена ли интеграция с LDAP."""
|
||||
return getattr(settings, "LDAP_ENABLED", False)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Position)
|
||||
def sync_position_to_ldap_on_save(sender, instance, created, **kwargs):
|
||||
"""Создает/обновляет POS-группу должности в LDAP при сохранении Position.
|
||||
|
||||
При создании: создает POS-группу (CN=POS_<name>)
|
||||
При обновлении: переименовывает POS-группу при изменении имени
|
||||
|
||||
Использует временный атрибут _skip_ldap_sync для отключения синхронизации.
|
||||
"""
|
||||
if not _is_ldap_enabled():
|
||||
return
|
||||
|
||||
if getattr(instance, '_skip_ldap_sync', False):
|
||||
return
|
||||
|
||||
try:
|
||||
svc = DirectoryService()
|
||||
|
||||
# reconcile_position гарантирует наличие POS-группы и обновляет её при необходимости
|
||||
# Также синхронизирует вложенность в целевые группы и участников
|
||||
svc.position_reconcile(instance)
|
||||
|
||||
logger.info(
|
||||
f"Synced position '{instance.name}' to LDAP (created={created})"
|
||||
)
|
||||
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
|
||||
logger.error(
|
||||
f"LDAP sync failed for Position {instance.id} (created={created}): {e}",
|
||||
exc_info=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error in LDAP sync for Position {instance.id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Position)
|
||||
def sync_position_to_ldap_on_delete(sender, instance, **kwargs):
|
||||
"""Удаляет POS-группу должности из LDAP при удалении Position."""
|
||||
if not _is_ldap_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
svc = DirectoryService()
|
||||
svc.position_delete(instance)
|
||||
logger.info(f"Deleted LDAP POS-group for position '{instance.name}'")
|
||||
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
|
||||
logger.error(
|
||||
f"LDAP delete failed for Position {instance.id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error in LDAP delete for Position {instance.id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Position.groups.through)
|
||||
def sync_position_groups_to_ldap(sender, instance, action, **kwargs):
|
||||
"""Синхронизирует изменения Position.groups с LDAP.
|
||||
|
||||
При изменении групп должности (add/remove/clear/set):
|
||||
- Обновляет вложенность POS-группы в целевые AD-группы
|
||||
- POS-группа добавляется в AD-группы, соответствующие Position.groups
|
||||
- POS-группа удаляется из AD-групп, не указанных в Position.groups
|
||||
|
||||
Реагирует на post_add, post_remove, post_clear для синхронизации вложенности.
|
||||
"""
|
||||
if not _is_ldap_enabled():
|
||||
return
|
||||
|
||||
if action not in ['post_add', 'post_remove', 'post_clear']:
|
||||
return
|
||||
|
||||
if getattr(instance, '_skip_ldap_sync', False):
|
||||
return
|
||||
|
||||
try:
|
||||
svc = DirectoryService()
|
||||
|
||||
# reconcile_position обновит вложенность POS-группы в целевые AD-группы
|
||||
# на основе текущего состояния Position.groups
|
||||
svc.position_reconcile(instance)
|
||||
|
||||
logger.info(
|
||||
f"Synced position '{instance.name}' groups to LDAP (action={action})"
|
||||
)
|
||||
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as e:
|
||||
logger.error(
|
||||
f"LDAP groups sync failed for Position {instance.id} (action={action}): {e}",
|
||||
exc_info=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error in LDAP groups sync for Position {instance.id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
@ -188,7 +188,7 @@ class LDAP3Backend(ModelBackend):
|
||||
svc_conn = Connection(server, auto_bind=True, receive_timeout=30)
|
||||
logger.debug("Service bind OK (anonymous/simple)")
|
||||
|
||||
# Базовый объектный фильтр (по умолчанию — пользователи AD)
|
||||
# Базовый объектный фильтр AD
|
||||
base_object_filter = getattr(
|
||||
settings,
|
||||
"LDAP_OBJECT_FILTER",
|
||||
@ -234,6 +234,7 @@ class LDAP3Backend(ModelBackend):
|
||||
ldap_filter = f"(&{base_object_filter}{id_filter})"
|
||||
logger.debug("FINAL LDAP search filter=%s", ldap_filter)
|
||||
|
||||
# AD атрибуты для поиска
|
||||
ok = svc_conn.search(
|
||||
search_base=user_base,
|
||||
search_filter=ldap_filter,
|
||||
@ -244,9 +245,11 @@ class LDAP3Backend(ModelBackend):
|
||||
getattr(settings, "LDAP_ATTR_MAIL", "mail"),
|
||||
"telephoneNumber",
|
||||
"mobile",
|
||||
"sAMAccountName",
|
||||
"userPrincipalName",
|
||||
"userAccountControl",
|
||||
"objectGUID",
|
||||
"userPrincipalName",
|
||||
"displayName",
|
||||
],
|
||||
size_limit=1,
|
||||
)
|
||||
@ -317,17 +320,6 @@ class LDAP3Backend(ModelBackend):
|
||||
if phone_e164:
|
||||
break
|
||||
|
||||
# --- AD disabled flag ---
|
||||
if getattr(settings, "LDAP_RESPECT_AD_DISABLED", False):
|
||||
uac_raw = get(entry, "userAccountControl")
|
||||
try:
|
||||
uac_int = int(str(uac_raw).strip())
|
||||
if (uac_int & 0x0002) != 0:
|
||||
logger.info("AD says account is DISABLED -> auth denied")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("UAC parse error: %s (ignored)", e)
|
||||
|
||||
# --- Find or create local user ---
|
||||
user = None
|
||||
if email and _model_has_field(UserModel, "email"):
|
||||
@ -337,51 +329,14 @@ class LDAP3Backend(ModelBackend):
|
||||
logger.debug("Lookup local user by %s=%r", PHONE_FIELD, phone_e164)
|
||||
user = UserModel.objects.filter(**{PHONE_FIELD: phone_e164}).first()
|
||||
|
||||
if user and getattr(settings, "LDAP_RESPECT_IS_ACTIVE", True):
|
||||
if not self.user_can_authenticate(user):
|
||||
logger.info("user_can_authenticate=False -> auth denied")
|
||||
return None
|
||||
if user and not self.user_can_authenticate(user):
|
||||
logger.info("user_can_authenticate=False -> auth denied")
|
||||
return None
|
||||
|
||||
auto_create = bool(
|
||||
getattr(settings, "LDAP_AUTO_CREATE", False)
|
||||
or getattr(settings, "LDAP_REGISTRATION_CREATE", False)
|
||||
)
|
||||
logger.debug("auto_create=%s", auto_create)
|
||||
|
||||
if user is None and auto_create:
|
||||
payload: dict[str, Any] = {}
|
||||
if _model_has_field(UserModel, "email") and email:
|
||||
payload["email"] = email
|
||||
if PHONE_FIELD and phone_e164:
|
||||
payload[PHONE_FIELD] = phone_e164
|
||||
if _model_has_field(UserModel, "first_name"):
|
||||
payload["first_name"] = first_name
|
||||
if _model_has_field(UserModel, "last_name"):
|
||||
payload["last_name"] = last_name
|
||||
|
||||
key_field = getattr(UserModel, "USERNAME_FIELD", "email")
|
||||
key_ok = (key_field != "email") or bool(payload.get("email"))
|
||||
phone_ok = (not PHONE_FIELD) or (PHONE_FIELD in payload)
|
||||
logger.debug(
|
||||
"Auto-create checks: key_field=%r key_ok=%s phone_ok=%s payload_keys=%s",
|
||||
key_field,
|
||||
key_ok,
|
||||
phone_ok,
|
||||
list(payload.keys()),
|
||||
)
|
||||
|
||||
if key_ok and phone_ok:
|
||||
user = UserModel.objects.create(**payload)
|
||||
logger.info("Local user CREATED: id=%s", getattr(user, "id", None))
|
||||
if self.user_can_authenticate(user) is False and hasattr(
|
||||
user, "is_active"
|
||||
):
|
||||
user.is_active = True
|
||||
user.save(update_fields=["is_active"])
|
||||
logger.debug("Local user activated (is_active=True)")
|
||||
else:
|
||||
logger.info("Auto-create refused (insufficient key data)")
|
||||
return None
|
||||
# Auto-create отключен для LDAP3Backend (создание через API)
|
||||
if user is None:
|
||||
logger.info("User not found in DB, auto-create disabled")
|
||||
return None
|
||||
|
||||
# --- Light profile sync ---
|
||||
if user:
|
||||
|
||||
@ -67,10 +67,10 @@ INSTALLED_APPS = [
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware", # CORS middleware должен быть перед CommonMiddleware
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware", # Нужен для Django Admin
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"reversion.middleware.RevisionMiddleware", # django-reversion для версионирования
|
||||
"simple_history.middleware.HistoryRequestMiddleware",
|
||||
@ -135,9 +135,9 @@ else:
|
||||
# которые определены ниже в разделе LDAP
|
||||
DATABASES["ldap"] = {
|
||||
"ENGINE": "ldapdb.backends.ldap",
|
||||
"NAME": os.getenv("LDAP_URI", "ldaps://dcii.robotail.local:636"),
|
||||
"USER": os.getenv("LDAP_BIND_DN", ""),
|
||||
"PASSWORD": os.getenv("LDAP_BIND_PASSWORD", ""),
|
||||
"NAME": os.getenv("LDAP_URI", "ldap://localhost:389"),
|
||||
"USER": os.getenv("LDAP_BIND_DN", "cn=admin,dc=eusrr,dc=local"),
|
||||
"PASSWORD": os.getenv("LDAP_BIND_PASSWORD", "AdminPassword123!"),
|
||||
}
|
||||
|
||||
# Database router для направления LDAP моделей в LDAP database
|
||||
@ -173,8 +173,6 @@ API_LOGIN_URL_NAME = "auth_front:login"
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api")
|
||||
LOGIN_URL = "auth_front:login"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
REGISTRATION_AUTO_LOGIN = True
|
||||
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24
|
||||
|
||||
PHONE_DEFAULT_REGION = os.getenv("PHONE_DEFAULT_REGION", "RU")
|
||||
PHONENUMBER_DEFAULT_REGION = "RU"
|
||||
@ -188,12 +186,12 @@ PHONENUMBER_DEFAULT_REGION = "RU"
|
||||
# ['*'] - разрешены все IP
|
||||
# ['192.168.1.0/24', '10.0.0.0/8'] - конкретные сети
|
||||
# ['192.168.1.100', '192.168.1.101'] - конкретные IP
|
||||
REGISTRATION_ALLOWED_IPS = [
|
||||
'127.0.0.0/8', # localhost
|
||||
'10.0.0.0/8', # приватная сеть класса A
|
||||
'172.16.0.0/12', # приватная сеть класса B (172.16-31.x.x)
|
||||
'172.11.0.0/16', # ваша корпоративная сеть
|
||||
'192.168.0.0/16', # приватная сеть класса C
|
||||
REGISTRATION_ALLOWED_IPS = [ "*",
|
||||
# '127.0.0.0/8', # localhost
|
||||
# '10.0.0.0/8', # приватная сеть класса A
|
||||
# '172.16.0.0/12', # приватная сеть класса B (172.16-31.x.x)
|
||||
# '172.11.0.0/16', # ваша корпоративная сеть
|
||||
# '192.168.0.0/16', # приватная сеть класса C
|
||||
]
|
||||
|
||||
# Медиа файлы
|
||||
@ -249,21 +247,29 @@ LOGGING = {
|
||||
# -----------------------------------------------------------------------------
|
||||
# БЕЗОПАСНОСТЬ И ПРОКСИ
|
||||
# -----------------------------------------------------------------------------
|
||||
# CSRF защита (для Django Admin и form-based views)
|
||||
CSRF_TRUSTED_ORIGINS = _split_env_list(
|
||||
os.getenv("CSRF_TRUSTED_ORIGINS", "https://*.sytes.net")
|
||||
)
|
||||
|
||||
# Настройки CSRF для AJAX запросов
|
||||
CSRF_COOKIE_HTTPONLY = False # Позволяет JavaScript читать cookie
|
||||
CSRF_COOKIE_HTTPONLY = False
|
||||
CSRF_COOKIE_SAMESITE = 'Lax'
|
||||
CSRF_USE_SESSIONS = False
|
||||
# Примечание: API endpoints с JWT используют authentication_classes = []
|
||||
# что отключает SessionAuthentication и автоматически bypass CSRF
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# EMAIL
|
||||
# -----------------------------------------------------------------------------
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend"
|
||||
)
|
||||
# В DEBUG режиме используем console backend (выводит письма в терминал)
|
||||
# В production - SMTP
|
||||
if DEBUG:
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend"
|
||||
)
|
||||
else:
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend"
|
||||
)
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "465"))
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
||||
@ -381,38 +387,31 @@ AUTHENTICATION_BACKENDS = [
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# LDAP (двусторонний обмен + LWW)
|
||||
# LDAP / Active Directory
|
||||
# -----------------------------------------------------------------------------
|
||||
LDAP_ENABLED = os.getenv("LDAP_ENABLED", "true").lower() == "true"
|
||||
|
||||
# Основное подключение
|
||||
LDAP_URI = os.getenv("LDAP_URI", "ldaps://dcii.robotail.local:636")
|
||||
LDAP_BIND_DN = os.getenv("LDAP_BIND_DN", "")
|
||||
LDAP_BIND_PASSWORD = os.getenv("LDAP_BIND_PASSWORD", "")
|
||||
LDAP_URI = os.getenv("LDAP_URI", "ldap://localhost:389")
|
||||
LDAP_BIND_DN = os.getenv("LDAP_BIND_DN", "cn=admin,dc=eusrr,dc=local")
|
||||
LDAP_BIND_PASSWORD = os.getenv("LDAP_BIND_PASSWORD", "AdminPassword123!")
|
||||
|
||||
# TLS/CA
|
||||
# путь к CA bundle/серту (если нужен)
|
||||
LDAP_CA_CERTS = os.getenv("LDAP_CA_CERTS", "")
|
||||
LDAP_TLS_REQUIRED = os.getenv("LDAP_TLS_REQUIRED", "true").lower() == "true"
|
||||
|
||||
# Где искать пользователей
|
||||
LDAP_USER_BASE = os.getenv("LDAP_USER_BASE", "OU=company,DC=robotail,DC=local")
|
||||
LDAP_USER_BASE = os.getenv("LDAP_USER_BASE", "OU=Users,DC=eusrr,DC=local")
|
||||
# Где создавать новых пользователей (может отличаться от базы поиска)
|
||||
LDAP_USERS_BASE = os.getenv("LDAP_USERS_BASE", LDAP_USER_BASE)
|
||||
# Базовый DN для операций создания (если не указан department_dn)
|
||||
LDAP_BASE_DN = os.getenv("LDAP_BASE_DN", LDAP_USERS_BASE)
|
||||
# UPN-суффикс для создания пользователей (userPrincipalName) - домен БЕЗ @
|
||||
LDAP_UPN_SUFFIX = os.getenv("LDAP_USER_UPN_SUFFIX", "robotail.local")
|
||||
LDAP_UPN_SUFFIX = os.getenv("LDAP_UPN_SUFFIX", "eusrr.local")
|
||||
|
||||
LDAP_USER_FILTER = os.getenv(
|
||||
"LDAP_USER_FILTER", "(&(objectCategory=person)(objectClass=user))"
|
||||
)
|
||||
# Для AD по умолчанию; для OpenLDAP можно обнулить в .env
|
||||
LDAP_ACTIVE_FILTER = os.getenv(
|
||||
"LDAP_ACTIVE_FILTER",
|
||||
"(!(userAccountControl:1.2.840.113556.1.4.803:=2))", # битовый NOT DISABLED
|
||||
)
|
||||
|
||||
|
||||
LDAP_ATTR_MAIL = os.getenv("LDAP_ATTR_MAIL", "mail")
|
||||
LDAP_ATTR_GIVENNAME = os.getenv("LDAP_ATTR_GIVENNAME", "givenName")
|
||||
@ -424,79 +423,35 @@ LDAP_PHONE_ATTRS = tuple(
|
||||
_split_env_list(os.getenv("LDAP_PHONE_ATTRS", "mobile,telephoneNumber"))
|
||||
)
|
||||
|
||||
LDAP_DEPT_ATTR = os.getenv("LDAP_DEPT_ATTR", "") # например, departmentNumber
|
||||
LDAP_SYNC_GROUPS = os.getenv("LDAP_SYNC_GROUPS", "false").lower() == "true"
|
||||
LDAP_GROUP_ATTR = os.getenv("LDAP_GROUP_ATTR", "memberOf")
|
||||
# Пример: {"CN=HR,OU=Groups,DC=...,DC=...": "hr"}
|
||||
LDAP_GROUP_MAP = {}
|
||||
LDAP_GROUPS_EXCLUSIVE = os.getenv(
|
||||
"LDAP_GROUPS_EXCLUSIVE", "false").lower() == "true"
|
||||
|
||||
# WRITE-BACK
|
||||
LDAP_WRITE_ENABLED = os.getenv("LDAP_WRITE_ENABLED", "false").lower() == "true"
|
||||
LDAP_WRITE_DN = os.getenv("LDAP_WRITE_DN", LDAP_BIND_DN)
|
||||
LDAP_WRITE_PASSWORD = os.getenv("LDAP_WRITE_PASSWORD", LDAP_BIND_PASSWORD)
|
||||
LDAP_WRITE_TIMEOUT = int(os.getenv("LDAP_WRITE_TIMEOUT", "5"))
|
||||
|
||||
# UPN суффикс для создания пользователей (домен БЕЗ @, например robotail.local)
|
||||
LDAP_UPN_SUFFIX = os.getenv("LDAP_USER_UPN_SUFFIX", "robotail.local")
|
||||
|
||||
# Белый список: локальные поля -> LDAP-атрибуты
|
||||
LDAP_WRITE_ATTRS = {
|
||||
"first_name": LDAP_ATTR_GIVENNAME,
|
||||
"last_name": LDAP_ATTR_SN,
|
||||
# фактическое имя локального телефонного поля подставляет код
|
||||
"phone": LDAP_ATTR_PHONE,
|
||||
# "photo": "jpegPhoto",
|
||||
}
|
||||
|
||||
# LWW (Last Writer Wins) — сравнение меток изменения
|
||||
# Для AD обычно whenChanged, для OpenLDAP — modifyTimestamp
|
||||
LDAP_ASSERT_ATTR_AD = "whenChanged"
|
||||
LDAP_ASSERT_ATTR_OL = "modifyTimestamp"
|
||||
LDAP_ASSERT_ATTR = os.getenv("LDAP_ASSERT_ATTR", LDAP_ASSERT_ATTR_AD)
|
||||
|
||||
# Локальное поле «последнее изменение» (ваше поле модели, напр. updated_at)
|
||||
LOCAL_ASSERT_FIELD = os.getenv("LOCAL_ASSERT_FIELD", "updated_at")
|
||||
LDAP_CREATE_EXTRA_ATTRS = {
|
||||
"sAMAccountName": "{username20}",
|
||||
"userPrincipalName": "{upn}",
|
||||
"givenName": "{first_name_or_dot}",
|
||||
"sn": "{last_name_or_dot}",
|
||||
"displayName": "{cn}",
|
||||
"mail": "{email}",
|
||||
}
|
||||
LDAP_SYNC_MODE = os.getenv("LDAP_SYNC_MODE", "lww") # lww|ldap|django
|
||||
LDAP_PURGE = os.getenv("LDAP_PURGE", "false").lower() == "true"
|
||||
|
||||
LDAP_CONNECT_TIMEOUT = int(os.getenv("LDAP_CONNECT_TIMEOUT", "5"))
|
||||
LDAP_OPERATION_TIMEOUT = int(os.getenv("LDAP_OPERATION_TIMEOUT", "10"))
|
||||
|
||||
|
||||
LDAP_USERS_BASE = os.getenv("LDAP_USERS_BASE")
|
||||
LDAP_USER_BASE = os.getenv("LDAP_USER_BASE")
|
||||
LDAP_DEPARTMENTS_BASE = os.getenv(
|
||||
"LDAP_DEPARTMENTS_BASE", "OU=Departments,OU=company,DC=robotail,DC=local"
|
||||
"LDAP_DEPARTMENTS_BASE", "OU=Departments,DC=eusrr,DC=local"
|
||||
)
|
||||
LDAP_DISMISSED_BASE = os.getenv(
|
||||
"LDAP_DISMISSED_BASE", "OU=Dismissed,OU=company,DC=robotail,DC=local"
|
||||
"LDAP_DISMISSED_BASE", "OU=Dismissed,DC=eusrr,DC=local"
|
||||
)
|
||||
LDAP_GROUPS_BASE = os.getenv("LDAP_GROUPS_BASE")
|
||||
|
||||
LDAP_AUTO_CREATE = os.getenv("LDAP_AUTO_CREATE", "False")
|
||||
|
||||
LDAP_RESPECT_IS_ACTIVE = True
|
||||
LDAP_RESPECT_AD_DISABLED = True
|
||||
LDAP_REGISTRATION_CREATE = True
|
||||
LDAP_POSITIONS_BASE = os.getenv("LDAP_POSITIONS_BASE")
|
||||
|
||||
LDAP_GROUPS_BASE = os.getenv("LDAP_GROUPS_BASE", "OU=Groups,DC=eusrr,DC=local")
|
||||
LDAP_POSITIONS_BASE = os.getenv("LDAP_POSITIONS_BASE", "OU=Positions,DC=eusrr,DC=local")
|
||||
|
||||
BRAND_NAME = os.getenv("BRAND_NAME", "HiRo")
|
||||
BRAND_LOGO = "img/logo.png"
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "eusrr_bot")
|
||||
|
||||
# Web Push Notifications (VAPID)
|
||||
# Ключи для авторизации push-уведомлений
|
||||
VAPID_PUBLIC_KEY = os.getenv(
|
||||
@ -632,9 +587,6 @@ THUMBNAIL_ALIASES = {
|
||||
},
|
||||
}
|
||||
|
||||
# django-reversion настройки
|
||||
REVERSION_SAVE_EMPTY_REVISIONS = False # Не сохранять пустые версии
|
||||
|
||||
# CORS Configuration for Next.js frontend
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
|
||||
@ -1,145 +1,184 @@
|
||||
aiofiles==24.1.0
|
||||
aiogram==3.21.0
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.12.15
|
||||
aiosignal==1.4.0
|
||||
amqp==5.3.1
|
||||
annotated-types==0.7.0
|
||||
anyio==4.12.0
|
||||
APScheduler==3.11.0
|
||||
# Core Django and ASGI
|
||||
Django==5.2.4
|
||||
asgiref==3.11.0
|
||||
attrs==25.4.0
|
||||
autobahn==25.12.2
|
||||
Automat==25.4.16
|
||||
beautifulsoup4==4.13.4
|
||||
billiard==4.2.4
|
||||
cbor2==5.7.1
|
||||
celery==5.5.3
|
||||
certifi==2025.11.12
|
||||
cffi==2.0.0
|
||||
gunicorn==23.0.0
|
||||
daphne==4.2.1
|
||||
|
||||
# Channels for WebSocket support
|
||||
channels==4.3.1
|
||||
channels_redis==4.3.0
|
||||
charset-normalizer==3.4.4
|
||||
click==8.3.1
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1.2
|
||||
click-repl==0.3.0
|
||||
colorama==0.4.6
|
||||
cron-descriptor==2.0.6
|
||||
confusable-homoglyphs==3.3.1
|
||||
constantly==23.10.4
|
||||
cryptography==45.0.6
|
||||
daphne==4.2.1
|
||||
defusedxml==0.7.1
|
||||
dj-database-url==3.0.1
|
||||
Django==5.2.4
|
||||
django-allauth==65.10.0
|
||||
|
||||
# Database drivers
|
||||
psycopg2-binary==2.9.10
|
||||
redis==6.4.0
|
||||
|
||||
# Django REST Framework
|
||||
djangorestframework==3.16.0
|
||||
djangorestframework_simplejwt==5.5.1
|
||||
|
||||
# Django Extensions and Utilities
|
||||
django-bootstrap5==25.1
|
||||
django-cors-headers==4.7.0
|
||||
django-extensions==4.1
|
||||
django-filter==25.1
|
||||
django-ldapdb==1.5.1
|
||||
django-phonenumber-field==8.1.0
|
||||
django-registration==5.2.1
|
||||
django-scheduler==0.12.0
|
||||
django-service-objects==0.7.1
|
||||
django-simple-history==3.10.1
|
||||
django-timezone-field==7.2.1
|
||||
django-watson==1.6.3
|
||||
django-widget-tweaks==1.5.0
|
||||
djangorestframework==3.16.0
|
||||
djangorestframework_simplejwt==5.5.1
|
||||
djoser==2.3.3
|
||||
et_xmlfile==2.0.0
|
||||
frozenlist==1.8.0
|
||||
gunicorn==23.0.0
|
||||
h11==0.16.0
|
||||
http_ece==1.2.1
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
humanize==4.15.0
|
||||
hyperlink==21.0.0
|
||||
icalendar==7.0.1
|
||||
idna==3.11
|
||||
Incremental==24.11.0
|
||||
iniconfig==2.3.0
|
||||
kombu==5.5.4
|
||||
|
||||
# LDAP Integration
|
||||
django-ldapdb==1.5.1
|
||||
ldap3==2.9.1
|
||||
magic-filter==1.0.12
|
||||
model-bakery==1.20.5
|
||||
msgpack==1.1.2
|
||||
multidict==6.7.0
|
||||
oauthlib==3.3.1
|
||||
openpyxl==3.1.5
|
||||
packaging==25.0
|
||||
phonenumbers==9.0.10
|
||||
pillow==11.3.0
|
||||
pluggy==1.6.0
|
||||
prometheus_client==0.23.1
|
||||
prompt_toolkit==3.0.52
|
||||
propcache==0.4.1
|
||||
psycopg2-binary==2.9.10
|
||||
py-ubjson==0.16.1
|
||||
py-vapid==1.9.2
|
||||
pyasn1==0.6.1
|
||||
pyasn1_modules==0.4.2
|
||||
pycparser==2.23
|
||||
pycryptodome==3.23.0
|
||||
pydantic==2.11.10
|
||||
pydantic_core==2.33.2
|
||||
Pygments==2.19.2
|
||||
PyJWT==2.10.1
|
||||
pyOpenSSL==25.1.0
|
||||
pytest==8.4.1
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-django==4.11.1
|
||||
python-crontab==3.3.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.1.1
|
||||
python-telegram-bot==22.3
|
||||
python3-openid==3.2.0
|
||||
pytz==2025.2
|
||||
pywebpush==2.1.2
|
||||
qrcode==8.1
|
||||
redis==6.4.0
|
||||
requests==2.32.4
|
||||
requests-oauthlib==2.0.0
|
||||
rules==3.5
|
||||
service-identity==24.2.0
|
||||
six==1.17.0
|
||||
social-auth-app-django==5.5.1
|
||||
social-auth-core==4.7.0
|
||||
soupsieve==2.8.1
|
||||
sqlparse==0.5.5
|
||||
tornado==6.5.4
|
||||
Twisted==25.5.0
|
||||
txaio==25.12.2
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2025.3
|
||||
tzlocal==5.3.1
|
||||
ujson==5.11.0
|
||||
urllib3==2.6.2
|
||||
|
||||
# Celery for async tasks
|
||||
celery==5.5.3
|
||||
django-celery-beat==2.7.0
|
||||
django-celery-results==2.5.1
|
||||
amqp==5.3.1
|
||||
billiard==4.2.4
|
||||
kombu==5.5.4
|
||||
vine==5.1.0
|
||||
wcwidth==0.2.14
|
||||
yarl==1.22.0
|
||||
zope.interface==8.1.1
|
||||
|
||||
# Celery и связанные пакеты (redis уже есть выше)
|
||||
django-celery-beat>=2.7.0
|
||||
django-celery-results>=2.5.1
|
||||
flower>=2.0.1
|
||||
|
||||
# Push Notifications
|
||||
django-push-notifications==3.1.0
|
||||
|
||||
# File Management with django-filer
|
||||
django-filer==3.4.4
|
||||
easy-thumbnails==2.10.1
|
||||
django-mptt==0.18.0 # Dependency for filer
|
||||
django-mptt==0.18.0
|
||||
|
||||
# Version Control for Models
|
||||
django-reversion==6.1.0
|
||||
|
||||
# Workflow State Machine
|
||||
django-fsm==3.0.1
|
||||
|
||||
# Permissions
|
||||
rules==3.5
|
||||
|
||||
# Push Notifications
|
||||
django-push-notifications==3.1.0
|
||||
py-vapid==1.9.2
|
||||
pywebpush==2.1.2
|
||||
http_ece==1.2.1
|
||||
|
||||
# Image Processing
|
||||
pillow==11.3.0
|
||||
qrcode==8.1
|
||||
|
||||
# Authentication and Security
|
||||
PyJWT==2.10.1
|
||||
cryptography==45.0.6
|
||||
pyOpenSSL==25.1.0
|
||||
cffi==2.0.0
|
||||
pycparser==2.23
|
||||
pycryptodome==3.23.0
|
||||
|
||||
# Phone Number Handling
|
||||
phonenumbers==9.0.10
|
||||
|
||||
# Excel Support
|
||||
openpyxl==3.1.5
|
||||
et_xmlfile==2.0.0
|
||||
|
||||
# HTTP Clients
|
||||
requests==2.32.4
|
||||
httpx==0.28.1
|
||||
httpcore==1.0.9
|
||||
h11==0.16.0
|
||||
certifi==2025.11.12
|
||||
|
||||
# ASGI and WebSocket
|
||||
autobahn==25.12.2
|
||||
Twisted==25.5.0
|
||||
txaio==25.12.2
|
||||
Automat==25.4.16
|
||||
hyperlink==21.0.0
|
||||
constantly==23.10.4
|
||||
Incremental==24.11.0
|
||||
service-identity==24.2.0
|
||||
zope.interface==8.1.1
|
||||
|
||||
# Validation and Data Handling
|
||||
pydantic==2.11.10
|
||||
pydantic_core==2.33.2
|
||||
attrs==25.4.0
|
||||
|
||||
# Calendar Support
|
||||
icalendar==7.0.1
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2025.2
|
||||
tzdata==2025.3
|
||||
tzlocal==5.3.1
|
||||
|
||||
# Configuration
|
||||
python-dotenv==1.1.1
|
||||
dj-database-url==3.0.1
|
||||
|
||||
# Monitoring
|
||||
prometheus_client==0.23.1
|
||||
|
||||
# Cron Jobs
|
||||
python-crontab==3.3.0
|
||||
cron-descriptor==2.0.6
|
||||
|
||||
# Utilities
|
||||
beautifulsoup4==4.13.4
|
||||
soupsieve==2.8.1
|
||||
click==8.3.1
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1.2
|
||||
click-repl==0.3.0
|
||||
defusedxml==0.7.1
|
||||
sqlparse==0.5.5
|
||||
six==1.17.0
|
||||
packaging==25.0
|
||||
Pygments==2.19.2
|
||||
colorama==0.4.6
|
||||
|
||||
# Serialization
|
||||
msgpack==1.1.2
|
||||
cbor2==5.7.1
|
||||
ujson==5.11.0
|
||||
py-ubjson==0.16.1
|
||||
|
||||
# Async Support
|
||||
aiofiles==24.1.0
|
||||
aiohttp==3.12.15
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiosignal==1.4.0
|
||||
anyio==4.12.0
|
||||
frozenlist==1.8.0
|
||||
multidict==6.7.0
|
||||
propcache==0.4.1
|
||||
yarl==1.22.0
|
||||
|
||||
# LDAP Dependencies
|
||||
pyasn1==0.6.1
|
||||
pyasn1_modules==0.4.2
|
||||
|
||||
# OAuth (minimal)
|
||||
oauthlib==3.3.1
|
||||
requests-oauthlib==2.0.0
|
||||
python3-openid==3.2.0
|
||||
|
||||
# Type Checking
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
annotated-types==0.7.0
|
||||
|
||||
# Testing
|
||||
pytest==8.4.1
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-django==4.11.1
|
||||
model-bakery==1.20.5
|
||||
iniconfig==2.3.0
|
||||
pluggy==1.6.0
|
||||
|
||||
# Terminal Utilities
|
||||
prompt_toolkit==3.0.52
|
||||
wcwidth==0.2.14
|
||||
|
||||
# Other
|
||||
idna==3.11
|
||||
charset-normalizer==3.4.4
|
||||
urllib3==2.6.2
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
# Базовая структура для тестового LDAP
|
||||
# Создается при инициализации контейнера OpenLDAP
|
||||
|
||||
# OU для пользователей (Users)
|
||||
dn: OU=Users,dc=eusrr,dc=local
|
||||
objectClass: organizationalUnit
|
||||
objectClass: top
|
||||
ou: Users
|
||||
description: Контейнер для пользователей EUSRR
|
||||
|
||||
# OU для отделов (Departments)
|
||||
dn: OU=Departments,dc=eusrr,dc=local
|
||||
objectClass: organizationalUnit
|
||||
objectClass: top
|
||||
ou: Departments
|
||||
description: Контейнер для отделов
|
||||
|
||||
# OU для групп (Groups)
|
||||
dn: OU=Groups,dc=eusrr,dc=local
|
||||
objectClass: organizationalUnit
|
||||
objectClass: top
|
||||
ou: Groups
|
||||
description: Контейнер для групп безопасности
|
||||
|
||||
# OU для должностей (Positions)
|
||||
dn: OU=Positions,dc=eusrr,dc=local
|
||||
objectClass: organizationalUnit
|
||||
objectClass: top
|
||||
ou: Positions
|
||||
description: Контейнер для должностей
|
||||
|
||||
# OU для уволенных (Dismissed)
|
||||
dn: OU=Dismissed,dc=eusrr,dc=local
|
||||
objectClass: organizationalUnit
|
||||
objectClass: top
|
||||
ou: Dismissed
|
||||
description: Контейнер для уволенных пользователей
|
||||
|
||||
# Тестовый отдел IT
|
||||
dn: OU=IT,OU=Departments,dc=eusrr,dc=local
|
||||
objectClass: organizationalUnit
|
||||
objectClass: top
|
||||
ou: IT
|
||||
description: IT Department
|
||||
|
||||
# Тестовый отдел HR
|
||||
dn: OU=HR,OU=Departments,dc=eusrr,dc=local
|
||||
objectClass: organizationalUnit
|
||||
objectClass: top
|
||||
ou: HR
|
||||
description: HR Department
|
||||
|
||||
# Группа безопасности - Developers
|
||||
dn: CN=Developers,OU=Groups,dc=eusrr,dc=local
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: Developers
|
||||
description: Developers group
|
||||
member: cn=admin,dc=eusrr,dc=local
|
||||
|
||||
# Группа безопасности - Managers
|
||||
dn: CN=Managers,OU=Groups,dc=eusrr,dc=local
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: Managers
|
||||
description: Managers group
|
||||
member: cn=admin,dc=eusrr,dc=local
|
||||
@ -1,217 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Скрипт для управления LDAP тестами
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Цвета для вывода
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
function print_green() {
|
||||
echo -e "${GREEN}$1${NC}"
|
||||
}
|
||||
|
||||
function print_yellow() {
|
||||
echo -e "${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
function print_red() {
|
||||
echo -e "${RED}$1${NC}"
|
||||
}
|
||||
|
||||
function start_ldap() {
|
||||
print_yellow "🐳 Запуск LDAP сервера..."
|
||||
docker-compose -f docker-compose.test.yml up -d
|
||||
|
||||
print_yellow "⏳ Ожидание запуска сервера (10 сек)..."
|
||||
sleep 10
|
||||
|
||||
if docker ps | grep -q "eusrr-test-ldap"; then
|
||||
print_green "✅ LDAP сервер запущен"
|
||||
print_green " - LDAP: ldap://localhost:10389"
|
||||
print_green " - Admin UI: http://localhost:8090"
|
||||
print_green " - Admin DN: cn=admin,dc=test,dc=local"
|
||||
print_green " - Password: test_change-me-redacted-secret"
|
||||
|
||||
# Загружаем базовую структуру если её ещё нет
|
||||
print_yellow "📋 Проверка базовой структуры LDAP..."
|
||||
if ldapsearch -x -H ldap://localhost:10389 \
|
||||
-b "ou=Users,dc=test,dc=local" \
|
||||
-D "cn=admin,dc=test,dc=local" \
|
||||
-w "test_change-me-redacted-secret" \
|
||||
2>/dev/null | grep -q "ou=Users"; then
|
||||
print_green "✅ Базовая структура уже загружена"
|
||||
else
|
||||
print_yellow "📥 Загрузка базовой структуры..."
|
||||
if [ -f "tests/ldap_fixtures/01-base-structure.ldif" ]; then
|
||||
ldapadd -x -H ldap://localhost:10389 \
|
||||
-D "cn=admin,dc=test,dc=local" \
|
||||
-w "test_change-me-redacted-secret" \
|
||||
-f tests/ldap_fixtures/01-base-structure.ldif \
|
||||
2>/dev/null && print_green "✅ Базовая структура загружена" || print_yellow "⚠️ Не удалось загрузить структуру (возможно уже существует)"
|
||||
else
|
||||
print_yellow "⚠️ Файл tests/ldap_fixtures/01-base-structure.ldif не найден"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
print_red "❌ Не удалось запустить LDAP сервер"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function stop_ldap() {
|
||||
print_yellow "🛑 Остановка LDAP сервера..."
|
||||
docker-compose -f docker-compose.test.yml stop
|
||||
print_green "✅ LDAP сервер остановлен"
|
||||
}
|
||||
|
||||
function restart_ldap() {
|
||||
print_yellow "🔄 Перезапуск LDAP сервера..."
|
||||
docker-compose -f docker-compose.test.yml restart
|
||||
sleep 5
|
||||
print_green "✅ LDAP сервер перезапущен"
|
||||
}
|
||||
|
||||
function clean_ldap() {
|
||||
print_yellow "🧹 Очистка данных LDAP..."
|
||||
docker-compose -f docker-compose.test.yml down -v
|
||||
rm -rf ldap_test_data ldap_test_config
|
||||
print_green "✅ Данные LDAP удалены"
|
||||
}
|
||||
|
||||
function status_ldap() {
|
||||
print_yellow "📊 Статус LDAP сервера:"
|
||||
docker-compose -f docker-compose.test.yml ps
|
||||
}
|
||||
|
||||
function logs_ldap() {
|
||||
print_yellow "📋 Логи LDAP сервера:"
|
||||
docker logs eusrr-test-ldap -f
|
||||
}
|
||||
|
||||
function test_ldap() {
|
||||
print_yellow "🧪 Запуск интеграционных тестов..."
|
||||
|
||||
# Проверяем что сервер запущен
|
||||
if ! docker ps | grep -q "eusrr-test-ldap"; then
|
||||
print_red "❌ LDAP сервер не запущен. Запустите: ./ldap-test.sh start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Запускаем тесты
|
||||
pytest -m integration tests/integration/ -v "$@"
|
||||
}
|
||||
|
||||
function test_unit() {
|
||||
print_yellow "🧪 Запуск unit тестов (без LDAP)..."
|
||||
pytest tests/api/v1/employees/test_ldap_optional_*.py -v "$@"
|
||||
}
|
||||
|
||||
function test_all() {
|
||||
print_yellow "🧪 Запуск всех тестов..."
|
||||
test_unit "$@"
|
||||
if docker ps | grep -q "eusrr-test-ldap"; then
|
||||
test_ldap "$@"
|
||||
else
|
||||
print_yellow "⚠️ LDAP сервер не запущен, интеграционные тесты пропущены"
|
||||
fi
|
||||
}
|
||||
|
||||
function check_ldap() {
|
||||
print_yellow "🔍 Проверка подключения к LDAP..."
|
||||
|
||||
if command -v ldapsearch &> /dev/null; then
|
||||
ldapsearch -x -H ldap://localhost:10389 \
|
||||
-b "dc=test,dc=local" \
|
||||
-D "cn=admin,dc=test,dc=local" \
|
||||
-w test_change-me-redacted-secret
|
||||
else
|
||||
print_yellow "ℹ️ ldapsearch не установлен, используем docker exec..."
|
||||
docker exec eusrr-test-ldap ldapsearch -x \
|
||||
-b "dc=test,dc=local" \
|
||||
-D "cn=admin,dc=test,dc=local" \
|
||||
-w test_change-me-redacted-secret
|
||||
fi
|
||||
}
|
||||
|
||||
function show_help() {
|
||||
cat << EOF
|
||||
🧪 LDAP Test Manager
|
||||
|
||||
Использование: ./ldap-test.sh [команда]
|
||||
|
||||
Команды:
|
||||
start Запустить LDAP сервер
|
||||
stop Остановить LDAP сервер
|
||||
restart Перезапустить LDAP сервер
|
||||
clean Удалить данные LDAP и контейнеры
|
||||
status Показать статус сервера
|
||||
logs Показать логи сервера
|
||||
check Проверить подключение к LDAP
|
||||
|
||||
test Запустить интеграционные тесты
|
||||
test-unit Запустить unit тесты (без LDAP)
|
||||
test-all Запустить все тесты
|
||||
|
||||
help Показать эту справку
|
||||
|
||||
Примеры:
|
||||
./ldap-test.sh start
|
||||
./ldap-test.sh test
|
||||
./ldap-test.sh test -k "register"
|
||||
./ldap-test.sh logs
|
||||
./ldap-test.sh clean && ./ldap-test.sh start
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Главная логика
|
||||
case "${1:-help}" in
|
||||
start)
|
||||
start_ldap
|
||||
;;
|
||||
stop)
|
||||
stop_ldap
|
||||
;;
|
||||
restart)
|
||||
restart_ldap
|
||||
;;
|
||||
clean)
|
||||
clean_ldap
|
||||
;;
|
||||
status)
|
||||
status_ldap
|
||||
;;
|
||||
logs)
|
||||
logs_ldap
|
||||
;;
|
||||
check)
|
||||
check_ldap
|
||||
;;
|
||||
test)
|
||||
shift
|
||||
test_ldap "$@"
|
||||
;;
|
||||
test-unit)
|
||||
shift
|
||||
test_unit "$@"
|
||||
;;
|
||||
test-all)
|
||||
shift
|
||||
test_all "$@"
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
print_red "❌ Неизвестная команда: $1"
|
||||
echo ""
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
353
backend/scripts/test_ldap_connection.py
Executable file
353
backend/scripts/test_ldap_connection.py
Executable file
@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Тестовый скрипт для проверки подключения к LDAP серверу.
|
||||
|
||||
Использование:
|
||||
python scripts/test_ldap_connection.py
|
||||
|
||||
Требования:
|
||||
- Запущенный LDAP сервер через docker-compose.ldap.yml
|
||||
- Настроенные переменные окружения в .env
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавить backend в PYTHONPATH
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
# Настроить Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
|
||||
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
from django.conf import settings
|
||||
from ldap3 import Server, Connection, ALL, SUBTREE, BASE
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
|
||||
|
||||
def print_header(title):
|
||||
"""Печать заголовка секции."""
|
||||
print("\n" + "=" * 70)
|
||||
print(f" {title}")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
def print_success(message):
|
||||
"""Печать сообщения об успехе."""
|
||||
print(f"✅ {message}")
|
||||
|
||||
|
||||
def print_error(message):
|
||||
"""Печать сообщения об ошибке."""
|
||||
print(f"❌ {message}")
|
||||
|
||||
|
||||
def print_info(message):
|
||||
"""Печать информационного сообщения."""
|
||||
print(f"ℹ️ {message}")
|
||||
|
||||
|
||||
def test_connection_settings():
|
||||
"""Проверка настроек LDAP из конфигурации Django."""
|
||||
print_header("1. Проверка настроек LDAP")
|
||||
|
||||
required_settings = [
|
||||
'LDAP_ENABLED',
|
||||
'LDAP_URI',
|
||||
'LDAP_BIND_DN',
|
||||
'LDAP_BIND_PASSWORD',
|
||||
'LDAP_BASE_DN',
|
||||
]
|
||||
|
||||
all_ok = True
|
||||
for setting in required_settings:
|
||||
value = getattr(settings, setting, None)
|
||||
if value:
|
||||
# Скрыть пароль
|
||||
display_value = '***' if 'PASSWORD' in setting else value
|
||||
print_success(f"{setting} = {display_value}")
|
||||
else:
|
||||
print_error(f"{setting} не настроен!")
|
||||
all_ok = False
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def test_basic_connection():
|
||||
"""Проверка базового подключения к LDAP серверу."""
|
||||
print_header("2. Проверка базового подключения")
|
||||
|
||||
try:
|
||||
ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
|
||||
print_info(f"Подключение к {ldap_host}...")
|
||||
|
||||
server = Server(ldap_host, get_info=ALL)
|
||||
conn = Connection(server, auto_bind=True)
|
||||
|
||||
print_success("Подключение к LDAP серверу установлено")
|
||||
print_info(f"Сервер: {server.info.other.get('vendor_name', ['Unknown'])[0]}")
|
||||
print_info(f"Версия: {server.info.other.get('vendor_version', ['Unknown'])[0]}")
|
||||
|
||||
conn.unbind()
|
||||
return True
|
||||
|
||||
except LDAPException as e:
|
||||
print_error(f"Ошибка подключения: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_bind_authentication():
|
||||
"""Проверка аутентификации с Bind DN."""
|
||||
print_header("3. Проверка аутентификации (Bind)")
|
||||
|
||||
try:
|
||||
ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
|
||||
bind_dn = getattr(settings, 'LDAP_BIND_DN', '')
|
||||
bind_password = getattr(settings, 'LDAP_BIND_PASSWORD', '')
|
||||
|
||||
if not bind_dn or not bind_password:
|
||||
print_error("LDAP_BIND_DN или LDAP_BIND_PASSWORD не настроены")
|
||||
return False
|
||||
|
||||
print_info(f"Аутентификация как: {bind_dn}")
|
||||
|
||||
server = Server(ldap_host, get_info=ALL)
|
||||
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
|
||||
print_success("Аутентификация успешна")
|
||||
print_info(f"Связанный DN: {conn.extend.standard.who_am_i()}")
|
||||
|
||||
conn.unbind()
|
||||
return True
|
||||
|
||||
except LDAPException as e:
|
||||
print_error(f"Ошибка аутентификации: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_search_base_dn():
|
||||
"""Проверка доступа к базовому DN."""
|
||||
print_header("4. Проверка доступа к базовому DN")
|
||||
|
||||
try:
|
||||
ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
|
||||
bind_dn = getattr(settings, 'LDAP_BIND_DN', '')
|
||||
bind_password = getattr(settings, 'LDAP_BIND_PASSWORD', '')
|
||||
base_dn = getattr(settings, 'LDAP_BASE_DN', '')
|
||||
|
||||
server = Server(ldap_host)
|
||||
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
|
||||
print_info(f"Поиск в базовом DN: {base_dn}")
|
||||
|
||||
success = conn.search(
|
||||
search_base=base_dn,
|
||||
search_filter='(objectClass=*)',
|
||||
search_scope=BASE,
|
||||
attributes=['*']
|
||||
)
|
||||
|
||||
if success:
|
||||
print_success(f"Базовый DN найден: {base_dn}")
|
||||
if conn.entries:
|
||||
entry = conn.entries[0]
|
||||
print_info(f"Атрибуты: {', '.join(entry.entry_attributes)}")
|
||||
else:
|
||||
print_error(f"Базовый DN не найден: {base_dn}")
|
||||
return False
|
||||
|
||||
conn.unbind()
|
||||
return True
|
||||
|
||||
except LDAPException as e:
|
||||
print_error(f"Ошибка поиска: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_search_users():
|
||||
"""Поиск пользователей в ou=Users."""
|
||||
print_header("5. Проверка поиска пользователей")
|
||||
|
||||
try:
|
||||
ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
|
||||
bind_dn = getattr(settings, 'LDAP_BIND_DN', '')
|
||||
bind_password = getattr(settings, 'LDAP_BIND_PASSWORD', '')
|
||||
users_base = getattr(settings, 'LDAP_USERS_BASE', '')
|
||||
|
||||
server = Server(ldap_host)
|
||||
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
|
||||
print_info(f"Поиск пользователей в: {users_base}")
|
||||
|
||||
success = conn.search(
|
||||
search_base=users_base,
|
||||
search_filter='(&(objectCategory=person)(objectClass=user))',
|
||||
search_scope=SUBTREE,
|
||||
attributes=['cn', 'mail', 'telephoneNumber', 'employeeNumber', 'sAMAccountName']
|
||||
)
|
||||
|
||||
if success:
|
||||
print_success(f"Найдено пользователей: {len(conn.entries)}")
|
||||
for entry in conn.entries[:5]: # Показать первых 5
|
||||
cn = entry.cn.value if hasattr(entry, 'cn') else 'N/A'
|
||||
mail = entry.mail.value if hasattr(entry, 'mail') else 'N/A'
|
||||
print_info(f" - {cn} ({mail})")
|
||||
if len(conn.entries) > 5:
|
||||
print_info(f" ... и еще {len(conn.entries) - 5}")
|
||||
else:
|
||||
print_error("Пользователи не найдены")
|
||||
return False
|
||||
|
||||
conn.unbind()
|
||||
return True
|
||||
|
||||
except LDAPException as e:
|
||||
print_error(f"Ошибка поиска пользователей: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_search_groups():
|
||||
"""Поиск групп в ou=Groups."""
|
||||
print_header("6. Проверка поиска групп")
|
||||
|
||||
try:
|
||||
ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
|
||||
bind_dn = getattr(settings, 'LDAP_BIND_DN', '')
|
||||
bind_password = getattr(settings, 'LDAP_BIND_PASSWORD', '')
|
||||
groups_base = getattr(settings, 'LDAP_GROUPS_BASE', '')
|
||||
|
||||
server = Server(ldap_host)
|
||||
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
|
||||
print_info(f"Поиск групп в: {groups_base}")
|
||||
|
||||
success = conn.search(
|
||||
search_base=groups_base,
|
||||
search_filter='(objectClass=groupOfNames)',
|
||||
search_scope=SUBTREE,
|
||||
attributes=['cn', 'description', 'member']
|
||||
)
|
||||
|
||||
if success:
|
||||
print_success(f"Найдено групп: {len(conn.entries)}")
|
||||
for entry in conn.entries:
|
||||
cn = entry.cn.value if hasattr(entry, 'cn') else 'N/A'
|
||||
desc = entry.description.value if hasattr(entry, 'description') else 'N/A'
|
||||
members_count = len(entry.member) if hasattr(entry, 'member') else 0
|
||||
print_info(f" - {cn}: {desc} ({members_count} members)")
|
||||
else:
|
||||
print_error("Группы не найдены")
|
||||
return False
|
||||
|
||||
conn.unbind()
|
||||
return True
|
||||
|
||||
except LDAPException as e:
|
||||
print_error(f"Ошибка поиска групп: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_django_ldap_integration():
|
||||
"""Проверка интеграции Django с LDAP через employees.ldap."""
|
||||
print_header("7. Проверка интеграции Django LDAP")
|
||||
|
||||
try:
|
||||
print_info("Импорт employees.ldap модулей...")
|
||||
from employees.ldap.infrastructure.connections import _ldap
|
||||
|
||||
print_success("Модули импортированы успешно")
|
||||
|
||||
print_info("Тестирование соединения через _ldap()...")
|
||||
with _ldap() as conn:
|
||||
base_dn = getattr(settings, 'LDAP_BASE_DN', '')
|
||||
success = conn.search(base_dn, '(objectClass=*)', search_scope=BASE)
|
||||
|
||||
if success:
|
||||
print_success("Соединение через Django LDAP wrapper работает")
|
||||
return True
|
||||
else:
|
||||
print_error("Поиск через Django LDAP wrapper не удался")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Ошибка интеграции Django LDAP: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_create_test_user():
|
||||
"""Тест создания тестового пользователя через ORM."""
|
||||
print_header("8. Тест создания пользователя (ОПЦИОНАЛЬНО)")
|
||||
|
||||
print_info("Этот тест создаст тестового пользователя в LDAP")
|
||||
print_info("Пропускаем автоматическое создание - используйте Django shell")
|
||||
print_info("Пример:")
|
||||
print_info(" from employees.ldap import LdapUser")
|
||||
print_info(" user = LdapUser()")
|
||||
print_info(" user.cn = 'Test User'")
|
||||
print_info(" user.sn = 'User'")
|
||||
print_info(" user.save()")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Основная функция - запуск всех тестов."""
|
||||
print("\n" + "🔬" * 35)
|
||||
print(" ТЕСТИРОВАНИЕ LDAP ПОДКЛЮЧЕНИЯ - EUSRR")
|
||||
print("🔬" * 35)
|
||||
|
||||
tests = [
|
||||
("Настройки LDAP", test_connection_settings),
|
||||
("Базовое подключение", test_basic_connection),
|
||||
("Аутентификация", test_bind_authentication),
|
||||
("Доступ к базовому DN", test_search_base_dn),
|
||||
("Поиск пользователей", test_search_users),
|
||||
("Поиск групп", test_search_groups),
|
||||
("Интеграция Django LDAP", test_django_ldap_integration),
|
||||
("Создание пользователя", test_create_test_user),
|
||||
]
|
||||
|
||||
results = []
|
||||
for name, test_func in tests:
|
||||
try:
|
||||
result = test_func()
|
||||
results.append((name, result))
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Тестирование прервано пользователем")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print_error(f"Неожиданная ошибка в тесте '{name}': {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results.append((name, False))
|
||||
|
||||
# Итоговый отчет
|
||||
print_header("ИТОГОВЫЙ ОТЧЕТ")
|
||||
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
|
||||
for name, result in results:
|
||||
status = "✅ PASS" if result else "❌ FAIL"
|
||||
print(f"{status} {name}")
|
||||
|
||||
print("\n" + "-" * 70)
|
||||
print(f"Результат: {passed}/{total} тестов пройдено")
|
||||
print("-" * 70)
|
||||
|
||||
if passed == total:
|
||||
print("\n🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ! LDAP интеграция работает корректно.")
|
||||
return 0
|
||||
else:
|
||||
print(f"\n⚠️ {total - passed} тест(ов) не пройдено. Проверьте конфигурацию.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@ -43,6 +43,10 @@ def extract_code_from_last_email() -> str | None:
|
||||
|
||||
|
||||
def register_payload(**overrides):
|
||||
# Минимальный валидный PNG 1x1 пиксель (прозрачный) в base64
|
||||
tiny_png_base64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)
|
||||
data = {
|
||||
"first_name": "Иван",
|
||||
"last_name": "Иванов",
|
||||
@ -50,7 +54,9 @@ def register_payload(**overrides):
|
||||
"email": "ivan@example.com",
|
||||
"password": "Str0ngPass!",
|
||||
"birth_date": "1990-01-01",
|
||||
# достаточно любого из контактов:
|
||||
"gender": 1, # 1 - Мужской, 2 - Женский
|
||||
"avatar": f"data:image/png;base64,{tiny_png_base64}",
|
||||
# Контактные поля опциональны, но можно указать одно:
|
||||
"telegram": "@ivan",
|
||||
# опционально можно добавить другие поля модели
|
||||
}
|
||||
@ -127,17 +133,6 @@ def test_register_missing_required_field(api, missing_field):
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def test_register_requires_at_least_one_contact(api):
|
||||
payload = register_payload(telegram="", whatsapp="", wechat="")
|
||||
resp = api.post(reverse("api:v1:register"), payload, format="json")
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert (
|
||||
"WhatsApp" in resp.json().get("detail", "")
|
||||
or "WeChat" in resp.json().get("detail", "")
|
||||
or "Telegram" in resp.json().get("detail", "")
|
||||
)
|
||||
|
||||
|
||||
def test_register_birth_date_must_be_valid_date(api):
|
||||
resp = register(api, birth_date="not-a-date")
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@ -353,15 +353,3 @@ def test_me_patch_updates_profile(api_client: APIClient):
|
||||
me.refresh_from_db()
|
||||
assert me.first_name == "Updated"
|
||||
|
||||
def test_me_patch_rejects_when_all_contacts_empty(api_client: APIClient):
|
||||
"""
|
||||
Ваша логика в me(): если пользователь не передал поля контактов и в итоге
|
||||
ни одного канала связи нет — вернуть 400.
|
||||
Создаём юзера без контактов и шлём PATCH без контакт-полей.
|
||||
"""
|
||||
me = make_user("nocc@example.com", telegram="", whatsapp="", wechat="")
|
||||
api_client.force_authenticate(user=me)
|
||||
|
||||
url = reverse("api:v1:employees-me")
|
||||
r = api_client.patch(url, {"first_name": "Try"}, format="json")
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@ -27,13 +27,19 @@ def api_client():
|
||||
@pytest.fixture
|
||||
def test_user_data():
|
||||
"""Базовые данные для регистрации"""
|
||||
# Минимальный валидный PNG 1x1 пиксель (прозрачный) в base64
|
||||
tiny_png_base64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)
|
||||
return {
|
||||
"email": f"test{next(_phone_seq)}@example.com",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"phone_number": _unique_phone(),
|
||||
"password": "SecurePass123!",
|
||||
"whatsapp": _unique_phone(),
|
||||
"gender": 1, # Обязательное поле: 1 - Мужской
|
||||
"avatar": f"data:image/png;base64,{tiny_png_base64}", # Обязательное поле
|
||||
"whatsapp": _unique_phone(), # Контактные поля опциональны, но можно указать
|
||||
"birth_date": "1990-01-01",
|
||||
}
|
||||
|
||||
|
||||
42
frontend/package-lock.json
generated
42
frontend/package-lock.json
generated
@ -15,6 +15,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"date-fns": "^4.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
@ -24,9 +25,11 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"react": "19.2.3",
|
||||
"react-avatar-editor": "^14.0.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-file-icon": "^1.6.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
@ -2387,6 +2390,15 @@
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-avatar-editor": {
|
||||
"version": "13.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-avatar-editor/-/react-avatar-editor-13.0.4.tgz",
|
||||
"integrity": "sha512-WQjmacEOoEYQb6CkAXYspmtruCPXFzLjEI92zV27yqveGBRkh7Quo5oYMvAgaEeqpXsGEr/wzXcdSTaH982Wtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-big-calendar": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-big-calendar/-/react-big-calendar-1.16.3.tgz",
|
||||
@ -6420,6 +6432,12 @@
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -6810,6 +6828,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-avatar-editor": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-avatar-editor/-/react-avatar-editor-14.0.0.tgz",
|
||||
"integrity": "sha512-NaQM3oo4u0a1/Njjutc2FjwKX35vQV+t6S8hovsbAlMpBN1ntIwP/g+Yr9eDIIfaNtRXL0AqboTnPmRxhD/i8A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-big-calendar": {
|
||||
"version": "1.19.4",
|
||||
"resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.19.4.tgz",
|
||||
@ -6866,6 +6894,20 @@
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz",
|
||||
"integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-file-icon": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-file-icon/-/react-file-icon-1.6.0.tgz",
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"date-fns": "^4.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
@ -26,9 +27,11 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"react": "19.2.3",
|
||||
"react-avatar-editor": "^14.0.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-file-icon": "^1.6.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
|
||||
@ -1,111 +1,691 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// Динамический импорт компонента кроппера (чтобы избежать SSR проблем)
|
||||
const AvatarCropper = dynamic(() => import('@/components/AvatarCropper'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface RegisterFormData {
|
||||
// Шаг 1: Основная информация
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone_number: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
|
||||
// Шаг 2: Личные данные
|
||||
birth_date: string;
|
||||
gender: 1 | 2 | '';
|
||||
patronymic: string;
|
||||
|
||||
// Шаг 3: Фото профиля
|
||||
avatar: string;
|
||||
|
||||
// Шаг 4: Дополнительно
|
||||
telegram: string;
|
||||
whatsapp: string;
|
||||
wechat: string;
|
||||
}
|
||||
|
||||
export default function Register() {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
|
||||
// Состояния для кроппера
|
||||
const [showCropper, setShowCropper] = useState(false);
|
||||
const [tempImage, setTempImage] = useState<string>('');
|
||||
|
||||
const [formData, setFormData] = useState<RegisterFormData>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone_number: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
birth_date: '',
|
||||
gender: '',
|
||||
patronymic: '',
|
||||
avatar: '',
|
||||
telegram: '',
|
||||
whatsapp: '',
|
||||
wechat: '',
|
||||
});
|
||||
|
||||
const totalSteps = 4;
|
||||
const stepTitles = [
|
||||
'Личные данные',
|
||||
'Контакты и пароль',
|
||||
'Фото профиля',
|
||||
'Дополнительно'
|
||||
];
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
setError(null);
|
||||
// Очистить ошибку конкретного поля при изменении
|
||||
if (fieldErrors[name]) {
|
||||
setFieldErrors(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[name];
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Проверка размера (макс 10MB для исходного файла)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('Размер файла не должен превышать 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка типа
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Выберите изображение');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
|
||||
// Проверка размера изображения
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (img.width < 200 || img.height < 200) {
|
||||
setError('Изображение слишком маленькое. Минимум 200x200 пикселей');
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем кроппер
|
||||
setTempImage(base64);
|
||||
setShowCropper(true);
|
||||
setError(null);
|
||||
};
|
||||
img.src = base64;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleCropComplete = (croppedImage: string) => {
|
||||
setFormData(prev => ({ ...prev, avatar: croppedImage }));
|
||||
setAvatarPreview(croppedImage);
|
||||
setShowCropper(false);
|
||||
setTempImage('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleCropCancel = () => {
|
||||
setShowCropper(false);
|
||||
setTempImage('');
|
||||
};
|
||||
|
||||
const validateStep = (step: number): boolean => {
|
||||
setError(null);
|
||||
|
||||
switch (step) {
|
||||
case 1:
|
||||
// Шаг 1: Личные данные (ФИО, дата рождения, пол)
|
||||
if (!formData.first_name.trim()) {
|
||||
setError('Введите имя');
|
||||
return false;
|
||||
}
|
||||
if (!formData.last_name.trim()) {
|
||||
setError('Введите фамилию');
|
||||
return false;
|
||||
}
|
||||
if (!formData.birth_date) {
|
||||
setError('Выберите дату рождения');
|
||||
return false;
|
||||
}
|
||||
if (!formData.gender) {
|
||||
setError('Выберите пол');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case 2:
|
||||
// Шаг 2: Контакты и пароль
|
||||
if (!formData.email.trim() || !formData.email.includes('@')) {
|
||||
setError('Введите корректный email');
|
||||
return false;
|
||||
}
|
||||
if (!formData.phone_number.trim()) {
|
||||
setError('Введите номер телефона');
|
||||
return false;
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
setError('Пароль должен содержать минимум 6 символов');
|
||||
return false;
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Пароли не совпадают');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case 3:
|
||||
// Шаг 3: Фото профиля
|
||||
if (!formData.avatar) {
|
||||
setError('Загрузите фото профиля');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case 4:
|
||||
// Шаг 4: Дополнительно (опциональный)
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (validateStep(currentStep)) {
|
||||
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateStep(currentStep)) return;
|
||||
|
||||
if (currentStep < totalSteps) {
|
||||
handleNext();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const registerData = {
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
email: formData.email,
|
||||
phone_number: formData.phone_number,
|
||||
password: formData.password,
|
||||
birth_date: formData.birth_date,
|
||||
gender: Number(formData.gender), // Конвертируем в число
|
||||
// Убираем data URI prefix из base64
|
||||
avatar: formData.avatar?.replace(/^data:image\/\w+;base64,/, '') || '',
|
||||
patronymic: formData.patronymic || undefined,
|
||||
telegram: formData.telegram || undefined,
|
||||
whatsapp: formData.whatsapp || undefined,
|
||||
wechat: formData.wechat || undefined,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/v1/auth/register/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(registerData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Очищаем предыдущие ошибки
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
// Обработка field-specific ошибок
|
||||
if (data.error === 'email_taken') {
|
||||
setFieldErrors({ email: 'Email уже зарегистрирован. Войдите в существующий аккаунт.' });
|
||||
setCurrentStep(1); // Вернуть на шаг с email
|
||||
} else if (data.error === 'phone_taken' || data.phone_number) {
|
||||
const phoneError = data.phone_number
|
||||
? (Array.isArray(data.phone_number) ? data.phone_number[0] : data.phone_number)
|
||||
: 'Номер телефона уже используется другим аккаунтом.';
|
||||
setFieldErrors({ phone_number: phoneError });
|
||||
setCurrentStep(1); // Вернуть на шаг с телефоном
|
||||
} else if (data.error === 'invalid_phone') {
|
||||
setFieldErrors({ phone_number: 'Некорректный формат номера телефона.' });
|
||||
setCurrentStep(1);
|
||||
} else if (data.email) {
|
||||
const emailError = Array.isArray(data.email) ? data.email[0] : data.email;
|
||||
setFieldErrors({ email: emailError });
|
||||
setCurrentStep(1);
|
||||
} else if (data.detail) {
|
||||
// Общая ошибка (например, IP-ограничение)
|
||||
setError(data.detail);
|
||||
} else {
|
||||
setError('Произошла ошибка при регистрации. Проверьте введённые данные.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Успешная регистрация - редирект на страницу подтверждения email
|
||||
router.push(`/verify-email?email=${encodeURIComponent(formData.email)}`);
|
||||
|
||||
} catch (err) {
|
||||
setError('Не удалось подключиться к серверу');
|
||||
console.error('Registration error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white text-gray-900">
|
||||
<div className="mx-auto flex min-h-screen max-w-4xl flex-col justify-center px-6 py-12 sm:px-10">
|
||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white p-8 shadow-xl ring-1 ring-gray-100">
|
||||
<h1 className="mb-2 text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl">
|
||||
Регистрация
|
||||
</h1>
|
||||
<p className="mb-8 text-sm text-gray-600">
|
||||
Создайте новый аккаунт, указав имя и контактные данные.
|
||||
</p>
|
||||
<div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-12 sm:px-10">
|
||||
<div className="mx-auto w-full max-w-lg rounded-2xl bg-white p-8 shadow-xl ring-1 ring-gray-100">
|
||||
{/* Заголовок */}
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2 text-3xl font-semibold tracking-tight text-gray-900">
|
||||
Регистрация
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
{stepTitles[currentStep - 1]} • Шаг {currentStep} из {totalSteps}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="firstName">
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Иван"
|
||||
{/* Прогресс-бар */}
|
||||
<div className="mb-8">
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length: totalSteps }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-2 flex-1 rounded-full transition-all ${
|
||||
index < currentStep
|
||||
? 'bg-sky-500'
|
||||
: index === currentStep - 1
|
||||
? 'bg-sky-300'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="lastName">
|
||||
Фамилия
|
||||
</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Иванов"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="login">
|
||||
Телефон или почта
|
||||
</label>
|
||||
<input
|
||||
id="login"
|
||||
name="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="example@mail.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="password">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="confirmPassword">
|
||||
Повторите пароль
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
|
||||
/>
|
||||
Я согласен с условиями использования
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center rounded-lg bg-sky-400 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
Создать аккаунт
|
||||
</button>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex w-full items-center justify-center rounded-lg border border-gray-200 px-4 py-3 text-sm font-semibold text-sky-700 transition hover:border-sky-200 hover:bg-sky-50 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
Уже есть аккаунт? Войти
|
||||
</Link>
|
||||
{/* Ошибка */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Шаг 1: Личные данные */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="first_name">
|
||||
Имя <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
required
|
||||
value={formData.first_name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Иван"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="last_name">
|
||||
Фамилия <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
required
|
||||
value={formData.last_name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Иванов"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="patronymic">
|
||||
Отчество
|
||||
</label>
|
||||
<input
|
||||
id="patronymic"
|
||||
name="patronymic"
|
||||
type="text"
|
||||
value={formData.patronymic}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Иванович"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="birth_date">
|
||||
Дата рождения <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="birth_date"
|
||||
name="birth_date"
|
||||
type="date"
|
||||
required
|
||||
value={formData.birth_date}
|
||||
onChange={handleInputChange}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="gender">
|
||||
Пол <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="gender"
|
||||
name="gender"
|
||||
required
|
||||
value={formData.gender}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
<option value="">Выберите пол</option>
|
||||
<option value="1">Мужской</option>
|
||||
<option value="2">Женский</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Шаг 2: Контакты и пароль */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="email">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="email"
|
||||
className={`w-full rounded-lg border px-4 py-3 text-sm text-gray-900 transition focus:outline-none focus:ring-2 ${
|
||||
fieldErrors.email
|
||||
? 'border-red-300 bg-red-50 focus:border-red-500 focus:ring-red-100'
|
||||
: 'border-gray-200 bg-gray-50 focus:border-sky-500 focus:bg-white focus:ring-sky-100'
|
||||
}`}
|
||||
placeholder="example@mail.com"
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{fieldErrors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="phone_number">
|
||||
Телефон <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="phone_number"
|
||||
name="phone_number"
|
||||
type="tel"
|
||||
required
|
||||
value={formData.phone_number}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="tel"
|
||||
className={`w-full rounded-lg border px-4 py-3 text-sm text-gray-900 transition focus:outline-none focus:ring-2 ${
|
||||
fieldErrors.phone_number
|
||||
? 'border-red-300 bg-red-50 focus:border-red-500 focus:ring-red-100'
|
||||
: 'border-gray-200 bg-gray-50 focus:border-sky-500 focus:bg-white focus:ring-sky-100'
|
||||
}`}
|
||||
placeholder="+7 (900) 123-45-67"
|
||||
/>
|
||||
{fieldErrors.phone_number ? (
|
||||
<p className="mt-1 text-sm text-red-600">{fieldErrors.phone_number}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-gray-500">Формат: +7XXXXXXXXXX</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="password">
|
||||
Пароль <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="confirmPassword">
|
||||
Повторите пароль <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Шаг 3: Фото профиля */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Фото профиля <span className="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
{avatarPreview ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Предпросмотр"
|
||||
className="h-40 w-40 rounded-full object-cover ring-4 ring-sky-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData(prev => ({ ...prev, avatar: '' }));
|
||||
setAvatarPreview(null);
|
||||
}}
|
||||
className="text-sm text-sky-600 hover:text-sky-700 font-medium"
|
||||
>
|
||||
Изменить фото
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-full">
|
||||
<label
|
||||
htmlFor="avatar-upload"
|
||||
className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 px-6 py-12 transition hover:border-sky-400 hover:bg-sky-50"
|
||||
>
|
||||
<svg
|
||||
className="h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm font-medium text-gray-700">
|
||||
Загрузить фото
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PNG, JPG до 5MB
|
||||
</p>
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Шаг 4: Дополнительно */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Эти поля необязательны, но помогут коллегам связаться с вами
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="telegram">
|
||||
Telegram
|
||||
</label>
|
||||
<input
|
||||
id="telegram"
|
||||
name="telegram"
|
||||
type="text"
|
||||
value={formData.telegram}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="@username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="whatsapp">
|
||||
WhatsApp
|
||||
</label>
|
||||
<input
|
||||
id="whatsapp"
|
||||
name="whatsapp"
|
||||
type="text"
|
||||
value={formData.whatsapp}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="+7 (900) 123-45-67"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="wechat">
|
||||
WeChat
|
||||
</label>
|
||||
<input
|
||||
id="wechat"
|
||||
name="wechat"
|
||||
type="text"
|
||||
value={formData.wechat}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="WeChat ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки навигации */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
{currentStep > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-4 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-lg bg-sky-500 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Подождите...
|
||||
</span>
|
||||
) : currentStep === totalSteps ? (
|
||||
'Создать аккаунт'
|
||||
) : (
|
||||
'Продолжить'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentStep === 1 && (
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-4 flex w-full items-center justify-center rounded-lg border border-gray-200 px-4 py-3 text-sm font-semibold text-sky-700 transition hover:border-sky-200 hover:bg-sky-50 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
Уже есть аккаунт? Войти
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
Регистрируясь, вы принимаете условия использования и подтверждаете ознакомление с политикой конфиденциальности.
|
||||
</p>
|
||||
|
||||
{currentStep === totalSteps && (
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
Регистрируясь, вы принимаете условия использования и политику конфиденциальности
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно кроппера */}
|
||||
{showCropper && tempImage && (
|
||||
<AvatarCropper
|
||||
initialImage={tempImage}
|
||||
onCropComplete={handleCropComplete}
|
||||
onCancel={handleCropCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
230
frontend/src/app/verify-email/page.tsx
Normal file
230
frontend/src/app/verify-email/page.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get('email');
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [resendSuccess, setResendSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
router.push('/register');
|
||||
}
|
||||
}, [email, router]);
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/verify-email/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
code: code.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (data.error === 'expired') {
|
||||
setError('Код истёк. Запросите новый код.');
|
||||
} else if (data.error === 'invalid_code') {
|
||||
setError('Неверный код подтверждения');
|
||||
} else if (data.error === 'user_not_found') {
|
||||
setError('Пользователь не найден');
|
||||
} else {
|
||||
setError('Ошибка подтверждения');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Редирект на страницу входа через 2 секунды
|
||||
setTimeout(() => {
|
||||
router.push('/login?verified=true');
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
setError('Не удалось подключиться к серверу');
|
||||
console.error('Verification error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setResendSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/resend-email/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (data.error === 'already_verified') {
|
||||
setError('Email уже подтвержден');
|
||||
} else if (data.error === 'user_not_found') {
|
||||
setError('Пользователь не найден');
|
||||
} else {
|
||||
setError('Ошибка отправки кода');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setResendSuccess(true);
|
||||
setCode(''); // Очистить поле кода
|
||||
|
||||
} catch (err) {
|
||||
setError('Не удалось отправить код');
|
||||
console.error('Resend error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white flex items-center justify-center px-6">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center ring-1 ring-gray-100">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-semibold text-gray-900">
|
||||
Email подтвержден!
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Ваш аккаунт активирован. Перенаправляем на страницу входа...
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-sky-500 px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-300"
|
||||
>
|
||||
Войти в аккаунт
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white flex items-center justify-center px-6">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 ring-1 ring-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-sky-100">
|
||||
<svg className="h-8 w-8 text-sky-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-semibold text-gray-900">
|
||||
Подтвердите email
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Мы отправили код подтверждения на <br />
|
||||
<span className="font-medium text-gray-900">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resendSuccess && (
|
||||
<div className="mb-6 rounded-lg bg-green-50 border border-green-200 p-4 text-sm text-green-800">
|
||||
Новый код отправлен на {email}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleVerify} className="space-y-6">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="code">
|
||||
Код подтверждения
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-center text-lg font-semibold tracking-widest text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Введите 6-значный код из письма
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || code.length !== 6}
|
||||
className="w-full rounded-lg bg-sky-500 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Проверяем...
|
||||
</span>
|
||||
) : (
|
||||
'Подтвердить email'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Не получили код?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={isLoading}
|
||||
className="font-medium text-sky-600 hover:text-sky-700 transition disabled:opacity-50"
|
||||
>
|
||||
Отправить повторно
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition"
|
||||
>
|
||||
← Вернуться к входу
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
frontend/src/components/AvatarCropper.tsx
Normal file
180
frontend/src/components/AvatarCropper.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import type { Area, Point } from 'react-easy-crop';
|
||||
|
||||
interface AvatarCropperProps {
|
||||
onCropComplete: (croppedImage: string) => void;
|
||||
onCancel: () => void;
|
||||
initialImage: string;
|
||||
}
|
||||
|
||||
export default function AvatarCropper({ onCropComplete, onCancel, initialImage }: AvatarCropperProps) {
|
||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
// Фиксированный размер области обрезки 3:4 (240x320px)
|
||||
const cropSize = { width: 240, height: 320 };
|
||||
|
||||
const onCropAreaChange = useCallback((croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
}, []);
|
||||
|
||||
const createCroppedImage = useCallback(async () => {
|
||||
if (!croppedAreaPixels) return;
|
||||
|
||||
const image = new Image();
|
||||
image.src = initialImage;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
image.onload = resolve;
|
||||
});
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Устанавливаем размер canvas равным обрезанной области
|
||||
canvas.width = croppedAreaPixels.width;
|
||||
canvas.height = croppedAreaPixels.height;
|
||||
|
||||
// Рисуем обрезанное изображение
|
||||
ctx.drawImage(
|
||||
image,
|
||||
croppedAreaPixels.x,
|
||||
croppedAreaPixels.y,
|
||||
croppedAreaPixels.width,
|
||||
croppedAreaPixels.height,
|
||||
0,
|
||||
0,
|
||||
croppedAreaPixels.width,
|
||||
croppedAreaPixels.height
|
||||
);
|
||||
|
||||
// Конвертируем в base64
|
||||
const croppedBase64 = canvas.toDataURL('image/jpeg', 0.9);
|
||||
onCropComplete(croppedBase64);
|
||||
}, [croppedAreaPixels, initialImage, onCropComplete]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
|
||||
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl">
|
||||
{/* Заголовок */}
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Настройка фото профиля
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Разместите лицо в центре овала. Фото будет использоваться для распознавания.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Область кадрирования */}
|
||||
<div className="relative h-96 bg-gray-100">
|
||||
<Cropper
|
||||
image={initialImage}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={3 / 4}
|
||||
cropSize={cropSize}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropAreaChange}
|
||||
objectFit="contain"
|
||||
restrictPosition={false}
|
||||
/>
|
||||
|
||||
{/* Направляющие рамки */}
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<svg
|
||||
width={cropSize.width}
|
||||
height={cropSize.height}
|
||||
viewBox="0 0 240 320"
|
||||
>
|
||||
{/* Овал для головы (70% ширины, верхняя часть) */}
|
||||
<ellipse
|
||||
cx="120"
|
||||
cy="100"
|
||||
rx="84"
|
||||
ry="100"
|
||||
fill="none"
|
||||
stroke="#38bdf8"
|
||||
strokeWidth="4"
|
||||
strokeDasharray="8,8"
|
||||
opacity="0.5"
|
||||
/>
|
||||
|
||||
{/* Шея */}
|
||||
<path
|
||||
d="M 90 180 L 85 220 L 155 220 L 150 180"
|
||||
fill="none"
|
||||
stroke="#38bdf8"
|
||||
strokeWidth="4"
|
||||
strokeDasharray="8,8"
|
||||
opacity="0.5"
|
||||
/>
|
||||
|
||||
{/* Плечи */}
|
||||
<path
|
||||
d="M 85 220 Q 50 240 30 280 L 30 320 M 155 220 Q 190 240 210 280 L 210 320"
|
||||
fill="none"
|
||||
stroke="#38bdf8"
|
||||
strokeWidth="4"
|
||||
strokeDasharray="8,8"
|
||||
opacity="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контролы масштабирования */}
|
||||
<div className="border-t border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7" />
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">
|
||||
Используйте ползунок для масштабирования
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="border-t border-gray-200 px-6 py-4">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-4 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createCroppedImage}
|
||||
className="flex-1 rounded-lg bg-sky-500 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-300"
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -541,10 +541,15 @@ export interface RegisterData {
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
patronymic?: string; // было middle_name
|
||||
birth_date: string; // YYYY-MM-DD
|
||||
gender: 1 | 2; // 1 - мужской, 2 - женский
|
||||
avatar: string; // base64 image
|
||||
patronymic?: string;
|
||||
telegram?: string;
|
||||
whatsapp?: string;
|
||||
wechat?: string;
|
||||
position?: number;
|
||||
skills?: number[];
|
||||
}
|
||||
|
||||
// Search types (django-watson)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user