787 lines
36 KiB
Python
787 lines
36 KiB
Python
"""
|
||
Сериализаторы для расписания.
|
||
"""
|
||
from rest_framework import serializers
|
||
from django.utils import timezone
|
||
from datetime import datetime, timedelta
|
||
from .models import Lesson, LessonTemplate, TimeSlot, Availability, LessonFile, LessonHomeworkSubmission, Subject, MentorSubject
|
||
from apps.users.serializers import UserSerializer, ClientSerializer
|
||
from apps.users.utils import format_datetime_for_user, get_user_timezone
|
||
from django.utils import timezone as django_timezone
|
||
import pytz
|
||
|
||
|
||
class LessonTemplateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для шаблона занятия."""
|
||
|
||
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
||
lessons_count = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = LessonTemplate
|
||
fields = [
|
||
'id', 'mentor', 'mentor_name', 'title', 'description',
|
||
'subject', 'duration', 'is_active', 'meeting_url',
|
||
'color', 'lessons_count', 'created_at', 'updated_at'
|
||
]
|
||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||
|
||
def get_lessons_count(self, obj):
|
||
"""Количество занятий созданных из шаблона."""
|
||
return obj.lessons.count()
|
||
|
||
|
||
class LessonSerializer(serializers.ModelSerializer):
|
||
"""Базовый сериализатор для занятия."""
|
||
|
||
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
||
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
|
||
group_name = serializers.CharField(source='group.name', read_only=True, allow_null=True)
|
||
template_title = serializers.CharField(source='template.title', read_only=True, allow_null=True)
|
||
|
||
# Предмет как строка (название) для удобства на фронтенде
|
||
subject = serializers.SerializerMethodField()
|
||
|
||
# Цена как число (DecimalField возвращает строку)
|
||
price = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, allow_null=True, coerce_to_string=False)
|
||
|
||
# Ссылка на встречу - разрешаем пустую строку
|
||
meeting_url = serializers.URLField(required=False, allow_blank=True, allow_null=True)
|
||
|
||
# Файлы урока
|
||
files = serializers.SerializerMethodField()
|
||
|
||
# Вычисляемые поля
|
||
is_upcoming = serializers.BooleanField(read_only=True)
|
||
is_past = serializers.BooleanField(read_only=True)
|
||
is_in_progress = serializers.BooleanField(read_only=True)
|
||
can_be_cancelled = serializers.BooleanField(read_only=True)
|
||
can_be_rescheduled = serializers.BooleanField(read_only=True)
|
||
|
||
def get_subject(self, obj):
|
||
"""Возвращаем название предмета вместо ID."""
|
||
if obj.subject:
|
||
return obj.subject.name
|
||
if obj.mentor_subject:
|
||
return obj.mentor_subject.name
|
||
if obj.subject_name:
|
||
return obj.subject_name
|
||
return None
|
||
|
||
def get_files(self, obj):
|
||
"""Получить файлы урока."""
|
||
files = obj.files.all()
|
||
return LessonFileSerializer(files, many=True).data
|
||
|
||
def to_representation(self, instance):
|
||
"""Переопределяем для конвертации времени в часовой пояс пользователя."""
|
||
data = super().to_representation(instance)
|
||
request = self.context.get('request')
|
||
user_timezone = 'UTC'
|
||
if request and hasattr(request, 'user') and request.user.is_authenticated:
|
||
user_timezone = getattr(request.user, 'timezone', None) or 'UTC'
|
||
datetime_fields = ['start_time', 'end_time', 'completed_at', 'created_at', 'updated_at']
|
||
for field in datetime_fields:
|
||
if field in data:
|
||
field_value = getattr(instance, field, None)
|
||
if field_value:
|
||
data[field] = format_datetime_for_user(field_value, user_timezone)
|
||
return data
|
||
|
||
class Meta:
|
||
model = Lesson
|
||
fields = [
|
||
'id', 'mentor', 'mentor_name', 'client', 'client_name', 'group', 'group_name',
|
||
'start_time', 'end_time', 'duration', 'title', 'description',
|
||
'subject', 'subject_name', 'mentor_subject', 'status', 'template', 'template_title',
|
||
'meeting_url', 'mentor_notes', 'homework_text', 'mentor_grade', 'school_grade',
|
||
'price', 'reminder_sent', 'files', 'is_upcoming', 'is_past', 'is_in_progress',
|
||
'can_be_cancelled', 'can_be_rescheduled',
|
||
'is_recurring', 'recurring_series_id', 'parent_lesson',
|
||
'completed_at', 'created_at', 'updated_at',
|
||
'livekit_room_name'
|
||
]
|
||
read_only_fields = [
|
||
'id', 'end_time', 'reminder_sent',
|
||
'created_at', 'updated_at', 'livekit_room_name'
|
||
]
|
||
|
||
def validate_meeting_url(self, value):
|
||
"""Нормализация meeting_url - пустая строка становится None."""
|
||
if value == '':
|
||
return None
|
||
return value
|
||
|
||
def validate(self, attrs):
|
||
"""Валидация данных занятия."""
|
||
# Для завершённых занятий разрешаем менять только price и status
|
||
if self.instance and self.instance.status == 'completed':
|
||
allowed = {'price', 'status'}
|
||
attrs = {k: v for k, v in attrs.items() if k in allowed}
|
||
if 'status' in attrs and attrs['status'] not in ('completed', 'cancelled'):
|
||
raise serializers.ValidationError({
|
||
'status': 'Для завершённого занятия можно только оставить "Завершено" или пометить как "Отменено"'
|
||
})
|
||
return attrs
|
||
|
||
# Нормализуем meeting_url - пустая строка становится None
|
||
if 'meeting_url' in attrs and attrs['meeting_url'] == '':
|
||
attrs['meeting_url'] = None
|
||
|
||
start_time = attrs.get('start_time')
|
||
duration = attrs.get('duration', 60)
|
||
|
||
# Проверка: допускаем создание занятий до 30 минут в прошлом
|
||
now = timezone.now()
|
||
tolerance = timedelta(minutes=30)
|
||
if start_time and start_time < now - tolerance:
|
||
raise serializers.ValidationError({
|
||
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
|
||
})
|
||
|
||
# Проверка конфликтов (только при создании или изменении времени)
|
||
if self.instance is None or 'start_time' in attrs:
|
||
mentor = attrs.get('mentor') or self.instance.mentor if self.instance else None
|
||
client = attrs.get('client') or self.instance.client if self.instance else None
|
||
|
||
if mentor and start_time:
|
||
end_time = start_time + timedelta(minutes=duration)
|
||
|
||
# Проверка конфликтов для ментора
|
||
mentor_conflicts = Lesson.objects.filter(
|
||
mentor=mentor,
|
||
status='scheduled',
|
||
start_time__lt=end_time,
|
||
end_time__gt=start_time
|
||
).exclude(pk=self.instance.pk if self.instance else None)
|
||
|
||
if mentor_conflicts.exists():
|
||
raise serializers.ValidationError({
|
||
'start_time': 'У ментора уже есть занятие в это время'
|
||
})
|
||
|
||
# Проверка конфликтов для клиента
|
||
if client:
|
||
client_conflicts = Lesson.objects.filter(
|
||
client=client,
|
||
status='scheduled',
|
||
start_time__lt=end_time,
|
||
end_time__gt=start_time
|
||
).exclude(pk=self.instance.pk if self.instance else None)
|
||
|
||
if client_conflicts.exists():
|
||
raise serializers.ValidationError({
|
||
'start_time': 'У клиента уже есть занятие в это время'
|
||
})
|
||
|
||
return attrs
|
||
|
||
|
||
class LessonDetailSerializer(LessonSerializer):
|
||
"""Детальный сериализатор для занятия с полной информацией."""
|
||
|
||
mentor = UserSerializer(read_only=True)
|
||
client = ClientSerializer(read_only=True)
|
||
template = LessonTemplateSerializer(read_only=True)
|
||
cancelled_by_name = serializers.CharField(
|
||
source='cancelled_by.get_full_name',
|
||
read_only=True,
|
||
allow_null=True
|
||
)
|
||
|
||
class Meta(LessonSerializer.Meta):
|
||
fields = LessonSerializer.Meta.fields + [
|
||
'cancelled_by', 'cancelled_by_name', 'cancellation_reason',
|
||
'cancelled_at', 'rescheduled_from'
|
||
]
|
||
|
||
|
||
class LessonCreateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для создания занятия."""
|
||
|
||
mentor = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||
subject_id = serializers.IntegerField(required=False, allow_null=True, source='subject')
|
||
mentor_subject_id = serializers.IntegerField(required=False, allow_null=True, source='mentor_subject')
|
||
subject_name = serializers.CharField(required=False, allow_blank=True, max_length=100)
|
||
price = serializers.DecimalField(max_digits=10, decimal_places=2, required=True, coerce_to_string=False)
|
||
|
||
class Meta:
|
||
model = Lesson
|
||
fields = [
|
||
'mentor', 'client', 'group', 'start_time', 'duration',
|
||
'title', 'description', 'subject_id', 'mentor_subject_id', 'subject_name', 'template', 'price',
|
||
'is_recurring'
|
||
]
|
||
|
||
def to_internal_value(self, data):
|
||
"""
|
||
Переопределяем для обработки start_time перед валидацией.
|
||
Фронтенд отправляет время в UTC (через .toISOString()), но это время уже
|
||
было конвертировано из локального времени браузера в UTC. Нам нужно
|
||
интерпретировать это время как локальное время пользователя (из его профиля)
|
||
и конвертировать в UTC для сохранения.
|
||
|
||
Пример:
|
||
- Пользователь с UTC+4 вводит "11.01.2025 22:15" в input
|
||
- Фронтенд конвертирует в UTC: "2025-01-11T18:15:00Z" (22:15 - 4 = 18:15)
|
||
- Бэкенд должен интерпретировать "18:15 UTC" как "22:15 UTC+4" и сохранить как "18:15 UTC"
|
||
- Но это неправильно! Нужно интерпретировать "18:15 UTC" как "18:15 UTC+4" = "14:15 UTC"
|
||
|
||
Правильный подход:
|
||
- Фронтенд отправляет время в UTC, но мы должны знать, что это время было
|
||
введено пользователем в его локальном часовом поясе
|
||
- Поэтому мы берем UTC время, интерпретируем его как локальное время пользователя,
|
||
и конвертируем обратно в UTC
|
||
"""
|
||
# Получаем часовой пояс пользователя из request
|
||
request = self.context.get('request')
|
||
user_timezone = 'UTC'
|
||
if request and hasattr(request, 'user') and request.user.is_authenticated:
|
||
user_timezone = request.user.timezone or 'UTC'
|
||
|
||
# Если start_time приходит как строка ISO в UTC (с 'Z' в конце)
|
||
# Фронтенд отправляет время в UTC, но это время было конвертировано из локального времени браузера.
|
||
# Нам нужно интерпретировать это время как локальное время пользователя (из профиля) и конвертировать в UTC.
|
||
if 'start_time' in data and isinstance(data['start_time'], str):
|
||
try:
|
||
# Парсим ISO строку
|
||
dt_str = data['start_time'].replace('Z', '+00:00')
|
||
dt_parsed = datetime.fromisoformat(dt_str)
|
||
|
||
# Конвертируем в pytz.UTC для единообразия
|
||
if dt_parsed.tzinfo is None:
|
||
# Если naive, интерпретируем как UTC
|
||
dt_utc = pytz.UTC.localize(dt_parsed)
|
||
elif dt_parsed.tzinfo == pytz.UTC:
|
||
dt_utc = dt_parsed
|
||
else:
|
||
# Если другой timezone (например, timezone.utc из стандартной библиотеки), конвертируем в pytz.UTC
|
||
dt_utc = dt_parsed.astimezone(pytz.UTC)
|
||
|
||
# Фронтенд уже правильно конвертировал время из локального времени браузера в UTC
|
||
# Просто сохраняем как есть, не делаем двойную конвертацию
|
||
data['start_time'] = dt_utc.isoformat()
|
||
except (ValueError, AttributeError, TypeError) as e:
|
||
# Если не удалось распарсить, оставляем как есть
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.warning(f"Error parsing start_time: {e}, original value: {data.get('start_time')}")
|
||
|
||
return super().to_internal_value(data)
|
||
|
||
def validate_price(self, value):
|
||
"""Валидация стоимости - должно быть больше 0."""
|
||
if value is None:
|
||
raise serializers.ValidationError('Стоимость обязательна для указания')
|
||
if value <= 0:
|
||
raise serializers.ValidationError('Стоимость должна быть больше 0')
|
||
return value
|
||
|
||
def validate_start_time(self, value):
|
||
"""
|
||
Дополнительная валидация start_time.
|
||
Убеждаемся, что время в UTC и aware.
|
||
"""
|
||
if value is None:
|
||
return value
|
||
|
||
# Если время уже aware (с timezone), проверяем, нужно ли конвертировать
|
||
if django_timezone.is_aware(value):
|
||
# Если timezone не UTC, конвертируем в UTC
|
||
if value.tzinfo != pytz.UTC:
|
||
return value.astimezone(pytz.UTC)
|
||
return value
|
||
|
||
# Если время naive (без timezone), интерпретируем его как UTC
|
||
try:
|
||
return pytz.UTC.localize(value)
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.warning(f"Error converting start_time to UTC: {e}")
|
||
return value
|
||
|
||
def validate(self, attrs):
|
||
"""Валидация при создании."""
|
||
start_time = attrs.get('start_time')
|
||
duration = attrs.get('duration', 60)
|
||
mentor = attrs.get('mentor')
|
||
client = attrs.get('client')
|
||
|
||
# Проверка что указан либо subject_id, либо mentor_subject_id, либо subject_name
|
||
# subject_id и mentor_subject_id приходят через source='subject' и source='mentor_subject'
|
||
# поэтому они попадают в attrs как числа (ID), а не как экземпляры
|
||
subject_id = attrs.get('subject') # Это будет ID из-за source='subject'
|
||
mentor_subject_id = attrs.get('mentor_subject') # Это будет ID из-за source='mentor_subject'
|
||
subject_name = attrs.get('subject_name', '')
|
||
|
||
# Очищаем attrs от ID, чтобы установить правильные экземпляры
|
||
if 'subject' in attrs and isinstance(attrs['subject'], (int, str)):
|
||
# Сохраняем ID для дальнейшей обработки
|
||
subject_id = int(attrs['subject']) if isinstance(attrs['subject'], str) else attrs['subject']
|
||
attrs.pop('subject') # Удаляем ID из attrs
|
||
|
||
if 'mentor_subject' in attrs and isinstance(attrs['mentor_subject'], (int, str)):
|
||
# Сохраняем ID для дальнейшей обработки
|
||
mentor_subject_id = int(attrs['mentor_subject']) if isinstance(attrs['mentor_subject'], str) else attrs['mentor_subject']
|
||
attrs.pop('mentor_subject') # Удаляем ID из attrs
|
||
|
||
if not subject_id and not mentor_subject_id and not subject_name:
|
||
raise serializers.ValidationError({
|
||
'subject': 'Необходимо указать предмет'
|
||
})
|
||
|
||
# Если указан subject_id, проверяем что предмет существует и активен
|
||
if subject_id:
|
||
try:
|
||
from .models import Subject
|
||
subject = Subject.objects.get(id=subject_id, is_active=True)
|
||
attrs['subject'] = subject # Устанавливаем экземпляр модели
|
||
attrs['subject_name'] = subject.name
|
||
except Subject.DoesNotExist:
|
||
raise serializers.ValidationError({
|
||
'subject_id': 'Предмет не найден или неактивен'
|
||
})
|
||
|
||
# Если указан mentor_subject_id, проверяем что он принадлежит ментору
|
||
if mentor_subject_id:
|
||
try:
|
||
from .models import MentorSubject
|
||
mentor_subject = MentorSubject.objects.get(id=mentor_subject_id, mentor=mentor)
|
||
attrs['mentor_subject'] = mentor_subject # Устанавливаем экземпляр модели
|
||
attrs['subject_name'] = mentor_subject.name
|
||
# Увеличиваем счетчик использования
|
||
mentor_subject.increment_usage()
|
||
except MentorSubject.DoesNotExist:
|
||
raise serializers.ValidationError({
|
||
'mentor_subject_id': 'Кастомный предмет не найден или не принадлежит вам'
|
||
})
|
||
|
||
# Если указан только subject_name (кастомный предмет), создаем MentorSubject
|
||
if subject_name and not subject_id and not mentor_subject_id:
|
||
from .models import MentorSubject
|
||
# Проверяем, нет ли уже такого предмета у ментора
|
||
existing = MentorSubject.objects.filter(
|
||
mentor=mentor,
|
||
name__iexact=subject_name.strip()
|
||
).first()
|
||
|
||
if existing:
|
||
# Используем существующий
|
||
attrs['mentor_subject'] = existing
|
||
attrs['subject_name'] = existing.name
|
||
existing.increment_usage()
|
||
else:
|
||
# Создаем новый
|
||
mentor_subject = MentorSubject.objects.create(
|
||
mentor=mentor,
|
||
name=subject_name.strip()
|
||
)
|
||
attrs['mentor_subject'] = mentor_subject
|
||
attrs['subject_name'] = mentor_subject.name
|
||
|
||
# Проверка: допускаем создание занятий до 30 минут в прошлом
|
||
if start_time:
|
||
if not django_timezone.is_aware(start_time):
|
||
start_time = pytz.UTC.localize(start_time)
|
||
elif start_time.tzinfo != pytz.UTC:
|
||
start_time = start_time.astimezone(pytz.UTC)
|
||
|
||
now = django_timezone.now()
|
||
tolerance = timedelta(minutes=30)
|
||
if start_time < now - tolerance:
|
||
raise serializers.ValidationError({
|
||
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
|
||
})
|
||
|
||
# Рассчитываем время окончания
|
||
end_time = start_time + timedelta(minutes=duration)
|
||
|
||
# Проверка конфликтов для ментора
|
||
if mentor:
|
||
mentor_conflicts = Lesson.objects.filter(
|
||
mentor=mentor,
|
||
status__in=['scheduled', 'in_progress']
|
||
).filter(
|
||
start_time__lt=end_time,
|
||
end_time__gt=start_time
|
||
)
|
||
|
||
# Исключаем текущее занятие при редактировании
|
||
if self.instance:
|
||
mentor_conflicts = mentor_conflicts.exclude(id=self.instance.id)
|
||
|
||
if mentor_conflicts.exists():
|
||
conflict = mentor_conflicts.first()
|
||
raise serializers.ValidationError({
|
||
'start_time': f'У вас уже есть занятие в это время: "{conflict.title}" ({conflict.start_time.strftime("%H:%M")} - {conflict.end_time.strftime("%H:%M")})'
|
||
})
|
||
|
||
# Проверка конфликтов для студента
|
||
if client:
|
||
client_conflicts = Lesson.objects.filter(
|
||
client=client,
|
||
status__in=['scheduled', 'in_progress']
|
||
).filter(
|
||
start_time__lt=end_time,
|
||
end_time__gt=start_time
|
||
)
|
||
|
||
# Исключаем текущее занятие при редактировании
|
||
if self.instance:
|
||
client_conflicts = client_conflicts.exclude(id=self.instance.id)
|
||
|
||
if client_conflicts.exists():
|
||
conflict = client_conflicts.first()
|
||
raise serializers.ValidationError({
|
||
'start_time': f'У студента уже есть занятие в это время: "{conflict.title}" ({conflict.start_time.strftime("%H:%M")} - {conflict.end_time.strftime("%H:%M")})'
|
||
})
|
||
|
||
return attrs
|
||
|
||
|
||
class LessonCancelSerializer(serializers.Serializer):
|
||
"""Сериализатор для отмены занятия."""
|
||
|
||
cancellation_reason = serializers.CharField(
|
||
required=False,
|
||
allow_blank=True,
|
||
max_length=500
|
||
)
|
||
|
||
|
||
class LessonRescheduleSerializer(serializers.Serializer):
|
||
"""Сериализатор для переноса занятия."""
|
||
|
||
new_start_time = serializers.DateTimeField(required=True)
|
||
|
||
def validate_new_start_time(self, value):
|
||
"""Проверка нового времени."""
|
||
if value <= timezone.now():
|
||
raise serializers.ValidationError(
|
||
'Новое время должно быть в будущем'
|
||
)
|
||
return value
|
||
|
||
|
||
class TimeSlotSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для временного слота."""
|
||
|
||
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
||
lesson_title = serializers.CharField(source='lesson.title', read_only=True, allow_null=True)
|
||
|
||
class Meta:
|
||
model = TimeSlot
|
||
fields = [
|
||
'id', 'mentor', 'mentor_name', 'start_time', 'end_time',
|
||
'is_available', 'is_booked', 'lesson', 'lesson_title',
|
||
'is_recurring', 'recurring_day', 'created_at', 'updated_at'
|
||
]
|
||
read_only_fields = ['id', 'is_booked', 'lesson', 'created_at', 'updated_at']
|
||
|
||
|
||
class AvailabilitySerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для доступности."""
|
||
|
||
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
||
day_name = serializers.CharField(source='get_day_of_week_display', read_only=True, allow_null=True)
|
||
|
||
class Meta:
|
||
model = Availability
|
||
fields = [
|
||
'id', 'mentor', 'mentor_name', 'day_of_week', 'day_name',
|
||
'specific_date', 'start_time', 'end_time', 'is_recurring',
|
||
'is_active', 'exception_dates', 'notes',
|
||
'created_at', 'updated_at'
|
||
]
|
||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||
|
||
def validate(self, attrs):
|
||
"""Валидация доступности."""
|
||
is_recurring = attrs.get('is_recurring', True)
|
||
day_of_week = attrs.get('day_of_week')
|
||
specific_date = attrs.get('specific_date')
|
||
start_time = attrs.get('start_time')
|
||
end_time = attrs.get('end_time')
|
||
|
||
# Проверка что указан либо день недели, либо конкретная дата
|
||
if is_recurring:
|
||
if day_of_week is None:
|
||
raise serializers.ValidationError({
|
||
'day_of_week': 'Укажите день недели для повторяющейся доступности'
|
||
})
|
||
else:
|
||
if specific_date is None:
|
||
raise serializers.ValidationError({
|
||
'specific_date': 'Укажите конкретную дату для разовой доступности'
|
||
})
|
||
|
||
# Проверка времени
|
||
if start_time and end_time and start_time >= end_time:
|
||
raise serializers.ValidationError({
|
||
'end_time': 'Время окончания должно быть позже времени начала'
|
||
})
|
||
|
||
return attrs
|
||
|
||
|
||
class LessonFileSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для файлов уроков."""
|
||
|
||
file_url = serializers.SerializerMethodField()
|
||
file_size_display = serializers.SerializerMethodField()
|
||
uploaded_by_name = serializers.CharField(source='uploaded_by.get_full_name', read_only=True)
|
||
|
||
class Meta:
|
||
model = LessonFile
|
||
fields = [
|
||
'id', 'lesson', 'file', 'material', 'source', 'filename',
|
||
'file_size', 'file_size_display', 'file_url', 'description',
|
||
'uploaded_by', 'uploaded_by_name', 'created_at'
|
||
]
|
||
read_only_fields = ['id', 'uploaded_by', 'created_at']
|
||
|
||
def get_file_url(self, obj):
|
||
"""Получить URL файла."""
|
||
return obj.get_file_url()
|
||
|
||
def get_file_size_display(self, obj):
|
||
"""Отформатированный размер файла."""
|
||
if obj.file_size:
|
||
size = obj.file_size
|
||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||
if size < 1024.0:
|
||
return f"{size:.1f} {unit}"
|
||
size /= 1024.0
|
||
return f"{size:.1f} TB"
|
||
return '0 B'
|
||
|
||
|
||
class LessonFileCreateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для создания файла урока."""
|
||
|
||
# Разрешаем не передавать filename/file_size в запросе - они будут заполнены автоматически
|
||
filename = serializers.CharField(required=False, allow_blank=True)
|
||
file_size = serializers.IntegerField(required=False, allow_null=True)
|
||
|
||
class Meta:
|
||
model = LessonFile
|
||
fields = [
|
||
'id', 'lesson', 'file', 'material', 'source', 'filename',
|
||
'file_size', 'description'
|
||
]
|
||
read_only_fields = ['id']
|
||
|
||
def validate(self, attrs):
|
||
"""Валидация: должен быть либо file, либо material."""
|
||
file = attrs.get('file')
|
||
material = attrs.get('material')
|
||
|
||
if not file and not material:
|
||
raise serializers.ValidationError(
|
||
'Необходимо указать либо файл для загрузки, либо материал из библиотеки'
|
||
)
|
||
|
||
if file and material:
|
||
raise serializers.ValidationError(
|
||
'Нельзя указать одновременно файл и материал'
|
||
)
|
||
|
||
# Если файл загружается, проверяем размер
|
||
if file:
|
||
max_size = 10 * 1024 * 1024 # 10 MB
|
||
if file.size > max_size:
|
||
raise serializers.ValidationError(
|
||
f'Размер файла не должен превышать 10 МБ. Текущий размер: {file.size / (1024*1024):.2f} МБ'
|
||
)
|
||
|
||
# Сохраняем размер и имя файла
|
||
attrs['file_size'] = file.size
|
||
if not attrs.get('filename'):
|
||
attrs['filename'] = file.name
|
||
attrs['source'] = 'uploaded'
|
||
else:
|
||
# Если выбран материал, берем данные из него
|
||
if not attrs.get('filename'):
|
||
attrs['filename'] = material.title if material else 'Материал'
|
||
if material and material.file:
|
||
attrs['file_size'] = material.file.size
|
||
attrs['source'] = 'material'
|
||
|
||
return attrs
|
||
|
||
|
||
class LessonCalendarSerializer(serializers.Serializer):
|
||
"""Сериализатор для календаря занятий."""
|
||
|
||
start_date = serializers.DateField(required=True)
|
||
end_date = serializers.DateField(required=True)
|
||
mentor_id = serializers.IntegerField(required=False)
|
||
client_id = serializers.IntegerField(required=False)
|
||
status = serializers.ChoiceField(
|
||
choices=['scheduled', 'in_progress', 'completed', 'cancelled', 'rescheduled'],
|
||
required=False
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
"""Валидация диапазона дат."""
|
||
start_date = attrs.get('start_date')
|
||
end_date = attrs.get('end_date')
|
||
|
||
if start_date and end_date and start_date > end_date:
|
||
raise serializers.ValidationError({
|
||
'end_date': 'Дата окончания должна быть позже даты начала'
|
||
})
|
||
|
||
# Ограничение диапазона (не более 6 месяцев — для календаря, смена месяцев)
|
||
if start_date and end_date:
|
||
delta = end_date - start_date
|
||
if delta.days > 180:
|
||
raise serializers.ValidationError(
|
||
'Диапазон не может превышать 180 дней'
|
||
)
|
||
|
||
return attrs
|
||
|
||
|
||
class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
||
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
|
||
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
|
||
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
||
subject = serializers.SerializerMethodField()
|
||
|
||
def get_subject(self, obj):
|
||
if obj.subject:
|
||
return obj.subject.name
|
||
if obj.mentor_subject:
|
||
return obj.mentor_subject.name
|
||
return obj.subject_name
|
||
|
||
def to_representation(self, instance):
|
||
data = super().to_representation(instance)
|
||
request = self.context.get('request')
|
||
user_timezone = 'UTC'
|
||
if request and hasattr(request, 'user') and request.user.is_authenticated:
|
||
user_timezone = getattr(request.user, 'timezone', None) or 'UTC'
|
||
for field in ('start_time', 'end_time'):
|
||
val = getattr(instance, field, None)
|
||
if val:
|
||
data[field] = format_datetime_for_user(val, user_timezone)
|
||
return data
|
||
|
||
class Meta:
|
||
model = Lesson
|
||
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'subject', 'subject_name']
|
||
|
||
|
||
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для ответа на ДЗ по уроку."""
|
||
|
||
student_name = serializers.CharField(source='student.get_full_name', read_only=True)
|
||
student_email = serializers.CharField(source='student.email', read_only=True)
|
||
checked_by_name = serializers.CharField(source='checked_by.get_full_name', read_only=True)
|
||
attachment_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = LessonHomeworkSubmission
|
||
fields = [
|
||
'id', 'lesson', 'student', 'student_name', 'student_email',
|
||
'content', 'attachment', 'attachment_url',
|
||
'status', 'score', 'feedback',
|
||
'checked_by', 'checked_by_name', 'checked_at',
|
||
'submitted_at', 'updated_at'
|
||
]
|
||
read_only_fields = ['id', 'submitted_at', 'updated_at', 'checked_at']
|
||
|
||
def get_attachment_url(self, obj):
|
||
"""Получить URL файла."""
|
||
if obj.attachment:
|
||
request = self.context.get('request')
|
||
if request:
|
||
return request.build_absolute_uri(obj.attachment.url)
|
||
return obj.attachment.url
|
||
return None
|
||
|
||
|
||
class LessonHomeworkSubmissionCreateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для создания ответа на ДЗ."""
|
||
|
||
class Meta:
|
||
model = LessonHomeworkSubmission
|
||
fields = ['lesson', 'content', 'attachment']
|
||
|
||
def create(self, validated_data):
|
||
"""Создать ответ на ДЗ."""
|
||
validated_data['student'] = self.context['request'].user
|
||
return super().create(validated_data)
|
||
|
||
|
||
class LessonHomeworkSubmissionGradeSerializer(serializers.Serializer):
|
||
"""Сериализатор для оценки ответа на ДЗ."""
|
||
|
||
score = serializers.IntegerField(
|
||
required=True,
|
||
min_value=0,
|
||
max_value=100,
|
||
help_text='Оценка от 0 до 100'
|
||
)
|
||
feedback = serializers.CharField(
|
||
required=False,
|
||
allow_blank=True,
|
||
help_text='Отзыв ментора'
|
||
)
|
||
|
||
|
||
class SubjectSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для предмета."""
|
||
|
||
class Meta:
|
||
model = Subject
|
||
fields = ['id', 'name', 'is_active', 'created_at', 'updated_at']
|
||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||
|
||
|
||
class MentorSubjectSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для кастомного предмета ментора."""
|
||
|
||
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
||
|
||
class Meta:
|
||
model = MentorSubject
|
||
fields = ['id', 'mentor', 'mentor_name', 'name', 'usage_count', 'created_at', 'updated_at']
|
||
read_only_fields = ['id', 'usage_count', 'created_at', 'updated_at']
|
||
|
||
|
||
class MentorSubjectCreateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для создания кастомного предмета ментора."""
|
||
|
||
mentor = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||
|
||
class Meta:
|
||
model = MentorSubject
|
||
fields = ['mentor', 'name']
|
||
|
||
def validate_name(self, value):
|
||
"""Валидация названия предмета."""
|
||
if not value or not value.strip():
|
||
raise serializers.ValidationError('Название предмета не может быть пустым')
|
||
return value.strip()
|
||
|
||
def validate(self, attrs):
|
||
"""Проверка, что у ментора еще нет такого предмета."""
|
||
mentor = attrs.get('mentor')
|
||
name = attrs.get('name')
|
||
|
||
if mentor and name:
|
||
existing = MentorSubject.objects.filter(
|
||
mentor=mentor,
|
||
name__iexact=name
|
||
).first()
|
||
|
||
if existing:
|
||
raise serializers.ValidationError({
|
||
'name': 'У вас уже есть предмет с таким названием'
|
||
})
|
||
|
||
return attrs
|