Compare commits

...

2 Commits

Author SHA1 Message Date
root a167683bd9 bug fix
Deploy to Production / deploy-production (push) Successful in 49s Details
2026-02-23 21:44:27 +03:00
root 9382eab7b2 fix bugs 2026-02-23 15:44:27 +03:00
37 changed files with 4682 additions and 4257 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 import transaction
from django.db.models import Q from django.db.models import Q
from .models import Chat, ChatParticipant from .models import Chat, ChatParticipant
class ChatService: class ChatService:
""" """
Сервис для работы с чатами. Сервис для работы с чатами.
Обеспечивает атомарность операций и предотвращает создание дубликатов. Обеспечивает атомарность операций и предотвращает создание дубликатов.
""" """
@staticmethod @staticmethod
def get_or_create_direct_chat(user1, user2, created_by=None): def get_or_create_direct_chat(user1, user2, created_by=None):
""" """
Получить или создать личный чат между двумя пользователями. Получить или создать личный чат между двумя пользователями.
Использует блокировку для предотвращения race condition Использует блокировку для предотвращения race condition
при одновременных запросах на создание чата. при одновременных запросах на создание чата.
Args: Args:
user1: Первый пользователь (User) user1: Первый пользователь (User)
user2: Второй пользователь (User) user2: Второй пользователь (User)
created_by: Кто создает чат (по умолчанию user1) created_by: Кто создает чат (по умолчанию user1)
Returns: Returns:
tuple: (chat, created) - объект чата и флаг создания tuple: (chat, created) - объект чата и флаг создания
""" """
if user1.id == user2.id: if user1.id == user2.id:
raise ValueError("Нельзя создать чат с самим собой") raise ValueError("Нельзя создать чат с самим собой")
# Нормализуем порядок пользователей для консистентного поиска # Нормализуем порядок пользователей для консистентного поиска
users = sorted([user1, user2], key=lambda u: u.id) users = sorted([user1, user2], key=lambda u: u.id)
with transaction.atomic(): with transaction.atomic():
# Ищем существующий чат между пользователями # Ищем существующий чат между пользователями
# Используем select_for_update для блокировки найденных записей # Используем select_for_update для блокировки найденных записей
existing_chat = Chat.objects.select_for_update().filter( existing_chat = Chat.objects.select_for_update().filter(
chat_type='direct', chat_type='direct',
participants__user=users[0] participants__user=users[0]
).filter( ).filter(
participants__user=users[1] participants__user=users[1]
).distinct().first() ).distinct().first()
if existing_chat: if existing_chat:
return existing_chat, False return existing_chat, False
# Чата нет - создаем новый # Чата нет - создаем новый
creator = created_by or users[0] creator = created_by or users[0]
chat = Chat.objects.create( chat = Chat.objects.create(
chat_type='direct', chat_type='direct',
created_by=creator created_by=creator
) )
# Определяем роли участников # Определяем роли участников
# Создатель становится админом # Создатель становится админом
ChatParticipant.objects.create( ChatParticipant.objects.create(
chat=chat, chat=chat,
user=users[0], user=users[0],
role='admin' if users[0] == creator else 'member' role='admin' if users[0] == creator else 'member'
) )
ChatParticipant.objects.create( ChatParticipant.objects.create(
chat=chat, chat=chat,
user=users[1], user=users[1],
role='admin' if users[1] == creator else 'member' role='admin' if users[1] == creator else 'member'
) )
return chat, True return chat, True
@staticmethod @staticmethod
def get_direct_chat(user1, user2): def get_direct_chat(user1, user2):
""" """
Получить существующий личный чат между двумя пользователями. Получить существующий личный чат между двумя пользователями.
Args: Args:
user1: Первый пользователь user1: Первый пользователь
user2: Второй пользователь user2: Второй пользователь
Returns: Returns:
Chat или None Chat или None
""" """
return Chat.objects.filter( return Chat.objects.filter(
chat_type='direct', chat_type='direct',
participants__user=user1 participants__user=user1
).filter( ).filter(
participants__user=user2 participants__user=user2
).distinct().first() ).distinct().first()
@staticmethod @staticmethod
def ensure_participant(chat, user, role='member'): def ensure_participant(chat, user, role='member'):
""" """
Убедиться что пользователь является участником чата. Убедиться что пользователь является участником чата.
Если нет - добавить его. Если нет - добавить его.
Args: Args:
chat: Чат chat: Чат
user: Пользователь user: Пользователь
role: Роль (по умолчанию 'member') role: Роль (по умолчанию 'member')
Returns: Returns:
tuple: (participant, created) tuple: (participant, created)
""" """
return ChatParticipant.objects.get_or_create( return ChatParticipant.objects.get_or_create(
chat=chat, chat=chat,
user=user, user=user,
defaults={'role': role} defaults={'role': role}
) )

View File

@ -479,6 +479,120 @@ def send_telegram_notification(notification):
raise 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 @shared_task
def send_bulk_notifications(notification_ids): def send_bulk_notifications(notification_ids):
""" """

View File

@ -1611,6 +1611,50 @@ class TelegramBot:
await query.answer("❌ Ошибка", show_alert=True) await query.answer("❌ Ошибка", show_alert=True)
return 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_'): if query.data.startswith('attendance_yes_') or query.data.startswith('attendance_no_'):
try: try:

