Compare commits

..

No commits in common. "a167683bd9c39a7db08d7ebd79bccc9ba2d77d63" and "d9121fe6ef01ecf6dcc017e4cf9617bdf343eec3" have entirely different histories.

37 changed files with 4257 additions and 4682 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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}
)

View File

@ -479,120 +479,6 @@ 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, only_if_someone_not_connected=False):
"""
Отправить ментору в Telegram сообщение о завершённом занятии
с кнопками «Занятие состоялось» / «Занятие отменилось».
only_if_someone_not_connected: при True отправить только если ментор или ученик не подключались
(при авто-завершении Celery). При False всегда отправить (ручное завершение, сигнал).
"""
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
logger.info(f'send_lesson_completion_confirmation_telegram: lesson_id={lesson_id}')
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:
logger.warning(f'Lesson {lesson_id}: mentor is None, skipping completion confirmation')
return
if not mentor.telegram_id:
logger.warning(
f'Lesson {lesson_id}: mentor {mentor.id} has no telegram_id linked, skipping completion confirmation'
)
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 'Не подключался'
someone_not_connected = not mentor_connected or not client_connected
if only_if_someone_not_connected and not someone_not_connected:
logger.info(f'Lesson {lesson_id}: both participants connected, skipping completion confirmation')
return
message = (
f"⏱ <b>Занятие завершилось по времени</b>\n\n"
f"📚 <b>{lesson.title}</b>\n"
f"👤 {student_name}\n\n"
f"🕐 <b>Время:</b> {start_str}{end_str}\n\n"
f"📡 <b>Подключения:</b>\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):
"""

View File

@ -1611,50 +1611,6 @@ 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"✅ <b>Подтверждено</b>\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"❌ <b>Отменено</b>\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:

View File

@ -1,32 +0,0 @@
# 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="Студент подключился",
),
),
]

View File

@ -364,20 +364,6 @@ 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,

View File

@ -102,7 +102,7 @@ class LessonSerializer(serializers.ModelSerializer):
'livekit_room_name'
]
read_only_fields = [
'id', 'end_time', 'reminder_sent',
'id', 'end_time', 'status', 'reminder_sent',
'created_at', 'updated_at', 'livekit_room_name'
]
@ -114,16 +114,6 @@ 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
@ -131,12 +121,10 @@ class LessonSerializer(serializers.ModelSerializer):
start_time = attrs.get('start_time')
duration = attrs.get('duration', 60)
# Проверка: допускаем создание занятий до 30 минут в прошлом
now = timezone.now()
tolerance = timedelta(minutes=30)
if start_time and start_time < now - tolerance:
# Проверка что занятие в будущем
if start_time and start_time <= timezone.now():
raise serializers.ValidationError({
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
'start_time': 'Занятие должно быть запланировано в будущем'
})
# Проверка конфликтов (только при создании или изменении времени)
@ -380,7 +368,8 @@ class LessonCreateSerializer(serializers.ModelSerializer):
attrs['mentor_subject'] = mentor_subject
attrs['subject_name'] = mentor_subject.name
# Проверка: допускаем создание занятий до 30 минут в прошлом
# Проверка что занятие в будущем
# Убеждаемся, что start_time в UTC и aware
if start_time:
if not django_timezone.is_aware(start_time):
start_time = pytz.UTC.localize(start_time)
@ -388,10 +377,9 @@ class LessonCreateSerializer(serializers.ModelSerializer):
start_time = start_time.astimezone(pytz.UTC)
now = django_timezone.now()
tolerance = timedelta(minutes=30)
if start_time < now - tolerance:
if start_time <= now:
raise serializers.ValidationError({
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
'start_time': f'Занятие должно быть запланировано в будущем. Текущее время: {now.isoformat()}, указанное время: {start_time.isoformat()}'
})
# Рассчитываем время окончания
@ -633,12 +621,12 @@ class LessonCalendarSerializer(serializers.Serializer):
'end_date': 'Дата окончания должна быть позже даты начала'
})
# Ограничение диапазона (не более 6 месяцев — для календаря, смена месяцев)
# Ограничение диапазона (не более 3 месяцев)
if start_date and end_date:
delta = end_date - start_date
if delta.days > 180:
if delta.days > 90:
raise serializers.ValidationError(
'Диапазон не может превышать 180 дней'
'Диапазон не может превышать 90 дней'
)
return attrs

View File

@ -1,105 +1,93 @@
"""
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, send_lesson_completion_confirmation_telegram
@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'
)
# Если занятие создано сразу в статусе completed (задним числом) — отправляем подтверждение в Telegram
if instance.status == 'completed':
send_lesson_completion_confirmation_telegram.delay(instance.id)
# Напоминания отправляются периодической задачей 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'):
# При переводе в completed — всегда отправляем подтверждение в Telegram (с кнопками «состоялось»/«отменилось»)
# Работает для ручного завершения и для занятий, созданных/завершённых задним числом
if instance.status == 'completed':
send_lesson_completion_confirmation_telegram.delay(instance.id)
# Общие уведомления — не отправляем, если занятие уже в прошлом
# (коррекция статуса/стоимости после факта, например отмена задним числом)
ref_time = instance.end_time or instance.start_time
if ref_time and ref_time < timezone.now():
return # Занятие уже прошло — пропускаем lesson_cancelled / lesson_completed
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'):
# Статус изменился
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
})

View File

