diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 542ff6f8..d717af5c 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -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"): diff --git a/backend/api/v1/employees/views/auth.py b/backend/api/v1/employees/views/auth.py index e728885b..7963eda3 100644 --- a/backend/api/v1/employees/views/auth.py +++ b/backend/api/v1/employees/views/auth.py @@ -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: # Есть неверифицированный пользователь - повторная отправка кода diff --git a/backend/api/v1/employees/views/positions.py b/backend/api/v1/employees/views/positions.py index 48a8ebf6..bbe2dd9c 100644 --- a/backend/api/v1/employees/views/positions.py +++ b/backend/api/v1/employees/views/positions.py @@ -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) diff --git a/backend/api/v2/auth/__init__.py b/backend/api/v2/auth/__init__.py deleted file mode 100644 index 8e37c332..00000000 --- a/backend/api/v2/auth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Auth API v2.""" diff --git a/backend/api/v2/auth/views.py b/backend/api/v2/auth/views.py deleted file mode 100644 index c120a5e1..00000000 --- a/backend/api/v2/auth/views.py +++ /dev/null @@ -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 diff --git a/backend/api/v2/calendar/__init__.py b/backend/api/v2/calendar/__init__.py deleted file mode 100644 index 46d4f779..00000000 --- a/backend/api/v2/calendar/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Calendar API v2.""" diff --git a/backend/api/v2/calendar/views.py b/backend/api/v2/calendar/views.py deleted file mode 100644 index 33923704..00000000 --- a/backend/api/v2/calendar/views.py +++ /dev/null @@ -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 diff --git a/backend/api/v2/communications/__init__.py b/backend/api/v2/communications/__init__.py deleted file mode 100644 index 7a9b3962..00000000 --- a/backend/api/v2/communications/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Communications API v2.""" diff --git a/backend/api/v2/communications/views.py b/backend/api/v2/communications/views.py deleted file mode 100644 index 564ed930..00000000 --- a/backend/api/v2/communications/views.py +++ /dev/null @@ -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 diff --git a/backend/api/v2/documents/__init__.py b/backend/api/v2/documents/__init__.py deleted file mode 100644 index d091fe2e..00000000 --- a/backend/api/v2/documents/__init__.py +++ /dev/null @@ -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 diff --git a/backend/api/v2/documents/views.py b/backend/api/v2/documents/views.py deleted file mode 100644 index 295e4961..00000000 --- a/backend/api/v2/documents/views.py +++ /dev/null @@ -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 diff --git a/backend/api/v2/employees/extended_views.py b/backend/api/v2/employees/extended_views.py deleted file mode 100644 index 5ebe1773..00000000 --- a/backend/api/v2/employees/extended_views.py +++ /dev/null @@ -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 diff --git a/backend/api/v2/employees/serializers.py b/backend/api/v2/employees/serializers.py deleted file mode 100644 index f66be3ae..00000000 --- a/backend/api/v2/employees/serializers.py +++ /dev/null @@ -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 diff --git a/backend/api/v2/feed/__init__.py b/backend/api/v2/feed/__init__.py deleted file mode 100644 index 5545facc..00000000 --- a/backend/api/v2/feed/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Feed API v2.""" diff --git a/backend/api/v2/feed/views.py b/backend/api/v2/feed/views.py deleted file mode 100644 index d9e54ed9..00000000 --- a/backend/api/v2/feed/views.py +++ /dev/null @@ -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 diff --git a/backend/api/v2/requests/__init__.py b/backend/api/v2/requests/__init__.py deleted file mode 100644 index 49f395e8..00000000 --- a/backend/api/v2/requests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Requests API v2.""" diff --git a/backend/api/v2/requests/views.py b/backend/api/v2/requests/views.py deleted file mode 100644 index 1b4aaa6e..00000000 --- a/backend/api/v2/requests/views.py +++ /dev/null @@ -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 diff --git a/backend/api/v2/urls.py b/backend/api/v2/urls.py deleted file mode 100644 index 4ff392c8..00000000 --- a/backend/api/v2/urls.py +++ /dev/null @@ -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)), -] diff --git a/backend/employees/ldap/directory_service.py b/backend/employees/ldap/directory_service.py index b3317857..dcffc752 100644 --- a/backend/employees/ldap/directory_service.py +++ b/backend/employees/ldap/directory_service.py @@ -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_) + 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) diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py index a5a734ab..46fc5cda 100644 --- a/backend/employees/ldap/services/user_service.py +++ b/backend/employees/ldap/services/user_service.py @@ -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 ) diff --git a/backend/employees/models.py b/backend/employees/models.py index 832c84d4..b9ea49a8 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -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' — направление последнего успешного синка. diff --git a/backend/employees/signals/ldap/__init__.py b/backend/employees/signals/ldap/__init__.py index ffdcdc32..defc01ea 100644 --- a/backend/employees/signals/ldap/__init__.py +++ b/backend/employees/signals/ldap/__init__.py @@ -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'] diff --git a/backend/employees/signals/ldap/position.py b/backend/employees/signals/ldap/position.py new file mode 100644 index 00000000..23a62abc --- /dev/null +++ b/backend/employees/signals/ldap/position.py @@ -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_) + При обновлении: переименовывает 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 + ) diff --git a/backend/eusrr_backend/auth_backends.py b/backend/eusrr_backend/auth_backends.py index e3b9a0a8..1c65b1ff 100644 --- a/backend/eusrr_backend/auth_backends.py +++ b/backend/eusrr_backend/auth_backends.py @@ -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: diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 21fda67f..c651015a 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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", diff --git a/backend/requirements.txt b/backend/requirements.txt index 81080332..fafc2834 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/scripts/ldap/init/01-base-structure.ldif b/backend/scripts/ldap/init/01-base-structure.ldif deleted file mode 100644 index f3442541..00000000 --- a/backend/scripts/ldap/init/01-base-structure.ldif +++ /dev/null @@ -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 diff --git a/backend/scripts/ldap/ldap-test.sh b/backend/scripts/ldap/ldap-test.sh deleted file mode 100644 index 98be18a8..00000000 --- a/backend/scripts/ldap/ldap-test.sh +++ /dev/null @@ -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 diff --git a/backend/scripts/test_ldap_connection.py b/backend/scripts/test_ldap_connection.py new file mode 100755 index 00000000..0cc77602 --- /dev/null +++ b/backend/scripts/test_ldap_connection.py @@ -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()) diff --git a/backend/tests/api/auth/test_auth_and_registration.py b/backend/tests/api/auth/test_auth_and_registration.py index 1bb20053..d9d17d38 100644 --- a/backend/tests/api/auth/test_auth_and_registration.py +++ b/backend/tests/api/auth/test_auth_and_registration.py @@ -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 diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index f124e280..65064c93 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -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 diff --git a/backend/tests/api/v1/employees/test_ldap_optional_register.py b/backend/tests/api/v1/employees/test_ldap_optional_register.py index a9476053..7f9e3009 100644 --- a/backend/tests/api/v1/employees/test_ldap_optional_register.py +++ b/backend/tests/api/v1/employees/test_ldap_optional_register.py @@ -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", } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7b1ef4ed..19ff695e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 2bfd899c..b3d7f064 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index 788e8f32..8d8ddd7c 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -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(null); + const [fieldErrors, setFieldErrors] = useState>({}); + const [avatarPreview, setAvatarPreview] = useState(null); + + // Состояния для кроппера + const [showCropper, setShowCropper] = useState(false); + const [tempImage, setTempImage] = useState(''); + + const [formData, setFormData] = useState({ + 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) => { + 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) => { + 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 (
-
-
-

