uchill/backend/apps/users/views.py

848 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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]
authentication_classes = []
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]
authentication_classes = []
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()
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]
authentication_classes = []
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()
# Токен для подтверждения 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]
authentication_classes = []
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]
authentication_classes = []
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]
authentication_classes = []
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]
authentication_classes = []
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]
authentication_classes = []
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]
authentication_classes = []
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/<id>/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',
'created_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