From 9382eab7b2347fb44c0ce27bd1387f6777505484 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 23 Feb 2026 15:44:27 +0300 Subject: [PATCH] fix bugs --- backend/apps/chat/serializers.py | 1160 +++---- backend/apps/chat/services.py | 222 +- backend/apps/notifications/tasks.py | 100 + backend/apps/notifications/telegram_bot.py | 44 + .../0011_add_mentor_client_connected_at.py | 32 + backend/apps/schedule/models.py | 14 + backend/apps/schedule/serializers.py | 28 +- backend/apps/schedule/signals.py | 190 +- backend/apps/schedule/tasks.py | 7 + backend/apps/users/geo_data/cities_ru.json | 21 +- backend/apps/users/profile_views.py | 17 +- backend/apps/video/livekit_views.py | 56 + backend/apps/video/models.py | 25 +- backend/apps/video/urls.py | 5 +- docker-compose.yml | 15 +- docker/nginx/nginx.conf | 6 - etc/docker/daemon.json.example | 13 + front_material/.dockerignore | 8 + front_material/api/homework.ts | 634 ++-- front_material/api/livekit.ts | 7 + front_material/api/schedule.ts | 2 + .../app/(auth)/forgot-password/page.tsx | 3 +- front_material/app/(auth)/register/page.tsx | 10 +- .../app/(auth)/reset-password/page.tsx | 7 +- .../app/(auth)/verify-email/page.tsx | 7 +- .../app/(protected)/schedule/page.tsx | 82 +- front_material/components/chat/ChatWindow.tsx | 13 +- .../components/checklesson/checklesson.tsx | 90 +- .../dashboard/CreateLessonDialog.tsx | 10 +- .../components/dashboard/ui/FlipCard.tsx | 64 +- .../homework/EditHomeworkDraftModal.tsx | 1436 ++++----- .../homework/HomeworkDetailsModal.tsx | 2820 ++++++++--------- .../components/livekit/LiveKitRoomContent.tsx | 13 + front_material/styles/globals.css | 16 +- front_material/utils/timezone.ts | 402 +-- 35 files changed, 3967 insertions(+), 3612 deletions(-) create mode 100644 backend/apps/schedule/migrations/0011_add_mentor_client_connected_at.py create mode 100644 etc/docker/daemon.json.example diff --git a/backend/apps/chat/serializers.py b/backend/apps/chat/serializers.py index a0ffe58..76a7833 100644 --- a/backend/apps/chat/serializers.py +++ b/backend/apps/chat/serializers.py @@ -1,580 +1,580 @@ -""" -Сериализаторы для чата и сообщений. -""" -from rest_framework import serializers -from django.db import models -from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction -from .services import ChatService -from apps.users.serializers import UserSerializer -from apps.users.mixins import TimezoneAwareSerializerMixin -from apps.users.utils import format_datetime_for_user - - -class ChatParticipantSerializer(serializers.ModelSerializer): - """Сериализатор участника чата.""" - - user = UserSerializer(read_only=True) - - class Meta: - model = ChatParticipant - fields = [ - 'id', - 'user', - 'role', - 'unread_count', - 'last_read_at', - 'is_muted', - 'is_pinned', - 'joined_at' - ] - read_only_fields = ['unread_count', 'last_read_at', 'joined_at'] - - -class MessageFileSerializer(serializers.ModelSerializer): - """Сериализатор файла сообщения.""" - - file = serializers.SerializerMethodField() - - class Meta: - model = MessageFile - fields = [ - 'id', - 'file', - 'file_name', - 'file_size', - 'file_type', - 'created_at' - ] - read_only_fields = ['file_name', 'file_size', 'file_type', 'created_at'] - - def get_file(self, obj): - """Получить полный URL файла.""" - request = self.context.get('request') - if request and obj.file: - return request.build_absolute_uri(obj.file.url) - elif obj.file: - # Если нет request, возвращаем относительный URL - return obj.file.url - return None - - -class MessageReactionSerializer(serializers.ModelSerializer): - """Сериализатор реакции на сообщение.""" - - user = UserSerializer(read_only=True) - - class Meta: - model = MessageReaction - fields = ['id', 'user', 'emoji', 'created_at'] - read_only_fields = ['user', 'created_at'] - - -class MessageSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer): - """Сериализатор сообщения.""" - - sender = UserSerializer(read_only=True) - files = MessageFileSerializer(many=True, read_only=True) - reactions = MessageReactionSerializer(many=True, read_only=True) - reply_to = serializers.SerializerMethodField() - is_read = serializers.SerializerMethodField() - is_read_by_others = serializers.SerializerMethodField() - - class Meta: - model = Message - fields = [ - 'id', - 'uuid', - 'chat', - 'sender', - 'message_type', - 'content', - 'reply_to', - 'files', - 'reactions', - 'is_edited', - 'edited_at', - 'is_deleted', - 'is_read', - 'is_read_by_others', - 'created_at' - ] - read_only_fields = [ - 'uuid', - 'sender', - 'is_edited', - 'edited_at', - 'is_deleted', - 'created_at' - ] - timezone_aware_fields = ['created_at', 'edited_at'] - - def get_reply_to(self, obj): - """Получить сообщение, на которое отвечают.""" - if obj.reply_to: - return { - 'uuid': str(obj.reply_to.uuid), - 'sender': obj.reply_to.sender.get_full_name() if obj.reply_to.sender else 'System', - 'content': obj.reply_to.content[:100] - } - return None - - def get_is_read(self, obj): - """Проверка прочитано ли сообщение текущим пользователем.""" - request = self.context.get('request') - if request and request.user.is_authenticated: - # Оптимизация: используем предзагруженные reads если доступны - if hasattr(obj, '_prefetched_objects_cache') and 'reads' in obj._prefetched_objects_cache: - reads = obj._prefetched_objects_cache['reads'] - return any(read.user_id == request.user.id for read in reads) - - # Fallback на запрос, если prefetch не был выполнен - return MessageRead.objects.filter( - message=obj, - user=request.user - ).exists() - return False - - def get_is_read_by_others(self, obj): - """Проверка прочитано ли сообщение другими участниками чата (для отображения статуса прочитанности).""" - request = self.context.get('request') - if not request or not request.user.is_authenticated: - return False - - # Проверяем только для сообщений, отправленных текущим пользователем - if obj.sender_id != request.user.id: - return False - - # Получаем всех участников чата кроме отправителя - chat = obj.chat - other_participants = chat.participants.exclude(user_id=obj.sender_id) - - if not other_participants.exists(): - return False - - # Проверяем, прочитано ли сообщение хотя бы одним другим участником - # Оптимизация: используем предзагруженные reads если доступны - if hasattr(obj, '_prefetched_objects_cache') and 'reads' in obj._prefetched_objects_cache: - reads = obj._prefetched_objects_cache['reads'] - other_participant_ids = set(other_participants.values_list('user_id', flat=True)) - return any(read.user_id in other_participant_ids for read in reads) - - # Fallback на запрос - other_participant_ids = list(other_participants.values_list('user_id', flat=True)) - return MessageRead.objects.filter( - message=obj, - user_id__in=other_participant_ids - ).exists() - - -class MessageCreateSerializer(serializers.ModelSerializer): - """Сериализатор создания сообщения.""" - - reply_to_uuid = serializers.UUIDField(required=False, allow_null=True) - content = serializers.CharField(required=False, allow_blank=True) - files = serializers.ListField( - child=serializers.FileField(), - required=False, - allow_empty=True - ) - preloaded_files = serializers.CharField( - required=False, - allow_blank=True, - help_text="JSON строка со списком предзагруженных файлов: [{'filename': 'uuid.ext', 'original_name': 'name.ext', 'size': 1234, 'content_type': 'image/jpeg'}]" - ) - - class Meta: - model = Message - fields = ['chat', 'content', 'message_type', 'reply_to_uuid', 'files', 'preloaded_files'] - - def validate(self, attrs): - """Валидация.""" - # Проверяем доступ к чату - request = self.context['request'] - chat = attrs['chat'] - - if not ChatParticipant.objects.filter(chat=chat, user=request.user).exists(): - raise serializers.ValidationError({ - 'chat': 'У вас нет доступа к этому чату' - }) - - # Проверяем, что есть либо content, либо файлы - content = attrs.get('content', '').strip() - files = attrs.get('files', []) - preloaded_files = attrs.get('preloaded_files', []) - - # Парсим preloaded_files если это строка - if isinstance(preloaded_files, str): - import json - try: - preloaded_files = json.loads(preloaded_files) - except json.JSONDecodeError: - preloaded_files = [] - - has_content = bool(content) - has_files = bool(files) or bool(preloaded_files) - - if not has_content and not has_files: - raise serializers.ValidationError({ - 'content': 'Сообщение не может быть пустым. Укажите текст или прикрепите файлы.' - }) - - # Проверяем reply_to - reply_to_uuid = attrs.pop('reply_to_uuid', None) - if reply_to_uuid: - try: - attrs['reply_to'] = Message.objects.get(uuid=reply_to_uuid, chat=chat) - except Message.DoesNotExist: - raise serializers.ValidationError({ - 'reply_to_uuid': 'Сообщение не найдено' - }) - - # Устанавливаем пустую строку для content если его нет - if not content: - attrs['content'] = '' - - return attrs - - def create(self, validated_data): - """Создание сообщения.""" - files_data = validated_data.pop('files', []) - preloaded_files_data = validated_data.pop('preloaded_files', '') - user = self.context['request'].user - chat = validated_data['chat'] - - message = Message.objects.create( - sender=user, - **validated_data - ) - - # Обрабатываем предзагруженные файлы - from .utils import move_file_from_preload_to_chat - from django.core.files import File - from django.conf import settings - import os - import json - - # Парсим JSON строку - preloaded_files_list = [] - if preloaded_files_data: - if isinstance(preloaded_files_data, str): - try: - parsed = json.loads(preloaded_files_data) - if isinstance(parsed, list): - preloaded_files_list = [item for item in parsed if isinstance(item, dict)] - except json.JSONDecodeError: - preloaded_files_list = [] - elif isinstance(preloaded_files_data, list): - preloaded_files_list = [item for item in preloaded_files_data if isinstance(item, dict)] - - for preloaded_file in preloaded_files_list: - try: - filename = preloaded_file.get('filename') - original_name = preloaded_file.get('original_name', filename) - file_size = preloaded_file.get('size', 0) - content_type = preloaded_file.get('content_type', 'application/octet-stream') - - # Перемещаем файл из preload в основную директорию - new_file_path = move_file_from_preload_to_chat(chat.id, filename) - - # Создаем запись MessageFile - full_path = os.path.join(settings.MEDIA_ROOT, new_file_path) - with open(full_path, 'rb') as f: - django_file = File(f, name=os.path.basename(new_file_path)) - MessageFile.objects.create( - message=message, - file=django_file, - file_name=original_name, - file_size=file_size, - file_type=content_type - ) - except Exception as e: - # Логируем ошибку, но не прерываем создание сообщения - import logging - logger = logging.getLogger(__name__) - logger.error(f"Ошибка при обработке предзагруженного файла {preloaded_file}: {e}") - - # Обрабатываем файлы, загруженные напрямую (для обратной совместимости) - for file in files_data: - MessageFile.objects.create( - message=message, - file=file, - file_name=file.name, - file_size=file.size, - file_type=file.content_type - ) - - return message - - -class ChatSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer): - """Сериализатор чата (список).""" - - created_by = UserSerializer(read_only=True) - last_message = serializers.SerializerMethodField() - my_participant = serializers.SerializerMethodField() - participants_count = serializers.SerializerMethodField() - other_participant = serializers.SerializerMethodField() - - class Meta: - model = Chat - fields = [ - 'id', - 'uuid', - 'chat_type', - 'name', - 'description', - 'avatar', - 'created_by', - 'lesson', - 'participants_count', - 'last_message', - 'my_participant', - 'other_participant', - 'messages_count', - 'last_message_at', - 'is_archived', - 'created_at' - ] - read_only_fields = [ - 'uuid', - 'created_by', - 'messages_count', - 'last_message_at', - 'created_at' - ] - timezone_aware_fields = ['created_at', 'last_message_at'] - - def get_participants_count(self, obj): - """Количество участников.""" - # Оптимизация: используем предзагруженные participants если доступны - if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache: - return len(obj._prefetched_objects_cache['participants']) - return obj.participants.count() - - def get_last_message(self, obj): - """Получить последнее сообщение.""" - # Оптимизация: используем предзагруженные messages если доступны - if hasattr(obj, '_prefetched_objects_cache') and 'messages' in obj._prefetched_objects_cache: - messages = [m for m in obj._prefetched_objects_cache['messages'] if not m.is_deleted] - last_message = messages[0] if messages else None - else: - last_message = obj.messages.filter(is_deleted=False).select_related('sender').prefetch_related('files', 'reads').first() - - if last_message: - # Получаем информацию о файлах - files_data = [] - if hasattr(last_message, '_prefetched_objects_cache') and 'files' in last_message._prefetched_objects_cache: - files = last_message._prefetched_objects_cache['files'] - else: - files = last_message.files.all() - - for file in files: - files_data.append({ - 'id': file.id, - 'file_name': file.file_name, - 'file_type': file.file_type, - 'file_size': file.file_size - }) - - # Проверяем, прочитано ли сообщение другими участниками (для отображения статуса) - is_read_by_others = False - request = self.context.get('request') - if request and request.user.is_authenticated and last_message.sender_id == request.user.id: - # Получаем всех участников чата кроме отправителя - other_participants = obj.participants.exclude(user_id=last_message.sender_id) - if other_participants.exists(): - # Проверяем, прочитано ли сообщение хотя бы одним другим участником - if hasattr(last_message, '_prefetched_objects_cache') and 'reads' in last_message._prefetched_objects_cache: - reads = last_message._prefetched_objects_cache['reads'] - other_participant_ids = set(other_participants.values_list('user_id', flat=True)) - is_read_by_others = any(read.user_id in other_participant_ids for read in reads) - else: - other_participant_ids = list(other_participants.values_list('user_id', flat=True)) - is_read_by_others = MessageRead.objects.filter( - message=last_message, - user_id__in=other_participant_ids - ).exists() - - return { - 'uuid': str(last_message.uuid), - 'sender': last_message.sender.get_full_name() if last_message.sender else 'System', - 'sender_id': last_message.sender_id, - 'content': last_message.content[:100] if last_message.content else '', - 'message_type': last_message.message_type, - 'files': files_data, - 'is_read_by_others': is_read_by_others, - 'created_at': format_datetime_for_user(last_message.created_at, request.user.timezone) if last_message.created_at and request and request.user.is_authenticated else (last_message.created_at.isoformat() if last_message.created_at else None) - } - return None - - def get_my_participant(self, obj): - """Получить данные участника для текущего пользователя.""" - request = self.context.get('request') - if request and request.user.is_authenticated: - # Оптимизация: используем предзагруженные participants если доступны - if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache: - participants = obj._prefetched_objects_cache['participants'] - participant = next((p for p in participants if p.user_id == request.user.id), None) - else: - try: - participant = obj.participants.get(user=request.user) - except ChatParticipant.DoesNotExist: - participant = None - - if participant: - return { - 'unread_count': participant.unread_count, - 'is_muted': participant.is_muted, - 'is_pinned': participant.is_pinned - } - return None - - def get_other_participant(self, obj): - """Получить информацию о собеседнике для личных чатов.""" - if obj.chat_type != 'direct': - return None - - request = self.context.get('request') - if not request or not request.user.is_authenticated: - return None - - # Оптимизация: используем предзагруженные participants если доступны - if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache: - participants = obj._prefetched_objects_cache['participants'] - other_participant = next((p for p in participants if p.user_id != request.user.id), None) - else: - other_participant = obj.participants.exclude(user=request.user).select_related('user').first() - - if other_participant and other_participant.user: - from datetime import timedelta - from django.utils import timezone - - user = other_participant.user - # КРИТИЧНО: Обновляем объект пользователя из базы данных, чтобы получить актуальное значение last_activity - # Это необходимо, так как middleware мог обновить last_activity после загрузки объекта - try: - user.refresh_from_db(fields=['last_activity']) - except Exception: - # Если не удалось обновить, используем текущее значение - pass - - # Определяем статус онлайн (активен в последние 15 минут) - # Интервал 15 минут для определения онлайн статуса - is_online = False - if user.last_activity: - time_diff = timezone.now() - user.last_activity - # Пользователь считается онлайн если активен в последние 15 минут - is_online = time_diff.total_seconds() < 900 # 15 минут = 900 секунд - # Если last_activity отсутствует, пользователь точно не онлайн - - # Получаем полный URL аватара - avatar_url = None - if user.avatar: - request = self.context.get('request') - if request: - avatar_url = request.build_absolute_uri(user.avatar.url) - else: - avatar_url = user.avatar.url - - return { - 'id': user.id, - 'email': user.email, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'full_name': user.get_full_name() or user.email, - 'avatar': avatar_url, - 'role': user.role, - 'is_online': is_online, - 'last_activity': format_datetime_for_user(user.last_activity, request.user.timezone) if user.last_activity and request and request.user.is_authenticated else (user.last_activity.isoformat() if user.last_activity else None) - } - return None - - -class ChatCreateSerializer(serializers.ModelSerializer): - """Сериализатор создания чата.""" - - participant_ids = serializers.ListField( - child=serializers.IntegerField(), - required=True, - allow_empty=False - ) - - class Meta: - model = Chat - fields = ['chat_type', 'name', 'description', 'avatar', 'participant_ids'] - - def validate_participant_ids(self, value): - """Валидация участников.""" - from apps.users.models import User - - # Проверяем что все пользователи существуют - users = User.objects.filter(id__in=value) - if users.count() != len(value): - raise serializers.ValidationError('Некоторые пользователи не найдены') - - return value - - def validate(self, attrs): - """Валидация.""" - # Для личного чата нужно ровно 2 участника (текущий + 1) - if attrs['chat_type'] == 'direct': - if len(attrs['participant_ids']) != 1: - raise serializers.ValidationError({ - 'participant_ids': 'Для личного чата нужен ровно 1 участник' - }) - - return attrs - - def create(self, validated_data): - """Создание чата.""" - from apps.users.models import User - - participant_ids = validated_data.pop('participant_ids') - user = self.context['request'].user - - # Для личного чата используем сервис с защитой от race condition - if validated_data['chat_type'] == 'direct': - other_user = User.objects.get(id=participant_ids[0]) - chat, _ = ChatService.get_or_create_direct_chat( - user1=user, - user2=other_user, - created_by=user - ) - return chat - - # Для группового чата - обычная логика - chat = Chat.objects.create( - created_by=user, - **validated_data - ) - - # Добавляем создателя как участника - ChatParticipant.objects.create( - chat=chat, - user=user, - role='admin' - ) - - # Добавляем остальных участников - users = list(User.objects.filter(id__in=participant_ids)) - participants_to_create = [ - ChatParticipant( - chat=chat, - user=participant_user, - role='member' - ) - for participant_user in users - ] - if participants_to_create: - ChatParticipant.objects.bulk_create(participants_to_create) - - return chat - - -class ChatDetailSerializer(ChatSerializer): - """Детальный сериализатор чата (с участниками).""" - - participants = ChatParticipantSerializer(many=True, read_only=True) - - class Meta(ChatSerializer.Meta): - fields = ChatSerializer.Meta.fields + ['participants'] +""" +Сериализаторы для чата и сообщений. +""" +from rest_framework import serializers +from django.db import models +from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction +from .services import ChatService +from apps.users.serializers import UserSerializer +from apps.users.mixins import TimezoneAwareSerializerMixin +from apps.users.utils import format_datetime_for_user + + +class ChatParticipantSerializer(serializers.ModelSerializer): + """Сериализатор участника чата.""" + + user = UserSerializer(read_only=True) + + class Meta: + model = ChatParticipant + fields = [ + 'id', + 'user', + 'role', + 'unread_count', + 'last_read_at', + 'is_muted', + 'is_pinned', + 'joined_at' + ] + read_only_fields = ['unread_count', 'last_read_at', 'joined_at'] + + +class MessageFileSerializer(serializers.ModelSerializer): + """Сериализатор файла сообщения.""" + + file = serializers.SerializerMethodField() + + class Meta: + model = MessageFile + fields = [ + 'id', + 'file', + 'file_name', + 'file_size', + 'file_type', + 'created_at' + ] + read_only_fields = ['file_name', 'file_size', 'file_type', 'created_at'] + + def get_file(self, obj): + """Получить полный URL файла.""" + request = self.context.get('request') + if request and obj.file: + return request.build_absolute_uri(obj.file.url) + elif obj.file: + # Если нет request, возвращаем относительный URL + return obj.file.url + return None + + +class MessageReactionSerializer(serializers.ModelSerializer): + """Сериализатор реакции на сообщение.""" + + user = UserSerializer(read_only=True) + + class Meta: + model = MessageReaction + fields = ['id', 'user', 'emoji', 'created_at'] + read_only_fields = ['user', 'created_at'] + + +class MessageSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer): + """Сериализатор сообщения.""" + + sender = UserSerializer(read_only=True) + files = MessageFileSerializer(many=True, read_only=True) + reactions = MessageReactionSerializer(many=True, read_only=True) + reply_to = serializers.SerializerMethodField() + is_read = serializers.SerializerMethodField() + is_read_by_others = serializers.SerializerMethodField() + + class Meta: + model = Message + fields = [ + 'id', + 'uuid', + 'chat', + 'sender', + 'message_type', + 'content', + 'reply_to', + 'files', + 'reactions', + 'is_edited', + 'edited_at', + 'is_deleted', + 'is_read', + 'is_read_by_others', + 'created_at' + ] + read_only_fields = [ + 'uuid', + 'sender', + 'is_edited', + 'edited_at', + 'is_deleted', + 'created_at' + ] + timezone_aware_fields = ['created_at', 'edited_at'] + + def get_reply_to(self, obj): + """Получить сообщение, на которое отвечают.""" + if obj.reply_to: + return { + 'uuid': str(obj.reply_to.uuid), + 'sender': obj.reply_to.sender.get_full_name() if obj.reply_to.sender else 'System', + 'content': obj.reply_to.content[:100] + } + return None + + def get_is_read(self, obj): + """Проверка прочитано ли сообщение текущим пользователем.""" + request = self.context.get('request') + if request and request.user.is_authenticated: + # Оптимизация: используем предзагруженные reads если доступны + if hasattr(obj, '_prefetched_objects_cache') and 'reads' in obj._prefetched_objects_cache: + reads = obj._prefetched_objects_cache['reads'] + return any(read.user_id == request.user.id for read in reads) + + # Fallback на запрос, если prefetch не был выполнен + return MessageRead.objects.filter( + message=obj, + user=request.user + ).exists() + return False + + def get_is_read_by_others(self, obj): + """Проверка прочитано ли сообщение другими участниками чата (для отображения статуса прочитанности).""" + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + + # Проверяем только для сообщений, отправленных текущим пользователем + if obj.sender_id != request.user.id: + return False + + # Получаем всех участников чата кроме отправителя + chat = obj.chat + other_participants = chat.participants.exclude(user_id=obj.sender_id) + + if not other_participants.exists(): + return False + + # Проверяем, прочитано ли сообщение хотя бы одним другим участником + # Оптимизация: используем предзагруженные reads если доступны + if hasattr(obj, '_prefetched_objects_cache') and 'reads' in obj._prefetched_objects_cache: + reads = obj._prefetched_objects_cache['reads'] + other_participant_ids = set(other_participants.values_list('user_id', flat=True)) + return any(read.user_id in other_participant_ids for read in reads) + + # Fallback на запрос + other_participant_ids = list(other_participants.values_list('user_id', flat=True)) + return MessageRead.objects.filter( + message=obj, + user_id__in=other_participant_ids + ).exists() + + +class MessageCreateSerializer(serializers.ModelSerializer): + """Сериализатор создания сообщения.""" + + reply_to_uuid = serializers.UUIDField(required=False, allow_null=True) + content = serializers.CharField(required=False, allow_blank=True) + files = serializers.ListField( + child=serializers.FileField(), + required=False, + allow_empty=True + ) + preloaded_files = serializers.CharField( + required=False, + allow_blank=True, + help_text="JSON строка со списком предзагруженных файлов: [{'filename': 'uuid.ext', 'original_name': 'name.ext', 'size': 1234, 'content_type': 'image/jpeg'}]" + ) + + class Meta: + model = Message + fields = ['chat', 'content', 'message_type', 'reply_to_uuid', 'files', 'preloaded_files'] + + def validate(self, attrs): + """Валидация.""" + # Проверяем доступ к чату + request = self.context['request'] + chat = attrs['chat'] + + if not ChatParticipant.objects.filter(chat=chat, user=request.user).exists(): + raise serializers.ValidationError({ + 'chat': 'У вас нет доступа к этому чату' + }) + + # Проверяем, что есть либо content, либо файлы + content = attrs.get('content', '').strip() + files = attrs.get('files', []) + preloaded_files = attrs.get('preloaded_files', []) + + # Парсим preloaded_files если это строка + if isinstance(preloaded_files, str): + import json + try: + preloaded_files = json.loads(preloaded_files) + except json.JSONDecodeError: + preloaded_files = [] + + has_content = bool(content) + has_files = bool(files) or bool(preloaded_files) + + if not has_content and not has_files: + raise serializers.ValidationError({ + 'content': 'Сообщение не может быть пустым. Укажите текст или прикрепите файлы.' + }) + + # Проверяем reply_to + reply_to_uuid = attrs.pop('reply_to_uuid', None) + if reply_to_uuid: + try: + attrs['reply_to'] = Message.objects.get(uuid=reply_to_uuid, chat=chat) + except Message.DoesNotExist: + raise serializers.ValidationError({ + 'reply_to_uuid': 'Сообщение не найдено' + }) + + # Устанавливаем пустую строку для content если его нет + if not content: + attrs['content'] = '' + + return attrs + + def create(self, validated_data): + """Создание сообщения.""" + files_data = validated_data.pop('files', []) + preloaded_files_data = validated_data.pop('preloaded_files', '') + user = self.context['request'].user + chat = validated_data['chat'] + + message = Message.objects.create( + sender=user, + **validated_data + ) + + # Обрабатываем предзагруженные файлы + from .utils import move_file_from_preload_to_chat + from django.core.files import File + from django.conf import settings + import os + import json + + # Парсим JSON строку + preloaded_files_list = [] + if preloaded_files_data: + if isinstance(preloaded_files_data, str): + try: + parsed = json.loads(preloaded_files_data) + if isinstance(parsed, list): + preloaded_files_list = [item for item in parsed if isinstance(item, dict)] + except json.JSONDecodeError: + preloaded_files_list = [] + elif isinstance(preloaded_files_data, list): + preloaded_files_list = [item for item in preloaded_files_data if isinstance(item, dict)] + + for preloaded_file in preloaded_files_list: + try: + filename = preloaded_file.get('filename') + original_name = preloaded_file.get('original_name', filename) + file_size = preloaded_file.get('size', 0) + content_type = preloaded_file.get('content_type', 'application/octet-stream') + + # Перемещаем файл из preload в основную директорию + new_file_path = move_file_from_preload_to_chat(chat.id, filename) + + # Создаем запись MessageFile + full_path = os.path.join(settings.MEDIA_ROOT, new_file_path) + with open(full_path, 'rb') as f: + django_file = File(f, name=os.path.basename(new_file_path)) + MessageFile.objects.create( + message=message, + file=django_file, + file_name=original_name, + file_size=file_size, + file_type=content_type + ) + except Exception as e: + # Логируем ошибку, но не прерываем создание сообщения + import logging + logger = logging.getLogger(__name__) + logger.error(f"Ошибка при обработке предзагруженного файла {preloaded_file}: {e}") + + # Обрабатываем файлы, загруженные напрямую (для обратной совместимости) + for file in files_data: + MessageFile.objects.create( + message=message, + file=file, + file_name=file.name, + file_size=file.size, + file_type=file.content_type + ) + + return message + + +class ChatSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer): + """Сериализатор чата (список).""" + + created_by = UserSerializer(read_only=True) + last_message = serializers.SerializerMethodField() + my_participant = serializers.SerializerMethodField() + participants_count = serializers.SerializerMethodField() + other_participant = serializers.SerializerMethodField() + + class Meta: + model = Chat + fields = [ + 'id', + 'uuid', + 'chat_type', + 'name', + 'description', + 'avatar', + 'created_by', + 'lesson', + 'participants_count', + 'last_message', + 'my_participant', + 'other_participant', + 'messages_count', + 'last_message_at', + 'is_archived', + 'created_at' + ] + read_only_fields = [ + 'uuid', + 'created_by', + 'messages_count', + 'last_message_at', + 'created_at' + ] + timezone_aware_fields = ['created_at', 'last_message_at'] + + def get_participants_count(self, obj): + """Количество участников.""" + # Оптимизация: используем предзагруженные participants если доступны + if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache: + return len(obj._prefetched_objects_cache['participants']) + return obj.participants.count() + + def get_last_message(self, obj): + """Получить последнее сообщение.""" + # Оптимизация: используем предзагруженные messages если доступны + if hasattr(obj, '_prefetched_objects_cache') and 'messages' in obj._prefetched_objects_cache: + messages = [m for m in obj._prefetched_objects_cache['messages'] if not m.is_deleted] + last_message = messages[0] if messages else None + else: + last_message = obj.messages.filter(is_deleted=False).select_related('sender').prefetch_related('files', 'reads').first() + + if last_message: + # Получаем информацию о файлах + files_data = [] + if hasattr(last_message, '_prefetched_objects_cache') and 'files' in last_message._prefetched_objects_cache: + files = last_message._prefetched_objects_cache['files'] + else: + files = last_message.files.all() + + for file in files: + files_data.append({ + 'id': file.id, + 'file_name': file.file_name, + 'file_type': file.file_type, + 'file_size': file.file_size + }) + + # Проверяем, прочитано ли сообщение другими участниками (для отображения статуса) + is_read_by_others = False + request = self.context.get('request') + if request and request.user.is_authenticated and last_message.sender_id == request.user.id: + # Получаем всех участников чата кроме отправителя + other_participants = obj.participants.exclude(user_id=last_message.sender_id) + if other_participants.exists(): + # Проверяем, прочитано ли сообщение хотя бы одним другим участником + if hasattr(last_message, '_prefetched_objects_cache') and 'reads' in last_message._prefetched_objects_cache: + reads = last_message._prefetched_objects_cache['reads'] + other_participant_ids = set(other_participants.values_list('user_id', flat=True)) + is_read_by_others = any(read.user_id in other_participant_ids for read in reads) + else: + other_participant_ids = list(other_participants.values_list('user_id', flat=True)) + is_read_by_others = MessageRead.objects.filter( + message=last_message, + user_id__in=other_participant_ids + ).exists() + + return { + 'uuid': str(last_message.uuid), + 'sender': last_message.sender.get_full_name() if last_message.sender else 'System', + 'sender_id': last_message.sender_id, + 'content': last_message.content[:100] if last_message.content else '', + 'message_type': last_message.message_type, + 'files': files_data, + 'is_read_by_others': is_read_by_others, + 'created_at': format_datetime_for_user(last_message.created_at, request.user.timezone) if last_message.created_at and request and request.user.is_authenticated else (last_message.created_at.isoformat() if last_message.created_at else None) + } + return None + + def get_my_participant(self, obj): + """Получить данные участника для текущего пользователя.""" + request = self.context.get('request') + if request and request.user.is_authenticated: + # Оптимизация: используем предзагруженные participants если доступны + if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache: + participants = obj._prefetched_objects_cache['participants'] + participant = next((p for p in participants if p.user_id == request.user.id), None) + else: + try: + participant = obj.participants.get(user=request.user) + except ChatParticipant.DoesNotExist: + participant = None + + if participant: + return { + 'unread_count': participant.unread_count, + 'is_muted': participant.is_muted, + 'is_pinned': participant.is_pinned + } + return None + + def get_other_participant(self, obj): + """Получить информацию о собеседнике для личных чатов.""" + if obj.chat_type != 'direct': + return None + + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return None + + # Оптимизация: используем предзагруженные participants если доступны + if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache: + participants = obj._prefetched_objects_cache['participants'] + other_participant = next((p for p in participants if p.user_id != request.user.id), None) + else: + other_participant = obj.participants.exclude(user=request.user).select_related('user').first() + + if other_participant and other_participant.user: + from datetime import timedelta + from django.utils import timezone + + user = other_participant.user + # КРИТИЧНО: Обновляем объект пользователя из базы данных, чтобы получить актуальное значение last_activity + # Это необходимо, так как middleware мог обновить last_activity после загрузки объекта + try: + user.refresh_from_db(fields=['last_activity']) + except Exception: + # Если не удалось обновить, используем текущее значение + pass + + # Определяем статус онлайн (активен в последние 15 минут) + # Интервал 15 минут для определения онлайн статуса + is_online = False + if user.last_activity: + time_diff = timezone.now() - user.last_activity + # Пользователь считается онлайн если активен в последние 15 минут + is_online = time_diff.total_seconds() < 900 # 15 минут = 900 секунд + # Если last_activity отсутствует, пользователь точно не онлайн + + # Получаем полный URL аватара + avatar_url = None + if user.avatar: + request = self.context.get('request') + if request: + avatar_url = request.build_absolute_uri(user.avatar.url) + else: + avatar_url = user.avatar.url + + return { + 'id': user.id, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'full_name': user.get_full_name() or user.email, + 'avatar': avatar_url, + 'role': user.role, + 'is_online': is_online, + 'last_activity': format_datetime_for_user(user.last_activity, request.user.timezone) if user.last_activity and request and request.user.is_authenticated else (user.last_activity.isoformat() if user.last_activity else None) + } + return None + + +class ChatCreateSerializer(serializers.ModelSerializer): + """Сериализатор создания чата.""" + + participant_ids = serializers.ListField( + child=serializers.IntegerField(), + required=True, + allow_empty=False + ) + + class Meta: + model = Chat + fields = ['chat_type', 'name', 'description', 'avatar', 'participant_ids'] + + def validate_participant_ids(self, value): + """Валидация участников.""" + from apps.users.models import User + + # Проверяем что все пользователи существуют + users = User.objects.filter(id__in=value) + if users.count() != len(value): + raise serializers.ValidationError('Некоторые пользователи не найдены') + + return value + + def validate(self, attrs): + """Валидация.""" + # Для личного чата нужно ровно 2 участника (текущий + 1) + if attrs['chat_type'] == 'direct': + if len(attrs['participant_ids']) != 1: + raise serializers.ValidationError({ + 'participant_ids': 'Для личного чата нужен ровно 1 участник' + }) + + return attrs + + def create(self, validated_data): + """Создание чата.""" + from apps.users.models import User + + participant_ids = validated_data.pop('participant_ids') + user = self.context['request'].user + + # Для личного чата используем сервис с защитой от race condition + if validated_data['chat_type'] == 'direct': + other_user = User.objects.get(id=participant_ids[0]) + chat, _ = ChatService.get_or_create_direct_chat( + user1=user, + user2=other_user, + created_by=user + ) + return chat + + # Для группового чата - обычная логика + chat = Chat.objects.create( + created_by=user, + **validated_data + ) + + # Добавляем создателя как участника + ChatParticipant.objects.create( + chat=chat, + user=user, + role='admin' + ) + + # Добавляем остальных участников + users = list(User.objects.filter(id__in=participant_ids)) + participants_to_create = [ + ChatParticipant( + chat=chat, + user=participant_user, + role='member' + ) + for participant_user in users + ] + if participants_to_create: + ChatParticipant.objects.bulk_create(participants_to_create) + + return chat + + +class ChatDetailSerializer(ChatSerializer): + """Детальный сериализатор чата (с участниками).""" + + participants = ChatParticipantSerializer(many=True, read_only=True) + + class Meta(ChatSerializer.Meta): + fields = ChatSerializer.Meta.fields + ['participants'] diff --git a/backend/apps/chat/services.py b/backend/apps/chat/services.py index ed55338..27c23a6 100644 --- a/backend/apps/chat/services.py +++ b/backend/apps/chat/services.py @@ -1,111 +1,111 @@ -""" -Сервисы для системы чата. -Централизованная логика создания и управления чатами. -""" -from django.db import transaction -from django.db.models import Q -from .models import Chat, ChatParticipant - - -class ChatService: - """ - Сервис для работы с чатами. - Обеспечивает атомарность операций и предотвращает создание дубликатов. - """ - - @staticmethod - def get_or_create_direct_chat(user1, user2, created_by=None): - """ - Получить или создать личный чат между двумя пользователями. - - Использует блокировку для предотвращения race condition - при одновременных запросах на создание чата. - - Args: - user1: Первый пользователь (User) - user2: Второй пользователь (User) - created_by: Кто создает чат (по умолчанию user1) - - Returns: - tuple: (chat, created) - объект чата и флаг создания - """ - if user1.id == user2.id: - raise ValueError("Нельзя создать чат с самим собой") - - # Нормализуем порядок пользователей для консистентного поиска - users = sorted([user1, user2], key=lambda u: u.id) - - with transaction.atomic(): - # Ищем существующий чат между пользователями - # Используем select_for_update для блокировки найденных записей - existing_chat = Chat.objects.select_for_update().filter( - chat_type='direct', - participants__user=users[0] - ).filter( - participants__user=users[1] - ).distinct().first() - - if existing_chat: - return existing_chat, False - - # Чата нет - создаем новый - creator = created_by or users[0] - chat = Chat.objects.create( - chat_type='direct', - created_by=creator - ) - - # Определяем роли участников - # Создатель становится админом - ChatParticipant.objects.create( - chat=chat, - user=users[0], - role='admin' if users[0] == creator else 'member' - ) - - ChatParticipant.objects.create( - chat=chat, - user=users[1], - role='admin' if users[1] == creator else 'member' - ) - - return chat, True - - @staticmethod - def get_direct_chat(user1, user2): - """ - Получить существующий личный чат между двумя пользователями. - - Args: - user1: Первый пользователь - user2: Второй пользователь - - Returns: - Chat или None - """ - return Chat.objects.filter( - chat_type='direct', - participants__user=user1 - ).filter( - participants__user=user2 - ).distinct().first() - - @staticmethod - def ensure_participant(chat, user, role='member'): - """ - Убедиться что пользователь является участником чата. - Если нет - добавить его. - - Args: - chat: Чат - user: Пользователь - role: Роль (по умолчанию 'member') - - Returns: - tuple: (participant, created) - """ - return ChatParticipant.objects.get_or_create( - chat=chat, - user=user, - defaults={'role': role} - ) +""" +Сервисы для системы чата. +Централизованная логика создания и управления чатами. +""" +from django.db import transaction +from django.db.models import Q +from .models import Chat, ChatParticipant + + +class ChatService: + """ + Сервис для работы с чатами. + Обеспечивает атомарность операций и предотвращает создание дубликатов. + """ + + @staticmethod + def get_or_create_direct_chat(user1, user2, created_by=None): + """ + Получить или создать личный чат между двумя пользователями. + + Использует блокировку для предотвращения race condition + при одновременных запросах на создание чата. + + Args: + user1: Первый пользователь (User) + user2: Второй пользователь (User) + created_by: Кто создает чат (по умолчанию user1) + + Returns: + tuple: (chat, created) - объект чата и флаг создания + """ + if user1.id == user2.id: + raise ValueError("Нельзя создать чат с самим собой") + + # Нормализуем порядок пользователей для консистентного поиска + users = sorted([user1, user2], key=lambda u: u.id) + + with transaction.atomic(): + # Ищем существующий чат между пользователями + # Используем select_for_update для блокировки найденных записей + existing_chat = Chat.objects.select_for_update().filter( + chat_type='direct', + participants__user=users[0] + ).filter( + participants__user=users[1] + ).distinct().first() + + if existing_chat: + return existing_chat, False + + # Чата нет - создаем новый + creator = created_by or users[0] + chat = Chat.objects.create( + chat_type='direct', + created_by=creator + ) + + # Определяем роли участников + # Создатель становится админом + ChatParticipant.objects.create( + chat=chat, + user=users[0], + role='admin' if users[0] == creator else 'member' + ) + + ChatParticipant.objects.create( + chat=chat, + user=users[1], + role='admin' if users[1] == creator else 'member' + ) + + return chat, True + + @staticmethod + def get_direct_chat(user1, user2): + """ + Получить существующий личный чат между двумя пользователями. + + Args: + user1: Первый пользователь + user2: Второй пользователь + + Returns: + Chat или None + """ + return Chat.objects.filter( + chat_type='direct', + participants__user=user1 + ).filter( + participants__user=user2 + ).distinct().first() + + @staticmethod + def ensure_participant(chat, user, role='member'): + """ + Убедиться что пользователь является участником чата. + Если нет - добавить его. + + Args: + chat: Чат + user: Пользователь + role: Роль (по умолчанию 'member') + + Returns: + tuple: (participant, created) + """ + return ChatParticipant.objects.get_or_create( + chat=chat, + user=user, + defaults={'role': role} + ) diff --git a/backend/apps/notifications/tasks.py b/backend/apps/notifications/tasks.py index 40c3c0d..bff4ae4 100644 --- a/backend/apps/notifications/tasks.py +++ b/backend/apps/notifications/tasks.py @@ -479,6 +479,106 @@ def send_telegram_notification(notification): raise +def _format_lesson_datetime_ru(dt, user_timezone='UTC'): + """Форматирует дату/время для русского языка: «23 февраля 2026, 14:30».""" + if dt is None: + return '—' + from apps.users.utils import convert_to_user_timezone + local_dt = convert_to_user_timezone(dt, user_timezone) + months_ru = ( + 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря' + ) + day = local_dt.day + month = months_ru[local_dt.month - 1] + year = local_dt.year + time_str = local_dt.strftime('%H:%M') + return f'{day} {month} {year}, {time_str}' + + +@shared_task +def send_lesson_completion_confirmation_telegram(lesson_id): + """ + Отправить ментору в Telegram сообщение о завершённом по времени занятии + с кнопками «Занятие состоялось» / «Занятие отменилось». + Вызывается при авто-завершении занятия Celery-задачей. + """ + import asyncio + from apps.schedule.models import Lesson + from apps.video.models import VideoRoom + from telegram import InlineKeyboardButton, InlineKeyboardMarkup + from .telegram_bot import send_telegram_message_with_buttons + + try: + lesson = Lesson.objects.select_related('mentor', 'client', 'client__user').get(id=lesson_id) + except Lesson.DoesNotExist: + logger.warning(f'Lesson {lesson_id} not found for completion confirmation') + return + + mentor = lesson.mentor + if not mentor or not mentor.telegram_id: + return + + tz = mentor.timezone or 'UTC' + + student_name = '' + if lesson.client and lesson.client.user: + student_name = lesson.client.user.get_full_name() or lesson.client.user.email or 'Ученик' + else: + student_name = 'Ученик' + + start_str = _format_lesson_datetime_ru(lesson.start_time, tz) + end_str = _format_lesson_datetime_ru(lesson.end_time, tz) + + # Подключения: из Lesson или VideoRoom + mentor_connected = lesson.mentor_connected_at is not None + client_connected = lesson.client_connected_at is not None + if not mentor_connected and not client_connected: + try: + vr = VideoRoom.objects.filter(lesson=lesson).first() + if vr: + mentor_connected = vr.mentor_joined_at is not None + client_connected = vr.client_joined_at is not None + except Exception: + pass + + mentor_status = '✅ Подключился' if mentor_connected else '❌ Не подключался' + client_status = '✅ Подключился' if client_connected else '❌ Не подключался' + + message = ( + f"⏱ Занятие завершилось по времени\n\n" + f"📚 {lesson.title}\n" + f"👤 {student_name}\n\n" + f"🕐 Время: {start_str} — {end_str}\n\n" + f"📡 Подключения:\n" + f" • Ментор: {mentor_status}\n" + f" • Ученик: {client_status}\n\n" + f"Подтвердите, пожалуйста:" + ) + + keyboard = [ + [ + InlineKeyboardButton("✅ Занятие состоялось", callback_data=f"lesson_confirm_{lesson_id}"), + InlineKeyboardButton("❌ Занятие отменилось", callback_data=f"lesson_cancel_{lesson_id}"), + ] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + success = loop.run_until_complete( + send_telegram_message_with_buttons( + mentor.telegram_id, message, reply_markup, parse_mode='HTML' + ) + ) + loop.close() + if success: + logger.info(f'Lesson {lesson_id} completion confirmation sent to mentor {mentor.id}') + except Exception as e: + logger.error(f'Error sending lesson completion confirmation to mentor: {e}', exc_info=True) + + @shared_task def send_bulk_notifications(notification_ids): """ diff --git a/backend/apps/notifications/telegram_bot.py b/backend/apps/notifications/telegram_bot.py index 5251fae..7cdf10c 100644 --- a/backend/apps/notifications/telegram_bot.py +++ b/backend/apps/notifications/telegram_bot.py @@ -1611,6 +1611,50 @@ class TelegramBot: await query.answer("❌ Ошибка", show_alert=True) return + # Обработка подтверждения занятия (Занятие состоялось / Занятие отменилось) + if query.data.startswith('lesson_confirm_') or query.data.startswith('lesson_cancel_'): + try: + lesson_id = int(query.data.split('_')[-1]) + is_confirmed = query.data.startswith('lesson_confirm_') + + from apps.schedule.models import Lesson + from django.utils import timezone + + lesson = await sync_to_async( + Lesson.objects.select_related('client', 'client__user', 'mentor').get + )(id=lesson_id) + user = update.effective_user + telegram_id = user.id + + # Только ментор может подтверждать + if not lesson.mentor or lesson.mentor.telegram_id != telegram_id: + await query.answer("❌ Только ментор занятия может подтвердить.", show_alert=True) + return + + if is_confirmed: + # Занятие состоялось — оставляем completed + await query.edit_message_text( + f"✅ Подтверждено\n\n" + f"Занятие «{lesson.title}» состоялось." + ) + else: + # Занятие отменилось — меняем статус + lesson.status = 'cancelled' + lesson.cancelled_at = timezone.now() + await sync_to_async(lesson.save)(update_fields=['status', 'cancelled_at']) + await query.edit_message_text( + f"❌ Отменено\n\n" + f"Занятие «{lesson.title}» отмечено как отменённое." + ) + + await query.answer() + except Lesson.DoesNotExist: + await query.answer("❌ Занятие не найдено", show_alert=True) + except Exception as e: + logger.error(f"Error handling lesson confirmation: {e}", exc_info=True) + await query.answer("❌ Ошибка обработки", show_alert=True) + return + # Обработка подтверждения присутствия if query.data.startswith('attendance_yes_') or query.data.startswith('attendance_no_'): try: diff --git a/backend/apps/schedule/migrations/0011_add_mentor_client_connected_at.py b/backend/apps/schedule/migrations/0011_add_mentor_client_connected_at.py new file mode 100644 index 0000000..3dc3673 --- /dev/null +++ b/backend/apps/schedule/migrations/0011_add_mentor_client_connected_at.py @@ -0,0 +1,32 @@ +# Generated manually + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("schedule", "0010_lesson_livekit_access_token_lesson_livekit_room_name_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="lesson", + name="mentor_connected_at", + field=models.DateTimeField( + blank=True, + help_text="Время подключения ментора к видеокомнате", + null=True, + verbose_name="Ментор подключился", + ), + ), + migrations.AddField( + model_name="lesson", + name="client_connected_at", + field=models.DateTimeField( + blank=True, + help_text="Время подключения студента к видеокомнате", + null=True, + verbose_name="Студент подключился", + ), + ), + ] diff --git a/backend/apps/schedule/models.py b/backend/apps/schedule/models.py index 0760138..7a46fd9 100644 --- a/backend/apps/schedule/models.py +++ b/backend/apps/schedule/models.py @@ -364,6 +364,20 @@ class Lesson(models.Model): # verbose_name='Время отправки напоминания' # ) + # Метрики подключения к видеокомнате (заполняются при подключении ментора/студента) + mentor_connected_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Ментор подключился', + help_text='Время подключения ментора к видеокомнате' + ) + client_connected_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Студент подключился', + help_text='Время подключения студента к видеокомнате' + ) + # Фактическое время завершения (если занятие завершено досрочно) completed_at = models.DateTimeField( null=True, diff --git a/backend/apps/schedule/serializers.py b/backend/apps/schedule/serializers.py index b03fc3f..ef37fd0 100644 --- a/backend/apps/schedule/serializers.py +++ b/backend/apps/schedule/serializers.py @@ -102,7 +102,7 @@ class LessonSerializer(serializers.ModelSerializer): 'livekit_room_name' ] read_only_fields = [ - 'id', 'end_time', 'status', 'reminder_sent', + 'id', 'end_time', 'reminder_sent', 'created_at', 'updated_at', 'livekit_room_name' ] @@ -114,6 +114,16 @@ class LessonSerializer(serializers.ModelSerializer): 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 @@ -121,10 +131,12 @@ class LessonSerializer(serializers.ModelSerializer): start_time = attrs.get('start_time') duration = attrs.get('duration', 60) - # Проверка что занятие в будущем - if start_time and start_time <= timezone.now(): + # Проверка: допускаем создание занятий до 30 минут в прошлом + now = timezone.now() + tolerance = timedelta(minutes=30) + if start_time and start_time < now - tolerance: raise serializers.ValidationError({ - 'start_time': 'Занятие должно быть запланировано в будущем' + 'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад' }) # Проверка конфликтов (только при создании или изменении времени) @@ -368,8 +380,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): attrs['mentor_subject'] = mentor_subject attrs['subject_name'] = mentor_subject.name - # Проверка что занятие в будущем - # Убеждаемся, что start_time в UTC и aware + # Проверка: допускаем создание занятий до 30 минут в прошлом if start_time: if not django_timezone.is_aware(start_time): start_time = pytz.UTC.localize(start_time) @@ -377,9 +388,10 @@ class LessonCreateSerializer(serializers.ModelSerializer): start_time = start_time.astimezone(pytz.UTC) now = django_timezone.now() - if start_time <= now: + tolerance = timedelta(minutes=30) + if start_time < now - tolerance: raise serializers.ValidationError({ - 'start_time': f'Занятие должно быть запланировано в будущем. Текущее время: {now.isoformat()}, указанное время: {start_time.isoformat()}' + 'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад' }) # Рассчитываем время окончания diff --git a/backend/apps/schedule/signals.py b/backend/apps/schedule/signals.py index 5e1a0f9..76ed4a8 100644 --- a/backend/apps/schedule/signals.py +++ b/backend/apps/schedule/signals.py @@ -1,93 +1,97 @@ -""" -Signals для приложения schedule. -Автоматические действия при изменении расписания. -""" - -from django.db.models.signals import post_save, pre_delete, pre_save -from django.dispatch import receiver -from django.utils import timezone -from datetime import timedelta - -from .models import Lesson -from apps.notifications.tasks import send_lesson_notification - - -@receiver(post_save, sender=Lesson) -def lesson_saved(sender, instance, created, **kwargs): - """ - Обработка создания или изменения занятия. - - При создании: - - Отправка уведомления ментору и клиенту - - Планирование напоминания перед занятием - - При изменении: - - Отправка уведомления об изменении времени/статуса - """ - if created: - # Новое занятие создано - send_lesson_notification.delay( - lesson_id=instance.id, - notification_type='lesson_created' - ) - # Напоминания отправляются периодической задачей send_lesson_reminders - # (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent) - else: - # Занятие изменено - # Проверяем, что именно изменилось - if instance.tracker.has_changed('start_time') or instance.tracker.has_changed('end_time'): - # Время изменилось - send_lesson_notification.delay( - lesson_id=instance.id, - notification_type='lesson_rescheduled' - ) - - if instance.tracker.has_changed('status'): - # Статус изменился - if instance.status == 'cancelled': - send_lesson_notification.delay( - lesson_id=instance.id, - notification_type='lesson_cancelled' - ) - elif instance.status == 'completed': - send_lesson_notification.delay( - lesson_id=instance.id, - notification_type='lesson_completed' - ) - - -@receiver(pre_delete, sender=Lesson) -def lesson_deleted(sender, instance, **kwargs): - """ - Обработка удаления занятия. - Отправка уведомления об отмене. - """ - if instance.status != 'cancelled': - send_lesson_notification.delay( - lesson_id=instance.id, - notification_type='lesson_cancelled' - ) - - -@receiver(pre_save, sender=Lesson) -def lesson_before_save(sender, instance, **kwargs): - """ - Действия перед сохранением занятия. - Инициализация tracker для отслеживания изменений. - """ - if not hasattr(instance, 'tracker'): - # Создаем простой tracker для отслеживания изменений - if instance.pk: - try: - old_instance = Lesson.objects.get(pk=instance.pk) - instance.tracker = type('obj', (object,), { - 'has_changed': lambda field: getattr(old_instance, field) != getattr(instance, field) - }) - except Lesson.DoesNotExist: - instance.tracker = type('obj', (object,), { - 'has_changed': lambda field: False - }) - else: - instance.tracker = type('obj', (object,), { - 'has_changed': lambda field: False - }) +""" +Signals для приложения schedule. +Автоматические действия при изменении расписания. +""" + +from django.db.models.signals import post_save, pre_delete, pre_save +from django.dispatch import receiver +from django.utils import timezone +from datetime import timedelta + +from .models import Lesson +from apps.notifications.tasks import send_lesson_notification + + +@receiver(post_save, sender=Lesson) +def lesson_saved(sender, instance, created, **kwargs): + """ + Обработка создания или изменения занятия. + + При создании: + - Отправка уведомления ментору и клиенту + - Планирование напоминания перед занятием + + При изменении: + - Отправка уведомления об изменении времени/статуса + """ + if created: + # Новое занятие создано + send_lesson_notification.delay( + lesson_id=instance.id, + notification_type='lesson_created' + ) + # Напоминания отправляются периодической задачей send_lesson_reminders + # (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent) + else: + # Занятие изменено + # Проверяем, что именно изменилось + if instance.tracker.has_changed('start_time') or instance.tracker.has_changed('end_time'): + # Время изменилось + send_lesson_notification.delay( + lesson_id=instance.id, + notification_type='lesson_rescheduled' + ) + + if instance.tracker.has_changed('status'): + # Статус изменился — не уведомляем, если занятие уже в прошлом + # (коррекция статуса/стоимости после факта, например отмена задним числом) + ref_time = instance.end_time or instance.start_time + if ref_time and ref_time < timezone.now(): + return # Занятие уже прошло — уведомления не отправляем + if instance.status == 'cancelled': + send_lesson_notification.delay( + lesson_id=instance.id, + notification_type='lesson_cancelled' + ) + elif instance.status == 'completed': + send_lesson_notification.delay( + lesson_id=instance.id, + notification_type='lesson_completed' + ) + + +@receiver(pre_delete, sender=Lesson) +def lesson_deleted(sender, instance, **kwargs): + """ + Обработка удаления занятия. + Отправка уведомления об отмене. + """ + if instance.status != 'cancelled': + send_lesson_notification.delay( + lesson_id=instance.id, + notification_type='lesson_cancelled' + ) + + +@receiver(pre_save, sender=Lesson) +def lesson_before_save(sender, instance, **kwargs): + """ + Действия перед сохранением занятия. + Инициализация tracker для отслеживания изменений. + """ + if not hasattr(instance, 'tracker'): + # Создаем простой tracker для отслеживания изменений + if instance.pk: + try: + old_instance = Lesson.objects.get(pk=instance.pk) + instance.tracker = type('obj', (object,), { + 'has_changed': lambda field: getattr(old_instance, field) != getattr(instance, field) + }) + except Lesson.DoesNotExist: + instance.tracker = type('obj', (object,), { + 'has_changed': lambda field: False + }) + else: + instance.tracker = type('obj', (object,), { + 'has_changed': lambda field: False + }) diff --git a/backend/apps/schedule/tasks.py b/backend/apps/schedule/tasks.py index 6e4172e..b1857df 100644 --- a/backend/apps/schedule/tasks.py +++ b/backend/apps/schedule/tasks.py @@ -437,6 +437,13 @@ def start_lessons_automatically(): logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}') except Exception as e: logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True) + + # Отправить ментору в Telegram сообщение с кнопками подтверждения + try: + from apps.notifications.tasks import send_lesson_completion_confirmation_telegram + send_lesson_completion_confirmation_telegram.delay(lesson.id) + except Exception as e: + logger.warning(f'Не удалось отправить подтверждение занятия в Telegram: {e}') if started_count > 0 or completed_count > 0: logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}') diff --git a/backend/apps/users/geo_data/cities_ru.json b/backend/apps/users/geo_data/cities_ru.json index 9b98fa3..b9dc762 100644 --- a/backend/apps/users/geo_data/cities_ru.json +++ b/backend/apps/users/geo_data/cities_ru.json @@ -19,7 +19,26 @@ { "country_code": "RU", "country_name": "Россия", "city": "Тюмень", "timezone": "Asia/Yekaterinburg" }, { "country_code": "RU", "country_name": "Россия", "city": "Иркутск", "timezone": "Asia/Irkutsk" }, { "country_code": "RU", "country_name": "Россия", "city": "Владивосток", "timezone": "Asia/Vladivostok" }, - { "country_code": "RU", "country_name": "Россия", "city": "Ульяновск", "timezone": "Europe/Samara" } + { "country_code": "RU", "country_name": "Россия", "city": "Ульяновск", "timezone": "Europe/Samara" }, + { "country_code": "RU", "country_name": "Россия", "city": "Улан-Удэ", "timezone": "Asia/Irkutsk" }, + { "country_code": "RU", "country_name": "Россия", "city": "Чита", "timezone": "Asia/Chita" }, + { "country_code": "RU", "country_name": "Россия", "city": "Хабаровск", "timezone": "Asia/Vladivostok" }, + { "country_code": "RU", "country_name": "Россия", "city": "Барнаул", "timezone": "Asia/Barnaul" }, + { "country_code": "RU", "country_name": "Россия", "city": "Томск", "timezone": "Asia/Tomsk" }, + { "country_code": "RU", "country_name": "Россия", "city": "Кемерово", "timezone": "Asia/Novokuznetsk" }, + { "country_code": "RU", "country_name": "Россия", "city": "Новокузнецк", "timezone": "Asia/Novokuznetsk" }, + { "country_code": "RU", "country_name": "Россия", "city": "Якутск", "timezone": "Asia/Yakutsk" }, + { "country_code": "RU", "country_name": "Россия", "city": "Магадан", "timezone": "Asia/Magadan" }, + { "country_code": "RU", "country_name": "Россия", "city": "Петропавловск-Камчатский", "timezone": "Asia/Kamchatka" }, + { "country_code": "RU", "country_name": "Россия", "city": "Южно-Сахалинск", "timezone": "Asia/Sakhalin" }, + { "country_code": "RU", "country_name": "Россия", "city": "Ижевск", "timezone": "Europe/Samara" }, + { "country_code": "RU", "country_name": "Россия", "city": "Оренбург", "timezone": "Asia/Yekaterinburg" }, + { "country_code": "RU", "country_name": "Россия", "city": "Рязань", "timezone": "Europe/Moscow" }, + { "country_code": "RU", "country_name": "Россия", "city": "Пенза", "timezone": "Europe/Moscow" }, + { "country_code": "RU", "country_name": "Россия", "city": "Липецк", "timezone": "Europe/Moscow" }, + { "country_code": "RU", "country_name": "Россия", "city": "Астрахань", "timezone": "Europe/Astrakhan" }, + { "country_code": "RU", "country_name": "Россия", "city": "Сочи", "timezone": "Europe/Moscow" }, + { "country_code": "RU", "country_name": "Россия", "city": "Калининград", "timezone": "Europe/Kaliningrad" } ] diff --git a/backend/apps/users/profile_views.py b/backend/apps/users/profile_views.py index 3207139..654cd29 100644 --- a/backend/apps/users/profile_views.py +++ b/backend/apps/users/profile_views.py @@ -36,6 +36,22 @@ POPULAR_CITIES = [ {"country_code": "RU", "country_name": "Россия", "city": "Самара", "timezone": "Europe/Samara"}, {"country_code": "RU", "country_name": "Россия", "city": "Красноярск", "timezone": "Asia/Krasnoyarsk"}, {"country_code": "RU", "country_name": "Россия", "city": "Владивосток", "timezone": "Asia/Vladivostok"}, + {"country_code": "RU", "country_name": "Россия", "city": "Улан-Удэ", "timezone": "Asia/Irkutsk"}, + {"country_code": "RU", "country_name": "Россия", "city": "Иркутск", "timezone": "Asia/Irkutsk"}, + {"country_code": "RU", "country_name": "Россия", "city": "Чита", "timezone": "Asia/Chita"}, + {"country_code": "RU", "country_name": "Россия", "city": "Хабаровск", "timezone": "Asia/Vladivostok"}, + {"country_code": "RU", "country_name": "Россия", "city": "Омск", "timezone": "Asia/Omsk"}, + {"country_code": "RU", "country_name": "Россия", "city": "Челябинск", "timezone": "Asia/Yekaterinburg"}, + {"country_code": "RU", "country_name": "Россия", "city": "Уфа", "timezone": "Asia/Yekaterinburg"}, + {"country_code": "RU", "country_name": "Россия", "city": "Ростов-на-Дону", "timezone": "Europe/Moscow"}, + {"country_code": "RU", "country_name": "Россия", "city": "Пермь", "timezone": "Asia/Yekaterinburg"}, + {"country_code": "RU", "country_name": "Россия", "city": "Воронеж", "timezone": "Europe/Moscow"}, + {"country_code": "RU", "country_name": "Россия", "city": "Волгоград", "timezone": "Europe/Moscow"}, + {"country_code": "RU", "country_name": "Россия", "city": "Краснодар", "timezone": "Europe/Moscow"}, + {"country_code": "RU", "country_name": "Россия", "city": "Барнаул", "timezone": "Asia/Barnaul"}, + {"country_code": "RU", "country_name": "Россия", "city": "Томск", "timezone": "Asia/Tomsk"}, + {"country_code": "RU", "country_name": "Россия", "city": "Якутск", "timezone": "Asia/Yakutsk"}, + {"country_code": "RU", "country_name": "Россия", "city": "Калининград", "timezone": "Europe/Kaliningrad"}, # Казахстан {"country_code": "KZ", "country_name": "Казахстан", "city": "Алматы", "timezone": "Asia/Almaty"}, {"country_code": "KZ", "country_name": "Казахстан", "city": "Астана", "timezone": "Asia/Almaty"}, @@ -43,7 +59,6 @@ POPULAR_CITIES = [ {"country_code": "BY", "country_name": "Беларусь", "city": "Минск", "timezone": "Europe/Minsk"}, # Украина {"country_code": "UA", "country_name": "Украина", "city": "Киев", "timezone": "Europe/Kyiv"}, - # Другие крупные города СНГ можно добавлять по мере необходимости ] diff --git a/backend/apps/video/livekit_views.py b/backend/apps/video/livekit_views.py index 28b7a24..abb1a79 100644 --- a/backend/apps/video/livekit_views.py +++ b/backend/apps/video/livekit_views.py @@ -275,6 +275,60 @@ def delete_livekit_room_by_lesson(request, lesson_id): ) +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def participant_connected(request): + """ + Отметить подключение участника к видеокомнате (для метрик). + Вызывается фронтендом при успешном подключении к LiveKit комнате. + + POST /api/video/livekit/participant-connected/ + Body: { "room_name": "uuid" } + """ + room_name = request.data.get('room_name') + if not room_name: + return Response( + {'error': 'room_name обязателен'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + import uuid as uuid_module + room_uuid = uuid_module.UUID(str(room_name)) + video_room = VideoRoom.objects.get(room_id=room_uuid) + except (ValueError, VideoRoom.DoesNotExist): + return Response( + {'error': 'Видеокомната не найдена'}, + status=status.HTTP_404_NOT_FOUND + ) + + user = request.user + client_user = video_room.client.user if hasattr(video_room.client, 'user') else video_room.client + if user != video_room.mentor and user != client_user: + return Response( + {'error': 'Нет доступа к этой видеокомнате'}, + status=status.HTTP_403_FORBIDDEN + ) + + from .models import VideoParticipant + participant, _ = VideoParticipant.objects.get_or_create( + room=video_room, + user=user, + defaults={ + 'is_connected': True, + 'is_audio_enabled': True, + 'is_video_enabled': True, + } + ) + if not participant.is_connected: + participant.is_connected = True + participant.save(update_fields=['is_connected']) + + video_room.mark_participant_joined(user) + + return Response({'success': True}, status=status.HTTP_200_OK) + + @api_view(['POST']) @permission_classes([IsAuthenticated]) def update_livekit_participant_media_state(request): @@ -336,6 +390,8 @@ def update_livekit_participant_media_state(request): 'is_video_enabled': video_enabled if video_enabled is not None else True, } ) + # Фиксируем подключение ментора/студента для метрик + video_room.mark_participant_joined(user) if not created: # Обновляем существующего участника diff --git a/backend/apps/video/models.py b/backend/apps/video/models.py index 76ab00a..d5dfeb7 100644 --- a/backend/apps/video/models.py +++ b/backend/apps/video/models.py @@ -1,4 +1,4 @@ -""" +""" Модели для видеоконференций. """ from django.db import models @@ -219,13 +219,26 @@ class VideoRoom(models.Model): self.save() def mark_participant_joined(self, user): - """Отметить что участник подключился.""" + """Отметить что участник подключился (также обновляет Lesson для метрик).""" + now = timezone.now() + update_fields = [] if user == self.mentor: - self.mentor_joined_at = timezone.now() + self.mentor_joined_at = now + update_fields.append('mentor_joined_at') elif user == self.client: - self.client_joined_at = timezone.now() - - self.save(update_fields=['mentor_joined_at', 'client_joined_at']) + self.client_joined_at = now + update_fields.append('client_joined_at') + + if update_fields: + self.save(update_fields=update_fields) + # Синхронизируем метрики на занятие для аналитики + lesson = self.lesson + if user == self.mentor and not lesson.mentor_connected_at: + lesson.mentor_connected_at = now + lesson.save(update_fields=['mentor_connected_at']) + elif user == self.client and not lesson.client_connected_at: + lesson.client_connected_at = now + lesson.save(update_fields=['client_connected_at']) @property def is_active(self): diff --git a/backend/apps/video/urls.py b/backend/apps/video/urls.py index 133c605..668addc 100644 --- a/backend/apps/video/urls.py +++ b/backend/apps/video/urls.py @@ -1,4 +1,4 @@ -""" +""" URL routing для видео API. """ from django.urls import path, include @@ -11,7 +11,7 @@ from .views import ( ) from .janus_views import JanusVideoRoomViewSet from .token_views import VideoRoomTokenViewSet -from .livekit_views import create_livekit_room, get_livekit_config, delete_livekit_room_by_lesson, update_livekit_participant_media_state +from .livekit_views import create_livekit_room, get_livekit_config, delete_livekit_room_by_lesson, update_livekit_participant_media_state, participant_connected router = DefaultRouter() router.register(r'rooms', VideoRoomViewSet, basename='videoroom') @@ -32,4 +32,5 @@ urlpatterns = [ path('livekit/config/', get_livekit_config, name='livekit-config'), path('livekit/rooms/lesson//', delete_livekit_room_by_lesson, name='livekit-delete-room-by-lesson'), path('livekit/update-media-state/', update_livekit_participant_media_state, name='livekit-update-media-state'), + path('livekit/participant-connected/', participant_connected, name='livekit-participant-connected'), ] diff --git a/docker-compose.yml b/docker-compose.yml index add18bf..0a649de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,8 @@ # Docker Compose PROD (порты не пересекаются с dev на одном хосте) # ============================================== # Порты на хосте (prod): db 5434, redis 6381, web 8123, nginx 8084, -# front_material 3010, yjs 1236, excalidraw 3004, whiteboard 8083, -# livekit 7880/7881, celery/beat — без портов (внутренние) +# front_material 3010, yjs 1236, excalidraw 3004, livekit 7880/7881, +# celery/beat — без портов (внутренние) # Dev использует: 5433, 6380, 8124, 8081, 3002, 1235, 3003, 8082, livekit 7890/7891 # # ВАЖНО: PROD использует отдельную сеть (prod_network) и именованные volumes @@ -273,17 +273,6 @@ services: networks: - prod_network - whiteboard: - build: - context: ./whiteboard-server - dockerfile: Dockerfile - container_name: platform_prod_whiteboard - restart: unless-stopped - ports: - - "8083:8080" - networks: - - prod_network - volumes: # ВАЖНО: Эти volumes содержат данные БД и Redis # НЕ используйте docker compose down --volumes без бэкапа! diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 2412df5..873ffe7 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -91,12 +91,6 @@ http { keepalive 32; } - upstream whiteboard { - least_conn; - server whiteboard:8080 max_fails=3 fail_timeout=30s; - keepalive 32; - } - upstream livekit { server livekit:7880 max_fails=3 fail_timeout=30s; keepalive 4; diff --git a/etc/docker/daemon.json.example b/etc/docker/daemon.json.example new file mode 100644 index 0000000..34de7cd --- /dev/null +++ b/etc/docker/daemon.json.example @@ -0,0 +1,13 @@ +{ + "builder": { + "gc": { + "defaultKeepStorage": "10GB", + "enabled": true + } + }, + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} diff --git a/front_material/.dockerignore b/front_material/.dockerignore index cf9605a..13fadf8 100644 --- a/front_material/.dockerignore +++ b/front_material/.dockerignore @@ -5,6 +5,7 @@ node_modules *.md .env*.local .env +.env.* .DS_Store *.log npm-debug.log* @@ -15,3 +16,10 @@ coverage .nyc_output .vscode .idea +docs +.cursor +agent-transcripts +__pycache__ +*.pyc +.pytest_cache +.mypy_cache diff --git a/front_material/api/homework.ts b/front_material/api/homework.ts index 8b4c2f5..48b25c2 100644 --- a/front_material/api/homework.ts +++ b/front_material/api/homework.ts @@ -1,317 +1,317 @@ -/** - * API модуль для домашних заданий - */ - -import apiClient from '@/lib/api-client'; - -export interface HomeworkMentor { - id: number; - email: string; - first_name: string; - last_name: string; -} - -/** Файл задания/решения (ментор прикрепляет к заданию, ученик видит и скачивает). */ -export interface HomeworkFileItem { - id: number; - file_type: 'assignment' | 'submission' | 'feedback'; - file: string; - filename: string; - file_size: number; - /** Признак изображения по расширению (с бэкенда) — показывать превью и открывать в модалке. */ - is_image?: boolean; - uploaded_by?: { id: number; first_name: string; last_name: string } | null; - created_at: string; -} - -export interface Homework { - id: number; - title: string; - description?: string; - mentor: HomeworkMentor; - lesson: number | null; - deadline: string | null; - max_score: number; - passing_score: number; - status: 'draft' | 'published' | 'archived'; - /** Черновик «заполнить позже» — создан при завершении урока, нужно дописать задание. */ - fill_later?: boolean; - total_submissions: number; - checked_submissions: number; - returned_submissions: number; - average_score: number; - is_overdue: boolean; - created_at: string; - published_at: string | null; - /** Файл задания (один), URL для скачивания. */ - attachment?: string | null; - /** Ссылка на материал (внешняя). */ - attachment_url?: string | null; - /** Дополнительные файлы задания (ментор прикрепляет несколько). */ - files?: HomeworkFileItem[] | null; - students?: { id: number; first_name: string; last_name: string; score: number | null; status: string }[] | null; - student_score?: { score: number | null; max_score: number; status: string } | null; - /** Только для ментора: количество решений с черновиком от ИИ (status=pending, ai_checked_at задано). */ - ai_draft_count?: number; -} - -export interface HomeworkSubmission { - id: number; - homework: { id: number; title: string; description?: string; max_score: number }; - student: { id: number; first_name: string; last_name: string; email: string }; - status: string; - content?: string; - /** Основной файл решения (URL для скачивания). */ - attachment?: string | null; - attachment_url?: string | null; - /** Доп. файлы решения (студент прикрепляет несколько). */ - files?: HomeworkFileItem[] | null; - score?: number | null; - feedback?: string; - /** HTML комментария проверки (markdown → HTML). */ - feedback_html?: string; - submitted_at: string; - checked_at?: string | null; - ai_score?: number | null; - ai_feedback?: string; - /** HTML превью черновика ИИ (markdown → HTML). */ - ai_feedback_html?: string; - ai_checked_at?: string | null; - /** True, если оценка опубликована автоматически через ИИ. */ - graded_by_ai?: boolean; - checked_by?: { id: number; first_name: string; last_name: string } | null; -} - -export async function getHomework(params?: { - status?: string; - page_size?: number; - child_id?: string; -}): Promise<{ results: Homework[]; count: number }> { - const q = new URLSearchParams(); - if (params?.status) q.append('status', params.status); - if (params?.page_size) q.append('page_size', String(params.page_size || 1000)); - if (params?.child_id) q.append('child_id', params.child_id); - const query = q.toString(); - const url = `/homework/homeworks/${query ? `?${query}` : ''}`; - const res = await apiClient.get<{ results: Homework[]; count: number } | Homework[]>(url); - const data = res.data; - if (Array.isArray(data)) { - return { results: data, count: data.length }; - } - return { - results: data?.results ?? [], - count: data?.count ?? 0, - }; -} - -export async function getHomeworkById(id: string | number): Promise { - const res = await apiClient.get(`/homework/homeworks/${id}/`); - return res.data; -} - -/** Создать домашнее задание (в т.ч. черновик для «заполнить позже»). По умолчанию макс. балл 5, проходной 1 (не учитывается). */ -export async function createHomework(data: { - title: string; - description?: string; - lesson_id?: number; - status?: 'draft' | 'published'; - /** Пометить как «заполнить позже» — отображается в колонке «Ожидают заполнения» у ментора. */ - fill_later?: boolean; - /** Максимальный балл (1–5 по умолчанию). По умолчанию 5. */ - max_score?: number; - /** Проходной балл (по умолчанию 1, не учитывается). */ - passing_score?: number; -}): Promise { - const payload = { - ...data, - max_score: data.max_score ?? 5, - passing_score: data.passing_score ?? 1, - }; - const res = await apiClient.post('/homework/homeworks/', payload); - return res.data; -} - -/** Опции запроса списка решений (например, отключить кэш для актуальных данных). */ -export interface GetHomeworkSubmissionsOptions { - cache?: boolean; - /** Для родителя: user_id ребёнка — вернуть решения этого ребёнка. */ - child_id?: string | null; -} - -export async function getHomeworkSubmissions( - homeworkId: string | number, - options?: GetHomeworkSubmissionsOptions -): Promise { - const params = new URLSearchParams({ homework_id: String(homeworkId) }); - if (options?.child_id) params.append('child_id', options.child_id); - const res = await apiClient.get<{ results: HomeworkSubmission[] } | HomeworkSubmission[]>( - `/homework/submissions/?${params.toString()}`, - { cache: options?.cache ?? false } - ); - const data = res.data; - if (Array.isArray(data)) return data; - return data?.results ?? []; -} - -export async function getMySubmission( - homeworkId: string | number, - options?: GetHomeworkSubmissionsOptions -): Promise { - const list = await getHomeworkSubmissions(homeworkId, options); - return list.length > 0 ? list[0] : null; -} - -/** Получить одно решение по ID (для детального просмотра). */ -export async function getHomeworkSubmission( - submissionId: string | number -): Promise { - const res = await apiClient.get( - `/homework/submissions/${submissionId}/` - ); - return res.data; -} - -/** - * ДЗ с оценками по предмету для графика прогресса. - * GET /api/homework/submissions/by_subject/ - */ -export async function getHomeworkSubmissionsBySubject(params: { - subject: string; - start_date?: string; - end_date?: string; - child_id?: string; -}): Promise<{ count: number; results: HomeworkSubmission[] }> { - const q = new URLSearchParams(); - q.append('subject', params.subject); - if (params.start_date) q.append('start_date', params.start_date); - if (params.end_date) q.append('end_date', params.end_date); - if (params.child_id) q.append('child_id', params.child_id); - const res = await apiClient.get<{ count: number; results: HomeworkSubmission[] }>( - `/homework/submissions/by_subject/?${q}` - ); - return res.data; -} - -export async function gradeSubmission( - submissionId: string | number, - data: { score: number; feedback?: string } -): Promise { - const res = await apiClient.post(`/homework/submissions/${submissionId}/grade/`, data); - return res.data; -} - -/** Использование токенов за один запрос (если API вернул). */ -export interface TokenUsage { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; -} - -export interface CheckWithAiResponse { - success: boolean; - ai_score: number; - ai_feedback: string; - /** HTML для отображения комментария ИИ (markdown + LaTeX → HTML). */ - ai_feedback_html?: string; - ai_checked_at?: string; - message?: string; - /** Токены за эту проверку (потрачено). Остаток лимита — в кабинете Timeweb. */ - usage?: TokenUsage; -} - -/** Проверить решение через ИИ. Ментор: задание + решение → комментарий и оценка 1–5. */ -export async function checkSubmissionWithAi( - submissionId: string | number -): Promise { - const res = await apiClient.post( - `/homework/submissions/${submissionId}/check_with_ai/` - ); - return res.data; -} - -export async function returnSubmissionForRevision( - submissionId: string | number, - feedback: string -): Promise { - const res = await apiClient.post(`/homework/submissions/${submissionId}/return_for_revision/`, { feedback }); - return res.data; -} - -/** Удалить своё решение (студент). Задание снова переходит в ожидание загрузки. */ -export async function deleteSubmission(submissionId: string | number): Promise { - await apiClient.delete(`/homework/submissions/${submissionId}/`); -} - -const MAX_HOMEWORK_FILE_SIZE = 50 * 1024 * 1024; // 50 МБ -const MAX_HOMEWORK_FILES = 10; - -export function validateHomeworkFiles(files: File[]): { valid: boolean; error?: string } { - if (files.length === 0) return { valid: true }; - if (files.length > MAX_HOMEWORK_FILES) { - return { valid: false, error: `Максимум ${MAX_HOMEWORK_FILES} файлов` }; - } - for (const f of files) { - if (f.size > MAX_HOMEWORK_FILE_SIZE) { - return { valid: false, error: `Файл "${f.name}" больше 50 МБ` }; - } - } - return { valid: true }; -} - -/** - * Обновить домашнее задание (для черновиков fill_later). - * PATCH /api/homework/homeworks/{id}/ - */ -export async function updateHomework( - homeworkId: string | number, - data: { - title?: string; - description?: string; - deadline?: string | null; - status?: 'draft' | 'published'; - fill_later?: boolean; - } -): Promise { - const res = await apiClient.patch(`/homework/homeworks/${homeworkId}/`, data); - return res.data; -} - -/** - * Опубликовать домашнее задание (из черновика в published). - * POST /api/homework/homeworks/{id}/publish/ - */ -export async function publishHomework(homeworkId: string | number): Promise { - const res = await apiClient.post(`/homework/homeworks/${homeworkId}/publish/`); - return res.data; -} - -export async function submitHomework( - homeworkId: string | number, - data: { content?: string; text?: string; files?: File[] }, - onUploadProgress?: (percent: number) => void -): Promise { - const hasFiles = data.files && data.files.length > 0; - if (hasFiles) { - const formData = new FormData(); - formData.append('homework_id', String(homeworkId)); - if (data.content) formData.append('content', data.content); - if (data.text) formData.append('content', data.text); - data.files!.forEach((f) => formData.append('attachment', f)); - const res = await apiClient.post(`/homework/submissions/`, formData, { - onUploadProgress: - onUploadProgress && - (function (event: { loaded: number; total?: number }) { - if (event.total && event.total > 0) { - const percent = Math.round((event.loaded / event.total) * 100); - onUploadProgress(Math.min(percent, 100)); - } - }), - }); - return res.data; - } - const res = await apiClient.post(`/homework/submissions/`, { - homework_id: homeworkId, - content: data.content || data.text || '', - }); - return res.data; -} +/** + * API модуль для домашних заданий + */ + +import apiClient from '@/lib/api-client'; + +export interface HomeworkMentor { + id: number; + email: string; + first_name: string; + last_name: string; +} + +/** Файл задания/решения (ментор прикрепляет к заданию, ученик видит и скачивает). */ +export interface HomeworkFileItem { + id: number; + file_type: 'assignment' | 'submission' | 'feedback'; + file: string; + filename: string; + file_size: number; + /** Признак изображения по расширению (с бэкенда) — показывать превью и открывать в модалке. */ + is_image?: boolean; + uploaded_by?: { id: number; first_name: string; last_name: string } | null; + created_at: string; +} + +export interface Homework { + id: number; + title: string; + description?: string; + mentor: HomeworkMentor; + lesson: number | null; + deadline: string | null; + max_score: number; + passing_score: number; + status: 'draft' | 'published' | 'archived'; + /** Черновик «заполнить позже» — создан при завершении урока, нужно дописать задание. */ + fill_later?: boolean; + total_submissions: number; + checked_submissions: number; + returned_submissions: number; + average_score: number; + is_overdue: boolean; + created_at: string; + published_at: string | null; + /** Файл задания (один), URL для скачивания. */ + attachment?: string | null; + /** Ссылка на материал (внешняя). */ + attachment_url?: string | null; + /** Дополнительные файлы задания (ментор прикрепляет несколько). */ + files?: HomeworkFileItem[] | null; + students?: { id: number; first_name: string; last_name: string; score: number | null; status: string }[] | null; + student_score?: { score: number | null; max_score: number; status: string } | null; + /** Только для ментора: количество решений с черновиком от ИИ (status=pending, ai_checked_at задано). */ + ai_draft_count?: number; +} + +export interface HomeworkSubmission { + id: number; + homework: { id: number; title: string; description?: string; max_score: number }; + student: { id: number; first_name: string; last_name: string; email: string }; + status: string; + content?: string; + /** Основной файл решения (URL для скачивания). */ + attachment?: string | null; + attachment_url?: string | null; + /** Доп. файлы решения (студент прикрепляет несколько). */ + files?: HomeworkFileItem[] | null; + score?: number | null; + feedback?: string; + /** HTML комментария проверки (markdown → HTML). */ + feedback_html?: string; + submitted_at: string; + checked_at?: string | null; + ai_score?: number | null; + ai_feedback?: string; + /** HTML превью черновика ИИ (markdown → HTML). */ + ai_feedback_html?: string; + ai_checked_at?: string | null; + /** True, если оценка опубликована автоматически через ИИ. */ + graded_by_ai?: boolean; + checked_by?: { id: number; first_name: string; last_name: string } | null; +} + +export async function getHomework(params?: { + status?: string; + page_size?: number; + child_id?: string; +}): Promise<{ results: Homework[]; count: number }> { + const q = new URLSearchParams(); + if (params?.status) q.append('status', params.status); + if (params?.page_size) q.append('page_size', String(params.page_size || 1000)); + if (params?.child_id) q.append('child_id', params.child_id); + const query = q.toString(); + const url = `/homework/homeworks/${query ? `?${query}` : ''}`; + const res = await apiClient.get<{ results: Homework[]; count: number } | Homework[]>(url); + const data = res.data; + if (Array.isArray(data)) { + return { results: data, count: data.length }; + } + return { + results: data?.results ?? [], + count: data?.count ?? 0, + }; +} + +export async function getHomeworkById(id: string | number): Promise { + const res = await apiClient.get(`/homework/homeworks/${id}/`); + return res.data; +} + +/** Создать домашнее задание (в т.ч. черновик для «заполнить позже»). По умолчанию макс. балл 5, проходной 1 (не учитывается). */ +export async function createHomework(data: { + title: string; + description?: string; + lesson_id?: number; + status?: 'draft' | 'published'; + /** Пометить как «заполнить позже» — отображается в колонке «Ожидают заполнения» у ментора. */ + fill_later?: boolean; + /** Максимальный балл (1–5 по умолчанию). По умолчанию 5. */ + max_score?: number; + /** Проходной балл (по умолчанию 1, не учитывается). */ + passing_score?: number; +}): Promise { + const payload = { + ...data, + max_score: data.max_score ?? 5, + passing_score: data.passing_score ?? 1, + }; + const res = await apiClient.post('/homework/homeworks/', payload); + return res.data; +} + +/** Опции запроса списка решений (например, отключить кэш для актуальных данных). */ +export interface GetHomeworkSubmissionsOptions { + cache?: boolean; + /** Для родителя: user_id ребёнка — вернуть решения этого ребёнка. */ + child_id?: string | null; +} + +export async function getHomeworkSubmissions( + homeworkId: string | number, + options?: GetHomeworkSubmissionsOptions +): Promise { + const params = new URLSearchParams({ homework_id: String(homeworkId) }); + if (options?.child_id) params.append('child_id', options.child_id); + const res = await apiClient.get<{ results: HomeworkSubmission[] } | HomeworkSubmission[]>( + `/homework/submissions/?${params.toString()}`, + { cache: options?.cache ?? false } + ); + const data = res.data; + if (Array.isArray(data)) return data; + return data?.results ?? []; +} + +export async function getMySubmission( + homeworkId: string | number, + options?: GetHomeworkSubmissionsOptions +): Promise { + const list = await getHomeworkSubmissions(homeworkId, options); + return list.length > 0 ? list[0] : null; +} + +/** Получить одно решение по ID (для детального просмотра). */ +export async function getHomeworkSubmission( + submissionId: string | number +): Promise { + const res = await apiClient.get( + `/homework/submissions/${submissionId}/` + ); + return res.data; +} + +/** + * ДЗ с оценками по предмету для графика прогресса. + * GET /api/homework/submissions/by_subject/ + */ +export async function getHomeworkSubmissionsBySubject(params: { + subject: string; + start_date?: string; + end_date?: string; + child_id?: string; +}): Promise<{ count: number; results: HomeworkSubmission[] }> { + const q = new URLSearchParams(); + q.append('subject', params.subject); + if (params.start_date) q.append('start_date', params.start_date); + if (params.end_date) q.append('end_date', params.end_date); + if (params.child_id) q.append('child_id', params.child_id); + const res = await apiClient.get<{ count: number; results: HomeworkSubmission[] }>( + `/homework/submissions/by_subject/?${q}` + ); + return res.data; +} + +export async function gradeSubmission( + submissionId: string | number, + data: { score: number; feedback?: string } +): Promise { + const res = await apiClient.post(`/homework/submissions/${submissionId}/grade/`, data); + return res.data; +} + +/** Использование токенов за один запрос (если API вернул). */ +export interface TokenUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +export interface CheckWithAiResponse { + success: boolean; + ai_score: number; + ai_feedback: string; + /** HTML для отображения комментария ИИ (markdown + LaTeX → HTML). */ + ai_feedback_html?: string; + ai_checked_at?: string; + message?: string; + /** Токены за эту проверку (потрачено). Остаток лимита — в кабинете Timeweb. */ + usage?: TokenUsage; +} + +/** Проверить решение через ИИ. Ментор: задание + решение → комментарий и оценка 1–5. */ +export async function checkSubmissionWithAi( + submissionId: string | number +): Promise { + const res = await apiClient.post( + `/homework/submissions/${submissionId}/check_with_ai/` + ); + return res.data; +} + +export async function returnSubmissionForRevision( + submissionId: string | number, + feedback: string +): Promise { + const res = await apiClient.post(`/homework/submissions/${submissionId}/return_for_revision/`, { feedback }); + return res.data; +} + +/** Удалить своё решение (студент). Задание снова переходит в ожидание загрузки. */ +export async function deleteSubmission(submissionId: string | number): Promise { + await apiClient.delete(`/homework/submissions/${submissionId}/`); +} + +const MAX_HOMEWORK_FILE_SIZE = 50 * 1024 * 1024; // 50 МБ +const MAX_HOMEWORK_FILES = 10; + +export function validateHomeworkFiles(files: File[]): { valid: boolean; error?: string } { + if (files.length === 0) return { valid: true }; + if (files.length > MAX_HOMEWORK_FILES) { + return { valid: false, error: `Максимум ${MAX_HOMEWORK_FILES} файлов` }; + } + for (const f of files) { + if (f.size > MAX_HOMEWORK_FILE_SIZE) { + return { valid: false, error: `Файл "${f.name}" больше 50 МБ` }; + } + } + return { valid: true }; +} + +/** + * Обновить домашнее задание (для черновиков fill_later). + * PATCH /api/homework/homeworks/{id}/ + */ +export async function updateHomework( + homeworkId: string | number, + data: { + title?: string; + description?: string; + deadline?: string | null; + status?: 'draft' | 'published'; + fill_later?: boolean; + } +): Promise { + const res = await apiClient.patch(`/homework/homeworks/${homeworkId}/`, data); + return res.data; +} + +/** + * Опубликовать домашнее задание (из черновика в published). + * POST /api/homework/homeworks/{id}/publish/ + */ +export async function publishHomework(homeworkId: string | number): Promise { + const res = await apiClient.post(`/homework/homeworks/${homeworkId}/publish/`); + return res.data; +} + +export async function submitHomework( + homeworkId: string | number, + data: { content?: string; text?: string; files?: File[] }, + onUploadProgress?: (percent: number) => void +): Promise { + const hasFiles = data.files && data.files.length > 0; + if (hasFiles) { + const formData = new FormData(); + formData.append('homework_id', String(homeworkId)); + if (data.content) formData.append('content', data.content); + if (data.text) formData.append('content', data.text); + data.files!.forEach((f) => formData.append('attachment', f)); + const res = await apiClient.post(`/homework/submissions/`, formData, { + onUploadProgress: + onUploadProgress && + (function (event: { loaded: number; total?: number }) { + if (event.total && event.total > 0) { + const percent = Math.round((event.loaded / event.total) * 100); + onUploadProgress(Math.min(percent, 100)); + } + }), + }); + return res.data; + } + const res = await apiClient.post(`/homework/submissions/`, { + homework_id: homeworkId, + content: data.content || data.text || '', + }); + return res.data; +} diff --git a/front_material/api/livekit.ts b/front_material/api/livekit.ts index a9ab727..091ed9d 100644 --- a/front_material/api/livekit.ts +++ b/front_material/api/livekit.ts @@ -50,3 +50,10 @@ export async function getLiveKitConfig(): Promise { const res = await apiClient.get('/video/livekit/config/'); return res.data; } + +/** + * Отметить подключение участника к видеокомнате (для метрик) + */ +export async function participantConnected(roomName: string): Promise { + await apiClient.post('/video/livekit/participant-connected/', { room_name: roomName }); +} diff --git a/front_material/api/schedule.ts b/front_material/api/schedule.ts index 139a1cf..170b640 100644 --- a/front_material/api/schedule.ts +++ b/front_material/api/schedule.ts @@ -147,6 +147,8 @@ export interface UpdateLessonData { start_time?: string; duration?: number; price?: number; + /** Для завершённых занятий — можно изменить статус (cancelled и т.д.) */ + status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; } /** diff --git a/front_material/app/(auth)/forgot-password/page.tsx b/front_material/app/(auth)/forgot-password/page.tsx index b7dc063..ad830b4 100644 --- a/front_material/app/(auth)/forgot-password/page.tsx +++ b/front_material/app/(auth)/forgot-password/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { requestPasswordReset } from '@/api/auth'; +import { getErrorMessage } from '@/lib/error-utils'; const loadMaterialComponents = async () => { await Promise.all([ @@ -39,7 +40,7 @@ export default function ForgotPasswordPage() { await requestPasswordReset({ email }); setSuccess(true); } catch (err: any) { - setError(err.response?.data?.detail || 'Ошибка при отправке запроса. Проверьте email.'); + setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.')); } finally { setLoading(false); } diff --git a/front_material/app/(auth)/register/page.tsx b/front_material/app/(auth)/register/page.tsx index 4a03022..53f4be0 100644 --- a/front_material/app/(auth)/register/page.tsx +++ b/front_material/app/(auth)/register/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { register } from '@/api/auth'; import { REFERRAL_STORAGE_KEY } from '@/api/referrals'; import { searchCitiesFromCSV, type CityOption } from '@/api/profile'; +import { getErrorMessage } from '@/lib/error-utils'; const loadMaterialComponents = async () => { await Promise.all([ @@ -144,14 +145,7 @@ export default function RegisterPage() { setRegistrationSuccess(true); return; } catch (err: any) { - setError( - err.response?.data?.detail || - (Array.isArray(err.response?.data?.email) - ? err.response.data.email[0] - : err.response?.data?.email) || - err.response?.data?.message || - 'Ошибка регистрации. Проверьте данные.' - ); + setError(getErrorMessage(err, 'Ошибка регистрации. Проверьте данные.')); } finally { setLoading(false); } diff --git a/front_material/app/(auth)/reset-password/page.tsx b/front_material/app/(auth)/reset-password/page.tsx index 7ede170..5b49177 100644 --- a/front_material/app/(auth)/reset-password/page.tsx +++ b/front_material/app/(auth)/reset-password/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { confirmPasswordReset } from '@/api/auth'; +import { getErrorMessage } from '@/lib/error-utils'; const loadMaterialComponents = async () => { await Promise.all([ @@ -43,11 +44,7 @@ function ResetPasswordContent() { await confirmPasswordReset(token, password, confirmPassword); setSuccess(true); } catch (err: any) { - setError( - err.response?.data?.error?.message || - err.response?.data?.detail || - 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.' - ); + setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.')); } finally { setLoading(false); } diff --git a/front_material/app/(auth)/verify-email/page.tsx b/front_material/app/(auth)/verify-email/page.tsx index cf617c6..02d01d3 100644 --- a/front_material/app/(auth)/verify-email/page.tsx +++ b/front_material/app/(auth)/verify-email/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { verifyEmail } from '@/api/auth'; +import { getErrorMessage } from '@/lib/error-utils'; const loadMaterialComponents = async () => { await Promise.all([ @@ -47,11 +48,7 @@ function VerifyEmailContent() { .catch((err: any) => { if (cancelled) return; setStatus('error'); - const msg = - err.response?.data?.error?.message || - err.response?.data?.detail || - 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.'; - setMessage(msg); + setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.')); }); return () => { diff --git a/front_material/app/(protected)/schedule/page.tsx b/front_material/app/(protected)/schedule/page.tsx index 4870583..12a9e5c 100644 --- a/front_material/app/(protected)/schedule/page.tsx +++ b/front_material/app/(protected)/schedule/page.tsx @@ -24,6 +24,7 @@ import { getSubjects, getMentorSubjects } from '@/api/subjects'; import { loadComponent } from '@/lib/material-components'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { ErrorDisplay } from '@/components/common/ErrorDisplay'; +import { getErrorMessage } from '@/lib/error-utils'; import type { CalendarLesson } from '@/components/calendar/calendar'; import type { CheckLessonFormData, CheckLessonProps } from '@/components/checklesson/checklesson'; import type { LessonPreview } from '@/api/dashboard'; @@ -61,7 +62,8 @@ export default function SchedulePage() { const [selectedSubjectId, setSelectedSubjectId] = useState(null); const [selectedMentorSubjectId, setSelectedMentorSubjectId] = useState(null); const [editingLessonId, setEditingLessonId] = useState(null); - + const [editingLessonStatus, setEditingLessonStatus] = useState(null); + // Компоненты Material Web const [buttonComponentsLoaded, setButtonComponentsLoaded] = useState(false); const [formComponentsLoaded, setFormComponentsLoaded] = useState(false); @@ -264,7 +266,9 @@ export default function SchedulePage() { duration, price: typeof details.price === 'number' ? details.price : undefined, is_recurring: !!(details as any).is_recurring, + status: (details as any).status ?? 'completed', }); + setEditingLessonStatus((details as any).status ?? null); // пробуем выставить предмет по названию const subjName = (details as any).subject_name || (details as any).subject || ''; @@ -329,20 +333,23 @@ export default function SchedulePage() { setFormError(null); try { - if (!formData.client) { - setFormError('Выберите ученика'); - setFormLoading(false); - return; - } - if (!selectedSubjectId && !selectedMentorSubjectId) { - setFormError('Выберите предмет'); - setFormLoading(false); - return; - } - if (!formData.start_date || !formData.start_time) { - setFormError('Укажите дату и время'); - setFormLoading(false); - return; + const isCompleted = editingLessonStatus === 'completed'; + if (!isCompleted) { + if (!formData.client) { + setFormError('Выберите ученика'); + setFormLoading(false); + return; + } + if (!selectedSubjectId && !selectedMentorSubjectId) { + setFormError('Выберите предмет'); + setFormLoading(false); + return; + } + if (!formData.start_date || !formData.start_time) { + setFormError('Укажите дату и время'); + setFormLoading(false); + return; + } } if (formData.price == null || formData.price < 0) { setFormError('Укажите стоимость занятия'); @@ -350,22 +357,26 @@ export default function SchedulePage() { return; } - // Конвертируем время из timezone пользователя в UTC - const startUtc = createDateTimeInUserTimezone( - formData.start_date, - formData.start_time, - user?.timezone - ); + const startUtc = !isCompleted + ? createDateTimeInUserTimezone(formData.start_date, formData.start_time, user?.timezone) + : ''; const title = generateTitle(); if (isEditingMode && editingLessonId) { - await updateLesson(editingLessonId, { - title, - description: formData.description, - start_time: startUtc, - duration: formData.duration, - price: formData.price, - }); + if (editingLessonStatus === 'completed') { + await updateLesson(editingLessonId, { + price: formData.price, + status: formData.status ?? 'completed', + }); + } else { + await updateLesson(editingLessonId, { + title, + description: formData.description, + start_time: startUtc, + duration: formData.duration, + price: formData.price, + }); + } } else { const payload: any = { client: formData.client, @@ -384,16 +395,10 @@ export default function SchedulePage() { setIsFormVisible(false); setEditingLessonId(null); + setEditingLessonStatus(null); loadLessons(); } catch (err: any) { - const msg = err?.response?.data - ? typeof err.response.data === 'object' - ? Object.entries(err.response.data) - .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`) - .join('\n') - : String(err.response.data) - : err?.message || 'Ошибка сохранения занятия'; - setFormError(msg); + setFormError(getErrorMessage(err, 'Не удалось сохранить занятие. Проверьте данные.')); } finally { setFormLoading(false); } @@ -407,9 +412,10 @@ export default function SchedulePage() { await deleteLesson(editingLessonId, deleteAllFuture); setIsFormVisible(false); setEditingLessonId(null); + setEditingLessonStatus(null); loadLessons(); } catch (err: any) { - setFormError(err?.message || 'Ошибка удаления занятия'); + setFormError(getErrorMessage(err, 'Не удалось удалить занятие.')); } finally { setFormLoading(false); } @@ -420,6 +426,7 @@ export default function SchedulePage() { setIsEditingMode(false); setFormError(null); setEditingLessonId(null); + setEditingLessonStatus(null); }; return ( @@ -476,6 +483,7 @@ export default function SchedulePage() { onSubmit={handleSubmit} onCancel={handleCancel} onDelete={isEditingMode ? handleDelete : undefined} + isCompletedLesson={editingLessonStatus === 'completed'} /> diff --git a/front_material/components/chat/ChatWindow.tsx b/front_material/components/chat/ChatWindow.tsx index 3c056dd..47d08dd 100644 --- a/front_material/components/chat/ChatWindow.tsx +++ b/front_material/components/chat/ChatWindow.tsx @@ -237,6 +237,7 @@ export function ChatWindow({ setLoadingMore(false); setPage(1); setHasMore(false); + (async () => { try { const pageSize = 30; @@ -244,7 +245,6 @@ export function ChatWindow({ ? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: pageSize }) : await getMessages(chat.id, { page: 1, page_size: pageSize }); const initial = (resp.results || []) as Message[]; - // сортируем по времени на всякий случай const sorted = [...initial].sort((a: any, b: any) => { const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; const tb = b?.created_at ? new Date(b.created_at).getTime() : 0; @@ -252,13 +252,14 @@ export function ChatWindow({ }); setMessages(sorted); setHasMore(!!(resp as any).next || ((resp as any).count ?? 0) > sorted.length); - // прочитанность отмечается по мере попадания сообщений в зону видимости (IntersectionObserver) + + // Молниеносный скролл вниз (мгновенно, без анимации) + requestAnimationFrame(() => { + const el = listRef.current; + if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'auto' }); + }); } finally { setLoading(false); - // scroll down - setTimeout(() => { - listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' }); - }, 50); } })(); }, [chat?.id, chatUuid]); diff --git a/front_material/components/checklesson/checklesson.tsx b/front_material/components/checklesson/checklesson.tsx index 3ed32b2..4994429 100644 --- a/front_material/components/checklesson/checklesson.tsx +++ b/front_material/components/checklesson/checklesson.tsx @@ -28,6 +28,8 @@ export interface CheckLessonFormData { duration: number; price: number | undefined; is_recurring: boolean; + /** Статус (для завершённых занятий — можно менять) */ + status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; } export interface CheckLessonProps { @@ -72,6 +74,8 @@ export interface CheckLessonProps { onCancel: () => void; /** Удалить занятие (только в режиме редактирования). deleteAllFuture — удалить всю цепочку постоянных. */ onDelete?: (deleteAllFuture: boolean) => void; + /** Редактируется завершённое занятие — можно менять только цену и статус */ + isCompletedLesson?: boolean; } export const CheckLesson: React.FC = ({ @@ -102,6 +106,7 @@ export const CheckLesson: React.FC = ({ onSubmit, onCancel, onDelete, + isCompletedLesson = false, }) => { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const navDisabled = lessonsLoading || isFormVisible; @@ -142,21 +147,11 @@ export const CheckLesson: React.FC = ({ width: '100%', height: '100%', minHeight: '548px', - perspective: '1000px', display: 'flex', flexDirection: 'column', }} > -
+
{/* Лицевая сторона: Список занятий */}
= ({ left: 0, width: '100%', height: '100%', - backfaceVisibility: 'hidden', - WebkitBackfaceVisibility: 'hidden', - transform: 'rotateY(0deg)', + opacity: isFormVisible ? 0 : 1, + visibility: isFormVisible ? 'hidden' : 'visible', + transition: 'opacity 0.2s ease', borderRadius: '20px', padding: '24px', overflowY: 'hidden', - transformOrigin: 'center center', display: 'flex', flexDirection: 'column', }} @@ -369,13 +363,12 @@ export const CheckLesson: React.FC = ({ left: 0, width: '100%', height: '100%', - backfaceVisibility: 'hidden', - WebkitBackfaceVisibility: 'hidden', - transform: 'rotateY(180deg)', + opacity: isFormVisible ? 1 : 0, + visibility: isFormVisible ? 'visible' : 'hidden', + transition: 'opacity 0.2s ease', borderRadius: '20px', padding: '24px', overflowY: 'auto', - transformOrigin: 'center center', display: 'flex', flexDirection: 'column', }} @@ -413,7 +406,11 @@ export const CheckLesson: React.FC = ({ margin: 0, }} > - {isEditingMode ? 'Редактировать занятие' : 'Создать занятие'} + {isCompletedLesson + ? 'Изменить завершённое занятие' + : isEditingMode + ? 'Редактировать занятие' + : 'Создать занятие'}
)} + {!isCompletedLesson && (
+ )} + {!isCompletedLesson && (
+ )} + {!isCompletedLesson && (
+ )} + {!isCompletedLesson && ( + <>
+ + )} + + {isCompletedLesson && ( +
+ + +

+ Изменение статуса задним числом не отправляет уведомления ученику и родителям +

+
+ )}
= ({ }} >
- {isEditingMode && onDelete && ( + {isEditingMode && !isCompletedLesson && onDelete && (
); diff --git a/front_material/components/homework/EditHomeworkDraftModal.tsx b/front_material/components/homework/EditHomeworkDraftModal.tsx index ca9459d..5f38d41 100644 --- a/front_material/components/homework/EditHomeworkDraftModal.tsx +++ b/front_material/components/homework/EditHomeworkDraftModal.tsx @@ -1,718 +1,718 @@ -'use client'; - -import React, { useEffect, useState, useRef } from 'react'; -import { - updateHomework, - publishHomework, - type Homework, - type HomeworkFileItem, -} from '@/api/homework'; -import { getMyMaterials } from '@/api/materials'; -import type { Material } from '@/api/materials'; -import apiClient from '@/lib/api-client'; - -const MAX_FILE_SIZE_MB = 10; -const MAX_FILES = 10; - -interface EditHomeworkDraftModalProps { - isOpen: boolean; - homework: Homework | null; - onClose: () => void; - onSuccess: () => void; -} - -function getFileUrl(file: HomeworkFileItem | null): string { - if (!file?.file) return ''; - if (file.file.startsWith('http')) return file.file; - const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : ''; - return file.file.startsWith('/') ? `${base}${file.file}` : `${base}/${file.file}`; -} - -export function EditHomeworkDraftModal({ - isOpen, - homework, - onClose, - onSuccess, -}: EditHomeworkDraftModalProps) { - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [deadline, setDeadline] = useState(''); - const [existingFiles, setExistingFiles] = useState([]); - const [newFiles, setNewFiles] = useState([]); - const [uploadingFiles, setUploadingFiles] = useState>(new Set()); - const [materials, setMaterials] = useState([]); - const [materialsLoading, setMaterialsLoading] = useState(false); - const [selectedMaterialIds, setSelectedMaterialIds] = useState>(new Set()); - const [materialsSearch, setMaterialsSearch] = useState(''); - const [saving, setSaving] = useState(false); - const [publishing, setPublishing] = useState(false); - const [error, setError] = useState(null); - const fileInputRef = useRef(null); - - useEffect(() => { - if (!isOpen || !homework) return; - setTitle(homework.title || ''); - setDescription(homework.description || ''); - setDeadline(homework.deadline ? homework.deadline.slice(0, 16) : ''); - setExistingFiles(homework.files?.filter(f => f.file_type === 'assignment') || []); - setNewFiles([]); - setSelectedMaterialIds(new Set()); - setError(null); - }, [isOpen, homework]); - - useEffect(() => { - if (!isOpen) return; - setMaterialsLoading(true); - getMyMaterials() - .then((list) => setMaterials(Array.isArray(list) ? list : [])) - .catch(() => setMaterials([])) - .finally(() => setMaterialsLoading(false)); - }, [isOpen]); - - const handleFileChange = async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - if (!files.length || !homework) return; - - const validFiles: File[] = []; - for (const file of files) { - if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { - setError(`Файл "${file.name}" больше ${MAX_FILE_SIZE_MB} МБ`); - continue; - } - if (existingFiles.length + newFiles.length + validFiles.length >= MAX_FILES) { - setError(`Максимум ${MAX_FILES} файлов`); - break; - } - validFiles.push(file); - } - - for (const file of validFiles) { - const fileKey = `${file.name}-${Date.now()}`; - setUploadingFiles((prev) => new Set(prev).add(fileKey)); - setNewFiles((prev) => [...prev, file]); - - try { - const formData = new FormData(); - formData.append('homework', String(homework.id)); - formData.append('file_type', 'assignment'); - formData.append('file', file); - - const res = await apiClient.post('/homework/files/', formData); - setExistingFiles((prev) => [...prev, res.data]); - setNewFiles((prev) => prev.filter((f) => f !== file)); - } catch (err) { - setError(err instanceof Error ? err.message : 'Ошибка загрузки файла'); - setNewFiles((prev) => prev.filter((f) => f !== file)); - } finally { - setUploadingFiles((prev) => { - const next = new Set(prev); - next.delete(fileKey); - return next; - }); - } - } - - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - const handleRemoveFile = async (fileId: number) => { - try { - await apiClient.delete(`/homework/files/${fileId}/`); - setExistingFiles((prev) => prev.filter((f) => f.id !== fileId)); - } catch (err) { - setError(err instanceof Error ? err.message : 'Ошибка удаления файла'); - } - }; - - const handleMaterialToggle = (materialId: string) => { - setSelectedMaterialIds((prev) => { - const next = new Set(prev); - if (next.has(materialId)) { - next.delete(materialId); - } else { - next.add(materialId); - } - return next; - }); - }; - - const attachMaterialFiles = async () => { - if (!homework || selectedMaterialIds.size === 0) return; - - for (const materialId of selectedMaterialIds) { - const material = materials.find((m) => String(m.id) === materialId); - if (!material?.file) continue; - - try { - const response = await fetch(material.file); - const blob = await response.blob(); - const filename = material.title || material.file.split('/').pop() || 'material'; - const file = new File([blob], filename, { type: blob.type }); - - const formData = new FormData(); - formData.append('homework', String(homework.id)); - formData.append('file_type', 'assignment'); - formData.append('file', file); - - const res = await apiClient.post('/homework/files/', formData); - setExistingFiles((prev) => [...prev, res.data]); - } catch { - // Ignore material attach errors - } - } - setSelectedMaterialIds(new Set()); - }; - - const handleSave = async () => { - if (!homework) return; - try { - setError(null); - setSaving(true); - await attachMaterialFiles(); - await updateHomework(homework.id, { - title: title.trim() || homework.title, - description: description.trim(), - deadline: deadline ? new Date(deadline).toISOString() : null, - }); - onSuccess(); - } catch (e) { - setError(e instanceof Error ? e.message : 'Ошибка сохранения'); - } finally { - setSaving(false); - } - }; - - const handlePublish = async () => { - if (!homework) return; - if (!title.trim()) { - setError('Укажите название задания'); - return; - } - if (!description.trim()) { - setError('Укажите текст задания'); - return; - } - try { - setError(null); - setPublishing(true); - await attachMaterialFiles(); - await updateHomework(homework.id, { - title: title.trim(), - description: description.trim(), - deadline: deadline ? new Date(deadline).toISOString() : null, - }); - await publishHomework(homework.id); - onSuccess(); - onClose(); - } catch (e) { - setError(e instanceof Error ? e.message : 'Ошибка публикации'); - } finally { - setPublishing(false); - } - }; - - if (!isOpen || !homework) return null; - - const isLoading = saving || publishing || uploadingFiles.size > 0; - - const filteredMaterials = materials.filter((m) => { - if (!materialsSearch.trim()) return true; - const q = materialsSearch.toLowerCase(); - return ( - (m.title || '').toLowerCase().includes(q) || - (m.description || '').toLowerCase().includes(q) - ); - }); - - return ( - <> -
-
- {/* Header */} -
-

- Заполнить домашнее задание -

- -
- - {/* Content */} -
-
- {/* Title */} -
- - setTitle(e.target.value)} - placeholder="Введите название" - disabled={isLoading} - style={{ - width: '100%', - padding: '12px 16px', - borderRadius: 12, - border: '1px solid var(--md-sys-color-outline)', - background: 'var(--md-sys-color-surface)', - fontSize: 15, - color: 'var(--md-sys-color-on-surface)', - }} - /> -
- - {/* Description */} -
- -