""" Сериализаторы для пользователей. """ 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-символьный код (цифры + латинские буквы) для добавления ментором '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 ) # Гарантированно задаём 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: # Если не удалось, код будет сгенерирован в RegisterView или при запросе профиля pass # Создаем профиль в зависимости от роли 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