View File

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

View File

@ -364,6 +364,20 @@ class Lesson(models.Model):
# verbose_name='Время отправки напоминания' # 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( completed_at = models.DateTimeField(
null=True, null=True,

View File

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

View File

@ -1,93 +1,105 @@
""" """
Signals для приложения schedule. Signals для приложения schedule.
Автоматические действия при изменении расписания. Автоматические действия при изменении расписания.
""" """
from django.db.models.signals import post_save, pre_delete, pre_save from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from .models import Lesson from .models import Lesson
from apps.notifications.tasks import send_lesson_notification from apps.notifications.tasks import send_lesson_notification, send_lesson_completion_confirmation_telegram
@receiver(post_save, sender=Lesson) @receiver(post_save, sender=Lesson)
def lesson_saved(sender, instance, created, **kwargs): def lesson_saved(sender, instance, created, **kwargs):
""" """
Обработка создания или изменения занятия. Обработка создания или изменения занятия.
При создании: При создании:
- Отправка уведомления ментору и клиенту - Отправка уведомления ментору и клиенту
- Планирование напоминания перед занятием - Планирование напоминания перед занятием
При изменении: При изменении:
- Отправка уведомления об изменении времени/статуса - Отправка уведомления об изменении времени/статуса
""" """
if created: if created:
# Новое занятие создано # Новое занятие создано
send_lesson_notification.delay( send_lesson_notification.delay(
lesson_id=instance.id, lesson_id=instance.id,
notification_type='lesson_created' notification_type='lesson_created'
) )
# Напоминания отправляются периодической задачей send_lesson_reminders # Если занятие создано сразу в статусе completed (задним числом) — отправляем подтверждение в Telegram
# (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent) if instance.status == 'completed':
else: send_lesson_completion_confirmation_telegram.delay(instance.id)
# Занятие изменено # Напоминания отправляются периодической задачей send_lesson_reminders
# Проверяем, что именно изменилось # (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
if instance.tracker.has_changed('start_time') or instance.tracker.has_changed('end_time'): else:
# Время изменилось # Занятие изменено
send_lesson_notification.delay( # Проверяем, что именно изменилось
lesson_id=instance.id, if instance.tracker.has_changed('start_time') or instance.tracker.has_changed('end_time'):
notification_type='lesson_rescheduled' # Время изменилось
) send_lesson_notification.delay(
lesson_id=instance.id,
if instance.tracker.has_changed('status'): notification_type='lesson_rescheduled'
# Статус изменился )
if instance.status == 'cancelled':
send_lesson_notification.delay( if instance.tracker.has_changed('status'):
lesson_id=instance.id, # При переводе в completed — всегда отправляем подтверждение в Telegram (с кнопками «состоялось»/«отменилось»)
notification_type='lesson_cancelled' # Работает для ручного завершения и для занятий, созданных/завершённых задним числом
) if instance.status == 'completed':
elif instance.status == 'completed': send_lesson_completion_confirmation_telegram.delay(instance.id)
send_lesson_notification.delay(
lesson_id=instance.id, # Общие уведомления — не отправляем, если занятие уже в прошлом
notification_type='lesson_completed' # (коррекция статуса/стоимости после факта, например отмена задним числом)
) ref_time = instance.end_time or instance.start_time
if ref_time and ref_time < timezone.now():
return # Занятие уже прошло — пропускаем lesson_cancelled / lesson_completed
@receiver(pre_delete, sender=Lesson) if instance.status == 'cancelled':
def lesson_deleted(sender, instance, **kwargs): send_lesson_notification.delay(
""" lesson_id=instance.id,
Обработка удаления занятия. notification_type='lesson_cancelled'
Отправка уведомления об отмене. )
""" elif instance.status == 'completed':
if instance.status != 'cancelled': send_lesson_notification.delay(
send_lesson_notification.delay( lesson_id=instance.id,
lesson_id=instance.id, notification_type='lesson_completed'
notification_type='lesson_cancelled' )
)
@receiver(pre_delete, sender=Lesson)
@receiver(pre_save, sender=Lesson) def lesson_deleted(sender, instance, **kwargs):
def lesson_before_save(sender, instance, **kwargs): """
""" Обработка удаления занятия.
Действия перед сохранением занятия. Отправка уведомления об отмене.
Инициализация tracker для отслеживания изменений. """
""" if instance.status != 'cancelled':
if not hasattr(instance, 'tracker'): send_lesson_notification.delay(
# Создаем простой tracker для отслеживания изменений lesson_id=instance.id,
if instance.pk: notification_type='lesson_cancelled'
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) @receiver(pre_save, sender=Lesson)
}) def lesson_before_save(sender, instance, **kwargs):
except Lesson.DoesNotExist: """
instance.tracker = type('obj', (object,), { Действия перед сохранением занятия.
'has_changed': lambda field: False Инициализация tracker для отслеживания изменений.
}) """
else: if not hasattr(instance, 'tracker'):
instance.tracker = type('obj', (object,), { # Создаем простой tracker для отслеживания изменений
'has_changed': lambda field: False 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 import logging
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from django.db.models import Count from django.db.models import Count, Q
from .models import Lesson, Subject, MentorSubject from .models import Lesson, Subject, MentorSubject
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -380,7 +380,8 @@ def start_lessons_automatically():
now = timezone.now() now = timezone.now()
started_count = 0 started_count = 0
completed_count = 0 completed_count = 0
logger.info(f'[start_lessons_automatically] Запуск')
try: try:
# Находим все запланированные занятия, которые должны начаться # Находим все запланированные занятия, которые должны начаться
# start_time <= now (время начала уже наступило) # start_time <= now (время начала уже наступило)
@ -402,17 +403,22 @@ def start_lessons_automatically():
for lesson in lessons_to_start_list: for lesson in lessons_to_start_list:
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"') logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"')
# Находим занятия, которые уже прошли и должны быть завершены # Находим занятия, которые уже прошли и должны быть завершены:
# end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение) # end_time < now - 15 минут (всегда ждём 15 мин после конца, даже если занятие создали задним числом)
# status in ['scheduled', 'in_progress'] (еще не завершены) fifteen_minutes_ago = now - timedelta(minutes=15)
five_minutes_ago = now - timedelta(minutes=5)
lessons_to_complete = Lesson.objects.filter( lessons_to_complete = Lesson.objects.filter(
status__in=['scheduled', 'in_progress'], status__in=['scheduled', 'in_progress'],
end_time__lt=five_minutes_ago end_time__lt=fifteen_minutes_ago
).select_related('mentor', 'client') ).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() # Оптимизация: используем bulk_update вместо цикла с save()
lessons_to_complete_list = list(lessons_to_complete)
for lesson in lessons_to_complete_list: for lesson in lessons_to_complete_list:
lesson.status = 'completed' lesson.status = 'completed'
lesson.completed_at = now lesson.completed_at = now
@ -437,6 +443,14 @@ def start_lessons_automatically():
logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}') logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}')
except Exception as e: except Exception as e:
logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True) 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: if started_count > 0 or completed_count > 0:
logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}') logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}')