@ -4,7 +4,7 @@ from celery import shared_task
import logging
from django.utils import timezone
from datetime import timedelta
from django.db.models import Count, Q
from django.db.models import Count
from .models import Lesson, Subject, MentorSubject
logger = logging.getLogger(__name__)
@ -380,8 +380,7 @@ def start_lessons_automatically():
now = timezone.now()
started_count = 0
completed_count = 0
logger.info(f'[start_lessons_automatically] Запуск')
try:
# Находим все запланированные занятия, которые должны начаться
# start_time <= now (время начала уже наступило)
@ -403,22 +402,17 @@ def start_lessons_automatically():
for lesson in lessons_to_start_list:
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"')
# Находим занятия, которые уже прошли и должны быть завершены:
# end_time < now - 15 минут (всегда ждём 15 мин после конца, даже если занятие создали задним числом)
fifteen_minutes_ago = now - timedelta(minutes=15)
# Находим занятия, которые уже прошли и должны быть завершены
# end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение)
# status in ['scheduled', 'in_progress'] (еще не завершены)
five_minutes_ago = now - timedelta(minutes=5)
lessons_to_complete = Lesson.objects.filter(
status__in=['scheduled', 'in_progress'],
end_time__lt=fifteen_minutes_ago
end_time__lt=five_minutes_ago
).select_related('mentor', 'client')
lessons_to_complete_list = list(lessons_to_complete)
if lessons_to_complete_list:
logger.info(
f'[start_lessons_automatically] Найдено {len(lessons_to_complete_list)} занятий '
f'для завершения (end_time < {fifteen_minutes_ago})'
)
# Оптимизация: используем bulk_update вместо цикла с save()
lessons_to_complete_list = list(lessons_to_complete)
for lesson in lessons_to_complete_list:
lesson.status = 'completed'
lesson.completed_at = now
@ -443,14 +437,6 @@ 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, only_if_someone_not_connected=True)
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}')

View File

@ -19,26 +19,7 @@
{ "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": "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" }
{ "country_code": "RU", "country_name": "Россия", "city": "Ульяновск", "timezone": "Europe/Samara" }
]

View File

@ -36,22 +36,6 @@ 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"},
@ -59,6 +43,7 @@ POPULAR_CITIES = [
{"country_code": "BY", "country_name": "Беларусь", "city": "Минск", "timezone": "Europe/Minsk"},
# Украина
{"country_code": "UA", "country_name": "Украина", "city": "Киев", "timezone": "Europe/Kyiv"},
# Другие крупные города СНГ можно добавлять по мере необходимости
]

View File