- Регистрация -

-

- Создайте новый аккаунт, указав имя и контактные данные. -

+
+
+ {/* Заголовок */} +
+

+ Регистрация +

+

+ {stepTitles[currentStep - 1]} • Шаг {currentStep} из {totalSteps} +

+
-
-
-
- - +
+ {Array.from({ length: totalSteps }).map((_, index) => ( +
-
-
- - -
+ ))}
-
- - -
-
-
- - -
-
- - -
-
- +
- - - Уже есть аккаунт? Войти - + {/* Ошибка */} + {error && ( +
+ {error} +
+ )} + + + {/* Шаг 1: Личные данные */} + {currentStep === 1 && ( +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} + + {/* Шаг 2: Контакты и пароль */} + {currentStep === 2 && ( +
+
+ + + {fieldErrors.email && ( +

{fieldErrors.email}

+ )} +
+ +
+ + + {fieldErrors.phone_number ? ( +

{fieldErrors.phone_number}

+ ) : ( +

Формат: +7XXXXXXXXXX

+ )} +
+ +
+
+ + +
+
+ + +
+
+
+ )} + + {/* Шаг 3: Фото профиля */} + {currentStep === 3 && ( +
+
+ + + {avatarPreview ? ( +
+ Предпросмотр + +
+ ) : ( +
+
+ +
+
+ )} +
+
+ )} + + {/* Шаг 4: Дополнительно */} + {currentStep === 4 && ( +
+

+ Эти поля необязательны, но помогут коллегам связаться с вами +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} + + {/* Кнопки навигации */} +
+ {currentStep > 1 && ( + + )} + + +
+ + {currentStep === 1 && ( + + Уже есть аккаунт? Войти + + )} -

- Регистрируясь, вы принимаете условия использования и подтверждаете ознакомление с политикой конфиденциальности. -

+ + {currentStep === totalSteps && ( +

+ Регистрируясь, вы принимаете условия использования и политику конфиденциальности +

+ )}
+ + {/* Модальное окно кроппера */} + {showCropper && tempImage && ( + + )}
); } diff --git a/frontend/src/app/verify-email/page.tsx b/frontend/src/app/verify-email/page.tsx new file mode 100644 index 00000000..428e6fc7 --- /dev/null +++ b/frontend/src/app/verify-email/page.tsx @@ -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(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 ( +
+
+
+ + + +
+

+ Email подтвержден! +

+

+ Ваш аккаунт активирован. Перенаправляем на страницу входа... +

+ + Войти в аккаунт + +
+
+ ); + } + + return ( +
+
+
+
+ + + +
+

+ Подтвердите email +

+

+ Мы отправили код подтверждения на
+ {email} +

+
+ + {error && ( +
+ {error} +
+ )} + + {resendSuccess && ( +
+ Новый код отправлен на {email} +
+ )} + +
+
+ + 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" + /> +

+ Введите 6-значный код из письма +

+
+ + +
+ +
+

+ Не получили код?{' '} + +

+
+ +
+ + ← Вернуться к входу + +
+
+
+ ); +} diff --git a/frontend/src/components/AvatarCropper.tsx b/frontend/src/components/AvatarCropper.tsx new file mode 100644 index 00000000..dde50b19 --- /dev/null +++ b/frontend/src/components/AvatarCropper.tsx @@ -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({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(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 ( +
+
+ {/* Заголовок */} +
+

+ Настройка фото профиля +

+

+ Разместите лицо в центре овала. Фото будет использоваться для распознавания. +

+
+ + {/* Область кадрирования */} +
+ + + {/* Направляющие рамки */} +
+ + {/* Овал для головы (70% ширины, верхняя часть) */} + + + {/* Шея */} + + + {/* Плечи */} + + +
+
+ + {/* Контролы масштабирования */} +
+
+ + + + setZoom(Number(e.target.value))} + className="flex-1" + /> + + + +
+

+ Используйте ползунок для масштабирования +

+
+ + {/* Кнопки действий */} +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index ef2e5ab5..71043fe4 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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)