View File

@ -19,7 +19,26 @@
{ "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": "Asia/Irkutsk" }, { "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": "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" }
] ]

View File

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

View File

@ -275,6 +275,84 @@ 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']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def update_livekit_participant_media_state(request): def update_livekit_participant_media_state(request):
@ -336,6 +414,8 @@ def update_livekit_participant_media_state(request):
'is_video_enabled': video_enabled if video_enabled is not None else True, 'is_video_enabled': video_enabled if video_enabled is not None else True,
} }
) )
# Фиксируем подключение ментора/студента для метрик
video_room.mark_participant_joined(user)
if not created: if not created:
# Обновляем существующего участника # Обновляем существующего участника

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -91,12 +91,6 @@ http {
keepalive 32; keepalive 32;
} }
upstream whiteboard {
least_conn;
server whiteboard:8080 max_fails=3 fail_timeout=30s;
keepalive 32;
}
upstream livekit { upstream livekit {
server livekit:7880 max_fails=3 fail_timeout=30s; server livekit:7880 max_fails=3 fail_timeout=30s;
keepalive 4; keepalive 4;

View File

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

View File

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

View File

@ -1,317 +1,317 @@
/** /**
* API модуль для домашних заданий * API модуль для домашних заданий
*/ */
import apiClient from '@/lib/api-client'; import apiClient from '@/lib/api-client';
export interface HomeworkMentor { export interface HomeworkMentor {
id: number; id: number;
email: string; email: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
} }
/** Файл задания/решения (ментор прикрепляет к заданию, ученик видит и скачивает). */ /** Файл задания/решения (ментор прикрепляет к заданию, ученик видит и скачивает). */
export interface HomeworkFileItem { export interface HomeworkFileItem {
id: number; id: number;
file_type: 'assignment' | 'submission' | 'feedback'; file_type: 'assignment' | 'submission' | 'feedback';
file: string; file: string;
filename: string; filename: string;
file_size: number; file_size: number;
/** Признак изображения по расширению (с бэкенда) — показывать превью и открывать в модалке. */ /** Признак изображения по расширению (с бэкенда) — показывать превью и открывать в модалке. */
is_image?: boolean; is_image?: boolean;
uploaded_by?: { id: number; first_name: string; last_name: string } | null; uploaded_by?: { id: number; first_name: string; last_name: string } | null;
created_at: string; created_at: string;
} }
export interface Homework { export interface Homework {
id: number; id: number;
title: string; title: string;
description?: string; description?: string;
mentor: HomeworkMentor; mentor: HomeworkMentor;
lesson: number | null; lesson: number | null;
deadline: string | null; deadline: string | null;
max_score: number; max_score: number;
passing_score: number; passing_score: number;
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
/** Черновик «заполнить позже» — создан при завершении урока, нужно дописать задание. */ /** Черновик «заполнить позже» — создан при завершении урока, нужно дописать задание. */
fill_later?: boolean; fill_later?: boolean;
total_submissions: number; total_submissions: number;
checked_submissions: number; checked_submissions: number;
returned_submissions: number; returned_submissions: number;
average_score: number; average_score: number;
is_overdue: boolean; is_overdue: boolean;
created_at: string; created_at: string;
published_at: string | null; published_at: string | null;
/** Файл задания (один), URL для скачивания. */ /** Файл задания (один), URL для скачивания. */
attachment?: string | null; attachment?: string | null;
/** Ссылка на материал (внешняя). */ /** Ссылка на материал (внешняя). */
attachment_url?: string | null; attachment_url?: string | null;
/** Дополнительные файлы задания (ментор прикрепляет несколько). */ /** Дополнительные файлы задания (ментор прикрепляет несколько). */
files?: HomeworkFileItem[] | null; files?: HomeworkFileItem[] | null;
students?: { id: number; first_name: string; last_name: string; score: number | null; status: string }[] | 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; student_score?: { score: number | null; max_score: number; status: string } | null;
/** Только для ментора: количество решений с черновиком от ИИ (status=pending, ai_checked_at задано). */ /** Только для ментора: количество решений с черновиком от ИИ (status=pending, ai_checked_at задано). */
ai_draft_count?: number; ai_draft_count?: number;
} }
export interface HomeworkSubmission { export interface HomeworkSubmission {
id: number; id: number;
homework: { id: number; title: string; description?: string; max_score: number }; homework: { id: number; title: string; description?: string; max_score: number };
student: { id: number; first_name: string; last_name: string; email: string }; student: { id: number; first_name: string; last_name: string; email: string };
status: string; status: string;
content?: string; content?: string;
/** Основной файл решения (URL для скачивания). */ /** Основной файл решения (URL для скачивания). */
attachment?: string | null; attachment?: string | null;
attachment_url?: string | null; attachment_url?: string | null;
/** Доп. файлы решения (студент прикрепляет несколько). */ /** Доп. файлы решения (студент прикрепляет несколько). */
files?: HomeworkFileItem[] | null; files?: HomeworkFileItem[] | null;
score?: number | null; score?: number | null;
feedback?: string; feedback?: string;
/** HTML комментария проверки (markdown → HTML). */ /** HTML комментария проверки (markdown → HTML). */
feedback_html?: string; feedback_html?: string;
submitted_at: string; submitted_at: string;
checked_at?: string | null; checked_at?: string | null;
ai_score?: number | null; ai_score?: number | null;
ai_feedback?: string; ai_feedback?: string;
/** HTML превью черновика ИИ (markdown → HTML). */ /** HTML превью черновика ИИ (markdown → HTML). */
ai_feedback_html?: string; ai_feedback_html?: string;
ai_checked_at?: string | null; ai_checked_at?: string | null;
/** True, если оценка опубликована автоматически через ИИ. */ /** True, если оценка опубликована автоматически через ИИ. */
graded_by_ai?: boolean; graded_by_ai?: boolean;
checked_by?: { id: number; first_name: string; last_name: string } | null; checked_by?: { id: number; first_name: string; last_name: string } | null;
} }
export async function getHomework(params?: { export async function getHomework(params?: {
status?: string; status?: string;
page_size?: number; page_size?: number;
child_id?: string; child_id?: string;
}): Promise<{ results: Homework[]; count: number }> { }): Promise<{ results: Homework[]; count: number }> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params?.status) q.append('status', params.status); if (params?.status) q.append('status', params.status);
if (params?.page_size) q.append('page_size', String(params.page_size || 1000)); if (params?.page_size) q.append('page_size', String(params.page_size || 1000));
if (params?.child_id) q.append('child_id', params.child_id); if (params?.child_id) q.append('child_id', params.child_id);
const query = q.toString(); const query = q.toString();
const url = `/homework/homeworks/${query ? `?${query}` : ''}`; const url = `/homework/homeworks/${query ? `?${query}` : ''}`;
const res = await apiClient.get<{ results: Homework[]; count: number } | Homework[]>(url); const res = await apiClient.get<{ results: Homework[]; count: number } | Homework[]>(url);
const data = res.data; const data = res.data;
if (Array.isArray(data)) { if (Array.isArray(data)) {
return { results: data, count: data.length }; return { results: data, count: data.length };
} }
return { return {
results: data?.results ?? [], results: data?.results ?? [],
count: data?.count ?? 0, count: data?.count ?? 0,
}; };
} }
export async function getHomeworkById(id: string | number): Promise<Homework> { export async function getHomeworkById(id: string | number): Promise<Homework> {
const res = await apiClient.get<Homework>(`/homework/homeworks/${id}/`); const res = await apiClient.get<Homework>(`/homework/homeworks/${id}/`);
return res.data; return res.data;
} }
/** Создать домашнее задание (в т.ч. черновик для «заполнить позже»). По умолчанию макс. балл 5, проходной 1 (не учитывается). */ /** Создать домашнее задание (в т.ч. черновик для «заполнить позже»). По умолчанию макс. балл 5, проходной 1 (не учитывается). */
export async function createHomework(data: { export async function createHomework(data: {
title: string; title: string;
description?: string; description?: string;
lesson_id?: number; lesson_id?: number;
status?: 'draft' | 'published'; status?: 'draft' | 'published';
/** Пометить как «заполнить позже» — отображается в колонке «Ожидают заполнения» у ментора. */ /** Пометить как «заполнить позже» — отображается в колонке «Ожидают заполнения» у ментора. */
fill_later?: boolean; fill_later?: boolean;
/** Максимальный балл (15 по умолчанию). По умолчанию 5. */ /** Максимальный балл (15 по умолчанию). По умолчанию 5. */
max_score?: number; max_score?: number;
/** Проходной балл (по умолчанию 1, не учитывается). */ /** Проходной балл (по умолчанию 1, не учитывается). */
passing_score?: number; passing_score?: number;
}): Promise<Homework> { }): Promise<Homework> {
const payload = { const payload = {
...data, ...data,
max_score: data.max_score ?? 5, max_score: data.max_score ?? 5,
passing_score: data.passing_score ?? 1, passing_score: data.passing_score ?? 1,
}; };
const res = await apiClient.post<Homework>('/homework/homeworks/', payload); const res = await apiClient.post<Homework>('/homework/homeworks/', payload);
return res.data; return res.data;
} }
/** Опции запроса списка решений (например, отключить кэш для актуальных данных). */ /** Опции запроса списка решений (например, отключить кэш для актуальных данных). */
export interface GetHomeworkSubmissionsOptions { export interface GetHomeworkSubmissionsOptions {
cache?: boolean; cache?: boolean;
/** Для родителя: user_id ребёнка — вернуть решения этого ребёнка. */ /** Для родителя: user_id ребёнка — вернуть решения этого ребёнка. */
child_id?: string | null; child_id?: string | null;
} }
export async function getHomeworkSubmissions( export async function getHomeworkSubmissions(
homeworkId: string | number, homeworkId: string | number,
options?: GetHomeworkSubmissionsOptions options?: GetHomeworkSubmissionsOptions
): Promise<HomeworkSubmission[]> { ): Promise<HomeworkSubmission[]> {
const params = new URLSearchParams({ homework_id: String(homeworkId) }); const params = new URLSearchParams({ homework_id: String(homeworkId) });
if (options?.child_id) params.append('child_id', options.child_id); if (options?.child_id) params.append('child_id', options.child_id);
const res = await apiClient.get<{ results: HomeworkSubmission[] } | HomeworkSubmission[]>( const res = await apiClient.get<{ results: HomeworkSubmission[] } | HomeworkSubmission[]>(
`/homework/submissions/?${params.toString()}`, `/homework/submissions/?${params.toString()}`,
{ cache: options?.cache ?? false } { cache: options?.cache ?? false }
); );
const data = res.data; const data = res.data;
if (Array.isArray(data)) return data; if (Array.isArray(data)) return data;
return data?.results ?? []; return data?.results ?? [];
} }
export async function getMySubmission( export async function getMySubmission(
homeworkId: string | number, homeworkId: string | number,
options?: GetHomeworkSubmissionsOptions options?: GetHomeworkSubmissionsOptions
): Promise<HomeworkSubmission | null> { ): Promise<HomeworkSubmission | null> {
const list = await getHomeworkSubmissions(homeworkId, options); const list = await getHomeworkSubmissions(homeworkId, options);
return list.length > 0 ? list[0] : null; return list.length > 0 ? list[0] : null;
} }
/** Получить одно решение по ID (для детального просмотра). */ /** Получить одно решение по ID (для детального просмотра). */
export async function getHomeworkSubmission( export async function getHomeworkSubmission(
submissionId: string | number submissionId: string | number
): Promise<HomeworkSubmission> { ): Promise<HomeworkSubmission> {
const res = await apiClient.get<HomeworkSubmission>( const res = await apiClient.get<HomeworkSubmission>(
`/homework/submissions/${submissionId}/` `/homework/submissions/${submissionId}/`
); );
return res.data; return res.data;
} }
/** /**
* ДЗ с оценками по предмету для графика прогресса. * ДЗ с оценками по предмету для графика прогресса.
* GET /api/homework/submissions/by_subject/ * GET /api/homework/submissions/by_subject/
*/ */
export async function getHomeworkSubmissionsBySubject(params: { export async function getHomeworkSubmissionsBySubject(params: {
subject: string; subject: string;
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;
child_id?: string; child_id?: string;
}): Promise<{ count: number; results: HomeworkSubmission[] }> { }): Promise<{ count: number; results: HomeworkSubmission[] }> {
const q = new URLSearchParams(); const q = new URLSearchParams();
q.append('subject', params.subject); q.append('subject', params.subject);
if (params.start_date) q.append('start_date', params.start_date); if (params.start_date) q.append('start_date', params.start_date);
if (params.end_date) q.append('end_date', params.end_date); if (params.end_date) q.append('end_date', params.end_date);
if (params.child_id) q.append('child_id', params.child_id); if (params.child_id) q.append('child_id', params.child_id);
const res = await apiClient.get<{ count: number; results: HomeworkSubmission[] }>( const res = await apiClient.get<{ count: number; results: HomeworkSubmission[] }>(
`/homework/submissions/by_subject/?${q}` `/homework/submissions/by_subject/?${q}`
); );
return res.data; return res.data;
} }
export async function gradeSubmission( export async function gradeSubmission(
submissionId: string | number, submissionId: string | number,
data: { score: number; feedback?: string } data: { score: number; feedback?: string }
): Promise<unknown> { ): Promise<unknown> {
const res = await apiClient.post(`/homework/submissions/${submissionId}/grade/`, data); const res = await apiClient.post(`/homework/submissions/${submissionId}/grade/`, data);
return res.data; return res.data;
} }
/** Использование токенов за один запрос (если API вернул). */ /** Использование токенов за один запрос (если API вернул). */
export interface TokenUsage { export interface TokenUsage {
prompt_tokens: number; prompt_tokens: number;
completion_tokens: number; completion_tokens: number;
total_tokens: number; total_tokens: number;
} }
export interface CheckWithAiResponse { export interface CheckWithAiResponse {
success: boolean; success: boolean;
ai_score: number; ai_score: number;
ai_feedback: string; ai_feedback: string;
/** HTML для отображения комментария ИИ (markdown + LaTeX → HTML). */ /** HTML для отображения комментария ИИ (markdown + LaTeX → HTML). */
ai_feedback_html?: string; ai_feedback_html?: string;
ai_checked_at?: string; ai_checked_at?: string;
message?: string; message?: string;
/** Токены за эту проверку (потрачено). Остаток лимита — в кабинете Timeweb. */ /** Токены за эту проверку (потрачено). Остаток лимита — в кабинете Timeweb. */
usage?: TokenUsage; usage?: TokenUsage;
} }
/** Проверить решение через ИИ. Ментор: задание + решение → комментарий и оценка 15. */ /** Проверить решение через ИИ. Ментор: задание + решение → комментарий и оценка 15. */
export async function checkSubmissionWithAi( export async function checkSubmissionWithAi(
submissionId: string | number submissionId: string | number
): Promise<CheckWithAiResponse> { ): Promise<CheckWithAiResponse> {
const res = await apiClient.post<CheckWithAiResponse>( const res = await apiClient.post<CheckWithAiResponse>(
`/homework/submissions/${submissionId}/check_with_ai/` `/homework/submissions/${submissionId}/check_with_ai/`
); );
return res.data; return res.data;
} }
export async function returnSubmissionForRevision( export async function returnSubmissionForRevision(
submissionId: string | number, submissionId: string | number,
feedback: string feedback: string
): Promise<unknown> { ): Promise<unknown> {
const res = await apiClient.post(`/homework/submissions/${submissionId}/return_for_revision/`, { feedback }); const res = await apiClient.post(`/homework/submissions/${submissionId}/return_for_revision/`, { feedback });
return res.data; return res.data;
} }
/** Удалить своё решение (студент). Задание снова переходит в ожидание загрузки. */ /** Удалить своё решение (студент). Задание снова переходит в ожидание загрузки. */
export async function deleteSubmission(submissionId: string | number): Promise<void> { export async function deleteSubmission(submissionId: string | number): Promise<void> {
await apiClient.delete(`/homework/submissions/${submissionId}/`); await apiClient.delete(`/homework/submissions/${submissionId}/`);
} }
const MAX_HOMEWORK_FILE_SIZE = 50 * 1024 * 1024; // 50 МБ const MAX_HOMEWORK_FILE_SIZE = 50 * 1024 * 1024; // 50 МБ
const MAX_HOMEWORK_FILES = 10; const MAX_HOMEWORK_FILES = 10;
export function validateHomeworkFiles(files: File[]): { valid: boolean; error?: string } { export function validateHomeworkFiles(files: File[]): { valid: boolean; error?: string } {
if (files.length === 0) return { valid: true }; if (files.length === 0) return { valid: true };
if (files.length > MAX_HOMEWORK_FILES) { if (files.length > MAX_HOMEWORK_FILES) {
return { valid: false, error: `Максимум ${MAX_HOMEWORK_FILES} файлов` }; return { valid: false, error: `Максимум ${MAX_HOMEWORK_FILES} файлов` };
} }
for (const f of files) { for (const f of files) {
if (f.size > MAX_HOMEWORK_FILE_SIZE) { if (f.size > MAX_HOMEWORK_FILE_SIZE) {
return { valid: false, error: `Файл "${f.name}" больше 50 МБ` }; return { valid: false, error: `Файл "${f.name}" больше 50 МБ` };
} }
} }
return { valid: true }; return { valid: true };
} }
/** /**
* Обновить домашнее задание (для черновиков fill_later). * Обновить домашнее задание (для черновиков fill_later).
* PATCH /api/homework/homeworks/{id}/ * PATCH /api/homework/homeworks/{id}/
*/ */
export async function updateHomework( export async function updateHomework(
homeworkId: string | number, homeworkId: string | number,
data: { data: {
title?: string; title?: string;
description?: string; description?: string;
deadline?: string | null; deadline?: string | null;
status?: 'draft' | 'published'; status?: 'draft' | 'published';
fill_later?: boolean; fill_later?: boolean;
} }
): Promise<Homework> { ): Promise<Homework> {
const res = await apiClient.patch<Homework>(`/homework/homeworks/${homeworkId}/`, data); const res = await apiClient.patch<Homework>(`/homework/homeworks/${homeworkId}/`, data);
return res.data; return res.data;
} }
/** /**
* Опубликовать домашнее задание (из черновика в published). * Опубликовать домашнее задание (из черновика в published).
* POST /api/homework/homeworks/{id}/publish/ * POST /api/homework/homeworks/{id}/publish/
*/ */
export async function publishHomework(homeworkId: string | number): Promise<Homework> { export async function publishHomework(homeworkId: string | number): Promise<Homework> {
const res = await apiClient.post<Homework>(`/homework/homeworks/${homeworkId}/publish/`); const res = await apiClient.post<Homework>(`/homework/homeworks/${homeworkId}/publish/`);
return res.data; return res.data;
} }
export async function submitHomework( export async function submitHomework(
homeworkId: string | number, homeworkId: string | number,
data: { content?: string; text?: string; files?: File[] }, data: { content?: string; text?: string; files?: File[] },
onUploadProgress?: (percent: number) => void onUploadProgress?: (percent: number) => void
): Promise<unknown> { ): Promise<unknown> {
const hasFiles = data.files && data.files.length > 0; const hasFiles = data.files && data.files.length > 0;
if (hasFiles) { if (hasFiles) {
const formData = new FormData(); const formData = new FormData();
formData.append('homework_id', String(homeworkId)); formData.append('homework_id', String(homeworkId));
if (data.content) formData.append('content', data.content); if (data.content) formData.append('content', data.content);
if (data.text) formData.append('content', data.text); if (data.text) formData.append('content', data.text);
data.files!.forEach((f) => formData.append('attachment', f)); data.files!.forEach((f) => formData.append('attachment', f));
const res = await apiClient.post(`/homework/submissions/`, formData, { const res = await apiClient.post(`/homework/submissions/`, formData, {
onUploadProgress: onUploadProgress:
onUploadProgress && onUploadProgress &&
(function (event: { loaded: number; total?: number }) { (function (event: { loaded: number; total?: number }) {
if (event.total && event.total > 0) { if (event.total && event.total > 0) {
const percent = Math.round((event.loaded / event.total) * 100); const percent = Math.round((event.loaded / event.total) * 100);
onUploadProgress(Math.min(percent, 100)); onUploadProgress(Math.min(percent, 100));
} }
}), }),
}); });
return res.data; return res.data;
} }
const res = await apiClient.post(`/homework/submissions/`, { const res = await apiClient.post(`/homework/submissions/`, {
homework_id: homeworkId, homework_id: homeworkId,
content: data.content || data.text || '', content: data.content || data.text || '',
}); });
return res.data; return res.data;
} }

