uchill/backend/apps/users/serializers.py

562 lines
22 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.

"""
Сериализаторы для пользователей.
"""
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_timezone(self, value):
"""Проверяем что timezone — валидный IANA идентификатор."""
if not value:
return 'Europe/Moscow'
import zoneinfo
try:
zoneinfo.ZoneInfo(value)
return value
except Exception:
return 'Europe/Moscow'
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