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:
lizerk 2026-03-19 00:48:13 +03:00
parent bf9bc8f341
commit 0539f50233
38 changed files with 2000 additions and 1192 deletions

View File

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

View File

@ -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:
# Есть неверифицированный пользователь - повторная отправка кода

View File

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

View File

@ -1 +0,0 @@
"""Auth API v2."""

View File

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

View File

@ -1 +0,0 @@
"""Calendar API v2."""

View File

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

View File

@ -1 +0,0 @@
"""Communications API v2."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
"""Feed API v2."""

View File

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

View File

@ -1 +0,0 @@
"""Requests API v2."""

View File

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

View File

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

View File

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

View File

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

View File

@ -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' — направление последнего успешного синка.

View File

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

View 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
)

View File

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

View File

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

View File

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

View File

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

View File

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

View 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())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

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