""" API views для пользователей и аутентификации. """ from rest_framework import generics, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework_simplejwt.tokens import RefreshToken from django.utils import timezone from django.db.models import Q import secrets import string import random import logging from .models import User, Client, Parent, Group from .serializers import ( UserDetailSerializer, UserSerializer, RegisterSerializer, LoginSerializer, TelegramAuthSerializer, ChangePasswordSerializer, PasswordResetRequestSerializer, PasswordResetConfirmSerializer, EmailVerificationSerializer, ClientSerializer, ParentSerializer, GroupSerializer, ) from .tasks import ( send_verification_email_task, send_password_reset_email_task, send_welcome_email_task, ) from .permissions import IsMentor from .telegram_auth import extract_telegram_user_data from .telegram_utils import get_telegram_bot_username from config.throttling import BurstRateThrottle class TelegramBotInfoView(generics.GenericAPIView): """ API endpoint для получения информации о Telegram боте. GET /api/auth/telegram/bot-info/ """ permission_classes = [AllowAny] def get(self, request, *args, **kwargs): """Получение имени бота для использования в Telegram Login Widget.""" bot_username, error_message = get_telegram_bot_username() if not bot_username: return Response({ 'success': False, 'error': error_message or 'Telegram бот не настроен или токен недействителен', 'bot_username': None, 'bot_url': None }, status=status.HTTP_400_BAD_REQUEST) return Response({ 'success': True, 'bot_username': bot_username, 'bot_url': f'https://t.me/{bot_username}' }, status=status.HTTP_200_OK) class TelegramAuthView(generics.GenericAPIView): """ API endpoint для авторизации через Telegram Login Widget. POST /api/auth/telegram/ """ serializer_class = TelegramAuthSerializer permission_classes = [AllowAny] throttle_classes = [BurstRateThrottle] def post(self, request, *args, **kwargs): """Авторизация или регистрация через Telegram.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) telegram_data = serializer.validated_data telegram_user_data = extract_telegram_user_data(telegram_data) telegram_id = telegram_user_data['telegram_id'] role = telegram_data.get('role', 'client') # Ищем пользователя по telegram_id try: user = User.objects.get(telegram_id=telegram_id) # Обновляем данные пользователя user.telegram_username = telegram_user_data.get('username', '') or user.telegram_username if telegram_user_data.get('first_name'): user.first_name = telegram_user_data['first_name'] if telegram_user_data.get('last_name'): user.last_name = telegram_user_data['last_name'] user.last_activity = timezone.now() user.save() is_new_user = False message = 'Вход выполнен успешно' except User.DoesNotExist: # Создаем нового пользователя # Генерируем email на основе telegram_id, если не указан email = f"telegram_{telegram_id}@telegram.local" # Проверяем, не занят ли email counter = 1 while User.objects.filter(email=email).exists(): email = f"telegram_{telegram_id}_{counter}@telegram.local" counter += 1 user = User.objects.create_user( email=email, telegram_id=telegram_id, telegram_username=telegram_user_data.get('username', ''), first_name=telegram_user_data.get('first_name', ''), last_name=telegram_user_data.get('last_name', ''), role=role, email_verified=True, # Telegram уже проверил пользователя ) # Устанавливаем неприводимый пароль, чтобы пользователь не мог войти через email user.set_unusable_password() user.save() # Гарантируем 8-символьный код для приглашений if not user.universal_code or len(str(user.universal_code or '').strip()) != 8: try: user.universal_code = user._generate_universal_code() user.save(update_fields=['universal_code']) except Exception as e: logger.warning(f'Ошибка генерации universal_code для Telegram пользователя {user.id}: {e}') # Пробуем ещё раз try: alphabet = string.ascii_uppercase + string.digits for _ in range(500): code = ''.join(random.choices(alphabet, k=8)) if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists(): user.universal_code = code user.save(update_fields=['universal_code']) break except Exception: pass # Код будет сгенерирован при следующем запросе профиля is_new_user = True message = 'Регистрация через Telegram выполнена успешно' # Генерируем JWT токены refresh = RefreshToken.for_user(user) return Response({ 'success': True, 'message': message, 'is_new_user': is_new_user, 'data': { 'user': UserDetailSerializer(user).data, 'tokens': { 'access': str(refresh.access_token), 'refresh': str(refresh), } } }, status=status.HTTP_200_OK) class RegisterView(generics.CreateAPIView): """ API endpoint для регистрации нового пользователя. POST /api/auth/register/ """ queryset = User.objects.all() serializer_class = RegisterSerializer permission_classes = [AllowAny] throttle_classes = [BurstRateThrottle] def create(self, request, *args, **kwargs): """Регистрация пользователя с отправкой email подтверждения.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() # Всегда задаём 8-символьный код при регистрации (для приглашений ментор/студент) logger = logging.getLogger(__name__) need_code = not user.universal_code or len(str(user.universal_code or '').strip()) != 8 if need_code: try: user.universal_code = user._generate_universal_code() user.save(update_fields=['universal_code']) except Exception as e: # Если не удалось сгенерировать код, пробуем ещё раз с большим количеством попыток logger.warning(f'Ошибка генерации universal_code для пользователя {user.id}: {e}, пробуем ещё раз') try: alphabet = string.ascii_uppercase + string.digits for _ in range(500): code = ''.join(random.choices(alphabet, k=8)) if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists(): user.universal_code = code user.save(update_fields=['universal_code']) break else: # Если всё равно не получилось, не прерываем регистрацию logger.error(f'Не удалось сгенерировать unique universal_code для пользователя {user.id} после 500 попыток') except Exception as e2: logger.error(f'Критическая ошибка генерации universal_code для пользователя {user.id}: {e2}') # Не прерываем регистрацию, код будет сгенерирован при следующем запросе профиля # Токен для подтверждения email verification_token = secrets.token_urlsafe(32) user.email_verification_token = verification_token user.save(update_fields=['email_verification_token']) # Отправляем email подтверждения (асинхронно через Celery) send_verification_email_task.delay(user.id, verification_token) # Генерируем JWT токены refresh = RefreshToken.for_user(user) # Берём пользователя из БД, чтобы в ответе точно был universal_code user.refresh_from_db() user_serializer = UserDetailSerializer(user, context={'request': request}) return Response({ 'success': True, 'message': 'Регистрация успешна. Проверьте email для подтверждения аккаунта.', 'data': { 'user': user_serializer.data, 'tokens': { 'access': str(refresh.access_token), 'refresh': str(refresh), } } }, status=status.HTTP_201_CREATED) class LoginView(generics.GenericAPIView): """ API endpoint для входа пользователя. POST /api/auth/login/ """ serializer_class = LoginSerializer permission_classes = [AllowAny] throttle_classes = [BurstRateThrottle] def post(self, request, *args, **kwargs): """Вход пользователя с выдачей JWT токенов.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.validated_data['user'] # Обновляем время последней активности user.last_activity = timezone.now() user.save(update_fields=['last_activity']) # Генерируем JWT токены (выдаем даже если email не подтвержден) refresh = RefreshToken.for_user(user) # Формируем сообщение в зависимости от статуса email if not user.email_verified: message = 'Вход выполнен. Для завершения регистрации необходимо подтвердить email адрес.' else: message = 'Вход выполнен успешно' # Сериализуем пользователя с контекстом запроса для правильных URL user_serializer = UserDetailSerializer(user, context={'request': request}) return Response({ 'success': True, 'message': message, 'data': { 'user': user_serializer.data, 'tokens': { 'access': str(refresh.access_token), 'refresh': str(refresh), } } }, status=status.HTTP_200_OK) class LoginByTokenView(generics.GenericAPIView): """ API endpoint для входа по персональному токену. POST /api/auth/login-by-token/ """ permission_classes = [AllowAny] throttle_classes = [BurstRateThrottle] def post(self, request, *args, **kwargs): token = request.data.get('token') if not token: return Response({'error': 'Токен обязателен'}, status=status.HTTP_400_BAD_REQUEST) try: user = User.objects.get(login_token=token) if not user.is_active: return Response({'error': 'Аккаунт неактивен'}, status=status.HTTP_403_FORBIDDEN) if user.is_blocked: return Response({'error': f'Аккаунт заблокирован. Причина: {user.blocked_reason}'}, status=status.HTTP_403_FORBIDDEN) user.last_login = timezone.now() user.last_activity = timezone.now() user.save(update_fields=['last_login', 'last_activity']) refresh = RefreshToken.for_user(user) user_serializer = UserDetailSerializer(user, context={'request': request}) return Response({ 'success': True, 'message': 'Вход выполнен успешно', 'data': { 'user': user_serializer.data, 'tokens': { 'access': str(refresh.access_token), 'refresh': str(refresh), } } }, status=status.HTTP_200_OK) except User.DoesNotExist: return Response({'error': 'Неверный токен'}, status=status.HTTP_404_NOT_FOUND) class LogoutView(generics.GenericAPIView): """ API endpoint для выхода пользователя. POST /api/auth/logout/ """ permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): """Выход пользователя (blacklist refresh токена).""" try: refresh_token = request.data.get('refresh') if refresh_token: token = RefreshToken(refresh_token) token.blacklist() return Response({ 'success': True, 'message': 'Выход выполнен успешно' }, status=status.HTTP_200_OK) except Exception as e: return Response({ 'success': False, 'error': { 'message': 'Ошибка при выходе', 'details': str(e) } }, status=status.HTTP_400_BAD_REQUEST) class ChangePasswordView(generics.GenericAPIView): """ API endpoint для смены пароля. POST /api/auth/change-password/ """ serializer_class = ChangePasswordSerializer permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): """Смена пароля пользователя.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = request.user # Проверяем старый пароль if not user.check_password(serializer.validated_data['old_password']): return Response({ 'success': False, 'error': { 'old_password': ['Неверный пароль'] } }, status=status.HTTP_400_BAD_REQUEST) # Устанавливаем новый пароль user.set_password(serializer.validated_data['new_password']) user.save() return Response({ 'success': True, 'message': 'Пароль успешно изменен' }, status=status.HTTP_200_OK) class PasswordResetRequestView(generics.GenericAPIView): """ API endpoint для запроса восстановления пароля. POST /api/auth/password-reset/ """ serializer_class = PasswordResetRequestSerializer permission_classes = [AllowAny] throttle_classes = [BurstRateThrottle] def post(self, request, *args, **kwargs): """Отправка email для восстановления пароля.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) email = serializer.validated_data['email'] try: user = User.objects.get(email=email) # Генерируем токен для сброса пароля reset_token = secrets.token_urlsafe(32) user.email_verification_token = reset_token user.save() # Отправляем email (асинхронно) send_password_reset_email_task.delay(user.id, reset_token) except User.DoesNotExist: # Не раскрываем информацию о существовании email pass return Response({ 'success': True, 'message': 'Если email существует, на него будет отправлено письмо с инструкциями' }, status=status.HTTP_200_OK) class PasswordResetConfirmView(generics.GenericAPIView): """ API endpoint для подтверждения восстановления пароля. POST /api/auth/password-reset-confirm/ """ serializer_class = PasswordResetConfirmSerializer permission_classes = [AllowAny] def post(self, request, *args, **kwargs): """Подтверждение восстановления пароля.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) token = serializer.validated_data['token'] new_password = serializer.validated_data['new_password'] try: user = User.objects.get(email_verification_token=token) user.set_password(new_password) user.email_verification_token = '' user.save() return Response({ 'success': True, 'message': 'Пароль успешно изменен' }, status=status.HTTP_200_OK) except User.DoesNotExist: return Response({ 'success': False, 'error': { 'message': 'Неверный или истекший токен' } }, status=status.HTTP_400_BAD_REQUEST) class EmailVerificationView(generics.GenericAPIView): """ API endpoint для подтверждения email. POST /api/auth/verify-email/ """ serializer_class = EmailVerificationSerializer permission_classes = [AllowAny] def post(self, request, *args, **kwargs): """Подтверждение email пользователя.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) token = serializer.validated_data['token'] try: user = User.objects.get(email_verification_token=token) user.email_verified = True user.email_verification_token = '' user.save() # Отправляем приветственное письмо после подтверждения email send_welcome_email_task.delay(user.id) return Response({ 'success': True, 'message': 'Email успешно подтвержден' }, status=status.HTTP_200_OK) except User.DoesNotExist: return Response({ 'success': False, 'error': { 'message': 'Неверный или истекший токен' } }, status=status.HTTP_400_BAD_REQUEST) class ResendVerificationEmailView(generics.GenericAPIView): """ API endpoint для повторной отправки письма подтверждения email. POST /api/auth/resend-verification/ Можно использовать с авторизацией или без (передавая email) """ permission_classes = [AllowAny] def post(self, request, *args, **kwargs): """Повторная отправка письма подтверждения email.""" # Если пользователь авторизован, используем его email if request.user.is_authenticated: user = request.user else: # Если не авторизован, получаем email из запроса email = request.data.get('email') if not email: return Response({ 'success': False, 'error': 'Email обязателен' }, status=status.HTTP_400_BAD_REQUEST) try: user = User.objects.get(email=email) except User.DoesNotExist: # Не раскрываем информацию о существовании email return Response({ 'success': True, 'message': 'Если email существует, на него будет отправлено письмо с подтверждением' }, status=status.HTTP_200_OK) # Если email уже подтвержден, не отправляем письмо if user.email_verified: return Response({ 'success': False, 'message': 'Email уже подтвержден' }, status=status.HTTP_400_BAD_REQUEST) # Генерируем новый токен для подтверждения verification_token = secrets.token_urlsafe(32) user.email_verification_token = verification_token user.save() # Отправляем email подтверждения (асинхронно через Celery) send_verification_email_task.delay(user.id, verification_token) return Response({ 'success': True, 'message': 'Письмо подтверждения отправлено на ваш email' }, status=status.HTTP_200_OK) class UserViewSet(viewsets.ModelViewSet): """ ViewSet для управления пользователями. """ @action(detail=False, methods=['get']) def export_data(self, request): """ Экспорт данных пользователя (GDPR compliance). GET /api/users/export_data/?format=json """ from .services import DataExportService format_type = request.query_params.get('format', 'json') if format_type not in ['json']: return Response( {'error': 'Неподдерживаемый формат. Доступен только JSON.'}, status=status.HTTP_400_BAD_REQUEST ) response = DataExportService.generate_export_file(request.user, format=format_type) if not response: return Response( {'error': 'Ошибка при экспорте данных'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) return response @action(detail=True, methods=['get'], url_path='avatar_url') def avatar_url(self, request, pk=None): """ Получить URL аватара пользователя по id (для плейсхолдера в видеозвоне). GET /api/users//avatar_url/ Доступно любому аутентифицированному пользователю. """ user = User.objects.filter(pk=pk).only('avatar').first() if not user: return Response({'avatar_url': None}, status=status.HTTP_404_NOT_FOUND) avatar_url = None if user.avatar: avatar_url = request.build_absolute_uri(user.avatar.url) return Response({'avatar_url': avatar_url}) @action(detail=False, methods=['get']) def contacts(self, request): """ Получить список пользователей, с которыми текущий пользователь может начать чат. Связи: - Ментор <-> Студенты - Студент <-> Менторы - Ментор <-> Родители его студентов - Родитель <-> Менторы его детей """ user = request.user contact_ids = set() if user.role == 'mentor': # Студенты ментора student_ids = User.objects.filter(role='client', client_profile__mentors=user).values_list('id', flat=True) contact_ids.update(student_ids) # Родители студентов ментора parent_ids = User.objects.filter(role='parent', parent_profile__children__mentors=user).values_list('id', flat=True) contact_ids.update(parent_ids) elif user.role == 'client': # Менторы студента mentor_ids = User.objects.filter(role='mentor', clients__user=user).values_list('id', flat=True) contact_ids.update(mentor_ids) # Родители студента parent_ids = User.objects.filter(role='parent', parent_profile__children__user=user).values_list('id', flat=True) contact_ids.update(parent_ids) elif user.role == 'parent': # Менторы детей родителя mentor_ids = User.objects.filter(role='mentor', clients__parents__user=user).values_list('id', flat=True) contact_ids.update(mentor_ids) # Дети родителя child_ids = User.objects.filter(role='client', client_profile__parents__user=user).values_list('id', flat=True) contact_ids.update(child_ids) # Получаем пользователей по ID contacts = User.objects.filter(id__in=contact_ids).distinct().only( 'id', 'email', 'first_name', 'last_name', 'role', 'phone', 'avatar', 'timezone', 'language', 'country', 'city', 'email_verified', 'is_active', 'created_at', 'last_activity' ) # Поиск по имени или email search = request.query_params.get('search') if search: contacts = contacts.filter( Q(first_name__icontains=search) | Q(last_name__icontains=search) | Q(email__icontains=search) ) # Пагинация page = self.paginate_queryset(contacts) if page is not None: serializer = UserSerializer(page, many=True, context={'request': request}) return self.get_paginated_response(serializer.data) serializer = UserSerializer(contacts, many=True, context={'request': request}) return Response(serializer.data) queryset = User.objects.all() serializer_class = UserSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """Фильтрация пользователей в зависимости от роли.""" user = self.request.user if user.is_superuser: # Оптимизация: используем only() для ограничения полей в списке return User.objects.all().only( 'id', 'email', 'first_name', 'last_name', 'role', 'phone', 'avatar', 'timezone', 'language', 'country', 'city', 'email_verified', 'is_active', 'created_at', 'last_activity' ) elif user.role == 'mentor': # Ментор видит только своих клиентов # Оптимизация: используем select_related для избежания N+1 запросов return User.objects.filter( role='client', clients__mentors=user ).select_related().distinct().only( 'id', 'email', 'first_name', 'last_name', 'role', 'phone', 'avatar', 'timezone', 'language', 'country', 'city', 'email_verified', 'is_active', 'created_at', 'last_activity' ) else: # Остальные видят только себя return User.objects.filter(id=user.id) class ClientViewSet(viewsets.ModelViewSet): """ ViewSet для управления клиентами. """ queryset = Client.objects.all() serializer_class = ClientSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """Фильтрация клиентов в зависимости от роли.""" user = self.request.user if user.is_superuser: queryset = Client.objects.all() elif user.role == 'mentor': # Ментор видит только своих клиентов queryset = Client.objects.filter(mentors=user) else: # Клиент видит только свой профиль queryset = Client.objects.filter(user=user) # Оптимизация: используем select_related и prefetch_related для избежания N+1 queryset = queryset.select_related('user').prefetch_related('mentors') # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'user_id', 'grade', 'school', 'learning_goals', 'enrollment_date', 'created_at' ) return queryset def create(self, request, *args, **kwargs): """Создание клиента с проверкой необходимости доплаты.""" response = super().create(request, *args, **kwargs) # Проверяем необходимость доплаты для менторов с тарифом "За ученика" if request.user.role == 'mentor': from apps.subscriptions.services import SubscriptionService subscription = SubscriptionService.get_active_subscription(request.user) if subscription and subscription.plan.subscription_type == 'per_student': # Получаем текущее количество клиентов current_count = Client.objects.filter(mentors=request.user).count() # Если превышен лимит оплаченных учеников if current_count > subscription.student_count: # Рассчитываем доплату payment_data = SubscriptionService.calculate_extra_students_payment( subscription=subscription, new_student_count=current_count ) # Добавляем информацию о необходимости доплаты в ответ response.data['requires_payment'] = True response.data['payment_info'] = { 'extra_students': payment_data['extra_students'], 'price_per_student': float(payment_data['price_per_student']), 'days_remaining': payment_data['days_remaining'], 'total_days': payment_data['total_days'], 'payment_amount': float(payment_data['payment_amount']), 'next_month_amount': float(payment_data['next_month_amount']), 'current_student_count': payment_data['current_student_count'], 'new_student_count': payment_data['new_student_count'] } else: response.data['requires_payment'] = False return response class ParentViewSet(viewsets.ModelViewSet): """ ViewSet для управления родителями. """ queryset = Parent.objects.all() serializer_class = ParentSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """Фильтрация родителей в зависимости от роли.""" user = self.request.user if user.is_superuser: queryset = Parent.objects.all() elif user.role == 'mentor': # Ментор видит родителей своих клиентов # children - это ManyToManyField к Client, mentors - ManyToManyField к User в Client queryset = Parent.objects.filter( children__mentors=user ).distinct() else: # Родитель видит только свой профиль queryset = Parent.objects.filter(user=user) # Оптимизация: используем select_related и prefetch_related для избежания N+1 queryset = queryset.select_related('user').prefetch_related('children', 'children__user') # Оптимизация: для списка используем only() для ограничения полей if self.action == 'list': queryset = queryset.only( 'id', 'user_id', 'relation_type', 'can_view_progress', 'can_view_schedule', 'can_receive_reports', 'created_at' ) return queryset class GroupViewSet(viewsets.ModelViewSet): """ ViewSet для управления группами. """ queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """Фильтрация групп в зависимости от роли.""" user = self.request.user if user.is_superuser: queryset = Group.objects.all() elif user.role == 'mentor': # Ментор видит только свои группы queryset = Group.objects.filter(mentor=user) elif user.role == 'client': # Клиент видит только группы, в которых он состоит try: client = Client.objects.get(user=user) queryset = Group.objects.filter(students=client) except Client.DoesNotExist: return Group.objects.none() else: return Group.objects.none() # Оптимизация: используем select_related и prefetch_related для избежания N+1 queryset = queryset.select_related('mentor').prefetch_related('students', 'students__user') # Оптимизация: для списка добавляем аннотации для подсчета студентов и уроков if self.action == 'list': from django.db.models import Count, Q from apps.schedule.models import Lesson queryset = queryset.annotate( students_count_annotated=Count('students', distinct=True), scheduled_lessons_annotated=Count( 'lessons', filter=Q(lessons__status__in=['scheduled', 'in_progress']) & ~Q(lessons__status='cancelled'), distinct=True ), completed_lessons_annotated=Count( 'lessons', filter=Q(lessons__status='completed') & ~Q(lessons__status='cancelled'), distinct=True ) ).only( 'id', 'name', 'description', 'mentor_id', 'max_students', 'is_active', 'created_at', 'updated_at' ) return queryset def list(self, request, *args, **kwargs): """ Список групп с кешированием. """ user = request.user # Кеширование: кеш на 2 минуты для каждого пользователя и страницы page = int(request.query_params.get('page', 1)) page_size = int(request.query_params.get('page_size', 20)) cache_key = f'groups_{user.id}_{page}_{page_size}' from django.core.cache import cache cached_data = cache.get(cache_key) if cached_data is not None: return Response(cached_data) # Вызываем родительский метод для получения данных response = super().list(request, *args, **kwargs) # Сохраняем в кеш на 2 минуты (120 секунд) cache.set(cache_key, response.data, 120) return response