View File

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

View File

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

View File

@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { requestPasswordReset } from '@/api/auth'; import { requestPasswordReset } from '@/api/auth';
import { getErrorMessage } from '@/lib/error-utils';
const loadMaterialComponents = async () => { const loadMaterialComponents = async () => {
await Promise.all([ await Promise.all([
@ -39,7 +40,7 @@ export default function ForgotPasswordPage() {
await requestPasswordReset({ email }); await requestPasswordReset({ email });
setSuccess(true); setSuccess(true);
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка при отправке запроса. Проверьте email.'); setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { register } from '@/api/auth'; import { register } from '@/api/auth';
import { REFERRAL_STORAGE_KEY } from '@/api/referrals'; import { REFERRAL_STORAGE_KEY } from '@/api/referrals';
import { searchCitiesFromCSV, type CityOption } from '@/api/profile'; import { searchCitiesFromCSV, type CityOption } from '@/api/profile';
import { getErrorMessage } from '@/lib/error-utils';
const loadMaterialComponents = async () => { const loadMaterialComponents = async () => {
await Promise.all([ await Promise.all([
@ -144,14 +145,7 @@ export default function RegisterPage() {
setRegistrationSuccess(true); setRegistrationSuccess(true);
return; return;
} catch (err: any) { } catch (err: any) {
setError( setError(getErrorMessage(err, 'Ошибка регистрации. Проверьте данные.'));
err.response?.data?.detail ||
(Array.isArray(err.response?.data?.email)
? err.response.data.email[0]
: err.response?.data?.email) ||
err.response?.data?.message ||
'Ошибка регистрации. Проверьте данные.'
);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -3,6 +3,7 @@
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { confirmPasswordReset } from '@/api/auth'; import { confirmPasswordReset } from '@/api/auth';
import { getErrorMessage } from '@/lib/error-utils';
const loadMaterialComponents = async () => { const loadMaterialComponents = async () => {
await Promise.all([ await Promise.all([
@ -43,11 +44,7 @@ function ResetPasswordContent() {
await confirmPasswordReset(token, password, confirmPassword); await confirmPasswordReset(token, password, confirmPassword);
setSuccess(true); setSuccess(true);
} catch (err: any) { } catch (err: any) {
setError( setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.'));
err.response?.data?.error?.message ||
err.response?.data?.detail ||
'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.'
);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -3,6 +3,7 @@
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { verifyEmail } from '@/api/auth'; import { verifyEmail } from '@/api/auth';
import { getErrorMessage } from '@/lib/error-utils';
const loadMaterialComponents = async () => { const loadMaterialComponents = async () => {
await Promise.all([ await Promise.all([
@ -47,11 +48,7 @@ function VerifyEmailContent() {
.catch((err: any) => { .catch((err: any) => {
if (cancelled) return; if (cancelled) return;
setStatus('error'); setStatus('error');
const msg = setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.'));
err.response?.data?.error?.message ||
err.response?.data?.detail ||
'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.';
setMessage(msg);
}); });
return () => { return () => {

View File

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

View File

@ -237,6 +237,7 @@ export function ChatWindow({
setLoadingMore(false); setLoadingMore(false);
setPage(1); setPage(1);
setHasMore(false); setHasMore(false);
(async () => { (async () => {
try { try {
const pageSize = 30; const pageSize = 30;
@ -244,7 +245,6 @@ export function ChatWindow({
? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: pageSize }) ? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: pageSize })
: await getMessages(chat.id, { page: 1, page_size: pageSize }); : await getMessages(chat.id, { page: 1, page_size: pageSize });
const initial = (resp.results || []) as Message[]; const initial = (resp.results || []) as Message[];
// сортируем по времени на всякий случай
const sorted = [...initial].sort((a: any, b: any) => { const sorted = [...initial].sort((a: any, b: any) => {
const ta = a?.created_at ? new Date(a.created_at).getTime() : 0; const ta = a?.created_at ? new Date(a.created_at).getTime() : 0;
const tb = b?.created_at ? new Date(b.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); setMessages(sorted);
setHasMore(!!(resp as any).next || ((resp as any).count ?? 0) > sorted.length); 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 { } finally {
setLoading(false); setLoading(false);
// scroll down
setTimeout(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' });
}, 50);
} }
})(); })();
}, [chat?.id, chatUuid]); }, [chat?.id, chatUuid]);

View File

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

View File

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

View File

@ -1,5 +1,5 @@
/** /**
* Flip-карточка с эффектом переворота (iOS 26). * Карточка с лицевой и обратной стороной (переключение без анимации переворота).
*/ */
'use client'; 'use client';
@ -15,9 +15,9 @@ export interface FlipCardProps {
height?: string | number; height?: string | number;
/** Дополнительный класс */ /** Дополнительный класс */
className?: string; className?: string;
/** Управляемый режим переворота (если задан) */ /** Управляемый режим (если задан) */
flipped?: boolean; flipped?: boolean;
/** Коллбек при смене состояния (для управляемого режима) */ /** Коллбек при смене состояния */
onFlippedChange?: (flipped: boolean) => void; onFlippedChange?: (flipped: boolean) => void;
} }
@ -43,52 +43,40 @@ export const FlipCard: React.FC<FlipCardProps> = ({
className={`flip-card ${className}`.trim()} className={`flip-card ${className}`.trim()}
style={{ style={{
position: 'relative', position: 'relative',
perspective: '1000px',
height: typeof height === 'number' ? `${height}px` : height, height: typeof height === 'number' ? `${height}px` : height,
width: '100%', width: '100%',
...(height === 'auto' && { minHeight: 340 }), ...(height === 'auto' && { minHeight: 340 }),
}} }}
> >
<div <div
className="flip-card-inner" className="flip-card-front"
style={{ style={{
position: 'relative', position: 'absolute',
top: 0,
left: 0,
width: '100%', width: '100%',
height: '100%', height: '100%',
transition: 'transform 0.6s', opacity: isFlipped ? 0 : 1,
transformStyle: 'preserve-3d', visibility: isFlipped ? 'hidden' : 'visible',
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)', transition: 'opacity 0.2s ease',
}} }}
> >
<div {front}
className="flip-card-front" </div>
style={{ <div
position: 'absolute', className="flip-card-back"
top: 0, style={{
left: 0, position: 'absolute',
width: '100%', top: 0,
height: '100%', left: 0,
backfaceVisibility: 'hidden', width: '100%',
WebkitBackfaceVisibility: 'hidden', height: '100%',
}} opacity: isFlipped ? 1 : 0,
> visibility: isFlipped ? 'visible' : 'hidden',
{front} transition: 'opacity 0.2s ease',
</div> }}
<div >
className="flip-card-back" {back}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
}}
>
{back}
</div>
</div> </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,6 +61,7 @@ import { isTrackReference } from '@livekit/components-core';
import '@/styles/livekit-components.css'; import '@/styles/livekit-components.css';
import '@/styles/livekit-theme.css'; import '@/styles/livekit-theme.css';
import { getLesson } from '@/api/schedule'; import { getLesson } from '@/api/schedule';
import { participantConnected } from '@/api/livekit';
import type { Lesson } from '@/api/schedule'; import type { Lesson } from '@/api/schedule';
import { getOrCreateLessonBoard } from '@/api/board'; import { getOrCreateLessonBoard } from '@/api/board';
@ -446,6 +447,20 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard,
getNavBadges().then(setNavBadges).catch(() => setNavBadges(null)); getNavBadges().then(setNavBadges).catch(() => setNavBadges(null));
}, [user]); }, [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(() => { useEffect(() => {
if (!showPlatformChat || !lessonId) { if (!showPlatformChat || !lessonId) {
if (!showPlatformChat) setLessonChat(null); if (!showPlatformChat) setLessonChat(null);

View File

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

View File

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