551 lines
22 KiB
Python
551 lines
22 KiB
Python
"""
|
||
Сериализаторы для пользователей.
|
||
"""
|
||
import re
|
||
from urllib.parse import unquote
|
||
from rest_framework import serializers
|
||
from django.contrib.auth.password_validation import validate_password
|
||
from django.contrib.auth import authenticate
|
||
from .models import User, Client, Parent, Group
|
||
|
||
|
||
def _decode_if_url_encoded(value: str) -> str:
|
||
"""Если строка в формате URL-encoded (%XX), декодирует в UTF-8."""
|
||
if not value or not isinstance(value, str):
|
||
return value
|
||
if re.search(r'%[0-9A-Fa-f]{2}', value):
|
||
try:
|
||
return unquote(value, encoding='utf-8')
|
||
except Exception:
|
||
pass
|
||
return value
|
||
|
||
|
||
class UserSerializer(serializers.ModelSerializer):
|
||
"""Базовый сериализатор пользователя."""
|
||
|
||
avatar_url = serializers.SerializerMethodField()
|
||
invitation_link = serializers.SerializerMethodField()
|
||
login_link = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = User
|
||
fields = [
|
||
'id', 'email', 'first_name', 'last_name', 'role',
|
||
'phone', 'avatar', 'avatar_url', 'birth_date', 'bio',
|
||
'telegram_id', 'telegram_username',
|
||
'timezone', 'language',
|
||
'country', 'city',
|
||
'email_verified', 'is_active',
|
||
'universal_code', # 8-символьный код (цифры + латинские буквы) для добавления ментором
|
||
'onboarding_tours_seen',
|
||
'invitation_link_token', 'invitation_link',
|
||
'login_token', 'login_link',
|
||
'notifications_enabled', 'email_notifications', 'telegram_notifications',
|
||
'created_at', 'last_activity'
|
||
]
|
||
read_only_fields = ['id', 'email_verified', 'universal_code', 'invitation_link_token', 'login_token', 'created_at', 'last_activity']
|
||
|
||
def get_avatar_url(self, obj):
|
||
"""Получить полный URL аватара."""
|
||
if obj.avatar:
|
||
request = self.context.get('request')
|
||
if request:
|
||
return request.build_absolute_uri(obj.avatar.url)
|
||
return obj.avatar.url
|
||
return None
|
||
|
||
def get_invitation_link(self, obj):
|
||
"""Получить полную ссылку-приглашение (только для менторов)."""
|
||
if obj.role == 'mentor' and obj.invitation_link_token:
|
||
from django.conf import settings
|
||
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
|
||
return f"{frontend_url}/invite/{obj.invitation_link_token}"
|
||
return None
|
||
|
||
def get_login_link(self, obj):
|
||
"""Получить персональную ссылку для входа (для учеников)."""
|
||
if obj.login_token:
|
||
from django.conf import settings
|
||
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
|
||
return f"{frontend_url}/login/token/{obj.login_token}"
|
||
return None
|
||
|
||
def to_representation(self, instance):
|
||
"""Декодируем first_name и last_name, если они пришли в БД в формате URL-encoded."""
|
||
data = super().to_representation(instance)
|
||
if 'first_name' in data and data['first_name']:
|
||
data['first_name'] = _decode_if_url_encoded(data['first_name'])
|
||
if 'last_name' in data and data['last_name']:
|
||
data['last_name'] = _decode_if_url_encoded(data['last_name'])
|
||
return data
|
||
|
||
|
||
class UserDetailSerializer(UserSerializer):
|
||
"""Детальный сериализатор пользователя с дополнительной информацией."""
|
||
|
||
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||
short_name = serializers.CharField(source='get_short_name', read_only=True)
|
||
is_mentor = serializers.BooleanField(read_only=True)
|
||
is_client = serializers.BooleanField(read_only=True)
|
||
is_parent = serializers.BooleanField(read_only=True)
|
||
|
||
class Meta(UserSerializer.Meta):
|
||
fields = UserSerializer.Meta.fields + [
|
||
'full_name', 'short_name', 'is_mentor', 'is_client', 'is_parent'
|
||
]
|
||
|
||
|
||
class RegisterSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для регистрации пользователя."""
|
||
|
||
password = serializers.CharField(
|
||
write_only=True,
|
||
required=True,
|
||
validators=[validate_password],
|
||
style={'input_type': 'password'}
|
||
)
|
||
password_confirm = serializers.CharField(
|
||
write_only=True,
|
||
required=True,
|
||
style={'input_type': 'password'}
|
||
)
|
||
|
||
class Meta:
|
||
model = User
|
||
fields = [
|
||
'email', 'password', 'password_confirm',
|
||
'first_name', 'last_name', 'role',
|
||
'phone', 'birth_date', 'timezone', 'language',
|
||
'country', 'city',
|
||
]
|
||
extra_kwargs = {
|
||
'first_name': {'required': True},
|
||
'last_name': {'required': True},
|
||
'city': {'required': True},
|
||
'timezone': {'required': True},
|
||
}
|
||
|
||
def validate_email(self, value):
|
||
"""Нормализация email в нижний регистр."""
|
||
return value.lower().strip() if value else value
|
||
|
||
def validate(self, attrs):
|
||
"""Проверка совпадения паролей."""
|
||
if attrs.get('password') != attrs.get('password_confirm'):
|
||
raise serializers.ValidationError({
|
||
'password_confirm': 'Пароли не совпадают'
|
||
})
|
||
return attrs
|
||
|
||
def validate_role(self, value):
|
||
"""Проверка допустимых ролей при регистрации."""
|
||
allowed_roles = ['client', 'mentor', 'parent']
|
||
if value not in allowed_roles:
|
||
raise serializers.ValidationError(
|
||
f'Недопустимая роль. Доступные роли: {", ".join(allowed_roles)}'
|
||
)
|
||
return value
|
||
|
||
def create(self, validated_data):
|
||
"""Создание пользователя."""
|
||
validated_data.pop('password_confirm')
|
||
password = validated_data.pop('password')
|
||
|
||
user = User.objects.create_user(
|
||
password=password,
|
||
**validated_data
|
||
)
|
||
|
||
# Создаем профиль в зависимости от роли
|
||
if user.role == 'client':
|
||
Client.objects.create(user=user)
|
||
elif user.role == 'parent':
|
||
Parent.objects.create(user=user)
|
||
|
||
return user
|
||
|
||
|
||
class TelegramAuthSerializer(serializers.Serializer):
|
||
"""Сериализатор для авторизации через Telegram."""
|
||
|
||
id = serializers.IntegerField(required=True)
|
||
first_name = serializers.CharField(required=True)
|
||
last_name = serializers.CharField(required=False, allow_blank=True)
|
||
username = serializers.CharField(required=False, allow_blank=True)
|
||
photo_url = serializers.URLField(required=False, allow_blank=True)
|
||
auth_date = serializers.IntegerField(required=True)
|
||
hash = serializers.CharField(required=True)
|
||
role = serializers.ChoiceField(
|
||
choices=['mentor', 'client'],
|
||
required=False,
|
||
default='client',
|
||
help_text='Роль пользователя при регистрации'
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
"""Валидация данных Telegram."""
|
||
from django.conf import settings
|
||
from .telegram_auth import validate_telegram_data
|
||
|
||
# Восстанавливаем hash для валидации
|
||
telegram_data = {
|
||
'id': attrs['id'],
|
||
'first_name': attrs['first_name'],
|
||
'last_name': attrs.get('last_name', ''),
|
||
'username': attrs.get('username', ''),
|
||
'photo_url': attrs.get('photo_url', ''),
|
||
'auth_date': attrs['auth_date'],
|
||
'hash': attrs['hash'],
|
||
}
|
||
|
||
# Удаляем пустые поля для валидации
|
||
telegram_data = {k: v for k, v in telegram_data.items() if v}
|
||
|
||
bot_token = settings.TELEGRAM_BOT_TOKEN
|
||
if not bot_token:
|
||
raise serializers.ValidationError("Telegram бот не настроен")
|
||
|
||
if not validate_telegram_data(telegram_data.copy(), bot_token):
|
||
raise serializers.ValidationError("Неверные данные Telegram")
|
||
|
||
return attrs
|
||
|
||
|
||
class LoginSerializer(serializers.Serializer):
|
||
"""Сериализатор для входа пользователя."""
|
||
|
||
email = serializers.EmailField(required=True)
|
||
password = serializers.CharField(
|
||
required=True,
|
||
write_only=True,
|
||
style={'input_type': 'password'}
|
||
)
|
||
|
||
def validate_email(self, value):
|
||
"""Нормализация email в нижний регистр."""
|
||
return value.lower().strip() if value else value
|
||
|
||
def validate(self, attrs):
|
||
"""Проверка учетных данных."""
|
||
email = attrs.get('email')
|
||
password = attrs.get('password')
|
||
|
||
if email and password:
|
||
# Проверяем существование пользователя
|
||
try:
|
||
user = User.objects.get(email=email)
|
||
except User.DoesNotExist:
|
||
raise serializers.ValidationError({
|
||
'email': 'Пользователь с таким email не найден'
|
||
})
|
||
|
||
# Проверяем пароль
|
||
if not user.check_password(password):
|
||
raise serializers.ValidationError({
|
||
'password': 'Неверный пароль'
|
||
})
|
||
|
||
# Проверяем активность пользователя
|
||
if not user.is_active:
|
||
raise serializers.ValidationError({
|
||
'email': 'Аккаунт неактивен'
|
||
})
|
||
|
||
# Проверяем блокировку
|
||
if user.is_blocked:
|
||
raise serializers.ValidationError({
|
||
'email': f'Аккаунт заблокирован. Причина: {user.blocked_reason}'
|
||
})
|
||
|
||
attrs['user'] = user
|
||
else:
|
||
raise serializers.ValidationError({
|
||
'email': 'Email и пароль обязательны'
|
||
})
|
||
|
||
return attrs
|
||
|
||
|
||
class ChangePasswordSerializer(serializers.Serializer):
|
||
"""Сериализатор для смены пароля."""
|
||
|
||
old_password = serializers.CharField(
|
||
required=True,
|
||
write_only=True,
|
||
style={'input_type': 'password'}
|
||
)
|
||
new_password = serializers.CharField(
|
||
required=True,
|
||
write_only=True,
|
||
validators=[validate_password],
|
||
style={'input_type': 'password'}
|
||
)
|
||
new_password_confirm = serializers.CharField(
|
||
required=True,
|
||
write_only=True,
|
||
style={'input_type': 'password'}
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
"""Проверка совпадения новых паролей."""
|
||
if attrs.get('new_password') != attrs.get('new_password_confirm'):
|
||
raise serializers.ValidationError({
|
||
'new_password_confirm': 'Пароли не совпадают'
|
||
})
|
||
return attrs
|
||
|
||
def validate_old_password(self, value):
|
||
"""Проверка старого пароля."""
|
||
user = self.context['request'].user
|
||
if not user.check_password(value):
|
||
raise serializers.ValidationError('Неверный старый пароль')
|
||
return value
|
||
|
||
|
||
class PasswordResetRequestSerializer(serializers.Serializer):
|
||
"""Сериализатор для запроса восстановления пароля."""
|
||
|
||
email = serializers.EmailField(required=True)
|
||
|
||
def validate_email(self, value):
|
||
"""Нормализация email в нижний регистр и проверка существования."""
|
||
# Нормализуем email в нижний регистр
|
||
normalized_email = value.lower().strip() if value else value
|
||
try:
|
||
User.objects.get(email=normalized_email)
|
||
except User.DoesNotExist:
|
||
# Не раскрываем информацию о существовании email
|
||
pass
|
||
return normalized_email
|
||
|
||
|
||
class PasswordResetConfirmSerializer(serializers.Serializer):
|
||
"""Сериализатор для подтверждения восстановления пароля."""
|
||
|
||
token = serializers.CharField(required=True)
|
||
new_password = serializers.CharField(
|
||
required=True,
|
||
write_only=True,
|
||
validators=[validate_password],
|
||
style={'input_type': 'password'}
|
||
)
|
||
new_password_confirm = serializers.CharField(
|
||
required=True,
|
||
write_only=True,
|
||
style={'input_type': 'password'}
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
"""Проверка совпадения паролей."""
|
||
if attrs.get('new_password') != attrs.get('new_password_confirm'):
|
||
raise serializers.ValidationError({
|
||
'new_password_confirm': 'Пароли не совпадают'
|
||
})
|
||
return attrs
|
||
|
||
|
||
class EmailVerificationSerializer(serializers.Serializer):
|
||
"""Сериализатор для подтверждения email."""
|
||
|
||
token = serializers.CharField(required=True)
|
||
|
||
|
||
class ClientSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для клиента."""
|
||
|
||
user = UserSerializer(read_only=True)
|
||
mentors = UserSerializer(many=True, read_only=True) # Добавляем менторов
|
||
scheduled_lessons = serializers.SerializerMethodField()
|
||
total_lessons = serializers.SerializerMethodField()
|
||
completed_lessons = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Client
|
||
fields = [
|
||
'id', 'user', 'mentors', 'grade', 'school', 'learning_goals',
|
||
'total_lessons', 'completed_lessons', 'scheduled_lessons',
|
||
'enrollment_date', 'created_at'
|
||
]
|
||
read_only_fields = [
|
||
'id', 'total_lessons', 'completed_lessons', 'scheduled_lessons',
|
||
'enrollment_date', 'created_at'
|
||
]
|
||
|
||
def get_scheduled_lessons(self, obj):
|
||
"""Количество запланированных занятий."""
|
||
# Оптимизация: если queryset был заранее аннотирован, не делаем отдельные запросы в БД
|
||
if hasattr(obj, 'scheduled_lessons_annotated'):
|
||
return int(getattr(obj, 'scheduled_lessons_annotated') or 0)
|
||
from apps.schedule.models import Lesson
|
||
request = self.context.get('request')
|
||
if request and request.user and request.user.role == 'mentor':
|
||
# Считаем только занятия этого ментора
|
||
return Lesson.objects.filter(
|
||
client=obj,
|
||
mentor=request.user,
|
||
status='scheduled'
|
||
).count()
|
||
# Если нет контекста, считаем все занятия
|
||
return Lesson.objects.filter(
|
||
client=obj,
|
||
status='scheduled'
|
||
).count()
|
||
|
||
def get_total_lessons(self, obj):
|
||
"""Общее количество занятий (все статусы кроме отмененных)."""
|
||
# Оптимизация: если queryset был заранее аннотирован, не делаем отдельные запросы в БД
|
||
if hasattr(obj, 'total_lessons_annotated'):
|
||
return int(getattr(obj, 'total_lessons_annotated') or 0)
|
||
from apps.schedule.models import Lesson
|
||
request = self.context.get('request')
|
||
if request and request.user and request.user.role == 'mentor':
|
||
# Считаем только занятия этого ментора
|
||
return Lesson.objects.filter(
|
||
client=obj,
|
||
mentor=request.user
|
||
).exclude(status='cancelled').count()
|
||
# Если нет контекста, считаем все занятия
|
||
return Lesson.objects.filter(
|
||
client=obj
|
||
).exclude(status='cancelled').count()
|
||
|
||
def get_completed_lessons(self, obj):
|
||
"""Количество завершенных занятий."""
|
||
# Оптимизация: если queryset был заранее аннотирован, не делаем отдельные запросы в БД
|
||
if hasattr(obj, 'completed_lessons_annotated'):
|
||
return int(getattr(obj, 'completed_lessons_annotated') or 0)
|
||
from apps.schedule.models import Lesson
|
||
request = self.context.get('request')
|
||
if request and request.user and request.user.role == 'mentor':
|
||
# Считаем только занятия этого ментора
|
||
return Lesson.objects.filter(
|
||
client=obj,
|
||
mentor=request.user,
|
||
status='completed'
|
||
).count()
|
||
# Если нет контекста, считаем все занятия
|
||
return Lesson.objects.filter(
|
||
client=obj,
|
||
status='completed'
|
||
).count()
|
||
|
||
|
||
class ParentSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для родителя."""
|
||
|
||
user = UserSerializer(read_only=True)
|
||
children = ClientSerializer(many=True, read_only=True)
|
||
|
||
class Meta:
|
||
model = Parent
|
||
fields = [
|
||
'id', 'user', 'children', 'relation_type',
|
||
'can_view_progress', 'can_view_schedule', 'can_receive_reports',
|
||
'created_at'
|
||
]
|
||
read_only_fields = ['id', 'created_at']
|
||
|
||
|
||
class GroupSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор учебной группы."""
|
||
|
||
mentor = UserSerializer(read_only=True)
|
||
students = ClientSerializer(many=True, read_only=True)
|
||
students_ids = serializers.ListField(
|
||
child=serializers.IntegerField(),
|
||
write_only=True,
|
||
required=False,
|
||
allow_empty=True,
|
||
)
|
||
students_count = serializers.SerializerMethodField()
|
||
scheduled_lessons = serializers.SerializerMethodField()
|
||
completed_lessons = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Group
|
||
fields = [
|
||
'id',
|
||
'mentor',
|
||
'name',
|
||
'description',
|
||
'students',
|
||
'students_ids',
|
||
'students_count',
|
||
'scheduled_lessons',
|
||
'completed_lessons',
|
||
'created_at',
|
||
'updated_at',
|
||
]
|
||
read_only_fields = ['id', 'mentor', 'students', 'students_count', 'scheduled_lessons', 'completed_lessons', 'created_at', 'updated_at']
|
||
|
||
def get_students_count(self, obj):
|
||
# Используем аннотацию, если она есть (для списка)
|
||
if hasattr(obj, 'students_count_annotated'):
|
||
return obj.students_count_annotated
|
||
return obj.students.count()
|
||
|
||
def _base_queryset(self, obj):
|
||
"""
|
||
Базовый queryset занятий, относящихся к этой группе.
|
||
Считаем ТОЛЬКО те уроки, которые явно привязаны к группе через поле Lesson.group,
|
||
чтобы не путать общую историю ученика с его занятиями в составе конкретной группы.
|
||
"""
|
||
from apps.schedule.models import Lesson
|
||
|
||
return Lesson.objects.filter(
|
||
mentor=obj.mentor,
|
||
group=obj,
|
||
).exclude(status='cancelled')
|
||
|
||
def get_scheduled_lessons(self, obj):
|
||
"""
|
||
Количество запланированных занятий для текущей группы.
|
||
Статусы: scheduled, in_progress.
|
||
"""
|
||
# Используем аннотацию, если она есть (для списка)
|
||
if hasattr(obj, 'scheduled_lessons_annotated'):
|
||
return obj.scheduled_lessons_annotated
|
||
qs = self._base_queryset(obj)
|
||
return qs.filter(status__in=['scheduled', 'in_progress']).count()
|
||
|
||
def get_completed_lessons(self, obj):
|
||
"""
|
||
Количество проведённых (завершённых) занятий для текущей группы.
|
||
Статус: completed.
|
||
"""
|
||
# Используем аннотацию, если она есть (для списка)
|
||
if hasattr(obj, 'completed_lessons_annotated'):
|
||
return obj.completed_lessons_annotated
|
||
qs = self._base_queryset(obj)
|
||
return qs.filter(status='completed').count()
|
||
|
||
def create(self, validated_data):
|
||
students_ids = validated_data.pop('students_ids', [])
|
||
request = self.context.get('request')
|
||
mentor = request.user if request else None
|
||
|
||
group = Group.objects.create(
|
||
mentor=mentor,
|
||
**validated_data,
|
||
)
|
||
|
||
if students_ids:
|
||
students = Client.objects.filter(id__in=students_ids)
|
||
group.students.set(students)
|
||
|
||
return group
|
||
|
||
def update(self, instance, validated_data):
|
||
students_ids = validated_data.pop('students_ids', None)
|
||
|
||
for attr, value in validated_data.items():
|
||
setattr(instance, attr, value)
|
||
instance.save()
|
||
|
||
if students_ids is not None:
|
||
students = Client.objects.filter(id__in=students_ids)
|
||
instance.students.set(students)
|
||
|
||
return instance
|