@ -275,84 +275,6 @@ 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", "lesson_id": 123 } lesson_id опционален, резерв при 404 по room_name
"""
room_name = request.data.get('room_name')
lesson_id = request.data.get('lesson_id')
if not room_name and not lesson_id:
return Response(
{'error': 'Укажите room_name или lesson_id'},
status=status.HTTP_400_BAD_REQUEST
)
video_room = None
if room_name:
try:
import uuid as uuid_module
room_uuid = uuid_module.UUID(str(room_name).strip())
video_room = VideoRoom.objects.get(room_id=room_uuid)
except (ValueError, VideoRoom.DoesNotExist):
pass
if not video_room and lesson_id:
try:
video_room = VideoRoom.objects.get(lesson_id=lesson_id)
except VideoRoom.DoesNotExist:
pass
if not video_room:
logger.warning(
'participant_connected: VideoRoom not found (room_name=%s, lesson_id=%s, user=%s)',
room_name, lesson_id, getattr(request.user, 'pk', request.user)
)
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
is_mentor = user.pk == video_room.mentor_id
client_pk = getattr(client_user, 'pk', None) or getattr(client_user, 'id', None)
is_client = client_pk is not None and user.pk == client_pk
if not is_mentor and not is_client:
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)
logger.info(
'participant_connected: marked %s for lesson %s (room %s)',
'mentor' if is_mentor else 'client',
video_room.lesson_id,
video_room.room_id,
)
return Response({'success': True}, status=status.HTTP_200_OK)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def update_livekit_participant_media_state(request):
@ -414,8 +336,6 @@ 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:
# Обновляем существующего участника

File diff suppressed because it is too large Load Diff

View File

@ -26,11 +26,10 @@ def create_video_room_for_lesson(sender, instance, created, **kwargs):
video_room = None
if not video_room:
client_user = instance.client.user if hasattr(instance.client, 'user') else instance.client
video_room = VideoRoom.objects.create(
lesson=instance,
mentor=instance.mentor,
client=client_user,
client=instance.client,
is_recording=True, # По умолчанию включаем запись
max_participants=2
)

View File

@ -1,36 +1,35 @@
"""
URL routing для видео API.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
VideoRoomViewSet,
VideoParticipantViewSet,
VideoCallLogViewSet,
ScreenRecordingViewSet
)
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, participant_connected
router = DefaultRouter()
router.register(r'rooms', VideoRoomViewSet, basename='videoroom')
router.register(r'participants', VideoParticipantViewSet, basename='videoparticipant')
router.register(r'logs', VideoCallLogViewSet, basename='videocalllog')
router.register(r'recordings', ScreenRecordingViewSet, basename='screenrecording')
# Janus Gateway endpoints (параллельно с ion-sfu)
router.register(r'janus', JanusVideoRoomViewSet, basename='janus-videoroom')
# Token-based access (публичный доступ по токену)
router.register(r'token', VideoRoomTokenViewSet, basename='videoroom-token')
urlpatterns = [
path('', include(router.urls)),
# LiveKit endpoints
path('livekit/create-room/', create_livekit_room, name='livekit-create-room'),
path('livekit/config/', get_livekit_config, name='livekit-config'),
path('livekit/rooms/lesson/<int:lesson_id>/', 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'),
]
"""
URL routing для видео API.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
VideoRoomViewSet,
VideoParticipantViewSet,
VideoCallLogViewSet,
ScreenRecordingViewSet
)
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
router = DefaultRouter()
router.register(r'rooms', VideoRoomViewSet, basename='videoroom')
router.register(r'participants', VideoParticipantViewSet, basename='videoparticipant')
router.register(r'logs', VideoCallLogViewSet, basename='videocalllog')
router.register(r'recordings', ScreenRecordingViewSet, basename='screenrecording')
# Janus Gateway endpoints (параллельно с ion-sfu)
router.register(r'janus', JanusVideoRoomViewSet, basename='janus-videoroom')
# Token-based access (публичный доступ по токену)
router.register(r'token', VideoRoomTokenViewSet, basename='videoroom-token')
urlpatterns = [
path('', include(router.urls)),
# LiveKit endpoints
path('livekit/create-room/', create_livekit_room, name='livekit-create-room'),
path('livekit/config/', get_livekit_config, name='livekit-config'),
path('livekit/rooms/lesson/<int:lesson_id>/', 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'),
]

View File

@ -81,12 +81,6 @@ app.conf.beat_schedule = {
# ============================================
# ЗАДАЧИ РАСПИСАНИЯ
# ============================================
# Автоматическое начало и завершение занятий по времени (каждую минуту)
'start-lessons-automatically': {
'task': 'apps.schedule.tasks.start_lessons_automatically',
'schedule': 60.0, # каждые 60 секунд
},
# Отправка напоминаний о занятиях за 1 час
'send-lesson-reminders': {

View File

@ -2,8 +2,8 @@
# Docker Compose PROD (порты не пересекаются с dev на одном хосте)
# ==============================================
# Порты на хосте (prod): db 5434, redis 6381, web 8123, nginx 8084,
# front_material 3010, yjs 1236, excalidraw 3004, livekit 7880/7881,
# celery/beat — без портов (внутренние)
# front_material 3010, yjs 1236, excalidraw 3004, whiteboard 8083,
# livekit 7880/7881, celery/beat — без портов (внутренние)
# Dev использует: 5433, 6380, 8124, 8081, 3002, 1235, 3003, 8082, livekit 7890/7891
#
# ВАЖНО: PROD использует отдельную сеть (prod_network) и именованные volumes
@ -273,6 +273,17 @@ 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 без бэкапа!

View File

@ -91,6 +91,12 @@ 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;

View File

@ -1,13 +0,0 @@
{
"builder": {
"gc": {
"defaultKeepStorage": "10GB",
"enabled": true
}
},
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}

View File

@ -5,7 +5,6 @@ node_modules
*.md
.env*.local
.env
.env.*
.DS_Store
*.log
npm-debug.log*
@ -16,10 +15,3 @@ coverage
.nyc_output
.vscode
.idea
docs
.cursor
agent-transcripts
__pycache__
*.pyc
.pytest_cache
.mypy_cache

View File

@ -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<Homework> {
const res = await apiClient.get<Homework>(`/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;
/** Максимальный балл (15 по умолчанию). По умолчанию 5. */
max_score?: number;
/** Проходной балл (по умолчанию 1, не учитывается). */
passing_score?: number;
}): Promise<Homework> {
const payload = {
...data,
max_score: data.max_score ?? 5,
passing_score: data.passing_score ?? 1,
};
const res = await apiClient.post<Homework>('/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<HomeworkSubmission[]> {
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<HomeworkSubmission | null> {
const list = await getHomeworkSubmissions(homeworkId, options);
return list.length > 0 ? list[0] : null;
}
/** Получить одно решение по ID (для детального просмотра). */
export async function getHomeworkSubmission(
submissionId: string | number
): Promise<HomeworkSubmission> {
const res = await apiClient.get<HomeworkSubmission>(
`/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<unknown> {
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;
}
/** Проверить решение через ИИ. Ментор: задание + решение → комментарий и оценка 15. */
export async function checkSubmissionWithAi(
submissionId: string | number
): Promise<CheckWithAiResponse> {
const res = await apiClient.post<CheckWithAiResponse>(
`/homework/submissions/${submissionId}/check_with_ai/`
);
return res.data;
}
export async function returnSubmissionForRevision(
submissionId: string | number,
feedback: string
): Promise<unknown> {
const res = await apiClient.post(`/homework/submissions/${submissionId}/return_for_revision/`, { feedback });
return res.data;
}
/** Удалить своё решение (студент). Задание снова переходит в ожидание загрузки. */
export async function deleteSubmission(submissionId: string | number): Promise<void> {
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<Homework> {
const res = await apiClient.patch<Homework>(`/homework/homeworks/${homeworkId}/`, data);
return res.data;
}
/**
* Опубликовать домашнее задание (из черновика в published).
* POST /api/homework/homeworks/{id}/publish/
*/
export async function publishHomework(homeworkId: string | number): Promise<Homework> {
const res = await apiClient.post<Homework>(`/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<unknown> {
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<Homework> {
const res = await apiClient.get<Homework>(`/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;
/** Максимальный балл (15 по умолчанию). По умолчанию 5. */
max_score?: number;
/** Проходной балл (по умолчанию 1, не учитывается). */
passing_score?: number;
}): Promise<Homework> {
const payload = {
...data,
max_score: data.max_score ?? 5,
passing_score: data.passing_score ?? 1,
};
const res = await apiClient.post<Homework>('/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<HomeworkSubmission[]> {
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<HomeworkSubmission | null> {
const list = await getHomeworkSubmissions(homeworkId, options);
return list.length > 0 ? list[0] : null;
}
/** Получить одно решение по ID (для детального просмотра). */
export async function getHomeworkSubmission(
submissionId: string | number
): Promise<HomeworkSubmission> {
const res = await apiClient.get<HomeworkSubmission>(
`/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<unknown> {
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;
}
/** Проверить решение через ИИ. Ментор: задание + решение → комментарий и оценка 15. */
export async function checkSubmissionWithAi(
submissionId: string | number
): Promise<CheckWithAiResponse> {
const res = await apiClient.post<CheckWithAiResponse>(
`/homework/submissions/${submissionId}/check_with_ai/`
);
return res.data;
}
export async function returnSubmissionForRevision(
submissionId: string | number,
feedback: string
): Promise<unknown> {
const res = await apiClient.post(`/homework/submissions/${submissionId}/return_for_revision/`, { feedback });
return res.data;
}
/** Удалить своё решение (студент). Задание снова переходит в ожидание загрузки. */
export async function deleteSubmission(submissionId: string | number): Promise<void> {
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<Homework> {
const res = await apiClient.patch<Homework>(`/homework/homeworks/${homeworkId}/`, data);
return res.data;
}
/**
* Опубликовать домашнее задание (из черновика в published).
* POST /api/homework/homeworks/{id}/publish/
*/
export async function publishHomework(homeworkId: string | number): Promise<Homework> {
const res = await apiClient.post<Homework>(`/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<unknown> {
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;
}

View File

@ -1,66 +1,52 @@
/**
* API для работы с LiveKit
*/
import apiClient from '@/lib/api-client';
export interface LiveKitRoomResponse {
room_name: string;
ws_url: string;
access_token: string;
server_url: string;
ice_servers?: Array<{
urls: string[];
username?: string;
credential?: string;
}>;
video_room_id?: number;
is_admin?: boolean;
lesson?: {
id: number;
title: string;
start_time: string;
end_time: string;
};
}
export interface LiveKitConfig {
server_url: string;
ice_servers?: Array<{
urls: string[];
username?: string;
credential?: string;
}>;
}
/**
* Создать LiveKit комнату для занятия
*/
export async function createLiveKitRoom(lessonId: number): Promise<LiveKitRoomResponse> {
const res = await apiClient.post<LiveKitRoomResponse>('/video/livekit/create-room/', {
lesson_id: lessonId,
});
return res.data;
}
/**
* Получить конфигурацию LiveKit
*/
export async function getLiveKitConfig(): Promise<LiveKitConfig> {
const res = await apiClient.get<LiveKitConfig>('/video/livekit/config/');
return res.data;
}
/**
* Отметить подключение участника к видеокомнате (для метрик).
* lessonId резерв при 404 по room_name (например, если room.name отличается от БД).
*/
export async function participantConnected(params: {
roomName: string;
lessonId?: number | null;
}): Promise<void> {
const { roomName, lessonId } = params;
const body: { room_name: string; lesson_id?: number } = { room_name: roomName };
if (lessonId != null) body.lesson_id = lessonId;
await apiClient.post('/video/livekit/participant-connected/', body);
}
/**
* API для работы с LiveKit
*/
import apiClient from '@/lib/api-client';
export interface LiveKitRoomResponse {
room_name: string;
ws_url: string;
access_token: string;
server_url: string;
ice_servers?: Array<{
urls: string[];
username?: string;
credential?: string;
}>;
video_room_id?: number;
is_admin?: boolean;
lesson?: {
id: number;
title: string;
start_time: string;
end_time: string;
};
}
export interface LiveKitConfig {
server_url: string;
ice_servers?: Array<{
urls: string[];
username?: string;
credential?: string;
}>;
}
/**
* Создать LiveKit комнату для занятия
*/
export async function createLiveKitRoom(lessonId: number): Promise<LiveKitRoomResponse> {
const res = await apiClient.post<LiveKitRoomResponse>('/video/livekit/create-room/', {
lesson_id: lessonId,
});
return res.data;
}
/**
* Получить конфигурацию LiveKit
*/
export async function getLiveKitConfig(): Promise<LiveKitConfig> {
const res = await apiClient.get<LiveKitConfig>('/video/livekit/config/');
return res.data;
}

View File

@ -147,8 +147,6 @@ export interface UpdateLessonData {
start_time?: string;
duration?: number;
price?: number;
/** Для завершённых занятий — можно изменить статус (cancelled и т.д.) */
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
}
/**

View File

@ -3,7 +3,6 @@
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([
@ -40,7 +39,7 @@ export default function ForgotPasswordPage() {
await requestPasswordReset({ email });
setSuccess(true);
} catch (err: any) {
setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.'));
setError(err.response?.data?.detail || 'Ошибка при отправке запроса. Проверьте email.');
} finally {
setLoading(false);
}

View File

@ -5,7 +5,6 @@ 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([
@ -145,7 +144,14 @@ export default function RegisterPage() {
setRegistrationSuccess(true);
return;
} catch (err: any) {
setError(getErrorMessage(err, 'Ошибка регистрации. Проверьте данные.'));
setError(
err.response?.data?.detail ||
(Array.isArray(err.response?.data?.email)
? err.response.data.email[0]
: err.response?.data?.email) ||
err.response?.data?.message ||
'Ошибка регистрации. Проверьте данные.'
);
} finally {
setLoading(false);
}

View File

@ -3,7 +3,6 @@
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([
@ -44,7 +43,11 @@ function ResetPasswordContent() {
await confirmPasswordReset(token, password, confirmPassword);
setSuccess(true);
} catch (err: any) {
setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.'));
setError(
err.response?.data?.error?.message ||
err.response?.data?.detail ||
'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.'
);
} finally {
setLoading(false);
}

View File

@ -3,7 +3,6 @@
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([
@ -48,7 +47,11 @@ function VerifyEmailContent() {
.catch((err: any) => {
if (cancelled) return;
setStatus('error');
setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.'));
const msg =
err.response?.data?.error?.message ||
err.response?.data?.detail ||
'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.';
setMessage(msg);
});
return () => {

View File

@ -24,7 +24,6 @@ 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';
@ -62,8 +61,7 @@ export default function SchedulePage() {
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
const [selectedMentorSubjectId, setSelectedMentorSubjectId] = useState<number | null>(null);
const [editingLessonId, setEditingLessonId] = useState<string | null>(null);
const [editingLessonStatus, setEditingLessonStatus] = useState<string | null>(null);
// Компоненты Material Web
const [buttonComponentsLoaded, setButtonComponentsLoaded] = useState(false);
const [formComponentsLoaded, setFormComponentsLoaded] = useState(false);
@ -266,9 +264,7 @@ 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 || '';
@ -333,23 +329,20 @@ export default function SchedulePage() {
setFormError(null);
try {
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.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('Укажите стоимость занятия');
@ -357,26 +350,22 @@ export default function SchedulePage() {
return;
}
const startUtc = !isCompleted
? createDateTimeInUserTimezone(formData.start_date, formData.start_time, user?.timezone)
: '';
// Конвертируем время из timezone пользователя в UTC
const startUtc = createDateTimeInUserTimezone(
formData.start_date,
formData.start_time,
user?.timezone
);
const title = generateTitle();
if (isEditingMode && editingLessonId) {
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,
});
}
await updateLesson(editingLessonId, {
title,
description: formData.description,
start_time: startUtc,
duration: formData.duration,
price: formData.price,
});
} else {
const payload: any = {
client: formData.client,
@ -395,10 +384,16 @@ export default function SchedulePage() {
setIsFormVisible(false);
setEditingLessonId(null);
setEditingLessonStatus(null);
loadLessons();
} catch (err: any) {
setFormError(getErrorMessage(err, 'Не удалось сохранить занятие. Проверьте данные.'));
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);
} finally {
setFormLoading(false);
}
@ -412,10 +407,9 @@ export default function SchedulePage() {
await deleteLesson(editingLessonId, deleteAllFuture);
setIsFormVisible(false);
setEditingLessonId(null);
setEditingLessonStatus(null);
loadLessons();
} catch (err: any) {
setFormError(getErrorMessage(err, 'Не удалось удалить занятие.'));
setFormError(err?.message || 'Ошибка удаления занятия');
} finally {
setFormLoading(false);
}
@ -426,7 +420,6 @@ export default function SchedulePage() {
setIsEditingMode(false);
setFormError(null);
setEditingLessonId(null);
setEditingLessonStatus(null);
};
return (
@ -483,7 +476,6 @@ export default function SchedulePage() {
onSubmit={handleSubmit}
onCancel={handleCancel}
onDelete={isEditingMode ? handleDelete : undefined}
isCompletedLesson={editingLessonStatus === 'completed'}
/>
</div>
</div>

View File

@ -237,7 +237,6 @@ export function ChatWindow({
setLoadingMore(false);
setPage(1);
setHasMore(false);
(async () => {
try {
const pageSize = 30;
@ -245,6 +244,7 @@ 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,14 +252,13 @@ export function ChatWindow({
});
setMessages(sorted);
setHasMore(!!(resp as any).next || ((resp as any).count ?? 0) > sorted.length);
// Молниеносный скролл вниз (мгновенно, без анимации)
requestAnimationFrame(() => {
const el = listRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'auto' });
});
// прочитанность отмечается по мере попадания сообщений в зону видимости (IntersectionObserver)
} finally {
setLoading(false);
// scroll down
setTimeout(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' });
}, 50);
}
})();
}, [chat?.id, chatUuid]);

View File

@ -28,8 +28,6 @@ export interface CheckLessonFormData {
duration: number;
price: number | undefined;
is_recurring: boolean;
/** Статус (для завершённых занятий — можно менять) */
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
}
export interface CheckLessonProps {
@ -74,8 +72,6 @@ export interface CheckLessonProps {
onCancel: () => void;
/** Удалить занятие (только в режиме редактирования). deleteAllFuture — удалить всю цепочку постоянных. */
onDelete?: (deleteAllFuture: boolean) => void;
/** Редактируется завершённое занятие — можно менять только цену и статус */
isCompletedLesson?: boolean;
}
export const CheckLesson: React.FC<CheckLessonProps> = ({
@ -106,7 +102,6 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
onSubmit,
onCancel,
onDelete,
isCompletedLesson = false,
}) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const navDisabled = lessonsLoading || isFormVisible;
@ -147,11 +142,21 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
width: '100%',
height: '100%',
minHeight: '548px',
perspective: '1000px',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
transformStyle: 'preserve-3d',
transition: 'transform 0.6s ease-in-out',
transform: isFormVisible ? 'rotateY(180deg)' : 'rotateY(0deg)',
}}
>
{/* Лицевая сторона: Список занятий */}
<div
className="ios-glass-panel"
@ -161,12 +166,13 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
left: 0,
width: '100%',
height: '100%',
opacity: isFormVisible ? 0 : 1,
visibility: isFormVisible ? 'hidden' : 'visible',
transition: 'opacity 0.2s ease',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(0deg)',
borderRadius: '20px',
padding: '24px',
overflowY: 'hidden',
transformOrigin: 'center center',
display: 'flex',
flexDirection: 'column',
}}
@ -363,12 +369,13 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
left: 0,
width: '100%',
height: '100%',
opacity: isFormVisible ? 1 : 0,
visibility: isFormVisible ? 'visible' : 'hidden',
transition: 'opacity 0.2s ease',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
borderRadius: '20px',
padding: '24px',
overflowY: 'auto',
transformOrigin: 'center center',
display: 'flex',
flexDirection: 'column',
}}
@ -406,11 +413,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
margin: 0,
}}
>
{isCompletedLesson
? 'Изменить завершённое занятие'
: isEditingMode
? 'Редактировать занятие'
: 'Создать занятие'}
{isEditingMode ? 'Редактировать занятие' : 'Создать занятие'}
</h3>
<button
type="button"
@ -456,7 +459,6 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
</div>
)}
{!isCompletedLesson && (
<div style={{ gridColumn: 1 }}>
<label
style={{
@ -477,9 +479,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
required
/>
</div>
)}
{!isCompletedLesson && (
<div style={{ gridColumn: 2 }}>
<label
style={{
@ -509,9 +509,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
required
/>
</div>
)}
{!isCompletedLesson && (
<div style={{ gridColumn: '1 / -1' }}>
<label
style={{
@ -553,10 +551,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
}}
/>
</div>
)}
{!isCompletedLesson && (
<>
<div style={{ gridColumn: 1 }}>
<label
style={{
@ -692,51 +687,10 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
<Switch
checked={formData.is_recurring}
onChange={(checked) => setFormData((prev) => ({ ...prev, is_recurring: checked }))}
disabled={formLoading || isCompletedLesson}
disabled={formLoading}
label="Постоянное занятие (повторяется еженедельно)"
/>
</div>
</>
)}
{isCompletedLesson && (
<div style={{ gridColumn: '1 / -1' }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 4,
}}
>
Статус
</label>
<select
value={formData.status ?? 'completed'}
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as 'completed' | 'cancelled' }))}
disabled={formLoading}
style={{
width: '100%',
padding: '12px 16px',
fontSize: 16,
color: 'var(--md-sys-color-on-surface)',
background: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline)',
borderRadius: 4,
fontFamily: 'inherit',
cursor: formLoading ? 'not-allowed' : 'pointer',
outline: 'none',
}}
>
<option value="completed">Завершено</option>
<option value="cancelled">Отменено</option>
</select>
<p style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 4, marginBottom: 0 }}>
Изменение статуса задним числом не отправляет уведомления ученику и родителям
</p>
</div>
)}
<div
style={{
@ -749,7 +703,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
}}
>
<div style={{ display: 'flex', gap: 12 }}>
{isEditingMode && !isCompletedLesson && onDelete && (
{isEditingMode && onDelete && (
<button
type="button"
onClick={handleDeleteClick}

View File

@ -7,7 +7,6 @@
import React, { useState, useEffect } from 'react';
import { loadComponent } from '@/lib/material-components';
import { createLesson } from '@/api/schedule';
import { getErrorMessage } from '@/lib/error-utils';
import { getStudents, Student } from '@/api/students';
import { getSubjects, getMentorSubjects, createMentorSubject, Subject, MentorSubject } from '@/api/subjects';
import { getCurrentUser, User } from '@/api/auth';
@ -301,7 +300,14 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
onClose();
} catch (err: any) {
console.error('[CreateLessonDialog] Ошибка создания занятия:', err);
setError(getErrorMessage(err, 'Не удалось создать занятие. Проверьте данные.'));
if (err.response?.data) {
const fieldErrors = Object.entries(err.response.data)
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
.join('\n');
setError(fieldErrors);
} else {
setError(err.message || 'Ошибка создания занятия');
}
} finally {
setLoading(false);
}

View File

@ -1,5 +1,5 @@
/**
* Карточка с лицевой и обратной стороной (переключение без анимации переворота).
* Flip-карточка с эффектом переворота (iOS 26).
*/
'use client';
@ -15,9 +15,9 @@ export interface FlipCardProps {
height?: string | number;
/** Дополнительный класс */
className?: string;
/** Управляемый режим (если задан) */
/** Управляемый режим переворота (если задан) */
flipped?: boolean;
/** Коллбек при смене состояния */
/** Коллбек при смене состояния (для управляемого режима) */
onFlippedChange?: (flipped: boolean) => void;
}
@ -43,40 +43,52 @@ export const FlipCard: React.FC<FlipCardProps> = ({
className={`flip-card ${className}`.trim()}
style={{
position: 'relative',
perspective: '1000px',
height: typeof height === 'number' ? `${height}px` : height,
width: '100%',
...(height === 'auto' && { minHeight: 340 }),
}}
>
<div
className="flip-card-front"
className="flip-card-inner"
style={{
position: 'absolute',
top: 0,
left: 0,
position: 'relative',
width: '100%',
height: '100%',
opacity: isFlipped ? 0 : 1,
visibility: isFlipped ? 'hidden' : 'visible',
transition: 'opacity 0.2s ease',
transition: 'transform 0.6s',
transformStyle: 'preserve-3d',
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
}}
>
{front}
</div>
<div
className="flip-card-back"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: isFlipped ? 1 : 0,
visibility: isFlipped ? 'visible' : 'hidden',
transition: 'opacity 0.2s ease',
}}
>
{back}
<div
className="flip-card-front"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
}}
>
{front}
</div>
<div
className="flip-card-back"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
}}
>
{back}
</div>
</div>
</div>
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -61,7 +61,6 @@ import { isTrackReference } from '@livekit/components-core';
import '@/styles/livekit-components.css';
import '@/styles/livekit-theme.css';
import { getLesson } from '@/api/schedule';
import { participantConnected } from '@/api/livekit';
import type { Lesson } from '@/api/schedule';
import { getOrCreateLessonBoard } from '@/api/board';
@ -447,20 +446,6 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard,
getNavBadges().then(setNavBadges).catch(() => setNavBadges(null));
}, [user]);
// Фиксируем подключение ментора/студента для метрик (lessonId — резервный поиск комнаты)
useEffect(() => {
const onConnected = () => {
if (room.name || lessonId)
participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {});
};
room.on(RoomEvent.Connected, onConnected);
if (room.state === 'connected' && (room.name || lessonId))
participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {});
return () => {
room.off(RoomEvent.Connected, onConnected);
};
}, [room]);
useEffect(() => {
if (!showPlatformChat || !lessonId) {
if (!showPlatformChat) setLessonChat(null);

View File

@ -2132,7 +2132,7 @@ img {
}
}
/* Flip-карточка (переключение без переворота) */
/* Flip-карточка эффект */
.flip-card {
position: relative;
width: 100%;
@ -2142,6 +2142,14 @@ img {
flex-direction: column;
}
.flip-card-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
}
.flip-card-front,
.flip-card-back {
position: absolute;
@ -2149,6 +2157,12 @@ img {
left: 0;
width: 100%;
height: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
display: flex;
flex-direction: column;
}
.flip-card-back {
transform: rotateY(180deg);
}

View File

@ -1,201 +1,201 @@
/**
* Утилиты для работы с часовыми поясами.
*
* Поддерживаемые форматы timezone:
* - UTC+X, UTC-X (например, "UTC+8", "UTC-5")
* - GMT+X, GMT-X
* - IANA названия (например, "Europe/Moscow", "Asia/Irkutsk")
*/
/**
* Парсить timezone и получить смещение в минутах.
*
* Примеры:
* - "UTC+8" -> 480 (8 * 60)
* - "UTC-5" -> -300 (-5 * 60)
* - "UTC+5:30" -> 330 (5 * 60 + 30)
*
* @returns смещение в минутах или null если не удалось распарсить
*/
export function parseTimezoneOffset(timezone: string | undefined): number | null {
if (!timezone) return null;
const trimmed = timezone.trim();
// Парсим формат UTC+X, UTC-X, GMT+X, GMT-X
const match = trimmed.match(/^(?:UTC|GMT)([+-])(\d{1,2})(?::(\d{2}))?$/i);
if (match) {
const sign = match[1] === '+' ? 1 : -1;
const hours = parseInt(match[2], 10);
const minutes = match[3] ? parseInt(match[3], 10) : 0;
return sign * (hours * 60 + minutes);
}
return null;
}
/**
* Получить смещение часового пояса в минутах.
*
* Поддерживает:
* - UTC+X формат (парсит напрямую)
* - IANA названия (использует Intl API)
*
* @returns смещение в минутах (положительное = восток от UTC)
*/
export function getTimezoneOffsetMinutes(timezone: string | undefined): number {
if (!timezone) {
// Браузерный timezone
return -new Date().getTimezoneOffset();
}
// Сначала пробуем распарсить UTC+X формат
const parsedOffset = parseTimezoneOffset(timezone);
if (parsedOffset !== null) {
return parsedOffset;
}
// Для IANA названий используем Intl API
try {
const now = new Date();
const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
return Math.round((tzDate.getTime() - utcDate.getTime()) / 60000);
} catch {
// Fallback на браузерный timezone
return -new Date().getTimezoneOffset();
}
}
/**
* Создать ISO строку даты/времени с учетом часового пояса пользователя.
*
* Пример: Если пользователь в Улан-Удэ (UTC+8) вводит 18:00,
* то нужно отправить на сервер 10:00 UTC (18:00 - 8 часов).
*
* @param dateStr - дата в формате 'YYYY-MM-DD'
* @param timeStr - время в формате 'HH:mm'
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8', 'Europe/Moscow')
* @returns ISO строка в UTC
*/
export function createDateTimeInUserTimezone(
dateStr: string,
timeStr: string,
userTimezone: string | undefined
): string {
// Парсим дату и время
const [year, month, day] = dateStr.split('-').map(Number);
const [hours, minutes] = timeStr.split(':').map(Number);
// Создаем дату как будто она в UTC
const utcDate = new Date(Date.UTC(year, month - 1, day, hours, minutes, 0, 0));
// Получаем смещение timezone пользователя
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
// Корректируем: вычитаем смещение, чтобы получить UTC
// Например: 18:00 в UTC+8 = 10:00 UTC, значит вычитаем 8 часов (480 минут)
utcDate.setMinutes(utcDate.getMinutes() - offsetMinutes);
return utcDate.toISOString();
}
/**
* Парсить ISO дату и получить локальную дату/время в часовом поясе пользователя.
*
* Работает с любым форматом timezone:
* - UTC+8: добавляет 8 часов к UTC
* - Europe/Moscow: использует Intl API
*
* @param isoString - ISO строка даты (например, '2026-02-21T10:00:00Z' для UTC)
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
* @returns объект с date и time в часовом поясе пользователя
*/
export function parseISOToUserTimezone(
isoString: string,
userTimezone: string | undefined
): { date: string; time: string; dateObj: Date } {
// Парсим ISO строку в UTC timestamp
const utcDate = new Date(isoString);
const utcMs = utcDate.getTime();
// Получаем смещение timezone пользователя в минутах
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
// Применяем смещение: UTC + offset = локальное время
// Например: 10:00 UTC + 8 часов = 18:00 в UTC+8
const localMs = utcMs + offsetMinutes * 60 * 1000;
const localDate = new Date(localMs);
// Извлекаем компоненты даты/времени в UTC (потому что мы уже добавили offset)
const year = localDate.getUTCFullYear();
const month = String(localDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(localDate.getUTCDate()).padStart(2, '0');
const hours = String(localDate.getUTCHours()).padStart(2, '0');
const minutes = String(localDate.getUTCMinutes()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
const timeStr = `${hours}:${minutes}`;
// Создаем Date объект для использования в UI (в локальном времени браузера)
const displayDate = new Date(`${dateStr}T${timeStr}`);
return {
date: dateStr,
time: timeStr,
dateObj: displayDate,
};
}
/**
* Форматировать дату для отображения в часовом поясе пользователя.
*
* @param isoString - ISO строка даты
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
* @param options - опции форматирования Intl.DateTimeFormat
*/
export function formatDateInUserTimezone(
isoString: string,
userTimezone: string | undefined,
options: Intl.DateTimeFormatOptions = {}
): string {
// Получаем локальное время в timezone пользователя
const parsed = parseISOToUserTimezone(isoString, userTimezone);
// Форматируем используя Intl (dateObj уже в правильном времени)
return new Intl.DateTimeFormat('ru-RU', options).format(parsed.dateObj);
}
/**
* Получить текущую дату/время в часовом поясе пользователя.
*/
export function getNowInUserTimezone(userTimezone: string | undefined): Date {
const now = new Date();
const parsed = parseISOToUserTimezone(now.toISOString(), userTimezone);
return parsed.dateObj;
}
/**
* Получить название часового пояса с offset.
* Например: 'Europe/Moscow' -> 'Europe/Moscow (UTC+3)'
* Для 'UTC+8' -> 'UTC+8'
*/
export function getTimezoneDisplayName(timezone: string): string {
if (!timezone) return '';
// Если уже в формате UTC+X, возвращаем как есть
if (/^(?:UTC|GMT)[+-]\d/i.test(timezone)) {
return timezone;
}
try {
const offsetMinutes = getTimezoneOffsetMinutes(timezone);
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
const mins = Math.abs(offsetMinutes) % 60;
const sign = offsetMinutes >= 0 ? '+' : '-';
const offsetStr = mins > 0 ? `${hours}:${mins.toString().padStart(2, '0')}` : `${hours}`;
return `${timezone} (UTC${sign}${offsetStr})`;
} catch {
return timezone;
}
}
/**
* Утилиты для работы с часовыми поясами.
*
* Поддерживаемые форматы timezone:
* - UTC+X, UTC-X (например, "UTC+8", "UTC-5")
* - GMT+X, GMT-X
* - IANA названия (например, "Europe/Moscow", "Asia/Irkutsk")
*/
/**
* Парсить timezone и получить смещение в минутах.
*
* Примеры:
* - "UTC+8" -> 480 (8 * 60)
* - "UTC-5" -> -300 (-5 * 60)
* - "UTC+5:30" -> 330 (5 * 60 + 30)
*
* @returns смещение в минутах или null если не удалось распарсить
*/
export function parseTimezoneOffset(timezone: string | undefined): number | null {
if (!timezone) return null;
const trimmed = timezone.trim();
// Парсим формат UTC+X, UTC-X, GMT+X, GMT-X
const match = trimmed.match(/^(?:UTC|GMT)([+-])(\d{1,2})(?::(\d{2}))?$/i);
if (match) {
const sign = match[1] === '+' ? 1 : -1;
const hours = parseInt(match[2], 10);
const minutes = match[3] ? parseInt(match[3], 10) : 0;
return sign * (hours * 60 + minutes);
}
return null;
}
/**
* Получить смещение часового пояса в минутах.
*
* Поддерживает:
* - UTC+X формат (парсит напрямую)
* - IANA названия (использует Intl API)
*
* @returns смещение в минутах (положительное = восток от UTC)
*/
export function getTimezoneOffsetMinutes(timezone: string | undefined): number {
if (!timezone) {
// Браузерный timezone
return -new Date().getTimezoneOffset();
}
// Сначала пробуем распарсить UTC+X формат
const parsedOffset = parseTimezoneOffset(timezone);
if (parsedOffset !== null) {
return parsedOffset;
}
// Для IANA названий используем Intl API
try {
const now = new Date();
const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
return Math.round((tzDate.getTime() - utcDate.getTime()) / 60000);
} catch {
// Fallback на браузерный timezone
return -new Date().getTimezoneOffset();
}
}
/**
* Создать ISO строку даты/времени с учетом часового пояса пользователя.
*
* Пример: Если пользователь в Улан-Удэ (UTC+8) вводит 18:00,
* то нужно отправить на сервер 10:00 UTC (18:00 - 8 часов).
*
* @param dateStr - дата в формате 'YYYY-MM-DD'
* @param timeStr - время в формате 'HH:mm'
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8', 'Europe/Moscow')
* @returns ISO строка в UTC
*/
export function createDateTimeInUserTimezone(
dateStr: string,
timeStr: string,
userTimezone: string | undefined
): string {
// Парсим дату и время
const [year, month, day] = dateStr.split('-').map(Number);
const [hours, minutes] = timeStr.split(':').map(Number);
// Создаем дату как будто она в UTC
const utcDate = new Date(Date.UTC(year, month - 1, day, hours, minutes, 0, 0));
// Получаем смещение timezone пользователя
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
// Корректируем: вычитаем смещение, чтобы получить UTC
// Например: 18:00 в UTC+8 = 10:00 UTC, значит вычитаем 8 часов (480 минут)
utcDate.setMinutes(utcDate.getMinutes() - offsetMinutes);
return utcDate.toISOString();
}
/**
* Парсить ISO дату и получить локальную дату/время в часовом поясе пользователя.
*
* Работает с любым форматом timezone:
* - UTC+8: добавляет 8 часов к UTC
* - Europe/Moscow: использует Intl API
*
* @param isoString - ISO строка даты (например, '2026-02-21T10:00:00Z' для UTC)
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
* @returns объект с date и time в часовом поясе пользователя
*/
export function parseISOToUserTimezone(
isoString: string,
userTimezone: string | undefined
): { date: string; time: string; dateObj: Date } {
// Парсим ISO строку в UTC timestamp
const utcDate = new Date(isoString);
const utcMs = utcDate.getTime();
// Получаем смещение timezone пользователя в минутах
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
// Применяем смещение: UTC + offset = локальное время
// Например: 10:00 UTC + 8 часов = 18:00 в UTC+8
const localMs = utcMs + offsetMinutes * 60 * 1000;
const localDate = new Date(localMs);
// Извлекаем компоненты даты/времени в UTC (потому что мы уже добавили offset)
const year = localDate.getUTCFullYear();
const month = String(localDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(localDate.getUTCDate()).padStart(2, '0');
const hours = String(localDate.getUTCHours()).padStart(2, '0');
const minutes = String(localDate.getUTCMinutes()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
const timeStr = `${hours}:${minutes}`;
// Создаем Date объект для использования в UI (в локальном времени браузера)
const displayDate = new Date(`${dateStr}T${timeStr}`);
return {
date: dateStr,
time: timeStr,
dateObj: displayDate,
};
}
/**
* Форматировать дату для отображения в часовом поясе пользователя.
*
* @param isoString - ISO строка даты
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
* @param options - опции форматирования Intl.DateTimeFormat
*/
export function formatDateInUserTimezone(
isoString: string,
userTimezone: string | undefined,
options: Intl.DateTimeFormatOptions = {}
): string {
// Получаем локальное время в timezone пользователя
const parsed = parseISOToUserTimezone(isoString, userTimezone);
// Форматируем используя Intl (dateObj уже в правильном времени)
return new Intl.DateTimeFormat('ru-RU', options).format(parsed.dateObj);
}
/**
* Получить текущую дату/время в часовом поясе пользователя.
*/
export function getNowInUserTimezone(userTimezone: string | undefined): Date {
const now = new Date();
const parsed = parseISOToUserTimezone(now.toISOString(), userTimezone);
return parsed.dateObj;
}
/**
* Получить название часового пояса с offset.
* Например: 'Europe/Moscow' -> 'Europe/Moscow (UTC+3)'
* Для 'UTC+8' -> 'UTC+8'
*/
export function getTimezoneDisplayName(timezone: string): string {
if (!timezone) return '';
// Если уже в формате UTC+X, возвращаем как есть
if (/^(?:UTC|GMT)[+-]\d/i.test(timezone)) {
return timezone;
}
try {
const offsetMinutes = getTimezoneOffsetMinutes(timezone);
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
const mins = Math.abs(offsetMinutes) % 60;
const sign = offsetMinutes >= 0 ? '+' : '-';
const offsetStr = mins > 0 ? `${hours}:${mins.toString().padStart(2, '0')}` : `${hours}`;
return `${timezone} (UTC${sign}${offsetStr})`;
} catch {
return timezone;
}
}